The lobby is a table being assembled

"Gather your wizards" leads, with the room code and the seat count
beneath it. The roster is the picture: each seat's portrait, name, and
role, an empty seat showing what fills it, and beneath it the two ways
to fill one — copy the invite link, add a computer wizard — with the
raw URL folded away for pasting by hand. Your own row carries the
wizard picker, a deliberate three-by-two with a check on the chosen
one, open by itself until you have a wizard. Adding a computer wizard
is a panel with difficulty and personality, a line on what the chosen
personality does, and one explicit button. Readiness and Start sit
together at the bottom: "Waiting for one more wizard" above the
button, "Start game · 2 wizards" once it can flip; the expansion
option is nearby and quiet, and abandoning the room is a small link.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-16 22:18:39 -04:00
co-authored by Claude Fable 5.1
parent 9dee01d393
commit f7991ee64f
+185 -90
View File
@@ -254,6 +254,17 @@
let showDiscards = $state(false);
let chatDraft = $state("");
let botTier = $state<typeof prefs.botTier>(prefs.botTier);
let botStyle = $state<"hunter" | "berserker" | "worrier" | "mystery">("hunter");
let addingBot = $state(false);
/** The wizard picker stands open until you have a wizard; after that, on request. */
let colorPickerToggled = $state<boolean | null>(null);
const colorPickerOpen = $derived(colorPickerToggled ?? (net.you != null && net.roomColors[net.you] === undefined));
const BOT_BLURBS = {
hunter: "A hunter goes for the treasure and carries it home.",
berserker: "A berserker goes for blood, and worries about treasure later.",
worrier: "A worrier keeps to the shadows and guards what is theirs.",
mystery: "Mystery deals one of the three at random; you learn which when the game ends.",
} as const;
/** Card whose official FAQ rulings are open. */
let faqCardId = $state<string | null>(null);
/** A discard-pile card enlarged above the pile. */
@@ -2420,26 +2431,9 @@
{:else if !local.active && !net.started}
<section class="boxlid">
<div class="boxlid-inner">
<div class="boxlid-title small">Room {net.roomId}</div>
{#if !net.spectating}
<div class="lobby-head">Your color</div>
<div class="standee-row" role="group" aria-label="choose your wizard">
{#each [0, 1, 2, 3, 4, 5] as c (c)}
{@const takenBy = Object.entries(net.roomColors).find(([, v]) => v === c)?.[0]}
<button
class="standee"
class:current={net.you != null && net.roomColors[net.you] === c}
class:taken={takenBy !== undefined && takenBy !== net.you}
disabled={takenBy !== undefined && takenBy !== net.you}
style:--ring={PLAYER_COLORS[c]}
onclick={() => net.pickColor(c)}
aria-label={`wizard color ${c + 1}${takenBy && takenBy !== net.you ? ` (taken by ${takenBy})` : ""}`}
>
<img src={tokenArt(`wizard-${c}`, "players")} alt="" />
</button>
{/each}
</div>
{/if}
<div class="boxlid-title small">Gather your wizards</div>
<div class="lobby-sub">Room <strong class="room-code">{net.roomId}</strong> · {net.players.length} of 6 seats filled</div>
{#if net.spectating}
{#if net.players.length < 6}
<!-- A friend's link lands here: the seat is the invitation, the gallery the afterthought. -->
@@ -2457,66 +2451,119 @@
<p class="waiting">👁 The table is full you watch from the Peanut Gallery. Waiting for the boards to flip</p>
{/if}
{/if}
<div class="lobby-head">Fill the table</div>
<div class="boxlid-tag">Two to six wizards enter the maze friends by code or link,
computer wizards seated below, or a mix of both.</div>
<div class="invite-row">
<code class="invite-link">{inviteLink}</code>
<button class="stamp tiny" onclick={copyInvite}>{inviteCopied ? "copied!" : "copy invite link"}</button>
</div>
<ul class="roster">
<ul class="table-roster" aria-label="the table">
{#each net.players as p (p)}
{@const chosen = net.roomColors[p]}
<li>
{@const isYou = p === net.you}
<li class="seat" class:you={isYou}>
{#if chosen !== undefined}
<img class="roster-standee" src={tokenArt(`wizard-${chosen}`, "players")} alt="" />
<img class="seat-portrait" src={tokenArt(`wizard-${chosen}`, "players")} alt="" />
{:else}
<span class="dot" style:background="#b3a687"></span>
<span class="seat-portrait empty"></span>
{/if}
<span class="seat-text">
<span class="seat-name">{p}{isYou ? " (you)" : ""}</span>
<span class="seat-role">
{#if net.roomBots[p]}⚙ {net.roomBots[p]}{:else if chosen === undefined}{p === net.hostId ? "host · " : ""}choosing a wizard…{:else if p === net.hostId}host{:else}wizard{/if}
</span>
</span>
{#if isYou && !net.spectating}
<button class="stamp tiny" onclick={() => (colorPickerToggled = !colorPickerOpen)} aria-expanded={colorPickerOpen}>
{colorPickerOpen ? "done" : "change wizard"}</button>
{/if}
{p}{p === net.hostId ? " — host" : ""}{net.roomBots[p] ? ` ${net.roomBots[p]}` : chosen === undefined ? " — choosing…" : ""}
{#if net.you === net.hostId && p !== net.hostId}
<button class="stamp tiny kick" title="clear this seat"
<button class="stamp tiny kick" title="clear this seat" aria-label={`clear ${p}'s seat`}
onclick={() => net.kickSeat(p)}>✕</button>
{/if}
</li>
{#if isYou && !net.spectating && colorPickerOpen}
<li class="color-pick" role="group" aria-label="choose your wizard">
{#each [0, 1, 2, 3, 4, 5] as c (c)}
{@const takenBy = Object.entries(net.roomColors).find(([, v]) => v === c)?.[0]}
<button
class="standee"
class:current={net.roomColors[p] === c}
class:taken={takenBy !== undefined && takenBy !== net.you}
disabled={takenBy !== undefined && takenBy !== net.you}
style:--ring={PLAYER_COLORS[c]}
onclick={() => { net.pickColor(c); colorPickerToggled = null; }}
aria-label={`wizard ${c + 1}${takenBy && takenBy !== net.you ? ` (taken by ${takenBy})` : ""}`}
>
<img src={tokenArt(`wizard-${c}`, "players")} alt="" />
</button>
{/each}
</li>
{/if}
{/each}
</ul>
{#if net.you === net.hostId}
{#if net.players.length < 6}
<div class="lobby-head">Seat a computer wizard</div>
<div class="boxlid-tag">An automaton plays a full seat by the rules — a hunter goes for treasure,
a berserker for blood, a worrier for the shadows between; mystery deals one at random.</div>
<span class="bot-row">
<span class="bot-label">⚙ seat a</span>
<select class="tier-pick" bind:value={botTier} aria-label="automaton difficulty"
onchange={() => { setPref("botTier", botTier); setPref("tierChosen", true); }}>
<option value="apprentice">apprentice</option>
<option value="adept">adept</option>
<option value="archmage">archmage</option>
</select>
<button class="stamp tiny" onclick={() => net.addBot("hunter", botTier)}>hunter</button>
<button class="stamp tiny" onclick={() => net.addBot("berserker", botTier)}>berserker</button>
<button class="stamp tiny" onclick={() => net.addBot("worrier", botTier)}>worrier</button>
<button class="stamp tiny" onclick={() => net.addBot(undefined, botTier)}>mystery</button>
</span>
<li class="seat open">
<span class="seat-portrait empty"></span>
<span class="seat-text">
<span class="seat-name">An empty seat</span>
<span class="seat-role">{net.players.length < 2 ? "one more wizard and the boards can flip" : "room for another"}</span>
</span>
</li>
{/if}
<label class="check">
</ul>
{#if !net.spectating}
<div class="fill-row">
<button class="stamp" onclick={copyInvite}>{inviteCopied ? "Invite link copied" : "Copy invite link"}</button>
{#if net.you === net.hostId && net.players.length < 6}
<button class="stamp" class:current={addingBot} onclick={() => (addingBot = !addingBot)} aria-expanded={addingBot}>
Add a computer wizard</button>
{/if}
</div>
<details class="invite-fallback">
<summary>the link itself, for pasting by hand</summary>
<code class="invite-link">{inviteLink}</code>
</details>
{#if net.you === net.hostId && addingBot && net.players.length < 6}
<div class="bot-panel">
<div class="bot-field">
<span class="bot-field-label">Difficulty</span>
<span class="seg" role="group" aria-label="automaton difficulty">
{#each ["apprentice", "adept", "archmage"] as t (t)}
<button class:current={botTier === t}
onclick={() => { botTier = t as typeof botTier; setPref("botTier", botTier); setPref("tierChosen", true); }}>{t}</button>
{/each}
</span>
</div>
<div class="bot-field">
<span class="bot-field-label">Personality</span>
<span class="seg" role="group" aria-label="automaton personality">
{#each ["hunter", "berserker", "worrier", "mystery"] as st (st)}
<button class:current={botStyle === st} onclick={() => (botStyle = st as typeof botStyle)}>{st}</button>
{/each}
</span>
</div>
<div class="bot-blurb">{BOT_BLURBS[botStyle]}</div>
<button class="stamp primary" onclick={() => net.addBot(botStyle === "mystery" ? undefined : botStyle, botTier)}>
Add computer wizard</button>
</div>
{/if}
{/if}
{#if net.you === net.hostId}
<label class="check option">
<input type="checkbox" bind:checked={withExpansion} />
Include Expansion Set #1 — monsters &amp; wands
</label>
<button
class="stamp big begin"
disabled={net.players.length < 2 || net.players.length > 6}
onclick={() => net.start(withExpansion)}
>
Start the game
<span class="sub">flip the boards · {net.players.length} wizard{net.players.length === 1 ? "" : "s"}</span>
</button>
{#if net.players.length < 2}
<div class="begin-hint">Seat a computer wizard, or invite a friend, and the boards can flip.</div>
{/if}
<button class="stamp tiny abandon" title="dissolve this room for everyone"
onclick={() => net.abandonRoom()}>abandon room</button>
<div class="ready">
{#if net.players.length < 2}
<div class="ready-note">Waiting for one more wizard</div>
{/if}
<button
class="stamp big begin"
disabled={net.players.length < 2 || net.players.length > 6}
onclick={() => net.start(withExpansion)}
>
{net.players.length < 2 ? "Start game" : `Start game · ${net.players.length} wizards`}
</button>
<button class="hint-cancel abandon" title="dissolve this room for everyone"
onclick={() => net.abandonRoom()}>abandon room</button>
</div>
{:else}
<p class="waiting">Waiting for {net.hostId} to flip the boards…</p>
{/if}
@@ -3476,7 +3523,23 @@
overflow: hidden;
}
.standee img { width: 100%; height: 100%; object-fit: cover; display: block; }
.standee { position: relative; }
.standee.current { border-color: var(--ring); box-shadow: 0 0 0 2px rgba(0,0,0,0.15); }
.standee.current::after {
content: "✓";
position: absolute;
right: 0.1rem;
bottom: 0.05rem;
width: 1rem;
height: 1rem;
border-radius: 50%;
background: var(--ring, #43331f);
color: #fff;
font-size: 0.7rem;
line-height: 1rem;
text-align: center;
font-weight: 700;
}
.standee.taken { opacity: 0.28; cursor: default; filter: grayscale(0.8); }
.setup-form { display: flex; flex-direction: column; gap: 0.7rem; margin-top: 0.8rem; }
.setup-input {
@@ -3614,10 +3677,6 @@
color: #8a6d3f;
margin: 1.1rem 0 0;
}
.stamp.big.begin { display: inline-flex; flex-direction: column; align-items: center; line-height: 1.15; }
.stamp.big.begin .sub { font-size: 0.68rem; letter-spacing: 0.1em; opacity: 0.75; margin-top: 0.15rem; }
.begin-hint { font-size: 0.85rem; color: #8a6d3f; margin-top: 0.4rem; }
.stamp.tiny.abandon { display: block; margin: 0.9rem auto 0; }
.about-link {
background: none;
border: none;
@@ -3655,18 +3714,65 @@
.boxlid-actions { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; justify-content: center; }
.or { font-size: 0.85rem; color: #6b5a41; }
.code { width: 6.5rem; padding: 0.55rem 0.4rem; text-transform: uppercase; text-align: center; letter-spacing: 0.15em; }
.roster { list-style: none; padding: 0; margin: 0 0 1rem; text-align: left; }
.roster li { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0; font-size: 1.05rem; }
.roster-standee { width: 1.9rem; height: 1.9rem; border-radius: 4px; object-fit: cover; }
.check { display: flex; gap: 0.5rem; align-items: center; justify-content: center; font-size: 0.92rem; margin-bottom: 1rem; }
.waiting { color: #6b5a41; font-style: italic; }
.invite-row {
display: flex;
align-items: center;
/* The lobby is a table being assembled: the roster is the picture, the
* ways to fill a seat sit beneath it, the start waits at the bottom. */
.lobby-sub { color: #6b5a41; font-size: 0.95rem; margin-top: 0.3rem; }
.room-code { font-family: "Courier Prime", monospace; letter-spacing: 0.12em; color: #43331f; }
.table-roster { list-style: none; padding: 0; margin: 1rem 0 0.8rem; text-align: left; }
.seat { display: flex; align-items: center; gap: 0.7rem; padding: 0.4rem 0.5rem; border-radius: 5px; }
.seat.you { background: rgba(255, 252, 240, 0.55); border: 1px solid #d8cdae; }
.seat.open { opacity: 0.7; }
.seat-portrait { width: 2.6rem; height: 2.6rem; border-radius: 5px; object-fit: cover; flex: 0 0 auto; }
.seat-portrait.empty { border: 1.5px dashed #b3a687; background: rgba(0, 0, 0, 0.03); box-sizing: border-box; }
.seat-text { display: flex; flex-direction: column; min-width: 0; flex: 1 1 auto; }
.seat-name { font-size: 1.05rem; font-weight: 700; }
.seat-role { font-size: 0.82rem; color: #6b5a41; }
.color-pick {
display: grid;
grid-template-columns: repeat(3, 3.1rem);
gap: 0.45rem;
justify-content: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
padding: 0.5rem 0 0.7rem;
}
.fill-row { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.5rem; }
.fill-row .stamp.current { background: #e4dbc2; }
.invite-fallback { margin: 0.4rem 0 0; font-size: 0.8rem; }
.invite-fallback summary { cursor: pointer; color: #8a6d3f; font-style: italic; }
.invite-fallback .invite-link { display: inline-block; margin-top: 0.3rem; word-break: break-all; }
.bot-panel {
margin-top: 0.8rem;
padding: 0.7rem 0.8rem;
border: 1px solid #b3a687;
border-radius: 5px;
background: rgba(255, 252, 240, 0.45);
display: flex;
flex-direction: column;
gap: 0.55rem;
align-items: center;
}
.bot-field { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 0.3rem 0.6rem; }
.bot-field-label { font-family: "Oswald", sans-serif; font-size: 0.68rem; letter-spacing: 0.12em; text-transform: uppercase; color: #8a6d3f; min-width: 5.2rem; text-align: right; }
.seg { display: inline-flex; flex-wrap: wrap; gap: 0.25rem; }
.seg button {
font-family: "Oswald", sans-serif;
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
background: none;
border: 1.5px solid #b3a687;
border-radius: 3px;
color: #6b5a41;
padding: 0.25rem 0.55rem;
cursor: pointer;
}
.seg button.current { border-color: #43331f; color: #43331f; background: #e4dbc2; }
.bot-blurb { font-size: 0.9rem; color: #6b5a41; font-style: italic; }
.check { display: flex; gap: 0.5rem; align-items: center; justify-content: center; font-size: 0.92rem; margin-bottom: 1rem; }
.check.option { margin: 1rem 0 0.6rem; font-size: 0.88rem; color: #6b5a41; }
.ready { display: flex; flex-direction: column; align-items: center; gap: 0.35rem; }
.ready-note { font-size: 0.88rem; color: #8a6d3f; font-style: italic; }
.ready .abandon { margin-top: 0.4rem; font-size: 0.8rem; opacity: 0.75; }
.waiting { color: #6b5a41; font-style: italic; }
.invite-link {
font-size: 0.8rem;
color: #6b5a41;
@@ -4335,17 +4441,6 @@
color: #43331f;
padding: 0.1rem 0.25rem;
}
.bot-row {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 0.35rem;
max-width: 100%;
font-size: 0.85rem;
color: #6b5a41;
}
.bot-label { white-space: nowrap; }
.discard-link {
background: none;
border: none;