/** * THE DISPLAY-STEP COLLECTOR — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 §§ 1-3. * * One ordered, watchable presentation step per accepted intent, so a player can see what everyone * else did instead of finding the board already rearranged. TODO #13: *"It's not fun to do my turn * and have magic happen in the background and then have to figure out what others did."* * * ONE HOOK, NOT TWO. The design anticipated wiring this into `GameSession.intent()` and * `GameSession.driveBots()` separately, with `LocalSession` doing its own thing for solitaire. It * does not need to: `src/server/session.ts` imports `submit` from `src/web/game.ts`, so solitaire, * live multiplayer and every bot turn already funnel through ONE function. Collecting there is what * makes solitaire a special case of multiplayer rather than a second implementation, which is the * standing design direction for this codebase. * * AND REPLAY IS INERT FOR FREE. `fromSave` and `fromMultiplayerSave` rebuild a game by calling * `applyIntent` + `record` + `drain` directly rather than `submit`, so a resumed server or a rebuilt * undo does NOT re-emit the whole game as steps. That was expected to need an explicit guard — the * plan calls it out as the same class of bug as #97, a mechanism firing on a path nobody pictured * it running on. It needs none, but the property is load-bearing: **if a replay path is ever moved * onto `submit()`, this becomes a real bug**, and `test/watchable.test.ts` pins it. * * WHAT A STEP IS. One accepted intent, or ONE AUTOMATIC PHASE — never one per `GameEvent`, because * the event list is not a complete reducer and a receiver could not rebuild state from it. It gets a * projected frame instead. * * PHASES EARN THEIR OWN STEPS, and that is TODO #18. `pump()` runs every automatic phase between one * click and the next and `drain()` records the whole batch at once, so New Train, the Mainline and * the shift change "look like they are being skipped entirely" — trains cross the Division in one * jump. Folding them into the triggering intent's step reproduces exactly that. So `submit()` steps * `advance()` one call at a time instead, and collects a step for each phase that actually DID * something. A phase that did nothing adds no narration and therefore produces no step at all, which * is Jesse's own rule (2026-09-09): "if nothing happens during a phase then we shouldn't lose time * to it." * * `drain()` is deliberately NOT changed. Replay, undo and `fromSave` all use it, and the * replay-inertness property below depends on their staying off this path. The stepped version lives * in `submit()` and makes the same `advance()` calls in the same order, so the resulting state is * identical — only the collection differs. */ import type { Intent } from '../engine/intents.ts'; import type { GameState, PlayerIndex, SeatIndex } from '../engine/state.ts'; import { seatOf } from '../engine/state.ts'; import { publicSnapshot } from './view.ts'; import type { PublicFrame } from './view.ts'; import { deltaPublicFrame } from './public-delta.ts'; import type { PublicFrameDelta } from './public-delta.ts'; /** * The wire format's version, on the ENVELOPE rather than on the projection. * * The plan's original sketch put `protocolVersion` inside `PublicFrame`. It does not belong there: * `PublicFrame` is a projection of the game and its property list is an allow-list that * `test/redaction.test.ts` enumerates, so a transport concern living in it would have to be * allow-listed as public game state, which it is not. The step is the message; the message carries * the version. */ export const DISPLAY_PROTOCOL_VERSION = 1; /** What produced a step: somebody's intent, or the Division advancing a phase by itself. */ export type StepCause = Intent['type'] | 'phase'; /** One watchable thing that happened, in order. */ export type DisplayStep = { protocolVersion: typeof DISPLAY_PROTOCOL_VERSION; /** Monotonic per game. 0.8.1's reconnecting display stream needs it to detect a gap; a queue only needs the order. */ seq: number; /** * Who acted — NULL for an automatic phase, which nobody did. * * Both are carried because Employee Rotation makes "which seat" and "which player" different * questions. */ player: PlayerIndex | null; seat: SeatIndex | null; /** What caused it — the input to pacing's kind classification. */ cause: StepCause; /** The public board after this intent and everything it drained, against the previous step. */ frame: PublicFrameDelta; /** The narration this intent added, in order, including any phase lines drained behind it. */ lines: { text: string; tone: string }[]; }; /** * Per-game collector state. * * Held on `Game` beside `log`, `cues` and `announced` and drained the same way, which is the * established convention in this codebase for "the model accumulated something, the view takes it". */ export type DisplayCollector = { /** Undrained steps, oldest first. */ steps: DisplayStep[]; /** The last public frame a step was built against, so the next delta has something to diff. */ last: PublicFrame | null; /** Next sequence number to assign. */ seq: number; }; export function newCollector(): DisplayCollector { return { steps: [], last: null, seq: 0 }; } /** * Record one accepted intent as a step. * * Called from `submit()` AFTER `record()` and `drain()`, so `state` is the position the intent * finally produced and `lines` is everything it caused to be said. The frame is projected * immediately and never from a retained `GameState` reference — a retained reference would resolve * to the FINAL state of a whole bot run, which is exactly the teleporting this exists to prevent. */ export function collectStep( collector: DisplayCollector, state: GameState, player: PlayerIndex | null, cause: StepCause, lines: { text: string; tone: string }[], ): void { const next = publicSnapshot(state); collector.steps.push({ protocolVersion: DISPLAY_PROTOCOL_VERSION, seq: collector.seq++, player, seat: player === null ? null : seatOf(state, player), cause, frame: deltaPublicFrame(collector.last, next), lines, }); collector.last = next; } /** Take everything collected so far, leaving the collector empty — `takeMoment()`'s pattern. */ export function takeSteps(collector: DisplayCollector): DisplayStep[] { return collector.steps.splice(0, collector.steps.length); }