Catch-up replays and named attention for async games

"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 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 01:26:27 -04:00
co-authored by Claude Fable 5
parent 4cbf5a013e
commit 295ad2ad55
5 changed files with 286 additions and 9 deletions
+11 -2
View File
@@ -17,6 +17,7 @@ import { extname, join, normalize, sep } from "node:path";
import { WebSocketServer, WebSocket } from "ws"; import { WebSocketServer, WebSocket } from "ws";
import type { Command, PlayerId } from "@wizwar/engine"; import type { Command, PlayerId } from "@wizwar/engine";
import { import {
catchUpSteps,
claimTransferCode, claimTransferCode,
createRoom, createRoom,
pickColor, pickColor,
@@ -122,7 +123,7 @@ function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): vo
function broadcastRoomState(room: Room): void { function broadcastRoomState(room: Room): void {
broadcast(room, (playerId) => roomInfo(room)); broadcast(room, (playerId) => roomInfo(room));
if (room.state) { 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); const result = runCommand(room, session.playerId, msg.command as Command);
if ("error" in result) return send(socket, { type: "error", message: result.error }); 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: "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; break;
} }
case "makeTransfer": { case "makeTransfer": {
@@ -213,6 +214,14 @@ wss.on("connection", (socket) => {
send(socket, { type: "transferClaimed", seat: result }); send(socket, { type: "transferClaimed", seat: result });
break; 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": { case "pickColor": {
const room = session.roomId ? getRoom(session.roomId) : undefined; const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
+46
View File
@@ -197,6 +197,8 @@ export interface GameSummary {
winner: PlayerId | null; winner: PlayerId | null;
activePlayerId: PlayerId | null; activePlayerId: PlayerId | null;
yourTurn: boolean; 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; round: number | null;
lastMoveAt: string | 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 active = s && s.phase === "playing" ? s.players[s.turn.activeIndex]!.id : null;
const waitingOn = s?.stack?.waitingOn ?? s?.pendingDiscard ?? s?.outOfTurnWindow?.playerId ?? null; const waitingOn = s?.stack?.waitingOn ?? s?.pendingDiscard ?? s?.outOfTurnWindow?.playerId ?? null;
const turnHolder = waitingOn ?? active; 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 { return {
roomId: room.id, roomId: room.id,
name: playerId, name: playerId,
@@ -216,6 +226,7 @@ export function summarize(room: Room, playerId: PlayerId): GameSummary {
winner: s?.winner ?? null, winner: s?.winner ?? null,
activePlayerId: active, activePlayerId: active,
yourTurn: s?.phase === "playing" && turnHolder === playerId, yourTurn: s?.phase === "playing" && turnHolder === playerId,
attention,
round: s?.turn.round ?? null, round: s?.turn.round ?? null,
lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : 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; 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[] { export function redactFor(events: GameEvent[], playerId: PlayerId): GameEvent[] {
return events.map((e) => redactEvent(e, playerId)).filter((e): e is GameEvent => e !== null); return events.map((e) => redactEvent(e, playerId)).filter((e): e is GameEvent => e !== null);
} }
+15 -2
View File
@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import { net } from "./net.svelte"; import { attentionLabel, net } from "./net.svelte";
import Board from "./Board.svelte"; import Board from "./Board.svelte";
import Card from "./Card.svelte"; import Card from "./Card.svelte";
import Help from "./Help.svelte"; import Help from "./Help.svelte";
import Replay from "./Replay.svelte";
import { local } from "./local.svelte"; import { local } from "./local.svelte";
import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey } from "@wizwar/engine"; import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey } from "@wizwar/engine";
import type { CardInstance, Side } from "@wizwar/engine"; import type { CardInstance, Side } from "@wizwar/engine";
@@ -494,6 +495,10 @@
<Help onclose={() => (showHelp = false)} /> <Help onclose={() => (showHelp = false)} />
{/if} {/if}
{#if net.catchUp && net.catchUp.length > 0}
<Replay steps={net.catchUp} onclose={() => net.closeCatchUp()} />
{/if}
{#if net.error} {#if net.error}
<div class="toast" role="alert">{net.error}</div> <div class="toast" role="alert">{net.error}</div>
{/if} {/if}
@@ -626,7 +631,7 @@
{:else if !g.started} {:else if !g.started}
waiting to start · {g.players.join(", ")} waiting to start · {g.players.join(", ")}
{:else if g.yourTurn} {:else if g.yourTurn}
YOUR TURN · round {g.round} · {timeAgo(g.lastMoveAt)} {attentionLabel(g.attention)} · round {g.round} · {timeAgo(g.lastMoveAt)}
{:else} {:else}
{g.activePlayerId}'s turn · round {g.round} · {timeAgo(g.lastMoveAt)} {g.activePlayerId}'s turn · round {g.round} · {timeAgo(g.lastMoveAt)}
{/if} {/if}
@@ -707,6 +712,13 @@
</section> </section>
<aside class="paper-rail"> <aside class="paper-rail">
{#if !local.active && net.missedMoves > 0}
<div class="slip catchup">
You missed {net.missedMoves} move{net.missedMoves === 1 ? "" : "s"}.
<button class="stamp tiny" onclick={() => net.requestCatchUp()}> Watch what happened</button>
<button class="hint-cancel" onclick={() => net.markSeen()}>skip</button>
</div>
{/if}
{#if view.phase === "finished"} {#if view.phase === "finished"}
<div class="slip winner">🏆 {view.winner} wins!</div> <div class="slip winner">🏆 {view.winner} wins!</div>
{:else if youMustRespond} {:else if youMustRespond}
@@ -1231,6 +1243,7 @@
} }
.slip.yours { border-left: 4px solid #2e7d32; } .slip.yours { border-left: 4px solid #2e7d32; }
.slip.urgent { border-left: 4px solid #b3372b; transform: rotate(0.4deg); } .slip.urgent { border-left: 4px solid #b3372b; transform: rotate(0.4deg); }
.slip.catchup { border-left: 4px solid #5b3f9e; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.slip.winner { .slip.winner {
font-family: "Oswald", sans-serif; font-family: "Oswald", sans-serif;
font-size: 1.25rem; font-size: 1.25rem;
+149
View File
@@ -0,0 +1,149 @@
<script lang="ts">
import Board from "./Board.svelte";
import { humanize } from "./net.svelte";
import type { GameEvent, GameView } from "@wizwar/engine";
let {
steps,
onclose,
}: {
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView }[];
onclose: () => void;
} = $props();
let idx = $state(0);
let playing = $state(true);
const step = $derived(steps[Math.min(idx, steps.length - 1)]!);
const lines = $derived(
step.events.map(humanize).filter((l): l is string => l !== null),
);
const atEnd = $derived(idx >= steps.length - 1);
$effect(() => {
if (!playing) return;
const t = setInterval(() => {
if (idx < steps.length - 1) idx += 1;
else playing = false;
}, 1500);
return () => clearInterval(t);
});
function onkeydown(e: KeyboardEvent) {
if (e.key === "Escape") onclose();
if (e.key === "ArrowRight") { playing = false; idx = Math.min(idx + 1, steps.length - 1); }
if (e.key === "ArrowLeft") { playing = false; idx = Math.max(idx - 1, 0); }
if (e.key === " ") { e.preventDefault(); playing = !playing; }
}
</script>
<svelte:window {onkeydown} />
<div class="replay-scrim">
<div class="replay" role="dialog" aria-modal="true" aria-label="what happened while you were away">
<header class="replay-head">
<span class="replay-title">While you were away</span>
<span class="replay-count">move {idx + 1} of {steps.length}</span>
<button class="replay-skip" onclick={onclose}>{atEnd ? "back to the game" : "skip to now"}</button>
</header>
<div class="replay-board">
<Board view={step.view} />
</div>
<div class="replay-caption">
<strong>{step.actor}</strong>
{#each lines as line, i (i)}<div>{line}</div>{/each}
{#if lines.length === 0}<div>…considers the maze.</div>{/if}
</div>
<div class="replay-controls">
<button onclick={() => { playing = false; idx = Math.max(0, idx - 1); }} aria-label="previous move"></button>
<button class="playpause" onclick={() => (playing = !playing)} aria-label={playing ? "pause" : "play"}>
{playing ? "❚❚" : "▶"}
</button>
<button onclick={() => { playing = false; idx = Math.min(steps.length - 1, idx + 1); }} aria-label="next move"></button>
</div>
</div>
</div>
<style>
.replay-scrim {
position: fixed;
inset: 0;
background: rgba(10, 12, 16, 0.88);
display: grid;
place-items: center;
z-index: 50;
padding: 1rem;
}
.replay {
background: #171a20;
border: 1px solid rgba(233, 225, 203, 0.25);
border-radius: 8px;
width: min(46rem, 100%);
max-height: calc(100dvh - 2rem);
display: flex;
flex-direction: column;
padding: 0.8rem 1rem 1rem;
color: #d8d2c0;
font-family: "Archivo Narrow", sans-serif;
}
.replay-head {
display: flex;
align-items: baseline;
gap: 0.8rem;
margin-bottom: 0.6rem;
}
.replay-title {
font-family: "Oswald", sans-serif;
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.85rem;
color: #e9e1cb;
}
.replay-count { font-size: 0.8rem; color: #8d8672; }
.replay-skip {
margin-left: auto;
background: none;
border: none;
color: #a49c86;
text-decoration: underline;
cursor: pointer;
font-size: 0.85rem;
}
.replay-board {
min-height: 0;
display: flex;
justify-content: center;
}
.replay-board :global(svg.board) {
max-height: calc(100dvh - 15rem);
width: auto;
max-width: 100%;
}
.replay-caption {
background: #efe8d4;
color: #3a2f1f;
border-radius: 3px;
padding: 0.5rem 0.75rem;
margin-top: 0.7rem;
font-family: "Courier Prime", monospace;
font-size: 0.78rem;
line-height: 1.45;
min-height: 3.4rem;
}
.replay-controls {
display: flex;
justify-content: center;
gap: 0.6rem;
margin-top: 0.6rem;
}
.replay-controls button {
background: #e9e1cb;
color: #43331f;
border: 1.5px solid #43331f;
border-radius: 3px;
min-width: 2.6rem;
padding: 0.35rem 0.6rem;
cursor: pointer;
font-size: 0.9rem;
}
.playpause { min-width: 3.4rem; }
</style>
+63 -3
View File
@@ -137,6 +137,12 @@ export function humanize(e: GameEvent): string | null {
const SEAT_KEY = "wizwar-seat"; const SEAT_KEY = "wizwar-seat";
const SEATS_KEY = "wizwar-seats"; const SEATS_KEY = "wizwar-seats";
const SEEN_KEY = "wizwar-seen";
function loadSeen(): Record<string, number> {
try { return JSON.parse(localStorage.getItem(SEEN_KEY) ?? "{}"); }
catch { return {}; }
}
export interface Seat { name: string; roomId: string; token: string } export interface Seat { name: string; roomId: string; token: string }
export interface GameSummary { export interface GameSummary {
@@ -148,10 +154,20 @@ export interface GameSummary {
winner: string | null; winner: string | null;
activePlayerId: string | null; activePlayerId: string | null;
yourTurn: boolean; yourTurn: boolean;
attention: "turn" | "counteract" | "discard" | "interrupt" | null;
round: number | null; round: number | null;
lastMoveAt: string | 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[] { function loadSeats(): Seat[] {
try { return JSON.parse(localStorage.getItem(SEATS_KEY) ?? "[]"); } try { return JSON.parse(localStorage.getItem(SEATS_KEY) ?? "[]"); }
catch { return []; } catch { return []; }
@@ -181,6 +197,12 @@ class Net {
); );
/** A transfer phrase we minted, to show the user. */ /** A transfer phrase we minted, to show the user. */
transferCode = $state<{ code: string; expiresAt: number } | null>(null); 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<string, number> = loadSeen();
private currentSeq = 0;
private lastYourTurn = new Map<string, boolean>(); private lastYourTurn = new Map<string, boolean>();
private pollTimer: ReturnType<typeof setInterval> | null = null; private pollTimer: ReturnType<typeof setInterval> | null = null;
@@ -237,8 +259,22 @@ class Net {
this.hostId = msg.hostId; this.hostId = msg.hostId;
this.started = msg.started; this.started = msg.started;
break; break;
case "state": case "state": {
this.view = msg.view; 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; break;
case "events": case "events":
for (const e of msg.events as GameEvent[]) { for (const e of msg.events as GameEvent[]) {
@@ -349,10 +385,15 @@ class Net {
if (!this.notificationsEnabled || typeof Notification === "undefined") return; if (!this.notificationsEnabled || typeof Notification === "undefined") return;
if (this.roomId === g.roomId && !document.hidden) return; // already looking at it if (this.roomId === g.roomId && !document.hidden) return; // already looking at it
try { try {
new Notification(`Wiz-War — your turn in ${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 ?? "?"}`, body: `${g.players.join(" vs ")} · round ${g.round ?? "?"}`,
tag: `wizwar-${g.roomId}`, tag: `wizwar-${g.roomId}`,
}); },
);
} catch { /* blocked at the OS level; the tab title still shows it */ } } catch { /* blocked at the OS level; the tab title still shows it */ }
} }
@@ -376,6 +417,25 @@ class Net {
this.send({ type: "pickColor", color }); 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 { command(command: Command): void {
this.send({ type: "command", command }); this.send({ type: "command", command });
} }