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:
co-authored by
Claude Fable 5
parent
4cbf5a013e
commit
295ad2ad55
@@ -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" });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user