The instant replay: a chronicle line's eye relives the turn, firsthand

The dream feature lands, behind a preference that ships off for now.
The chronicle's lines grow structure — each knows its turn, counted by
the same boundary events on both ends of the wire (the server counts
the deal's own events too, or every number would sit one turn behind).
The first notable line of a turn — combat, spectacle, a targeted cast
— wears a small eye; clicking it asks the server for that one turn's
steps, rebuilt and redacted like any reel, and the replay opens
STRAIGHT into first person through the eyes of the wizard whose turn
it was: their position, the viewer's knowledge, nothing private leaked.
It plays that turn and stops. Save-video and the board toggle come
along for free.

Two camera bugs die with it: the replay tween effect tracked its own
writes, restarting the ease every frame until walks decayed into the
slow wall-piercing drift Eric saw — untracked reads run one tween per
step now. And the reel camera follows whichever eyes the reel wears,
not always your own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-23 16:05:07 -04:00
co-authored by Claude Fable 5
parent 68be32c43e
commit f52d0d88a9
6 changed files with 196 additions and 21 deletions
+47
View File
@@ -416,6 +416,53 @@ export function redactFor(events: GameEvent[], playerId: PlayerId): GameEvent[]
return events.map((e) => redactEvent(e, playerId)).filter((e): e is GameEvent => e !== null);
}
/** The events that begin a turn — the client's chronicle counts these
* identically, so a turn number names the same stretch on both ends. */
const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]);
/**
* One turn's reel: every command whose events touch turn `turnIndex`
* (0-counted across boundary events). A command that ends one turn and
* starts the next belongs to both, so a reel opens with the blow that
* began it and closes on the handover.
*/
export function momentSteps(room: Room, playerId: PlayerId, turnIndex: 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 = 80;
const { state: fresh, events: dealt } = createGame(room.state.config);
let current = fresh;
// The deal's own events open the first turn — count them, or every
// turn number would sit one behind the chronicle's.
let counter = -1;
for (const e of dealt) if (TURN_BOUNDARY.has(e.type)) counter++;
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;
const before = counter;
for (const e of result.events) if (TURN_BOUNDARY.has(e.type)) counter++;
if (before > turnIndex) break;
if (before <= turnIndex && turnIndex <= counter) {
const prevAt = entry.seq > 0 ? room.log[entry.seq - 1]!.at : "";
const said = room.chat
.filter((c) => c.at > prevAt && c.at <= entry.at)
.map((c) => ({ player: c.player, text: c.text }));
steps.push({
seq: entry.seq,
actor: entry.playerId,
events: redactFor(result.events, playerId),
view: viewFor(current, playerId),
...(said.length > 0 ? { chat: said } : {}),
});
if (steps.length >= MAX_STEPS) break;
}
}
if (steps.length === 0) return { error: "no such turn yet" };
return steps;
}
// ---------------------------------------------------------------------------
// Seat transfers: a spoken-word one-time code hands a seat to another device.
// Ephemeral by design — a server restart voids pending codes, never seats.