diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index c94b6b0..1af2a91 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -17,6 +17,7 @@ import { extname, join, normalize, sep } from "node:path";
import { WebSocketServer, WebSocket } from "ws";
import type { Command, PlayerId } from "@wizwar/engine";
import {
+ catchUpSteps,
claimTransferCode,
createRoom,
pickColor,
@@ -122,7 +123,7 @@ function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): vo
function broadcastRoomState(room: Room): void {
broadcast(room, (playerId) => roomInfo(room));
if (room.state) {
- broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId) }));
+ broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
}
}
@@ -189,7 +190,7 @@ wss.on("connection", (socket) => {
const result = runCommand(room, session.playerId, msg.command as Command);
if ("error" in result) return send(socket, { type: "error", message: result.error });
broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) }));
- broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId) }));
+ broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
break;
}
case "makeTransfer": {
@@ -213,6 +214,14 @@ wss.on("connection", (socket) => {
send(socket, { type: "transferClaimed", seat: result });
break;
}
+ case "catchUp": {
+ const room = session.roomId ? getRoom(session.roomId) : undefined;
+ if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
+ const steps = catchUpSteps(room, session.playerId, Number(msg.sinceSeq ?? 0));
+ if ("error" in steps) return send(socket, { type: "error", message: steps.error });
+ send(socket, { type: "catchUp", 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 fdf6922..109278e 100644
--- a/packages/server/src/rooms.ts
+++ b/packages/server/src/rooms.ts
@@ -197,6 +197,8 @@ export interface GameSummary {
winner: PlayerId | null;
activePlayerId: PlayerId | null;
yourTurn: boolean;
+ /** WHY it is your turn: a normal turn, or an out-of-turn demand. */
+ attention: "turn" | "counteract" | "discard" | "interrupt" | null;
round: number | null;
lastMoveAt: string | null;
}
@@ -207,6 +209,14 @@ export function summarize(room: Room, playerId: PlayerId): GameSummary {
const active = s && s.phase === "playing" ? s.players[s.turn.activeIndex]!.id : null;
const waitingOn = s?.stack?.waitingOn ?? s?.pendingDiscard ?? s?.outOfTurnWindow?.playerId ?? null;
const turnHolder = waitingOn ?? active;
+ let attention: "turn" | "counteract" | "discard" | "interrupt" | null = null;
+ if (s?.phase === "playing" && turnHolder === playerId) {
+ attention =
+ s.stack?.waitingOn === playerId ? "counteract"
+ : s.pendingDiscard === playerId ? "discard"
+ : s.outOfTurnWindow?.playerId === playerId ? "interrupt"
+ : "turn";
+ }
return {
roomId: room.id,
name: playerId,
@@ -216,6 +226,7 @@ export function summarize(room: Room, playerId: PlayerId): GameSummary {
winner: s?.winner ?? null,
activePlayerId: active,
yourTurn: s?.phase === "playing" && turnHolder === playerId,
+ attention,
round: s?.turn.round ?? null,
lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : null,
};
@@ -225,6 +236,41 @@ export function viewForPlayer(room: Room, playerId: PlayerId): GameView | null {
return room.state ? viewFor(room.state, playerId) : null;
}
+export interface CatchUpStep {
+ seq: number;
+ actor: PlayerId;
+ events: GameEvent[];
+ view: GameView;
+}
+
+/**
+ * Rebuild the game and capture a redacted view after each command from
+ * `sinceSeq` on — the "what happened while you were away" reel.
+ */
+export function catchUpSteps(room: Room, playerId: PlayerId, sinceSeq: 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 = 200;
+ const from = Math.max(sinceSeq, room.log.length - MAX_STEPS);
+ const { state: fresh } = createGame(room.state.config);
+ let current = fresh;
+ 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;
+ if (entry.seq >= from) {
+ steps.push({
+ seq: entry.seq,
+ actor: entry.playerId,
+ events: redactFor(result.events, playerId),
+ view: viewFor(current, playerId),
+ });
+ }
+ }
+ return steps;
+}
+
export function redactFor(events: GameEvent[], playerId: PlayerId): GameEvent[] {
return events.map((e) => redactEvent(e, playerId)).filter((e): e is GameEvent => e !== null);
}
diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte
index 0129601..caf26da 100644
--- a/packages/web/src/App.svelte
+++ b/packages/web/src/App.svelte
@@ -1,8 +1,9 @@
+
+