From 295ad2ad552eb62aa86b0a0533eca7d60713fc43 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 16 Aug 2026 01:26:27 -0400 Subject: [PATCH] Catch-up replays and named attention for async games MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "While you were away": returning to a game with unseen moves shows a banner — "You missed N moves. Watch what happened" — that opens a replay reel. The server rebuilds the game and captures a redacted per-move view for the viewer (own hand only, capped at the last 200 moves); the client plays the reel on a full board with the actor and humanized events captioned per step, auto-advancing with pause, step-back/forward, arrow-key control, and skip-to-now. Seen progress is tracked per room in the browser (every state broadcast now carries the log sequence), so the banner only appears when there is genuinely something to watch. Attention between turns is now named, not just signaled: game summaries carry WHY a game waits on you — your turn, counteract (you're being attacked mid-someone-else's-turn), forced discard, or a pending interruption — the ledger prints it ("UNDER ATTACK — respond!"), and browser notifications say "you are under attack in GNSK!" rather than a generic your-turn. Co-Authored-By: Claude Fable 5 --- packages/server/src/index.ts | 13 ++- packages/server/src/rooms.ts | 46 ++++++++++ packages/web/src/App.svelte | 17 +++- packages/web/src/Replay.svelte | 149 +++++++++++++++++++++++++++++++++ packages/web/src/net.svelte.ts | 70 ++++++++++++++-- 5 files changed, 286 insertions(+), 9 deletions(-) create mode 100644 packages/web/src/Replay.svelte 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 @@ + + + +
+ +
+ + diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 0ed08c9..37a8a6e 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -137,6 +137,12 @@ export function humanize(e: GameEvent): string | null { const SEAT_KEY = "wizwar-seat"; const SEATS_KEY = "wizwar-seats"; +const SEEN_KEY = "wizwar-seen"; + +function loadSeen(): Record { + try { return JSON.parse(localStorage.getItem(SEEN_KEY) ?? "{}"); } + catch { return {}; } +} export interface Seat { name: string; roomId: string; token: string } export interface GameSummary { @@ -148,10 +154,20 @@ export interface GameSummary { winner: string | null; activePlayerId: string | null; yourTurn: boolean; + attention: "turn" | "counteract" | "discard" | "interrupt" | null; round: number | null; lastMoveAt: string | null; } +export function attentionLabel(a: GameSummary["attention"]): string { + switch (a) { + case "counteract": return "UNDER ATTACK — respond!"; + case "discard": return "DISCARD to your hand limit"; + case "interrupt": return "your interruption is waiting"; + default: return "YOUR TURN"; + } +} + function loadSeats(): Seat[] { try { return JSON.parse(localStorage.getItem(SEATS_KEY) ?? "[]"); } catch { return []; } @@ -181,6 +197,12 @@ class Net { ); /** A transfer phrase we minted, to show the user. */ transferCode = $state<{ code: string; expiresAt: number } | null>(null); + /** Moves you haven't watched yet in the current room. */ + missedMoves = $state(0); + /** A catch-up reel delivered by the server. */ + catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView }[] | null>(null); + private seen: Record = loadSeen(); + private currentSeq = 0; private lastYourTurn = new Map(); private pollTimer: ReturnType | null = null; @@ -237,8 +259,22 @@ class Net { this.hostId = msg.hostId; this.started = msg.started; break; - case "state": + case "state": { this.view = msg.view; + if (typeof msg.seq === "number" && this.roomId) { + this.currentSeq = msg.seq; + const last = this.seen[this.roomId] ?? 0; + this.missedMoves = Math.max(0, msg.seq - last); + // Watching live counts as seeing; only a fresh arrival has a gap. + if (this.missedMoves === 0 || document.visibilityState === "visible") { + // A live update while present marks itself seen. + if (last >= msg.seq - 1) this.markSeen(); + } + } + break; + } + case "catchUp": + this.catchUp = msg.steps; break; case "events": for (const e of msg.events as GameEvent[]) { @@ -349,10 +385,15 @@ class Net { if (!this.notificationsEnabled || typeof Notification === "undefined") return; if (this.roomId === g.roomId && !document.hidden) return; // already looking at it try { - new Notification(`Wiz-War — your turn in ${g.roomId}`, { - body: `${g.players.join(" vs ")} · round ${g.round ?? "?"}`, - tag: `wizwar-${g.roomId}`, - }); + new Notification( + g.attention === "counteract" + ? `Wiz-War — you are under attack in ${g.roomId}!` + : `Wiz-War — your turn in ${g.roomId}`, + { + body: `${g.players.join(" vs ")} · round ${g.round ?? "?"}`, + tag: `wizwar-${g.roomId}`, + }, + ); } catch { /* blocked at the OS level; the tab title still shows it */ } } @@ -376,6 +417,25 @@ class Net { this.send({ type: "pickColor", color }); } + /** Ask for the reel of everything since we last watched. */ + requestCatchUp(): void { + if (!this.roomId) return; + this.send({ type: "catchUp", sinceSeq: this.seen[this.roomId] ?? 0 }); + } + + /** All caught up: remember it and clear the banner. */ + markSeen(): void { + if (!this.roomId) return; + this.seen[this.roomId] = this.currentSeq; + localStorage.setItem(SEEN_KEY, JSON.stringify(this.seen)); + this.missedMoves = 0; + } + + closeCatchUp(): void { + this.catchUp = null; + this.markSeen(); + } + command(command: Command): void { this.send({ type: "command", command }); }