Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 4 additions & 129 deletions src/lib/components/character-token-node.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
import type { Character, HelperToken } from '$lib/types/characters';
import { Skull, HeartPulse, X, Plus, Settings2, UserRoundPlus, UserRoundX, Save } from '@lucide/svelte';
import { Skull, HeartPulse, X, Plus, Settings2, UserRoundPlus } from '@lucide/svelte';
import { CHARACTERS_BY_TYPE } from '$lib/botc-data/trouble-brewing.svelte';

const typeColors: Record<string, string> = {
Expand All @@ -13,26 +13,17 @@
export interface TokenData extends Record<string, unknown> {
character: Character;
playerName?: string;
knownPlayers?: string[];
availableHelperTokens?: HelperToken[];
updatePlayerAssignment?: (characterName: string, playerName: string) => Promise<void>;
openAssignmentDialog?: (characterName: string) => void;
}

let { data }: { data: TokenData } = $props();

let isDead = $state(false);
let helperTokenIds = $state<string[]>([]);
let modal: HTMLDialogElement;
let manualPlayerName = $state('');
let assignmentError = $state('');
let savingAssignment = $state(false);

const IconComponent = $derived(data.character.icon);
const playerOptionsId = $derived(
`player-options-${data.character.name.toLowerCase().replace(/\s+/g, '-')}`
);
const availableHelperTokens = $derived(data.availableHelperTokens || []);
const knownPlayers = $derived(data.knownPlayers || []);
const assignedHelperTokens = $derived(
availableHelperTokens.filter((ht) => helperTokenIds.includes(ht.id))
);
Expand Down Expand Up @@ -69,62 +60,7 @@

function openAssignmentModal(event: MouseEvent) {
event.stopPropagation();
assignmentError = '';
manualPlayerName = data.playerName ?? '';
modal.showModal();
}

async function saveAssignment(event: SubmitEvent) {
event.preventDefault();
event.stopPropagation();

const playerName = manualPlayerName.trim();
if (!playerName) {
assignmentError = 'Enter a player name to assign.';
return;
}

if (!data.updatePlayerAssignment) {
assignmentError = 'Assignment controls are unavailable.';
return;
}

savingAssignment = true;
assignmentError = '';

try {
await data.updatePlayerAssignment(data.character.name, playerName);
modal.close();
} catch (error) {
assignmentError =
error instanceof Error ? error.message : 'Failed to update player assignment.';
} finally {
savingAssignment = false;
}
}

async function clearAssignment(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();

if (!data.updatePlayerAssignment) {
assignmentError = 'Assignment controls are unavailable.';
return;
}

savingAssignment = true;
assignmentError = '';

try {
await data.updatePlayerAssignment(data.character.name, '');
manualPlayerName = '';
modal.close();
} catch (error) {
assignmentError =
error instanceof Error ? error.message : 'Failed to remove player assignment.';
} finally {
savingAssignment = false;
}
data.openAssignmentDialog?.(data.character.name);
}
</script>

Expand Down Expand Up @@ -214,65 +150,4 @@
{/if}
</div>
{/if}
</div>

<dialog class="modal" bind:this={modal}>
<div class="modal-box max-w-xs">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
</form>
<h3 class="text-lg font-bold">Assign player to {data.character.name}</h3>
<form class="mt-4 flex flex-col gap-2" onsubmit={saveAssignment}>
<label class="input input-sm input-bordered flex items-center gap-2">
<input
class="grow text-sm"
type="text"
bind:value={manualPlayerName}
list={knownPlayers.length > 0 ? playerOptionsId : undefined}
placeholder="Player name"
onmousedown={stopNodeInteraction}
/>
</label>

{#if knownPlayers.length > 0}
<datalist id={playerOptionsId}>
{#each knownPlayers as knownPlayer}
<option value={knownPlayer}></option>
{/each}
</datalist>
{/if}

<div class="flex items-center gap-2">
<button
type="submit"
class="btn btn-primary btn-sm flex-1"
disabled={savingAssignment}
onmousedown={stopNodeInteraction}
>
<Save size={12} />
<span>{savingAssignment ? 'Saving...' : 'Save'}</span>
</button>

{#if data.playerName}
<button
type="button"
class="btn btn-error btn-outline btn-sm"
disabled={savingAssignment}
onclick={clearAssignment}
onmousedown={stopNodeInteraction}
>
<UserRoundX size={12} />
<span>Remove</span>
</button>
{/if}
</div>

{#if assignmentError}
<p class="text-xs text-error">{assignmentError}</p>
{/if}
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</div>
159 changes: 153 additions & 6 deletions src/lib/components/grimoire.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
} from "$lib/botc-data/trouble-brewing.svelte";
import { onMount, onDestroy } from "svelte";
import type { Character } from "$lib/types/characters";
import { Save, UserRoundX } from "@lucide/svelte";

function findCharacterByName(name: string): Character | null {
for (const characters of Object.values(CHARACTERS_BY_TYPE)) {
Expand All @@ -37,8 +38,19 @@
let remainingSeconds = $state(5 * 60);
let isPolling = $state(true);
let lobbyNotFound = $state(false);
let assignmentModal: HTMLDialogElement;
let assignmentCharacterName = $state<string | null>(null);
let assignmentPlayerName = $state("");
let assignmentError = $state("");
let savingAssignment = $state(false);
const POLL_DURATION = 5 * 60 * 1000; // 5 minutes
const POLL_INTERVAL = 10 * 1000; // 10 seconds
const assignmentKnownPlayers = $derived(getKnownPlayers(characterToPlayer));
const assignmentOptionsId = $derived(
assignmentCharacterName
? `player-options-${assignmentCharacterName.toLowerCase().replace(/\s+/g, "-")}`
: "player-options-assignment"
);

function getKnownPlayers(charToPlayer: Record<string, string>): string[] {
return [...new Set(
Expand Down Expand Up @@ -68,6 +80,77 @@
isPolling = false;
}

function openAssignmentDialog(characterName: string) {
assignmentCharacterName = characterName;
assignmentPlayerName = characterToPlayer[characterName] ?? "";
assignmentError = "";
if (!assignmentModal?.open) {
assignmentModal?.showModal();
}
}

function clearAssignmentDialogState() {
assignmentError = "";
savingAssignment = false;
assignmentCharacterName = null;
assignmentPlayerName = "";
}

async function saveAssignment(event: SubmitEvent) {
event.preventDefault();

if (!assignmentCharacterName) {
assignmentError = "No character selected.";
return;
}

const playerName = assignmentPlayerName.trim();
if (!playerName) {
assignmentError = "Enter a player name to assign.";
return;
}

savingAssignment = true;
assignmentError = "";

try {
await updatePlayerAssignment(assignmentCharacterName, playerName);
assignmentModal?.close();
} catch (error) {
assignmentError =
error instanceof Error
? error.message
: "Failed to update player assignment.";
} finally {
savingAssignment = false;
}
}

async function clearAssignment(event: MouseEvent) {
event.preventDefault();

if (!assignmentCharacterName) {
assignmentError = "No character selected.";
return;
}

savingAssignment = true;
assignmentError = "";

try {
await updatePlayerAssignment(assignmentCharacterName, "");
assignmentPlayerName = "";
assignmentModal?.close();
} catch (error) {
assignmentError =
error instanceof Error
? error.message
: "Failed to remove player assignment.";
} finally {
savingAssignment = false;
}
}

function startPolling() {
stopPolling();
pollStartTime = Date.now();
Expand Down Expand Up @@ -123,7 +206,6 @@
}

function syncAssignments(newCharToPlayer: Record<string, string>) {
const knownPlayers = getKnownPlayers(newCharToPlayer);
characterToPlayer = newCharToPlayer;

if (nodes.length === 0 || nodes.length !== Object.keys(newCharToPlayer).length) {
Expand All @@ -139,9 +221,8 @@
data: {
...data,
playerName,
knownPlayers,
availableHelperTokens,
updatePlayerAssignment,
openAssignmentDialog,
} satisfies TokenData,
};
});
Expand All @@ -154,7 +235,6 @@
const centerX = 300;
const centerY = 300;
const radius = 250;
const knownPlayers = getKnownPlayers(charToPlayer);

const characters: Character[] = [];
for (const characterName of Object.keys(charToPlayer)) {
Expand All @@ -178,9 +258,8 @@
data: {
character,
playerName,
knownPlayers,
availableHelperTokens,
updatePlayerAssignment,
openAssignmentDialog,
} satisfies TokenData,
};
});
Expand Down Expand Up @@ -262,6 +341,74 @@
</div>
{/if}

<dialog class="modal" bind:this={assignmentModal} onclose={clearAssignmentDialogState}>
<div class="modal-box max-w-xs">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">
</button>
</form>
<h3 class="text-lg font-bold">
{#if assignmentCharacterName}
Assign player to {assignmentCharacterName}
{:else}
Assign player
{/if}
</h3>
<form class="mt-4 flex flex-col gap-2" onsubmit={saveAssignment}>
<label class="input input-sm input-bordered flex items-center gap-2">
<input
class="grow text-sm"
type="text"
bind:value={assignmentPlayerName}
list={assignmentKnownPlayers.length > 0
? assignmentOptionsId
: undefined}
placeholder="Player name"
/>
</label>

{#if assignmentKnownPlayers.length > 0}
<datalist id={assignmentOptionsId}>
{#each assignmentKnownPlayers as knownPlayer}
<option value={knownPlayer}></option>
{/each}
</datalist>
{/if}

<div class="flex items-center gap-2">
<button
type="submit"
class="btn btn-primary btn-sm flex-1"
disabled={savingAssignment}
>
<Save size={12} />
<span>{savingAssignment ? "Saving..." : "Save"}</span>
</button>

{#if assignmentCharacterName && characterToPlayer[assignmentCharacterName]}
<button
type="button"
class="btn btn-error btn-outline btn-sm"
disabled={savingAssignment}
onclick={clearAssignment}
>
<UserRoundX size={12} />
<span>Remove</span>
</button>
{/if}
</div>

{#if assignmentError}
<p class="text-xs text-error">{assignmentError}</p>
{/if}
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>

<style>
:global(.svelte-flow) {
--xy-background-color: var(--color-base-100);
Expand Down