diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index a2eabf6..bbb3bac 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -35,6 +35,7 @@ import { WebSocketServer, WebSocket } from "ws"; import type { Command, PlayerId } from "@wizwar/engine"; import { catchUpSteps, + momentSteps, claimTransferCode, createRoom, pickColor, @@ -507,6 +508,20 @@ wss.on("connection", (socket) => { send(socket, { type: "catchUp", steps }); break; } + case "moment": { + // A chronicle line's instant-replay eye: one turn's reel. + const room = session.roomId ? getRoom(session.roomId) : undefined; + if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); + const now = Date.now(); + if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) { + return send(socket, { type: "error", message: "catching up already — one moment" }); + } + session.lastCatchUpAt = now; + const steps = momentSteps(room, session.playerId, Number(msg.turn ?? -1)); + if ("error" in steps) return send(socket, { type: "error", message: steps.error }); + send(socket, { type: "moment", steps }); + 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" }); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index a38b262..25f376b 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -416,6 +416,53 @@ export function redactFor(events: GameEvent[], playerId: PlayerId): GameEvent[] return events.map((e) => redactEvent(e, playerId)).filter((e): e is GameEvent => e !== null); } +/** The events that begin a turn — the client's chronicle counts these + * identically, so a turn number names the same stretch on both ends. */ +const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]); + +/** + * One turn's reel: every command whose events touch turn `turnIndex` + * (0-counted across boundary events). A command that ends one turn and + * starts the next belongs to both, so a reel opens with the blow that + * began it and closes on the handover. + */ +export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number): CatchUpStep[] | { error: string } { + if (!room.state) return { error: "game not started" }; + if (!room.players.includes(playerId)) return { error: "you hold no seat in this room" }; + const MAX_STEPS = 80; + const { state: fresh, events: dealt } = createGame(room.state.config); + let current = fresh; + // The deal's own events open the first turn — count them, or every + // turn number would sit one behind the chronicle's. + let counter = -1; + for (const e of dealt) if (TURN_BOUNDARY.has(e.type)) counter++; + const steps: CatchUpStep[] = []; + for (const entry of room.log) { + const result = applyCommand(current, entry.playerId, entry.command); + if (!result.ok) return { error: `replay diverged at seq ${entry.seq}` }; + current = result.state; + const before = counter; + for (const e of result.events) if (TURN_BOUNDARY.has(e.type)) counter++; + if (before > turnIndex) break; + if (before <= turnIndex && turnIndex <= counter) { + const prevAt = entry.seq > 0 ? room.log[entry.seq - 1]!.at : ""; + const said = room.chat + .filter((c) => c.at > prevAt && c.at <= entry.at) + .map((c) => ({ player: c.player, text: c.text })); + steps.push({ + seq: entry.seq, + actor: entry.playerId, + events: redactFor(result.events, playerId), + view: viewFor(current, playerId), + ...(said.length > 0 ? { chat: said } : {}), + }); + if (steps.length >= MAX_STEPS) break; + } + } + if (steps.length === 0) return { error: "no such turn yet" }; + return steps; +} + // --------------------------------------------------------------------------- // Seat transfers: a spoken-word one-time code hands a seat to another device. // Ephemeral by design — a server restart voids pending codes, never seats. diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 0129931..618a42e 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1375,6 +1375,11 @@ onchange={(e) => setPref("flourishes", e.currentTarget.checked)} /> Spell flourishes (the animated effects) +