Hotseat setup round: pick a count, then each wizard names themselves

The comma-separated name field is gone. Hotseat now starts with a
2-6 count picker and "Gather N wizards", which opens a naming round
in the pass-the-device style: "Wizard 1 of 3 — what is your name?",
type it, pass the device on; the last wizard's button reads "Flip the
boards" and starts the game. Duplicate and empty names are refused
in place, the roster-so-far shows beneath, and "never mind" backs
out. Verified end to end: three names entered, game started, opening
handoff went to the die-roll winner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 01:12:13 -04:00
co-authored by Claude Fable 5
parent ac82c28750
commit 98d7dee5c7
2 changed files with 92 additions and 12 deletions
+65 -12
View File
@@ -14,7 +14,8 @@
let joinCode = $state(""); let joinCode = $state("");
let claimPhrase = $state(""); let claimPhrase = $state("");
let showHelp = $state(false); let showHelp = $state(false);
let hotseatNames = $state(""); let hotseatCount = $state(2);
let setupName = $state("");
/** Tap-to-inspect: hide the enlarged card for the current selection. */ /** Tap-to-inspect: hide the enlarged card for the current selection. */
let inspectorHidden = $state(false); let inspectorHidden = $state(false);
$effect(() => { $effect(() => {
@@ -497,7 +498,31 @@
</div> </div>
{/if} {/if}
{#if local.handoffTo} {#if local.setup}
<div class="scrim-handoff">
<div class="handoff-card">
<div class="handoff-eyebrow">wizard {local.setup.names.length + 1} of {local.setup.count}</div>
<div class="handoff-name setup-title">What is your name?</div>
<form class="setup-form" onsubmit={(e) => {
e.preventDefault();
const err = local.submitName(setupName);
if (err) net.error = err;
else setupName = "";
}}>
<!-- svelte-ignore a11y_autofocus -->
<input class="setup-input" bind:value={setupName} maxlength="20"
placeholder="e.g. Mordecai" autofocus />
<button class="stamp" type="submit" disabled={!setupName.trim()}>
{local.setup.names.length + 1 === local.setup.count ? "Flip the boards" : "Pass the device on"}
</button>
</form>
{#if local.setup.names.length > 0}
<div class="setup-roster">so far: {local.setup.names.join(", ")}</div>
{/if}
<button class="hint-cancel setup-cancel" onclick={() => local.cancelSetup()}>never mind</button>
</div>
</div>
{:else if local.handoffTo}
<div class="scrim-handoff" role="button" tabindex="0" <div class="scrim-handoff" role="button" tabindex="0"
onclick={() => local.takeSeat()} onkeydown={(e) => e.key === "Enter" && local.takeSeat()}> onclick={() => local.takeSeat()} onkeydown={(e) => e.key === "Enter" && local.takeSeat()}>
<div class="handoff-card"> <div class="handoff-card">
@@ -529,14 +554,15 @@
</div> </div>
<div class="hotseat-row"> <div class="hotseat-row">
<input class="claim-input wide" bind:value={hotseatNames} <span class="hotseat-label">or play here, passing the device:</span>
placeholder="hotseat: names, comma-separated" aria-label="hotseat player names" /> <div class="count-picker" role="group" aria-label="number of wizards">
<button class="stamp tiny" disabled={hotseatNames.split(",").filter((n) => n.trim()).length < 2} {#each [2, 3, 4, 5, 6] as n (n)}
onclick={() => { <button class="count-btn" class:current={hotseatCount === n}
const err = local.start(hotseatNames.split(","), withExpansion); onclick={() => (hotseatCount = n)}>{n}</button>
if (err) net.error = err; {/each}
}}> </div>
Play here <button class="stamp tiny" onclick={() => { setupName = ""; local.beginSetup(hotseatCount, withExpansion); }}>
Gather {hotseatCount} wizards
</button> </button>
{#if local.hasSave()} {#if local.hasSave()}
<button class="stamp tiny" onclick={() => local.resume()}>Resume hotseat</button> <button class="stamp tiny" onclick={() => local.resume()}>Resume hotseat</button>
@@ -865,7 +891,6 @@
} }
.mast-leave:hover { color: #d8d2c0; } .mast-leave:hover { color: #d8d2c0; }
.mast-help-solo { margin-left: auto; } .mast-help-solo { margin-left: auto; }
.claim-input.wide { width: 17rem; }
.hotseat-row { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; justify-content: center; margin-top: 1.3rem; } .hotseat-row { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; justify-content: center; margin-top: 1.3rem; }
.hotseat-exp { margin-top: 0.4rem; margin-bottom: 0; font-size: 0.85rem; } .hotseat-exp { margin-top: 0.4rem; margin-bottom: 0; font-size: 0.85rem; }
@@ -899,6 +924,34 @@
line-height: 1.15; line-height: 1.15;
} }
.handoff-hint { font-size: 0.85rem; color: #6b5a41; margin-top: 0.4rem; } .handoff-hint { font-size: 0.85rem; color: #6b5a41; margin-top: 0.4rem; }
.setup-title { font-size: 2.2rem; }
.setup-form { display: flex; flex-direction: column; gap: 0.7rem; margin-top: 0.8rem; }
.setup-input {
background: #f4eede;
border: 1px solid #b3a687;
border-radius: 4px;
padding: 0.6rem 0.8rem;
font-family: "Archivo Narrow", sans-serif;
font-size: 1.1rem;
color: #43331f;
text-align: center;
}
.setup-roster { margin-top: 0.8rem; font-size: 0.85rem; color: #6b5a41; }
.setup-cancel { margin: 0.6rem auto 0; display: block; }
.hotseat-label { flex-basis: 100%; font-size: 0.85rem; color: #6b5a41; }
.count-picker { display: flex; gap: 0.3rem; }
.count-btn {
width: 2.1rem;
height: 2.1rem;
border-radius: 50%;
border: 1.5px solid #6b5a41;
background: none;
color: #43331f;
font-family: "Oswald", sans-serif;
font-size: 0.95rem;
cursor: pointer;
}
.count-btn.current { background: #43331f; color: #e9e1cb; }
.toast { .toast {
background: #6d2119; background: #6d2119;
@@ -1277,7 +1330,7 @@
.boxlid { padding: 1rem 0; } .boxlid { padding: 1rem 0; }
.boxlid-inner { padding: 1.5rem 1.2rem 1.4rem; } .boxlid-inner { padding: 1.5rem 1.2rem 1.4rem; }
.boxlid-title { font-size: 2.3rem; } .boxlid-title { font-size: 2.3rem; }
.claim-input.wide, .claim-input { width: 100%; flex: 1 1 100%; } .claim-input { width: 100%; flex: 1 1 100%; }
.hotseat-row .stamp, .claim-row .stamp { flex: 0 0 auto; } .hotseat-row .stamp, .claim-row .stamp { flex: 0 0 auto; }
.mast-title { font-size: 1.05rem; white-space: nowrap; } .mast-title { font-size: 1.05rem; white-space: nowrap; }
.mast-sub { display: none; } .mast-sub { display: none; }
+27
View File
@@ -35,6 +35,8 @@ function actorId(state: GameState): PlayerId {
class LocalGame { class LocalGame {
active = $state(false); active = $state(false);
/** Hotseat setup round: players name themselves one by one. */
setup = $state<{ count: number; expansion: boolean; names: PlayerId[] } | null>(null);
gameState = $state<GameState | null>(null); gameState = $state<GameState | null>(null);
viewerId = $state<PlayerId | null>(null); viewerId = $state<PlayerId | null>(null);
/** Set while the device should be handed to the named player. */ /** Set while the device should be handed to the named player. */
@@ -51,6 +53,31 @@ class LocalGame {
return localStorage.getItem(SAVE_KEY) !== null; return localStorage.getItem(SAVE_KEY) !== null;
} }
beginSetup(count: number, expansion: boolean): void {
this.setup = { count, expansion, names: [] };
}
cancelSetup(): void {
this.setup = null;
}
/** One wizard names themselves; the last name starts the game. */
submitName(raw: string): string | null {
if (!this.setup) return "no setup in progress";
const name = raw.trim();
if (!name) return "every wizard needs a name";
if (this.setup.names.some((n) => n.toLowerCase() === name.toLowerCase())) {
return "that name is already taken at this table";
}
this.setup = { ...this.setup, names: [...this.setup.names, name] };
if (this.setup.names.length === this.setup.count) {
const { names, expansion } = this.setup;
this.setup = null;
return this.start(names, expansion);
}
return null;
}
start(names: PlayerId[], expansion: boolean): string | null { start(names: PlayerId[], expansion: boolean): string | null {
const cleaned = [...new Set(names.map((n) => n.trim()).filter(Boolean))]; const cleaned = [...new Set(names.map((n) => n.trim()).filter(Boolean))];
if (cleaned.length < 2 || cleaned.length > 6) return "two to six wizards, each with a name"; if (cleaned.length < 2 || cleaned.length > 6) return "two to six wizards, each with a name";