Watch the whole game from the deal
The seed + command log has been the whole game all along; now it can be watched. Finished games offer "⟲ Watch the whole game" beside the face-up hands: the server replays every command from seq 0 through the same redacted catch-up pipeline (the 200-step window remains a live-game courtesy; full replays wait for the game to finish), and the reel plays it titled "The whole tale, from the deal" with new 1×/2×/4× speed controls for the long tellings. Each viewer rewatches from their own seat — hidden information stays hidden in the retelling exactly as it was at the table. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
37c38d5c1a
commit
cdcd1de71a
@@ -293,7 +293,7 @@ wss.on("connection", (socket) => {
|
|||||||
return send(socket, { type: "error", message: "catching up already — one moment" });
|
return send(socket, { type: "error", message: "catching up already — one moment" });
|
||||||
}
|
}
|
||||||
session.lastCatchUpAt = now;
|
session.lastCatchUpAt = now;
|
||||||
const steps = catchUpSteps(room, session.playerId, Number(msg.sinceSeq ?? 0));
|
const steps = catchUpSteps(room, session.playerId, Number(msg.sinceSeq ?? 0), msg.full === true);
|
||||||
if ("error" in steps) return send(socket, { type: "error", message: steps.error });
|
if ("error" in steps) return send(socket, { type: "error", message: steps.error });
|
||||||
send(socket, { type: "catchUp", steps });
|
send(socket, { type: "catchUp", steps });
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -268,11 +268,12 @@ export interface CatchUpStep {
|
|||||||
* Rebuild the game and capture a redacted view after each command from
|
* Rebuild the game and capture a redacted view after each command from
|
||||||
* `sinceSeq` on — the "what happened while you were away" reel.
|
* `sinceSeq` on — the "what happened while you were away" reel.
|
||||||
*/
|
*/
|
||||||
export function catchUpSteps(room: Room, playerId: PlayerId, sinceSeq: number): CatchUpStep[] | { error: string } {
|
export function catchUpSteps(room: Room, playerId: PlayerId, sinceSeq: number, full = false): CatchUpStep[] | { error: string } {
|
||||||
if (!room.state) return { error: "game not started" };
|
if (!room.state) return { error: "game not started" };
|
||||||
if (!room.players.includes(playerId)) return { error: "you hold no seat in this room" };
|
if (!room.players.includes(playerId)) return { error: "you hold no seat in this room" };
|
||||||
|
if (full && room.state.phase !== "finished") return { error: "full replays wait for the game to finish" };
|
||||||
const MAX_STEPS = 200;
|
const MAX_STEPS = 200;
|
||||||
const from = Math.max(sinceSeq, room.log.length - MAX_STEPS);
|
const from = full ? 0 : Math.max(sinceSeq, room.log.length - MAX_STEPS);
|
||||||
const { state: fresh } = createGame(room.state.config);
|
const { state: fresh } = createGame(room.state.config);
|
||||||
let current = fresh;
|
let current = fresh;
|
||||||
const steps: CatchUpStep[] = [];
|
const steps: CatchUpStep[] = [];
|
||||||
|
|||||||
@@ -1293,7 +1293,12 @@
|
|||||||
|
|
||||||
{#if view.phase === "finished" && view.revealedHands}
|
{#if view.phase === "finished" && view.revealedHands}
|
||||||
<div class="final-reveal" aria-label="all hands revealed">
|
<div class="final-reveal" aria-label="all hands revealed">
|
||||||
<div class="reveal-head">The hands, face-up</div>
|
<div class="reveal-head">
|
||||||
|
The hands, face-up
|
||||||
|
{#if !local.active}
|
||||||
|
<button class="stamp tiny" onclick={() => net.requestFullReplay()}>⟲ Watch the whole game</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{#each view.players as p (p.id)}
|
{#each view.players as p (p.id)}
|
||||||
<div class="reveal-row">
|
<div class="reveal-row">
|
||||||
<span class="reveal-name" class:reveal-winner={p.id === view.winner}>
|
<span class="reveal-name" class:reveal-winner={p.id === view.winner}>
|
||||||
@@ -1926,6 +1931,10 @@
|
|||||||
margin-bottom: 0.6rem;
|
margin-bottom: 0.6rem;
|
||||||
}
|
}
|
||||||
.reveal-head {
|
.reveal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
font-family: "Oswald", sans-serif;
|
font-family: "Oswald", sans-serif;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.12em;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
let idx = $state(0);
|
let idx = $state(0);
|
||||||
let playing = $state(true);
|
let playing = $state(true);
|
||||||
|
let speed = $state(1);
|
||||||
const step = $derived(steps[Math.min(idx, steps.length - 1)]!);
|
const step = $derived(steps[Math.min(idx, steps.length - 1)]!);
|
||||||
const lines = $derived(
|
const lines = $derived(
|
||||||
step.events.map(humanize).filter((l): l is string => l !== null),
|
step.events.map(humanize).filter((l): l is string => l !== null),
|
||||||
@@ -24,7 +25,7 @@
|
|||||||
const t = setInterval(() => {
|
const t = setInterval(() => {
|
||||||
if (idx < steps.length - 1) idx += 1;
|
if (idx < steps.length - 1) idx += 1;
|
||||||
else playing = false;
|
else playing = false;
|
||||||
}, 1500);
|
}, 1500 / speed);
|
||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@
|
|||||||
<div class="replay-scrim">
|
<div class="replay-scrim">
|
||||||
<div class="replay" role="dialog" aria-modal="true" aria-label="what happened while you were away">
|
<div class="replay" role="dialog" aria-modal="true" aria-label="what happened while you were away">
|
||||||
<header class="replay-head">
|
<header class="replay-head">
|
||||||
<span class="replay-title">While you were away</span>
|
<span class="replay-title">{steps[0]?.seq === 0 ? "The whole tale, from the deal" : "While you were away"}</span>
|
||||||
<span class="replay-count">move {idx + 1} of {steps.length}</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>
|
<button class="replay-skip" onclick={onclose}>{atEnd ? "back to the game" : "skip to now"}</button>
|
||||||
</header>
|
</header>
|
||||||
@@ -59,6 +60,9 @@
|
|||||||
{playing ? "❚❚" : "▶"}
|
{playing ? "❚❚" : "▶"}
|
||||||
</button>
|
</button>
|
||||||
<button onclick={() => { playing = false; idx = Math.min(steps.length - 1, idx + 1); }} aria-label="next move">▶</button>
|
<button onclick={() => { playing = false; idx = Math.min(steps.length - 1, idx + 1); }} aria-label="next move">▶</button>
|
||||||
|
{#each [1, 2, 4] as x (x)}
|
||||||
|
<button class="speed" class:current={speed === x} onclick={() => (speed = x)}>{x}×</button>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,6 +133,11 @@
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
min-height: 3.4rem;
|
min-height: 3.4rem;
|
||||||
}
|
}
|
||||||
|
.replay-controls .speed {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.replay-controls .speed.current { opacity: 1; text-decoration: underline; }
|
||||||
.replay-controls {
|
.replay-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -461,6 +461,11 @@ class Net {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Ask for the reel of everything since we last watched. */
|
/** Ask for the reel of everything since we last watched. */
|
||||||
|
/** The whole game from the deal, once it is finished. */
|
||||||
|
requestFullReplay(): void {
|
||||||
|
this.send({ type: "catchUp", sinceSeq: 0, full: true });
|
||||||
|
}
|
||||||
|
|
||||||
requestCatchUp(): void {
|
requestCatchUp(): void {
|
||||||
if (!this.roomId) return;
|
if (!this.roomId) return;
|
||||||
this.send({ type: "catchUp", sinceSeq: this.seen[this.roomId] ?? 0 });
|
this.send({ type: "catchUp", sinceSeq: this.seen[this.roomId] ?? 0 });
|
||||||
|
|||||||
Reference in New Issue
Block a user