Lobby standee picker for online rooms
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
77ae65b263
commit
4cbf5a013e
@@ -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<string, string> = {
|
||||
".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 : [];
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface Room {
|
||||
tokens: Map<PlayerId, string>;
|
||||
seed: number;
|
||||
expansion: boolean;
|
||||
/** Lobby standee choices (colorIndex 0-5), by player. */
|
||||
colorChoices: Map<PlayerId, number>;
|
||||
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<number>();
|
||||
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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user