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
This commit is contained in:
Jesse.Markowitz
2026-09-09 15:31:46 -04:00
co-authored by Claude Opus 5
parent 312e0301e0
commit 02289e94b8
25 changed files with 2669 additions and 145 deletions
+134
View File
@@ -0,0 +1,134 @@
/**
* 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);
}
+2 -2
View File
@@ -155,7 +155,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
return {
tone: 'plain',
where: e.to,
text: `CREW moved ${at(e.from)} → ${at(e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`,
text: `Moved ${train(e.trayId)} ${at(e.from)} → ${at(e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`,
};
case 'carsCoupled': {
/**
@@ -181,7 +181,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
return {
tone: 'good',
where: e.at,
text: `SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], so the right car is now on the end and can be spotted`,
text: `Used the SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], so the right car is now on the end and can be spotted`,
};
case 'carsDropped':
// WHICH END. A cut comes off an outer end (§A.3) and the end decides everything that follows:
+167
View File
@@ -0,0 +1,167 @@
/**
* HOW LONG EACH STEP IS SHOWN — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 5.
*
* Shared rather than living in `src/web/`, so the 0.8.1 seatless board paces identically to a
* player's own screen. Two views of one game that disagreed about how fast it looks would be worse
* than either alone.
*
* WHY BY KIND RATHER THAN BY BUDGET. The obvious scheme is to give the whole backlog a time budget
* and divide it by the queue length. Measured against real games, that does exactly the wrong
* thing. From `public/replays/`: ~60 stages per game and ~5 intents per player per stage, so a
* four-player table produces ~15 other-player steps per stage — but 44 of a 307-intent game are
* `draw.end` and 60 are `loadUnload.end`, bookkeeping nobody wants to watch, while the thing that
* is worth watching is rare and clustered. Two of the three published replays contain no
* `switch.move` at all; the third has bursts of 14, 6, 6 and 6, and `trayMoved`'s own narration says
* "N of 6 Moves left" because six is the engine's cap per crew. So a uniform budget spends the
* player's attention on `draw.end` and rushes the switching.
*
* Assigning dwell by kind and letting the total fall out costs ~40s of animation across a whole
* 60-stage game, against ~3.6 minutes for a flat 700ms — better switching visibility for a fifth of
* the time. Jesse, 2026-09-09, on what matters: *"I definitely want to watch other players struggle
* with the switching exercises … I don't think reading the switching in the log will be anywhere
* nearly as interesting as watching the trains actually move on the board."*
*
* PACING IS CLIENT-SIDE ONLY. The server emits steps as fast as it likes and the client decides how
* to show them, which is what keeps Gitea#20's "do not slow the authoritative game" true.
*/
import type { StepCause } from './display-step.ts';
export type StepKind = 'switching' | 'action' | 'phase' | 'bookkeeping';
/**
* THE TUNING TABLE — dwell in milliseconds per kind.
*
* Start generous and tune down by playing; Jesse, 2026-09-09: *"start at 1s and tune down."* This is
* the committed default and changing it needs a web rebuild, which in the `.s9pk` is a release — so
* it is deliberately not the only way to change the pacing. A viewer's own `pace` multiplier
* (`Settings`, `localStorage`) and a `?pace=` URL parameter both scale these without one, and
* `pace = 0` turns the animation off entirely, which is also TODO #18's "a player who has seen it a
* hundred times will want it off".
*
* NOT IN GAME-CREATION SETTINGS, on Jesse's call 2026-09-09: dwell is presentation, not a rule, and
* `config` rides along in saves and replays. If it ever moves there, the config field supplies this
* table's multiplier — the table, the classification and the queue do not change.
*/
export const DWELL: Record<StepKind, number> = {
/** A train physically moving on the board. The thing worth watching, and protected accordingly. */
switching: 1000,
/** A card, a car or a load changing hands somewhere visible. */
action: 250,
/**
* An automatic phase that DID something — TODO #18.
*
* Only reached when the phase actually narrated: `submit()` collects no step for a phase that
* changed nothing, so this is never spent on the empty ones Jesse is content to guess at. Between
* an ordinary action and a switching move, because the Mainline phase moves trains the length of
* the Division and is the clearest case of "stuff just happened without being able to see how".
*/
phase: 600,
/** Turn and phase bookkeeping. Nothing moved; do not spend the player's attention on it. */
bookkeeping: 0,
};
/**
* Which kind an intent is.
*
* Exhaustive over `Intent['type']` on purpose — a `default` would silently drop a newly added intent
* into whatever tier the fallback names, and the failure mode is invisible (a move that never gets
* a beat, or bookkeeping that stalls the queue for a second). `test/pacing.test.ts` walks every
* member of the union so a new intent cannot land here unclassified.
*/
export function kindOf(cause: StepCause): StepKind {
switch (cause) {
// The Division advancing itself — New Train, the Mainline, the shift change (TODO #18).
case 'phase':
return 'phase';
// The crew and its train moving, coupling, setting out and re-ordering — §6.1 and Appendix A.
case 'switch.move':
case 'switch.dropCars':
case 'switch.sortConsist':
case 'maneuver.flyingSwitch':
case 'maneuver.redFlags':
return 'switching';
// Something visible changed hands or position, but no train drove anywhere.
case 'card.play':
case 'card.discard':
case 'draw.fromHomeOffice':
case 'draw.fromDepartment':
case 'newTrain.placeCar':
case 'newTrain.passCar':
case 'newTrain.secondSection':
case 'newTrain.startExtra':
case 'porter.board':
case 'porter.detrain':
case 'laborer.startLoad':
case 'laborer.advanceLoad':
case 'laborer.beginUnload':
case 'freightAgent.stockOutbound':
case 'freightAgent.clearInbound':
case 'freightAgent.unjam':
case 'mainline.clearance':
case 'mainline.modify':
case 'mainline.redFlag':
case 'mainline.yardOffice':
case 'redFlag.play':
return 'action';
// Ending a phase or a turn, choosing what to do, voting. The consequences are worth watching;
// the declaration itself is not, and there are more of these than of anything else.
case 'localOps.choose':
case 'loadUnload.end':
case 'draw.end':
case 'switch.end':
case 'freightAgent.end':
case 'game.extend':
return 'bookkeeping';
}
}
/** How long to show one step, in ms, at a given speed. `pace` of 0 means "do not animate at all". */
export function dwellFor(cause: StepCause, pace = 1): number {
return Math.round(DWELL[kindOf(cause)] * Math.max(0, pace));
}
/**
* How long to show one STEP — the form the queue actually uses.
*
* A step that said nothing gets no dwell, whatever caused it. That is one rule covering two cases
* arrived at separately: a phase where nothing happened (Jesse, 2026-09-09 — *"if nothing happens
* during a phase then we shouldn't lose time to it"*), and a phase that only handed the turn on,
* which changes the board but has nothing on it to look at. Structurally typed so this file does not
* have to import `DisplayStep` back from the module that imports `StepCause` from it.
*/
export function dwellForStep(
step: { cause: StepCause; lines: readonly unknown[]; frame: { table: object } },
pace = 1,
): number {
if (step.lines.length > 0) return dwellFor(step.cause, pace);
/**
* A SILENT STEP EARNS A BEAT ONLY WHEN THE CLOCK TURNED OVER — which is TODO #18 exactly: "give
* every phase a visible beat", for New Train, the Mainline and the shift change.
*
* Measured, because the obvious rule was wrong twice. "No narration, no dwell" looked right and
* silently killed #18: a phase can move trains without saying anything, and those steps were being
* flashed past. "Anything that changed the board" is wrong the other way: `submit()` steps
* `advance()` about 4.6 times per intent and most of those merely hand the turn on, so beating on
* all of them would cost a quarter of an hour a game. The phase turning over is the thing a player
* is being shown, and there are about 180 of those in a full game.
*/
const table = step.frame.table as Record<string, unknown>;
const turned = 'phase' in table || 'phaseKey' in table || 'stage' in table || 'day' in table;
return turned ? dwellFor(step.cause, pace) : 0;
}
/**
* How many steps in a queue are actually going to be WATCHED.
*
* This is the number the "N behind" counter shows, and it is deliberately not `queue.length`. With
* bookkeeping dwelling at zero, a backlog of 17 where 12 are `*.end` would read "17", plummet to 5
* the instant it started, and then crawl — which is not the steady countdown the counter is for.
* Thirteen dwelling steps means thirteen things you are going to see.
*/
export function watchableCount(causes: readonly StepCause[], pace = 1): number {
return causes.filter((c) => dwellFor(c, pace) > 0).length;
}
+132
View File
@@ -0,0 +1,132 @@
/**
* Delta for the SEATLESS public frame — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 3.
*
* `frame-delta.ts` solves the same-shaped problem for a seated player's `Frame` and does NOT carry
* over, which is worth saying plainly because reusing it looks obvious and is wrong. It nulls three
* TOP-LEVEL keys — `cells`, `facilities`, `division` — and a `PublicFrame` has only the last of
* those. Its `cells` and `facilities` live one level down, inside `districts[]`, one entry per seat,
* and that is where nearly all of the bytes are.
*
* **So the districts are deltaed PER SEAT rather than as one array.** One accepted intent changes
* one district; comparing the whole array as a unit would resend every other player's board on
* every step, which is exactly the cost this exists to avoid. On a four-player table that is three
* boards of waste per step, and a step is emitted for every bot move as well as every human one.
*
* It is also a TRUE PARTIAL rather than a full frame with holes in it, which is the other place
* `frame-delta.ts` does not carry over. See `PublicFrameDelta` below for the measurement that forced
* that; in short, most steps change one field and shipping the other thirty-four cost 16.7 MB a game.
*
* The convention that does carry over, kept identical so a reader of one file can read the other:
* an absent or `null` field means "unchanged since the last thing sent to this receiver", and the
* receiving side merges against the last full frame it actually holds. A first connect or a
* reconnect after a gap sends a full frame instead — the display stream resets rather than replaying
* (§ v0.8.0).
*
* Node-free by design, like `frame-delta.ts`: the server and the browser both import this directly.
*/
import type { CellView, DivisionView, FacilityView, PublicDistrict, PublicFrame } from './view.ts';
/**
* One district with its two heavy fields nulled when unchanged.
*
* `seat` is the identity and is always present — it is what the receiver matches on. `player` and
* `name` are always sent too, and deliberately: Employee Rotation moves players between districts,
* so the pairing of seat to player is itself news, and it costs two small fields to never have to
* reason about whether a relabelling was missed.
*/
export type PublicDistrictDelta = Omit<PublicDistrict, 'cells' | 'facilities'> & {
cells: CellView[] | null;
facilities: FacilityView[] | null;
};
/** The shared-table half of a `PublicFrame` — everything that is not the Division or a district. */
type PublicTable = Omit<PublicFrame, 'division' | 'districts'>;
/**
* A `PublicFrame` reduced to WHAT CHANGED.
*
* **Partial, not a full frame with holes**, and that distinction was measured rather than assumed.
* The first version of this spread `...next` and nulled only the board fields, so every step shipped
* all 35 top-level properties even when the sole change was whose turn it was. Once TODO #18 gave
* automatic phases their own steps, most steps became exactly that — a turn handed on, nothing to
* look at — and a full 6-day game cost **19.4 MB**, of which **16.7 MB was those silent steps at
* ~11 KB each**. As a partial they are a few dozen bytes.
*/
export type PublicFrameDelta = {
/** Only the shared-table fields whose value differs from the previous frame. */
table: Partial<PublicTable>;
/** The Division, only when it changed. */
division: DivisionView[] | null;
/** Only the districts that changed, each carrying only the board fields that changed. */
districts: PublicDistrictDelta[];
};
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
const TABLE_KEYS = (frame: PublicFrame): (keyof PublicTable)[] =>
(Object.keys(frame) as (keyof PublicFrame)[]).filter(
(k): k is keyof PublicTable => k !== 'division' && k !== 'districts',
);
/**
* `previous` is the last public frame actually sent to THIS receiver, or `null` for a first connect
* or a reset — in which case everything is sent in full.
*/
export function deltaPublicFrame(previous: PublicFrame | null, next: PublicFrame): PublicFrameDelta {
const before = new Map(previous?.districts.map((d) => [d.seat, d]) ?? []);
const table: Partial<PublicTable> = {};
for (const key of TABLE_KEYS(next)) {
if (previous === null || !same(previous[key], next[key])) {
(table as Record<string, unknown>)[key] = next[key];
}
}
const districts: PublicDistrictDelta[] = [];
for (const d of next.districts) {
const was = before.get(d.seat);
const cells = was && same(was.cells, d.cells) ? null : d.cells;
const facilities = was && same(was.facilities, d.facilities) ? null : d.facilities;
// A district with nothing new is left out entirely rather than sent as a row of nulls: on a
// four-player table three of them are unchanged on every single step.
if (was && cells === null && facilities === null && same(was, d)) continue;
districts.push({ ...d, cells, facilities });
}
return {
table,
division: previous !== null && same(previous.division, next.division) ? null : next.division,
districts,
};
}
/** The receiving side: merges a delta back onto the last full public frame this receiver holds. */
export function applyPublicDelta(previous: PublicFrame | null, delta: PublicFrameDelta): PublicFrame {
const base = previous ?? (delta.table as PublicTable);
const merged = { ...base, ...delta.table } as PublicTable;
const bySeat = new Map((previous?.districts ?? []).map((d) => [d.seat, d]));
for (const d of delta.districts) {
const was = bySeat.get(d.seat);
bySeat.set(d.seat, {
...d,
cells: d.cells ?? need(was?.cells, `districts[seat ${d.seat}].cells`),
facilities: d.facilities ?? need(was?.facilities, `districts[seat ${d.seat}].facilities`),
});
}
return {
...merged,
division: delta.division ?? need(previous?.division, 'division'),
districts: [...bySeat.values()].sort((a, b) => a.seat - b.seat),
};
}
/**
* A delta that says "unchanged" against a receiver that has nothing to merge onto is a bug in the
* SENDER's bookkeeping, not a recoverable state — it means the two sides disagree about what has
* been delivered, and quietly producing a frame with a missing board would put a blank district in
* front of a player. `frame-delta.ts` throws in the same situation and for the same reason.
*/
function need<T>(value: T | undefined, what: string): T {
if (value === undefined) {
throw new Error(`deltaPublicFrame said "${what}" is unchanged, but there is no previous frame to merge onto`);
}
return value;
}