Files
station-master/src/sim/display-step.ts
T
Jesse.MarkowitzandClaude Opus 5 02289e94b8 v0.8.0 — the board replays what everyone else did, instead of arriving rearranged
TODO #13, #15 and #18 — Gitea#20 steps 2-4 pointed at a seated player's own screen.
Every accepted intent, and every automatic phase that does anything, becomes an
ordered presentation step. A bot's whole switching turn used to land in one push;
now it arrives as a run of steps, the district panel follows whoever is acting,
and a [N behind] … [Skip] row says how far the board is from the game.

Solitaire runs the same path — one collector inside submit(), which both session
kinds already funnel through — which is where its automatic phases finally get a
visible beat.

Dwell is assigned by kind: switching holds the screen, turn bookkeeping costs
nothing, and the clock turning over earns the beat. Tunable per viewer without a
rebuild, and off entirely at pace 0.

Also: switching was the one class of action logging unattributed, and now names
its train. Reasoning, measurements and the three things that turned out wrong are
in CHANGELOG.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-09 15:31:46 -04:00

135 lines
6.4 KiB
TypeScript

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