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}