The camera always wears the turn-taker's eyes, and knows whose turn zero is

Eric's rule, applied everywhere: every reel — moment, catch-up, whole
tale — follows the wizard whose turn it is, the pov switching at each
handover as boundaries scroll past. And the J8LL mystery dies with it:
turn zero's opening boundary lives in the DEAL, before any command, so
scanning a moment's steps found the NEXT turn's start instead and put
the camera in the wrong wizard's head. momentSteps now names the owner
from the very boundary that opened the turn — counted through the deal
— and carries it to the client and the share page, where it also fixes
the same latent mis-titling.

Also aboard: the cutaway shot. When the eyes aim at something they
cannot see — a wraith summoned across the maze, a spell through walls —
the camera cuts to the target's cell for that beat, standing back down
its deepest corridor, and cuts home on the next step. The wraith no
longer materializes off-screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-23 18:56:02 -04:00
co-authored by Claude Fable 5
parent 6788de2f6e
commit a28d27eedc
6 changed files with 79 additions and 41 deletions
+8 -19
View File
@@ -117,20 +117,9 @@ function shareData(id: string): ShareData | null {
const share = getShare(id);
const room = share ? getRoom(share.roomId) : undefined;
if (share && room?.state) {
const steps = momentSteps(room, SPECTATOR, share.turn);
if (!("error" in steps) && steps.length > 0) {
let actor: string = steps[steps.length - 1]!.actor;
let round = 0;
outer: for (const st of steps) {
for (const e of st.events) {
if (e.type === "turnStarted" || e.type === "extraTurnStarted") {
actor = e.player;
if (e.type === "turnStarted") round = e.round;
break outer;
}
}
}
data = { steps: steps as unknown as ShareData["steps"], actor, round };
const reel = momentSteps(room, SPECTATOR, share.turn);
if (!("error" in reel) && reel.steps.length > 0) {
data = { steps: reel.steps as unknown as ShareData["steps"], actor: reel.owner, round: reel.round };
}
}
if (shareCache.size > 50) shareCache.clear();
@@ -624,8 +613,8 @@ wss.on("connection", (socket) => {
return send(socket, { type: "error", message: "one moment" });
}
session.lastCatchUpAt = now;
const steps = momentSteps(room, session.playerId, turn);
if ("error" in steps) return send(socket, { type: "error", message: steps.error });
const reel = momentSteps(room, session.playerId, turn);
if ("error" in reel) return send(socket, { type: "error", message: reel.error });
const share = mintShare(room.id, turn);
send(socket, { type: "share", id: share.id, turn });
break;
@@ -639,9 +628,9 @@ wss.on("connection", (socket) => {
return send(socket, { type: "error", message: "catching up already — one moment" });
}
session.lastCatchUpAt = now;
const steps = momentSteps(room, session.playerId, Number(msg.turn ?? -1));
if ("error" in steps) return send(socket, { type: "error", message: steps.error });
send(socket, { type: "moment", steps });
const reel = momentSteps(room, session.playerId, Number(msg.turn ?? -1));
if ("error" in reel) return send(socket, { type: "error", message: reel.error });
send(socket, { type: "moment", steps: reel.steps, owner: reel.owner });
break;
}
case "pickColor": {
+24 -5
View File
@@ -420,13 +420,22 @@ export function redactFor(events: GameEvent[], playerId: PlayerId): GameEvent[]
* identically, so a turn number names the same stretch on both ends. */
const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]);
export interface MomentReel {
steps: CatchUpStep[];
/** The wizard whose turn this is — the reel's eyes. */
owner: PlayerId;
round: number;
}
/**
* 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.
* began it and closes on the handover. The owner comes from the very
* boundary that opened the turn — which for turn 0 lives in the DEAL,
* before any command, so no scan of the steps could find it.
*/
export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number): CatchUpStep[] | { error: string } {
export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number): MomentReel | { error: string } {
if (!room.state) return { error: "game not started" };
// The SPECTATOR builds share pages: public knowledge only, no seat.
if (playerId !== SPECTATOR && !room.players.includes(playerId)) {
@@ -438,14 +447,24 @@ export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number):
// 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++;
let owner: PlayerId | null = null;
let round = 0;
const bump = (e: GameEvent) => {
if (!TURN_BOUNDARY.has(e.type)) return;
counter++;
if (counter === turnIndex && owner === null && "player" in e) {
owner = (e as { player: PlayerId }).player;
if ("round" in e) round = (e as { round: number }).round;
}
};
for (const e of dealt) bump(e);
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++;
for (const e of result.events) bump(e);
if (before > turnIndex) break;
if (before <= turnIndex && turnIndex <= counter) {
const prevAt = entry.seq > 0 ? room.log[entry.seq - 1]!.at : "";
@@ -463,7 +482,7 @@ export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number):
}
}
if (steps.length === 0) return { error: "no such turn yet" };
return steps;
return { steps, owner: owner ?? steps[steps.length - 1]!.actor, round };
}
// ---------------------------------------------------------------------------