Files
wizwar6e/packages/server/src/rooms.ts
T
Eric WagonerandClaude Fable 5 36b3ffe9a6 Wire online multiplayer: game rooms, protocol, playable Svelte client
Server: room registry with 4-letter codes, host/join/start flow, the
authoritative command loop (seed + append-only command log per room —
the replay/async foundation), and per-player redacted views and events
broadcast after every change. Client: lobby, SVG board (floors, walls,
doors, homes, color-keyed treasures and wizard tokens matching the
physical set's six colors, warp arrows), click-to-move, click-to-punch,
card hand with tooltips from verified card text, cast flow with number
card attachment and waterbolt split, edge-click targeting for wall
spells, counteract-or-pass prompt, discard flow, end-turn draw
selector, and a humanized event log. Verified end-to-end over real
websockets with two clients: join, start, private deals, moves, turn
sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 19:52:10 -04:00

112 lines
3.1 KiB
TypeScript

// Game rooms: the server is the authority. Each room holds one GameState and
// the append-only command log (the seed + log IS the game — the basis for
// replays and async play). Clients get per-player redacted views and events.
import {
applyCommand,
createGame,
redactEvent,
viewFor,
type Command,
type GameEvent,
type GameState,
type GameView,
type PlayerId,
} from "@wizwar/engine";
export interface LoggedCommand {
seq: number;
playerId: PlayerId;
command: Command;
at: string; // ISO timestamp (server-side wall clock; not used by the engine)
}
export interface Room {
id: string;
hostId: PlayerId;
players: PlayerId[]; // join order
seed: number;
state: GameState | null; // null until started
log: LoggedCommand[];
events: GameEvent[]; // full history (unredacted — redact per recipient)
}
const rooms = new Map<string, Room>();
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
function makeRoomCode(): string {
let code = "";
for (let i = 0; i < 4; i++) {
code += ROOM_CODE_ALPHABET[Math.floor(Math.random() * ROOM_CODE_ALPHABET.length)];
}
return rooms.has(code) ? makeRoomCode() : code;
}
export function createRoom(hostId: PlayerId): Room {
const room: Room = {
id: makeRoomCode(),
hostId,
players: [hostId],
seed: Math.floor(Math.random() * 0xffffffff),
state: null,
log: [],
events: [],
};
rooms.set(room.id, room);
return room;
}
export function getRoom(id: string): Room | undefined {
return rooms.get(id.toUpperCase());
}
export function joinRoom(room: Room, playerId: PlayerId): string | null {
if (room.state) return "game already started";
if (room.players.includes(playerId)) return null; // rejoin is fine
if (room.players.length >= 4) return "room is full";
room.players.push(playerId);
return null;
}
export function startGame(room: Room): { events: GameEvent[] } | { error: string } {
if (room.state) return { error: "already started" };
const n = room.players.length;
if (n !== 2 && n !== 4) return { error: "supported player counts: 2 or 4" };
const { state, events } = createGame({
playerIds: room.players,
seed: room.seed,
sets: ["basic"],
});
room.state = state;
room.events.push(...events);
return { events };
}
export function runCommand(
room: Room,
playerId: PlayerId,
command: Command,
): { events: GameEvent[] } | { error: string } {
if (!room.state) return { error: "game not started" };
const result = applyCommand(room.state, playerId, command);
if (!result.ok) return { error: result.error };
room.state = result.state;
room.log.push({
seq: room.log.length,
playerId,
command,
at: new Date().toISOString(),
});
room.events.push(...result.events);
return { events: result.events };
}
export function viewForPlayer(room: Room, playerId: PlayerId): GameView | null {
return room.state ? viewFor(room.state, playerId) : null;
}
export function redactFor(events: GameEvent[], playerId: PlayerId): GameEvent[] {
return events.map((e) => redactEvent(e, playerId)).filter((e): e is GameEvent => e !== null);
}