From 4cbf5a013e48e5a5bb3f6ec2697225fd57aef0ca Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 16 Aug 2026 01:19:42 -0400 Subject: [PATCH] Lobby standee picker for online rooms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Waiting rooms now show the six wizard standees: tap to claim yours, claimed ones gray out with the claimant named for screen readers, the roster shows each player's chosen standee (or "choosing..."), and conflicts are refused first-come. Choices sync live to everyone in the room, resolve to first-free defaults for the undecided when the host flips the boards, persist in the start line of the room log (so restored games keep their colors), and flow through the same engine config field the hotseat picker uses. Also fixed: the static-serving realpath guard crashed the dev server when no client build exists — it now 404s static requests instead. Verified end to end: claim, conflict refusal, second pick, start, and in-game colorIndex 5/2. Co-Authored-By: Claude Fable 5 --- packages/server/src/index.ts | 18 +++++++++++++- packages/server/src/rooms.ts | 43 ++++++++++++++++++++++++++++++---- packages/server/src/store.ts | 2 ++ packages/web/src/App.svelte | 27 ++++++++++++++++++++- packages/web/src/net.svelte.ts | 7 ++++++ 5 files changed, 91 insertions(+), 6 deletions(-) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 4d2aea0..c94b6b0 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -19,6 +19,7 @@ import type { Command, PlayerId } from "@wizwar/engine"; import { claimTransferCode, createRoom, + pickColor, getRoom, joinRoom, loadPersistedRooms, @@ -42,9 +43,15 @@ const MIME: Record = { ".png": "image/png", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".woff2": "font/woff2", ".json": "application/json", }; -const staticRoot = realpathSync(normalize(STATIC_DIR)); +// The client build may be absent in development (vite serves it instead); +// serve a 404 for static requests in that case rather than dying on boot. +const staticRoot = existsSync(STATIC_DIR) ? realpathSync(normalize(STATIC_DIR)) : null; const httpServer = createServer((req, res) => { try { + if (!staticRoot) { + res.writeHead(404).end("client not built"); + return; + } let url: string; try { url = decodeURIComponent((req.url ?? "/").split("?")[0]!); @@ -100,6 +107,7 @@ function roomInfo(room: Room) { players: room.players, hostId: room.hostId, started: room.state !== null, + colors: Object.fromEntries(room.colorChoices), }; } @@ -205,6 +213,14 @@ wss.on("connection", (socket) => { send(socket, { type: "transferClaimed", seat: result }); break; } + case "pickColor": { + const room = session.roomId ? getRoom(session.roomId) : undefined; + if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); + const problem = pickColor(room, session.playerId, Number(msg.color)); + if (problem) return send(socket, { type: "error", message: problem }); + broadcastRoomState(room); + break; + } case "myGames": { // {seats: [{roomId, name, token}]} -> summaries for valid seats. const seats = Array.isArray(msg.seats) ? msg.seats : []; diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 73c94b0..fdf6922 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -32,6 +32,8 @@ export interface Room { tokens: Map; seed: number; expansion: boolean; + /** Lobby standee choices (colorIndex 0-5), by player. */ + colorChoices: Map; state: GameState | null; // null until started log: LoggedCommand[]; events: GameEvent[]; // full history (unredacted — redact per recipient) @@ -72,6 +74,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } { tokens: new Map([[hostId, hashToken(token)]]), seed: randomInt(0, 0xffffffff), expansion: false, + colorChoices: new Map(), state: null, log: [], events: [], @@ -111,13 +114,43 @@ export function joinRoom( return { token: fresh }; } -function startInMemory(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } { +/** Everyone gets their chosen standee; the undecided get the first free one. */ +export function resolveColors(room: Room): number[] { + const taken = new Set(); + const resolved: number[] = []; + for (const p of room.players) { + const choice = room.colorChoices.get(p); + if (choice !== undefined && !taken.has(choice)) { + resolved.push(choice); + taken.add(choice); + } else { + const free = [0, 1, 2, 3, 4, 5].find((c) => !taken.has(c))!; + resolved.push(free); + taken.add(free); + } + } + return resolved; +} + +export function pickColor(room: Room, playerId: PlayerId, color: number): string | null { + if (room.state) return "the game has started — your robes are dyed"; + if (!room.players.includes(playerId)) return "you hold no seat in this room"; + if (!Number.isInteger(color) || color < 0 || color > 5) return "no such wizard"; + for (const [other, c] of room.colorChoices) { + if (other !== playerId && c === color) return "that wizard has been claimed"; + } + room.colorChoices.set(playerId, color); + return null; +} + +function startInMemory(room: Room, expansion: boolean, colors?: number[]): { events: GameEvent[] } | { error: string } { const n = room.players.length; if (n < 2 || n > 6) return { error: "supported player counts: 2 to 6" }; const { state, events } = createGame({ playerIds: room.players, seed: room.seed, sets: expansion ? ["basic", "expansion1"] : ["basic"], + ...(colors ? { colors } : {}), }); room.expansion = expansion; room.state = state; @@ -127,9 +160,10 @@ function startInMemory(room: Room, expansion: boolean): { events: GameEvent[] } export function startGame(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } { if (room.state) return { error: "already started" }; - const result = startInMemory(room, expansion); + const colors = resolveColors(room); + const result = startInMemory(room, expansion, colors); if ("error" in result) return result; - appendLine(room.id, { kind: "start", expansion }); + appendLine(room.id, { kind: "start", expansion, colors }); return result; } @@ -286,6 +320,7 @@ export function loadPersistedRooms(): void { tokens: new Map([[meta.hostId, hostHash]]), seed: meta.seed, expansion: false, + colorChoices: new Map(), state: null, log: [], events: [], @@ -297,7 +332,7 @@ export function loadPersistedRooms(): void { room.players.push(line.name); room.tokens.set(line.name, joinHash); } else if (line.kind === "start") { - const r = startInMemory(room, line.expansion); + const r = startInMemory(room, line.expansion, line.colors); if ("error" in r) throw new Error(`replay start failed: ${r.error}`); } else if (line.kind === "command") { if (!room.state) throw new Error("command before start in log"); diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts index 8f67199..ac1460a 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -29,6 +29,8 @@ export interface JoinLine { export interface StartLine { kind: "start"; expansion: boolean; + /** Final wizard colors, in player join order. */ + colors?: number[]; } export interface CommandLine { diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index f91dd41..0129601 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -647,9 +647,33 @@
Share the code. Two to six wizards enter the maze.
    {#each net.players as p (p)} -
  • {p}{p === net.hostId ? " — host" : ""}
  • + {@const chosen = net.roomColors[p]} +
  • + {#if chosen !== undefined} + + {:else} + + {/if} + {p}{p === net.hostId ? " — host" : ""}{chosen === undefined ? " — choosing…" : ""} +
  • {/each}
+
+ {#each [0, 1, 2, 3, 4, 5] as c (c)} + {@const takenBy = Object.entries(net.roomColors).find(([, v]) => v === c)?.[0]} + + {/each} +
{#if net.you === net.hostId}