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 }); }