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
+4 -4
View File
@@ -1679,7 +1679,7 @@ describe('the Crew Tray is a train, and must be made up to leave (§8.2, Appendi
const build = (toNose: boolean): string[] => {
const s = game();
const id = placeTray(s, at(0, 0), [car('boxcar')] as never);
reduce(s, { type: 'carsCoupled', trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose });
reduce(s, { type: 'carsCoupled', player: 0, trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose });
return s.trays.get(id)!.consist.map((c) => c.type);
};
assert.deepEqual(build(true), ['hopper', 'boxcar'], 'running forward takes cars on the nose');
@@ -1694,11 +1694,11 @@ describe('the Crew Tray is a train, and must be made up to leave (§8.2, Appendi
const tray = s.trays.get(id)!;
tray.engineAt = 0;
reduce(s, { type: 'carsCoupled', trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose: true });
reduce(s, { type: 'carsCoupled', player: 0, trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose: true });
assert.equal(tray.engineAt, 1, 'the engine should now have a car ahead of it');
assert.deepEqual(tray.consist.map((c) => c.type), ['hopper', 'boxcar']);
reduce(s, { type: 'carsDropped', trayId: id, at: at(0, 0), stock: [car('hopper')] as never, fromNose: true });
reduce(s, { type: 'carsDropped', player: 0, trayId: id, at: at(0, 0), stock: [car('hopper')] as never, fromNose: true });
assert.equal(tray.engineAt, 0, 'setting out the nose cars puts the engine back in front');
assert.deepEqual(tray.consist.map((c) => c.type), ['boxcar']);
});
@@ -1823,7 +1823,7 @@ describe('the engine is drawn pointing east or west, whatever track it is standi
* So `facing` stays a PORT (movement needs one) and `railFacingOf` is what the board draws.
*/
const moved = (id: string, facing: 'n' | 's' | 'e' | 'w') =>
({ type: 'trayMoved', trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, facing }) as const;
({ type: 'trayMoved', player: 0, trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, facing }) as const;
it('carries the east-west sense across north-south track', () => {
const s = game();
+2 -1
View File
@@ -24,12 +24,13 @@ import { impediments, narrate } from '../src/sim/narrate.ts';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { actionMenu } from '../src/web/game.ts';
import { newCollector } from '../src/sim/display-step.ts';
import type { Game } from '../src/web/game.ts';
/** The thin wrapper `actionMenu` expects, built directly around an already-created multi-player state
* — `newGame` (game.ts) hardcodes one player, so it cannot construct this for a multi-seat game. */
const wrap = (s: GameState): Game =>
({ state: s, seed: s.seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null });
({ state: s, seed: s.seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null, display: newCollector() });
const competitive: GameConfig = {
mode: 'competitive',
+124
View File
@@ -0,0 +1,124 @@
/**
* DWELL BY KIND — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 5.
*
* The classification is exhaustive over `Intent['type']` at COMPILE time: `kindOf` declares a
* `StepKind` return and has no `default`, so a new intent breaks the build rather than landing
* silently in a fallback tier. These tests add the part the compiler cannot do — they read the
* intent union out of the source, so the guard survives someone later adding a `default:` that
* would swallow the very thing the exhaustiveness was protecting.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { DWELL, dwellFor, dwellForStep, kindOf, watchableCount } from '../src/sim/pacing.ts';
import type { StepKind } from '../src/sim/pacing.ts';
import type { Intent } from '../src/engine/intents.ts';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
/** Every `type: '…'` literal in the Intent union, read from the source rather than hand-listed. */
function declaredIntents(): string[] {
const src = readFileSync(join(root, 'src/engine/intents.ts'), 'utf8');
return [...new Set([...src.matchAll(/type: '([a-zA-Z.]+)'/g)].map((m) => m[1]!))].sort();
}
const KINDS: StepKind[] = ['switching', 'action', 'phase', 'bookkeeping'];
describe('pacing — dwell by kind', () => {
it('classifies every intent the engine declares', () => {
const declared = declaredIntents();
assert.ok(declared.length > 25, `only found ${declared.length} intents — the parse is wrong`);
for (const intent of declared) {
const kind = kindOf(intent as Intent['type']);
assert.ok(
KINDS.includes(kind),
`${intent} classified as "${kind}", which is not a StepKind — a default case has crept in`,
);
}
});
it('protects switching and collapses bookkeeping', () => {
// The two ends of the measured argument: a switching move is the thing worth watching, and
// `*.end` bookkeeping is over half of a real game's intents.
assert.equal(kindOf('switch.move'), 'switching');
assert.equal(kindOf('switch.dropCars'), 'switching');
assert.equal(kindOf('switch.sortConsist'), 'switching');
assert.equal(kindOf('draw.end'), 'bookkeeping');
assert.equal(kindOf('loadUnload.end'), 'bookkeeping');
assert.equal(kindOf('switch.end'), 'bookkeeping');
assert.equal(kindOf('localOps.choose'), 'bookkeeping');
assert.ok(DWELL.switching > DWELL.action, 'switching must outrank an ordinary action');
assert.equal(DWELL.bookkeeping, 0, 'bookkeeping must cost the player no time at all');
});
it('starts switching at a full second, per the 2026-09-09 decision', () => {
// Jesse: "start at 1s and tune down". Pinned so a later tune is a deliberate edit rather than
// a drift, and so the number in the plan and the number in the code cannot disagree.
assert.equal(DWELL.switching, 1000);
assert.equal(dwellFor('switch.move'), 1000);
});
it('scales with the viewer\'s pace, and 0 turns it off', () => {
assert.equal(dwellFor('switch.move', 1), 1000);
assert.equal(dwellFor('switch.move', 0.5), 500);
assert.equal(dwellFor('switch.move', 2), 2000);
// TODO #18's "a player who has seen it a hundred times will want it off" — no second mechanism.
for (const intent of declaredIntents()) {
assert.equal(dwellFor(intent as Intent['type'], 0), 0, `${intent} still dwells at pace 0`);
}
// A negative pace is a corrupt preference, not a request to run time backwards.
assert.equal(dwellFor('switch.move', -3), 0);
});
it('counts only the steps a player will actually watch', () => {
/**
* The counter's whole point. A backlog of 17 where 12 are bookkeeping must read "5", not "17"
* followed by an instant plummet to 5 — the countdown is meant to be steady enough to decide
* whether to press Skip.
*/
const queue: Intent['type'][] = [
...Array<Intent['type']>(12).fill('draw.end'),
...Array<Intent['type']>(5).fill('switch.move'),
];
assert.equal(queue.length, 17);
assert.equal(watchableCount(queue), 5);
assert.equal(watchableCount(queue, 0), 0, 'with animation off, nothing is behind');
});
it('a silent step beats only when the clock turns over — TODO #18', () => {
/**
* Both obvious rules were wrong, so both are pinned. "No narration, no dwell" flashed past
* phases that moved trains without saying so, killing the very thing #18 asks for. "Anything
* that changed the board" beat on every turn hand-off — `submit()` steps `advance()` about 4.6
* times per intent — which came to a quarter of an hour a game.
*/
const silent = { cause: 'phase' as const, lines: [] as string[] };
assert.equal(dwellForStep({ ...silent, frame: { table: { actor: 2 } } }), 0, 'a turn hand-off shows nothing');
assert.equal(dwellForStep({ ...silent, frame: { table: {} } }), 0, 'a step that changed nothing shows nothing');
assert.equal(dwellForStep({ ...silent, frame: { table: { phase: 'mainline' } } }), DWELL.phase);
assert.equal(dwellForStep({ ...silent, frame: { table: { stage: 4 } } }), DWELL.phase);
// Narration always earns the dwell of whatever caused it, clock or no clock.
assert.equal(
dwellForStep({ cause: 'switch.move', lines: ['moved'], frame: { table: {} } }),
DWELL.switching,
);
});
it('a real switching turn is watchable in a few seconds, not tens of them', () => {
// Six moves is the engine's cap per crew ("N of 6 Moves left"), so this is the worst ordinary
// case for one crew and the arithmetic the design promised: ~6s to watch a whole exercise.
const turn: Intent['type'][] = [
'localOps.choose',
...Array<Intent['type']>(6).fill('switch.move'),
'switch.end',
];
const total = turn.reduce((ms, i) => ms + dwellFor(i), 0);
assert.equal(total, 6000);
assert.equal(watchableCount(turn), 6, 'the choose and the end are not things to watch');
});
});
+186
View File
@@ -0,0 +1,186 @@
/**
* THE SEATLESS PUBLIC DELTA — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 3.
*
* The property that matters is RECONSTRUCTION: a receiver that started from one full frame and
* merged every delta since must hold exactly what a fresh `publicSnapshot()` would give it. A delta
* scheme that is merely smaller is worthless if the two sides drift, and the drift would show up as
* a board that is subtly wrong rather than as an error.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { pump } from '../src/engine/advance.ts';
import { createGame } from '../src/engine/setup.ts';
import { legalActions } from '../src/engine/legal.ts';
import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts';
import { applyIntent } from '../src/engine/apply.ts';
import { currentActorOfState, publicSnapshot } from '../src/sim/view.ts';
import type { PublicFrame } from '../src/sim/view.ts';
import { applyPublicDelta, deltaPublicFrame } from '../src/sim/public-delta.ts';
const config: GameConfig = {
mode: 'competitive',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
function newState(seed: number, players = 3, rotation = false): GameState {
const s = createGame({
id: `delta-${seed}`,
seed,
config: rotation
? { ...config, optionalRules: { ...config.optionalRules, employeeRotation: true } }
: config,
playerNames: Array.from({ length: players }, (_, i) => `p${i}`),
});
pump(s);
return s;
}
/** Plays one legal action, preferring a switch move so districts actually change between frames. */
function step(s: GameState, actor: PlayerIndex): boolean {
const options = legalActions(s, actor);
if (options.length === 0) return false;
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
const chosen = move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!;
const r = applyIntent(s, actor, chosen);
if (!r.ok) return false;
pump(s);
return true;
}
describe('public frame delta', () => {
it('reconstructs exactly what a fresh projection produces, over a long chain', () => {
for (const seed of [1917398, 4242]) {
const s = newState(seed);
let sent: PublicFrame | null = null;
let held: PublicFrame | null = null;
let steps = 0;
for (let i = 0; i < 300; i++) {
const actor = currentActorOfState(s);
if (actor === null) break;
if (!step(s, actor)) break;
const next = publicSnapshot(s);
const delta = deltaPublicFrame(sent, next);
held = applyPublicDelta(held, delta);
sent = next;
steps++;
assert.deepEqual(
held,
next,
`merged frame drifted from a fresh projection at step ${steps} (seed ${seed})`,
);
}
assert.ok(steps > 20, `only ${steps} steps for seed ${seed} — the chain proved little`);
}
});
it('sends a district board only when that district changed', () => {
const s = newState(1917398);
const first = publicSnapshot(s);
// Nothing has moved, so a delta against an identical frame must null every board.
const idle = deltaPublicFrame(first, publicSnapshot(s));
assert.equal(idle.division, null, 'the Division was unchanged and must not be resent');
assert.equal(idle.districts.length, 0, 'an unchanged district must be omitted, not sent as nulls');
assert.deepEqual(idle.table, {}, 'an unchanged table must send no fields at all');
// Now move one player. Only that seat's board may be sent — this is the whole point of keying
// the delta by seat rather than comparing `districts` as one array.
let moved: PublicIndexed | null = null;
for (let i = 0; i < 200 && moved === null; i++) {
const actor = currentActorOfState(s);
if (actor === null) break;
const before = publicSnapshot(s);
if (!step(s, actor)) break;
const after = publicSnapshot(s);
const changed = after.districts.filter(
(d) => JSON.stringify(d.cells) !== JSON.stringify(before.districts.find((b) => b.seat === d.seat)?.cells),
);
if (changed.length === 1) moved = { seat: changed[0]!.seat, before, after };
}
assert.ok(moved !== null, 'no single-district change occurred, so this test proved nothing');
const delta = deltaPublicFrame(moved.before, moved.after);
assert.equal(delta.districts.length, 1, 'only the district that changed may be sent');
assert.equal(delta.districts[0]!.seat, moved.seat);
assert.notEqual(delta.districts[0]!.cells, null, 'the district that changed must carry its board');
});
it('a step that changes one field sends one field — the reason this is a partial', () => {
/**
* MEASURED, not assumed. The first version spread the whole frame and nulled only the boards, so
* a step whose sole change was whose turn it is still shipped all 35 top-level properties. Once
* TODO #18 gave automatic phases their own steps, most steps became exactly that, and a full game
* cost 19.4 MB of which 16.7 MB was those. This is the guard against that returning.
*/
const s = newState(1917398);
const before = publicSnapshot(s);
const full = JSON.stringify(deltaPublicFrame(null, before)).length;
// Hand the turn on without touching a board, which is what an automatic phase mostly does.
const after = { ...before, actor: ((before.actor ?? 0) + 1) as PlayerIndex };
const delta = deltaPublicFrame(before, after);
assert.deepEqual(Object.keys(delta.table), ['actor'], 'only the field that changed may be sent');
assert.equal(delta.districts.length, 0);
assert.equal(delta.division, null);
const size = JSON.stringify(delta).length;
assert.ok(size < 120, `a one-field delta serialised to ${size} bytes`);
assert.ok(size * 100 < full, `a one-field delta (${size}B) is not much smaller than a full frame (${full}B)`);
});
it('always carries seat, player and name, so Employee Rotation cannot be missed', () => {
// Rotation moves players between districts, so the seat→player pairing is itself news. Those
// fields are small and are never nulled; the boards they label are what the delta saves.
const s = newState(777, 3, true);
const a = publicSnapshot(s);
// A full frame carries every district, each labelled — that is what a receiver matches on later.
const full = deltaPublicFrame(null, a);
assert.equal(full.districts.length, a.districts.length);
for (const d of full.districts) {
assert.equal(typeof d.seat, 'number');
assert.equal(typeof d.player, 'number');
assert.ok(typeof d.name === 'string' && d.name.length > 0, 'every district must stay labelled');
}
// And a district sent at all always carries its labels, even when only its board moved: rotation
// makes the seat→player pairing news in its own right.
const rotated = { ...a, districts: a.districts.map((d, i) => (i === 0 ? { ...d, player: ((d.player + 1) % 3) as PlayerIndex } : d)) };
const delta = deltaPublicFrame(a, rotated);
assert.equal(delta.districts.length, 1, 'a relabelled district must be sent even with no board change');
assert.equal(typeof delta.districts[0]!.player, 'number');
});
it('a first frame is sent whole', () => {
const s = newState(4242);
const full = deltaPublicFrame(null, publicSnapshot(s));
assert.notEqual(full.division, null);
for (const d of full.districts) {
assert.notEqual(d.cells, null, `seat ${d.seat} must be sent in full on a first frame`);
assert.notEqual(d.facilities, null);
}
// And it merges with no previous frame at all.
assert.deepEqual(applyPublicDelta(null, full), publicSnapshot(s));
});
it('refuses to merge an "unchanged" board it has nothing to merge onto', () => {
// A sender whose bookkeeping has drifted would otherwise hand a player a blank district.
const s = newState(4242);
const a = publicSnapshot(s);
const unchanged = deltaPublicFrame(a, publicSnapshot(s));
assert.throws(() => applyPublicDelta(null, unchanged), /no previous frame to merge onto/);
});
});
type PublicIndexed = { seat: number; before: PublicFrame; after: PublicFrame };
+101 -13
View File
@@ -244,11 +244,67 @@ describe('redaction — the shared narration log never carries a seat\'s secrets
describe('#91 — nothing private survives serialisation, in any state', () => {
const names = ['Ann', 'Bob', 'Cy'];
/** Everything one seat can see, as one string: their Frame, the public board, and their lines. */
const everythingSeatSees = (g: ReturnType<typeof newMultiplayerGame>, seat: PlayerIndex): string =>
JSON.stringify(snapshot(g.state, g.log, null, null, null, false, seat)) +
'\n' + JSON.stringify(publicSnapshot(g.state)) +
'\n' + g.log.map((l) => l.text).join('\n');
/**
* Everything one seat can see, split into the two halves the checks below treat differently.
*
* `structural` is the machine-readable state: their Frame, the public board, and the frame of every
* presentation step they are sent (v0.8.0, TODO #13). `narration` is what the table was TOLD.
*
* Steps are folded in here rather than given a test of their own so every case below covers them:
* the blind draw, the pending decision, Employee Rotation before and after the seating moves, and
* the played-out game. Their `lines` are a slice of `g.log` by construction, so the log covers the
* narration half of a step and does not need to be searched twice.
*/
const everythingSeatSees = (g: ReturnType<typeof newMultiplayerGame>, seat: PlayerIndex): {
structural: string;
history: string;
narration: string[];
} => ({
/**
* `[]` for the Frame's own lines, MATCHING PRODUCTION. `frameFor()` (`server/session.ts`) has
* passed no log since #97 — narration goes out incrementally through `Push.lines` instead — so
* embedding it here audits a path that no longer exists, and worse, it puts the whole log inside
* `structural` where the face-up-pile rule below cannot reach it. The log is audited in full as
* `narration`; this is a de-duplication, not a relaxation.
*/
structural:
JSON.stringify(snapshot(g.state, [], null, null, null, false, seat)) +
'\n' + JSON.stringify(publicSnapshot(g.state)),
/**
* THE STEP FRAMES ARE A RECORD OF WHAT WAS PUBLIC OVER TIME, not a view of the position now —
* so they get the PRECISE check and not the fuzzy one, for the same reason the face-up-pile
* lines do.
*
* Every one is built by `deltaPublicFrame` over `publicSnapshot`, which the allow-list test at
* the bottom of this file pins property by property; that is what guarantees a step frame is
* clean. Searching their accumulation for a card NAME asks "was this ever public?" and answers
* a question nobody was posing: Train 6 sat face-up in a Department at step 40 and is in Ann's
* hand at step 120, and both facts are correct. A card ID is different — narration never renders
* one and no public field carries an opponent's, so finding one anywhere is still proof.
*/
history: JSON.stringify(g.display.steps.map((step) => step.frame)),
narration: g.log.map((l) => l.text),
});
/**
* A FACE-UP PILE IS ALLOWED TO NAME THE CARD ON IT, and the log is history rather than a view.
*
* §2.6: the three Department piles and the Salvage Yard are face up, "so players can audit
* discards" — a discard goes onto one precisely so a rival can take it. So "Player Ann discarded
* Train 6 face-up on top of Department 3" is the record working, and it stays in the log after Ann
* takes the card back into her hand. The name-based check below would otherwise read that historical
* line as proof of what Ann is holding NOW, which is how it reported a leak against correct code on
* seed 1917398.
*
* These lines are excluded from the NAME check only. The card-id check and the seed check still run
* over them, because those are precise: an id is unique, so finding one is proof, and narration
* never renders a raw id.
*
* **This does not weaken the blind-draw detection**, which is the leak this whole net was built
* for (v0.7.9.2, "Red Flags"): a blind draw names the HOME OFFICE DECK, which is face down and
* matches nothing here.
*/
const namesAFaceUpPile = (line: string): boolean => /Department|Salvage/i.test(line);
/**
* Every secret belonging to somebody OTHER than `seat`: their card ids, and the names those ids
@@ -265,8 +321,14 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
* This is what caught the blind-draw leak in v0.7.9.2: "Red Flags" was in exactly one hand, and it
* was in the log.
*/
const secretsOfOthers = (g: ReturnType<typeof newMultiplayerGame>, seat: PlayerIndex): { what: string; value: string }[] => {
const out: { what: string; value: string }[] = [];
const secretsOfOthers = (
g: ReturnType<typeof newMultiplayerGame>,
seat: PlayerIndex,
): { what: string; value: string; precise: boolean }[] => {
// `precise` marks evidence that is proof on its own — a card id is unique, so finding one
// anywhere is a leak. A NAME is circumstantial and is searched over a narrower string; see
// `namesAFaceUpPile`.
const out: { what: string; value: string; precise: boolean }[] = [];
// How many cards in the whole game carry each name, and how many of those are in a given hand.
const totalByName = new Map<string, number>();
for (const id of g.state.cards.keys()) {
@@ -282,10 +344,10 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
heldByName.set(n, (heldByName.get(n) ?? 0) + 1);
}
for (const id of hand) {
out.push({ what: `${p.name}'s card id`, value: id });
out.push({ what: `${p.name}'s card id`, value: id, precise: true });
const name = cardName(g.state, id);
if (totalByName.get(name) === heldByName.get(name)) {
out.push({ what: `${p.name}'s card name, unique to their hand`, value: name });
out.push({ what: `${p.name}'s card name, unique to their hand`, value: name, precise: false });
}
}
}
@@ -295,15 +357,19 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
/** Runs the whole net over one state, and says which state failed if it does. */
const audit = (g: ReturnType<typeof newMultiplayerGame>, where: string): void => {
for (const seat of g.state.players.map((p) => p.index)) {
const seen = everythingSeatSees(g, seat);
for (const { what, value } of secretsOfOthers(g, seat)) {
const { structural, history, narration } = everythingSeatSees(g, seat);
const everything = structural + '\n' + history + '\n' + narration.join('\n');
// Names are fuzzy evidence, so they are searched everywhere EXCEPT the lines a face-up pile
// is entitled to name a card on. Ids are precise and are searched everywhere.
const forNames = structural + '\n' + narration.filter((l) => !namesAFaceUpPile(l)).join('\n');
for (const { what, value, precise } of secretsOfOthers(g, seat)) {
assert.ok(
!seen.includes(value),
!(precise ? everything : forNames).includes(value),
`${where}: seat ${seat} can see ${what} ("${value}")`,
);
}
// The seed is the whole future of the deal and must not reach a seat by any route.
assert.ok(!seen.includes(String(g.seed)), `${where}: seat ${seat} can see the seed ${g.seed}`);
assert.ok(!everything.includes(String(g.seed)), `${where}: seat ${seat} can see the seed ${g.seed}`);
}
// And the spectator board, which has no seat and is therefore entitled to nothing private.
const pub = JSON.stringify(publicSnapshot(g.state));
@@ -346,6 +412,28 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
audit(g, 'after a blind draw');
});
it('the net actually sees the presentation steps it claims to cover (v0.8.0)', () => {
/**
* Guards the COVERAGE, not the code. `everythingSeatSees` folds `display.steps` into the string
* every case above is audited against — which is worth nothing if that array is empty in
* practice. So: play a real game, and assert both that steps accumulated and that the audited
* string contains them.
*/
const g = newMultiplayerGame(1917398, config, names);
play(g, 120);
assert.ok(g.display.steps.length > 20, `only ${g.display.steps.length} steps — the net covers little`);
const { history, narration } = everythingSeatSees(g, 0 as PlayerIndex);
assert.ok(
history.includes(JSON.stringify(g.display.steps.map((step) => step.frame))),
'the audited string does not actually contain the step frames',
);
// And a step's own narration is a slice of the log, so the log half covers it.
const fromSteps = g.display.steps.flatMap((step) => step.lines.map((l) => l.text));
assert.ok(fromSteps.length > 0, 'the steps carried no narration to cover');
assert.ok(fromSteps.every((t) => narration.includes(t)), 'a step said something the log did not');
audit(g, 'a played game with presentation steps');
});
it('mid-game, with real hands and a built board', () => {
// A DISTINCTIVE seed, deliberately. Seed 7 makes the seed check meaningless — "7" is in "Train
// 7", in every coordinate and in half the numbers on the board — so it reported a leak that was
+56 -9
View File
@@ -6,6 +6,9 @@
*/
import { describe, it } from 'node:test';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import assert from 'node:assert/strict';
import { pump } from '../src/engine/advance.ts';
@@ -42,9 +45,9 @@ const SAMPLES: GameEvent[] = [
{ type: 'phaseBegan', phase: 'mainline' },
{ type: 'actorChanged', player: 0 },
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
{ type: 'trayMoved', trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
{ type: 'carsCoupled', trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
{ type: 'carsDropped', trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
{ type: 'trayMoved', player: 0, trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
{ type: 'carsCoupled', player: 0, trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
{ type: 'carsDropped', player: 0, trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
{ type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' },
{ type: 'cardPlayed', player: 0, cardId: 'c1', placement: { row: 1, col: 0 }, variant: 0 },
{ type: 'officeUpgraded', player: 0, from: 'whistlePost', to: 'depot' },
@@ -72,12 +75,56 @@ const SAMPLES: GameEvent[] = [
describe('narration', () => {
it('covers every event type the engine can emit', () => {
// Guards against a new event type slipping in unnarrated.
const covered = new Set(SAMPLES.map((e) => e.type));
const declared = new Set<string>();
for (const e of SAMPLES) declared.add(e.type);
assert.equal(covered.size, 30, 'sample list is out of step with GameEvent');
assert.equal(declared.size, 30);
/**
* THIS TEST USED TO BUILD BOTH SETS FROM `SAMPLES` and compare them to each other, so it could
* only ever assert that the sample list had 30 distinct entries — the one thing it could not
* detect was the thing its comment promised, a new `GameEvent` slipping in unnarrated. Fixed
* 2026-09-09 while adding the v0.8.0 step collector, which made the event union load-bearing for
* a second reader.
*
* The union is read out of `src/engine/events.ts` rather than hand-listed, the same way
* `test/pacing.test.ts` reads the intent union: a list maintained by hand is a list that goes
* stale, which is how this got here.
*/
const here = dirname(fileURLToPath(import.meta.url));
const declared = new Set(
[...readFileSync(join(here, '../src/engine/events.ts'), 'utf8').matchAll(/type: '([a-zA-Z]+)'/g)]
.map((m) => m[1]!),
);
const narrated = new Set(
[...readFileSync(join(here, '../src/sim/narrate.ts'), 'utf8').matchAll(/case '([a-zA-Z]+)':/g)]
.map((m) => m[1]!),
);
assert.ok(declared.size > 40, `only ${declared.size} event types parsed — the parse is wrong`);
// THE INVARIANT THAT MATTERS: an event the engine can emit and `narrate` has no case for falls
// through to a placeholder, in front of a player. This is the check the old version promised.
const unnarrated = [...declared].filter((t) => !narrated.has(t));
assert.deepEqual(unnarrated, [], 'these event types can be emitted and have no narration case');
const covered = new Set<string>(SAMPLES.map((e) => e.type));
const unknown = [...covered].filter((t) => !declared.has(t));
assert.deepEqual(unknown, [], 'these samples name an event the engine no longer declares');
/**
* THE KNOWN GAP, PINNED SO IT CANNOT GROW.
*
* `SAMPLES` exercises the TEXT of 30 of the 55 declared events; the other 25 have a narration
* case (checked above) but no sample, so nothing proves their sentence is any good. Found
* 2026-09-09 — the old test built both of its sets from `SAMPLES` and compared them to each
* other, so it could only ever assert that the sample list had 30 distinct entries, and the one
* thing it could not detect was the thing its comment promised.
*
* Pinned rather than fixed: writing 25 fixtures is a job of its own, and a bad sentence is worth
* finding deliberately rather than in a rush. What this does guarantee is that a NEW event type
* cannot join the unsampled set silently.
*/
const unsampled = [...declared].filter((t) => !covered.has(t)).sort();
assert.equal(
unsampled.length,
25,
`the unsampled set changed (${unsampled.length}): add a sample for a new event, or update this count`,
);
});
it('gives every event a specific, non-empty sentence', () => {
+204
View File
@@ -0,0 +1,204 @@
/**
* THE ANIMATION QUEUE — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 §§ 5-6.
*
* Driven against REAL steps from a real game rather than hand-built fixtures, because the properties
* that matter are about what actual play produces: a bot's whole switching turn arriving in one
* burst, and a backlog that is mostly bookkeeping.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { legalActions } from '../src/engine/legal.ts';
import type { GameConfig } from '../src/engine/state.ts';
import { currentActor, newMultiplayerGame, submit } from '../src/web/game.ts';
import { publicSnapshot } from '../src/sim/view.ts';
import { takeSteps } from '../src/sim/display-step.ts';
import type { DisplayStep } from '../src/sim/display-step.ts';
import { createStepQueue } from '../src/web/step-queue.ts';
import { DWELL } from '../src/sim/pacing.ts';
const config: GameConfig = {
mode: 'competitive',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
/**
* Plays a real game and returns its steps, preferring switch moves so a burst actually occurs.
*
* 400 moves, not 120: switching is not legal until there is track laid and a train in the district,
* and on this seed the first `switch.move` is at move 144. A shorter run produces a queue with no
* switching in it at all, which would make the pacing assertions here vacuous.
*/
function realSteps(seed: number, moves: number): { steps: DisplayStep[]; final: ReturnType<typeof publicSnapshot> } {
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
takeSteps(game.display);
const steps: DisplayStep[] = [];
for (let i = 0; i < moves; i++) {
const actor = currentActor(game);
if (actor === null) break;
const options = legalActions(game.state, actor);
if (options.length === 0) break;
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
steps.push(...takeSteps(game.display));
}
return { steps, final: publicSnapshot(game.state) };
}
/** The baseline a queue starts from, matching what a connect push carries. */
function baseline(seed: number): ReturnType<typeof publicSnapshot> {
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
return publicSnapshot(game.state);
}
describe('the step queue', () => {
it('shows the whole burst in order and lands on the real board', () => {
const { steps, final } = realSteps(1917398, 400);
assert.ok(steps.length > 30, `only ${steps.length} steps — this proved little`);
const q = createStepQueue();
q.reset(baseline(1917398));
q.push(steps);
// Run a clock forward until it settles, in 50ms ticks like a render loop would.
let now = 0;
for (let i = 0; i < 20_000 && q.busy(); i++) {
q.advance(now);
now += 50;
}
assert.equal(q.busy(), false, 'the queue never drained');
assert.deepEqual(q.current(), final, 'the animated board did not land on the real one');
assert.equal(q.showing()?.seq, steps[steps.length - 1]!.seq, 'the caption is not on the last step');
});
it('a burst of switching takes real time, and bookkeeping takes none', () => {
const { steps } = realSteps(1917398, 400);
const q = createStepQueue();
q.reset(baseline(1917398));
// Only the bookkeeping: it must all collapse into a single advance.
const bookkeeping = steps.filter((s) => s.cause.endsWith('.end') || s.cause === 'localOps.choose');
assert.ok(bookkeeping.length > 10, 'not enough bookkeeping steps to prove the collapse');
q.push(bookkeeping);
q.advance(0);
q.advance(0);
assert.equal(q.busy(), false, `${bookkeeping.length} bookkeeping steps should cost no time at all`);
// And switching: each one must hold the screen.
const switching = steps.filter((s) => s.cause.startsWith('switch.') && s.cause !== 'switch.end');
assert.ok(switching.length >= 6, `only ${switching.length} switching steps found`);
const q2 = createStepQueue();
q2.reset(baseline(1917398));
q2.push(switching.slice(0, 6));
q2.advance(0);
assert.equal(q2.behind(), 5, 'the first is shown at once; five are still to watch');
q2.advance(DWELL.switching - 1);
assert.equal(q2.behind(), 5, 'a switching move must not be replaced early');
q2.advance(DWELL.switching);
assert.equal(q2.behind(), 4, 'and must be replaced once its dwell is up');
});
it('counts only what will be watched, so the countdown is steady', () => {
// The counter's whole purpose: a backlog of mostly-bookkeeping must not read as a huge number
// that collapses the instant it starts.
const { steps } = realSteps(1917398, 400);
const q = createStepQueue();
q.reset(baseline(1917398));
q.push(steps);
const behind = q.behind();
assert.ok(behind > 0 && behind < steps.length, `behind ${behind} of ${steps.length} queued`);
q.advance(0);
let ticks = 0;
let previous = q.behind();
let now = 0;
while (q.busy() && ticks++ < 20_000) {
now += 50;
q.advance(now);
const nowBehind = q.behind();
assert.ok(nowBehind <= previous, 'the counter must never go up while draining');
previous = nowBehind;
}
assert.equal(q.behind(), 0);
});
it('skip jumps to the real board without losing a single state on the way', () => {
const { steps, final } = realSteps(1917398, 400);
const q = createStepQueue();
q.reset(baseline(1917398));
q.push(steps);
q.advance(0);
assert.equal(q.skip(), true, 'there was a backlog to skip');
assert.equal(q.busy(), false);
assert.equal(q.behind(), 0);
// Skip applies every delta rather than jumping the chain, so the board is exact.
assert.deepEqual(q.current(), final, 'skipping produced a board the game was never in');
assert.equal(q.skip(), false, 'skipping an empty queue changes nothing');
});
it('pace 0 turns animation off entirely — TODO #18', () => {
const { steps, final } = realSteps(1917398, 400);
const q = createStepQueue(() => 0);
q.reset(baseline(1917398));
q.push(steps);
// One advance at a single instant must consume everything: nothing dwells at all.
q.advance(0);
q.advance(0);
assert.equal(q.busy(), false, 'with animation off, nothing may be left waiting');
assert.equal(q.behind(), 0, 'nothing is "behind" when nothing is being animated');
assert.deepEqual(q.current(), final);
});
it('pace scales the wait without changing the order', () => {
const { steps } = realSteps(1917398, 400);
const switching = steps.filter((s) => s.cause.startsWith('switch.') && s.cause !== 'switch.end').slice(0, 3);
assert.equal(switching.length, 3);
const half = createStepQueue(() => 0.5);
half.reset(baseline(1917398));
half.push(switching);
half.advance(0);
half.advance(DWELL.switching / 2);
assert.equal(half.behind(), 1, 'at half pace, half the dwell should have advanced one step');
});
it('a reset discards the backlog rather than merging it onto a new baseline', () => {
/**
* A reconnecting client holds steps whose deltas chain off a baseline the server has moved past.
* Merging them onto the new one would draw a board that never existed — and `applyPublicDelta`
* would throw the moment a "null means unchanged" field had nothing to merge onto.
*/
const { steps, final } = realSteps(1917398, 400);
const q = createStepQueue();
q.reset(baseline(1917398));
q.push(steps.slice(0, 10));
q.advance(0);
assert.ok(q.busy());
q.reset(final);
assert.equal(q.busy(), false, 'a reset must empty the queue');
assert.equal(q.behind(), 0);
assert.deepEqual(q.current(), final);
// And the caption survives: a reconnect should not blank the "what just happened" line.
assert.ok(q.showing() !== null, 'the caption should survive a reset');
});
it('draws nothing before a reset has arrived', () => {
const q = createStepQueue();
assert.equal(q.current(), null);
assert.equal(q.advance(0), false);
assert.equal(q.behind(), 0);
assert.equal(q.showing(), null);
});
});
+311
View File
@@ -0,0 +1,311 @@
/**
* THE WATCHABLE TABLE — v0.8.0, Gitea#20 / TODO #13, #15, #18.
*
* One shared, ordered presentation of everyone else's turns, on a seated player's own screen. The
* design is `docs/plans/jitsi-common-board.md` § v0.8.0; this file is its tests.
*
* Starting with ATTRIBUTION, because the caption row and the history panel both read these lines
* and a line that does not say who acted is useless on a screen built to answer "what did they
* just do?".
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { applyIntent } from '../src/engine/apply.ts';
import { legalActions } from '../src/engine/legal.ts';
import type { GameConfig, PlayerIndex } from '../src/engine/state.ts';
import { fromMultiplayerSave, newGame, newMultiplayerGame, submit } from '../src/web/game.ts';
import { currentActor } from '../src/web/game.ts';
import { applyPublicDelta } from '../src/sim/public-delta.ts';
import { publicSnapshot } from '../src/sim/view.ts';
import type { PublicFrame } from '../src/sim/view.ts';
import { takeSteps } from '../src/sim/display-step.ts';
import { createSession } from '../src/server/session.ts';
import { kindOf } from '../src/sim/pacing.ts';
const config: GameConfig = {
mode: 'competitive',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
/**
* The four events a switching turn is made of. Every one of them used to arrive in the shared log
* unattributed: `record()` (`web/game.ts`) prefixes a line with the player's name only when the
* event itself carries `player`, and these four were the only events in their class that did not
* — `cardDrawn`, `cardPlayed`, `cardDiscarded`, `carPlacedOnTrain`, `loadStarted`, `loadCompleted`,
* `flyingSwitch` and `localOpsOptionChosen` all did. So a switching turn read as an attributed
* bracket around anonymous contents:
*
* Player Alice chose to switch ← attributed
* CREW moved (1,2) → (1,3) — 4 of 6 ← whose train?
* Player Alice finished Local Operations ← attributed
*
* Measured 2026-09-09 and fixed with the feature that reads them, not filed.
*/
const SWITCHING_EVENTS = ['trayMoved', 'carsCoupled', 'carsDropped', 'consistSorted'] as const;
/** How each of those four reads in the log, so the assertions can find them by text. */
const SWITCHING_LINE = /^Player .+ (moved (Train |the local crew)|coupled \d+ car|set out |used the SMALL YARD)/;
describe('switching is attributed — TODO #13', () => {
it('every switching event carries the player who acted', () => {
/**
* Driven by PREFERRING switch moves rather than taking the first legal action, because bot
* switching is clustered rather than spread: two of the three published replays contain no
* `switch.move` at all, so a game driven by `options[0]` can finish without ever exercising
* this. The counter below then guards against the test passing vacuously.
*/
let seen = 0;
for (const seed of [1917398, 191056, 4242]) {
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
for (let i = 0; i < 800; i++) {
const actor = currentActor(game);
if (actor === null) break;
const options = legalActions(game.state, actor);
if (options.length === 0) break;
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
const chosen = move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!;
// Read the events this intent produces before applying it for real, so the assertion sees
// exactly what `record()` will be handed.
const preview = applyIntent(structuredClone(game.state), actor, chosen);
if (preview.ok) {
for (const e of preview.events) {
if ((SWITCHING_EVENTS as readonly string[]).includes(e.type)) {
assert.ok(
'player' in e,
`${e.type} carries no player, so the log cannot say whose crew it was`,
);
assert.equal(
(e as { player: PlayerIndex }).player,
actor,
`${e.type} names the wrong player`,
);
seen++;
}
}
}
if (!submit(game, chosen)) break;
}
}
assert.ok(seen > 0, 'no switching event was produced, so this test proved nothing');
});
it('reads as a player action in the log, not as anonymous plain text', () => {
let lines = 0;
for (const seed of [1917398, 4242]) {
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
for (let i = 0; i < 800; i++) {
const actor = currentActor(game);
if (actor === null) break;
const options = legalActions(game.state, actor);
if (options.length === 0) break;
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
}
for (const line of game.log) {
// The old wording. `uncapitalise` deliberately leaves an acronym alone (`^[A-Z][a-z]` only),
// so "CREW moved" and "SMALL YARD —" would have survived the prefix and read as
// "Player Alice CREW moved …". Both were reworded to compose.
assert.doesNotMatch(
line.text,
/^CREW moved|^SMALL YARD —/,
`an unattributed switching line survived: ${line.text}`,
);
if (SWITCHING_LINE.test(line.text)) {
assert.equal(line.tone, 'act', `a switching line must read as somebody's move: ${line.text}`);
lines++;
}
}
}
assert.ok(lines > 0, 'no switching line reached the log, so this test proved nothing');
});
});
describe('the display-step collector — TODO #13', () => {
it('emits one step per accepted intent plus one per automatic phase, in order', () => {
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
let accepted = 0;
for (let i = 0; i < 120; i++) {
const actor = currentActor(game);
if (actor === null) break;
const options = legalActions(game.state, actor);
if (options.length === 0) break;
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
accepted++;
}
assert.ok(accepted > 30, `only ${accepted} intents accepted — this proved little`);
const steps = takeSteps(game.display);
/**
* TWO KINDS OF STEP SINCE TODO #18: one per accepted intent, and one per automatic phase that
* did anything. So the count is no longer `accepted` — but every intent must still have exactly
* one step, which is the invariant that matters.
*/
const byIntent = steps.filter((s) => s.cause !== 'phase');
const byPhase = steps.filter((s) => s.cause === 'phase');
assert.equal(byIntent.length, accepted, 'one step per accepted intent, no more and no fewer');
assert.ok(byPhase.length > 0, 'no phase produced a step — TODO #18 is not being served');
steps.forEach((s, i) => {
assert.equal(s.seq, i, 'sequence numbers must be dense and in order');
assert.equal(s.protocolVersion, 1);
assert.ok(kindOf(s.cause), `step ${i} carries a cause pacing cannot classify`);
// A phase is nobody's move; an intent is always somebody's.
assert.equal(s.player === null, s.cause === 'phase', `step ${i} disagrees about who acted`);
assert.equal(s.seat === null, s.cause === 'phase');
});
assert.equal(takeSteps(game.display).length, 0, 'draining must empty the collector');
});
it('a rejected intent produces no step', () => {
const game = newMultiplayerGame(4242, config, ['Alice', 'Bob', 'Carol']);
takeSteps(game.display);
// Somebody else's turn: refused before the engine is touched, so nothing to present.
const notMyTurn = ((currentActor(game) ?? 0) + 1) % 3;
assert.equal(submit(game, { type: 'draw.end' }, notMyTurn as PlayerIndex), false);
assert.equal(takeSteps(game.display).length, 0, 'a refused intent must not be presented');
});
it('the step deltas reconstruct the public board exactly', () => {
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
let held: PublicFrame | null = null;
for (let i = 0; i < 150; i++) {
const actor = currentActor(game);
if (actor === null) break;
const options = legalActions(game.state, actor);
if (options.length === 0) break;
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
for (const s of takeSteps(game.display)) held = applyPublicDelta(held, s.frame);
}
assert.deepEqual(held, publicSnapshot(game.state), 'the animated board drifted from the real one');
});
/**
* THE PROPERTY THAT IS CURRENTLY FREE AND MUST STAY THAT WAY.
*
* `fromSave`/`fromMultiplayerSave` rebuild a game with `applyIntent` + `record` + `drain` rather
* than `submit`, so a resumed server does not re-emit the whole game as steps and burn the
* sequence. The plan expected this to need an explicit guard. It does not — but move a replay
* path onto `submit()` and it silently becomes a real bug, which is why this is pinned.
*/
it('replaying a save emits no steps at all', () => {
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
for (let i = 0; i < 80; i++) {
const actor = currentActor(game);
if (actor === null) break;
const options = legalActions(game.state, actor);
if (options.length === 0) break;
if (!submit(game, options[0]!)) break;
}
assert.ok(game.history.length > 20, 'need a real history to replay');
const rebuilt = fromMultiplayerSave(game.seed, config, ['Alice', 'Bob', 'Carol'], game.history);
assert.equal(
rebuilt.game.display.steps.length,
0,
'a replay re-emitted the whole game as display steps',
);
assert.equal(rebuilt.game.display.seq, 0, 'a replay burned display sequence numbers');
});
it('solitaire collects the same way multiplayer does', () => {
// The standing design direction: solitaire is a special case of multiplayer, not a second
// implementation. Both go through one `submit()`, so this needs no separate code path — and
// that is exactly what makes TODO #18 fall out of TODO #13's mechanism.
const game = newGame(4242);
let accepted = 0;
for (let i = 0; i < 60; i++) {
const actor = currentActor(game);
if (actor === null) break;
const options = legalActions(game.state, actor);
if (options.length === 0) break;
if (!submit(game, options[0]!)) break;
accepted++;
}
assert.ok(accepted > 10, 'the solitaire game did not get going');
const collected = takeSteps(game.display);
assert.equal(
collected.filter((s) => s.cause !== 'phase').length,
accepted,
'solitaire must collect a step per intent too',
);
// And solitaire is where TODO #18 lives — its phases must earn beats on the same path.
assert.ok(collected.some((s) => s.cause === 'phase'), 'solitaire got no phase steps');
});
});
describe('steps reach a seated player — TODO #13', () => {
it('never replays the opening bot turns at the first client to connect', () => {
/**
* `buildSession` runs `driveBotTurns()` at construction, so with bots ahead of you in the order
* the game has already moved before anybody can connect. Those steps must be DROPPED, not
* queued: a connecting client's `publicReset` is the board as it stands after those very moves,
* so replaying them onto it would draw positions the game had already left.
*
* Found by review 2026-09-09 rather than by a failing test, which is why this one exists.
*/
const session = createSession(1917398, config, ['Alice', 'Bob', 'Carol'], [1, 2]);
const push = session.connect(0 as PlayerIndex);
assert.ok(push.publicReset, 'a connecting client needs a baseline');
assert.equal(push.steps, undefined, 'the connect push must carry no steps at all');
// And the first real broadcast must carry only what THIS move produced — nothing older.
const option = push.menu?.options[0];
assert.ok(option, 'seat 0 should have something to do');
const r = session.intent(0 as PlayerIndex, 1, option);
assert.ok(r.accepted);
const steps = [...r.pushes.values()][0]?.steps ?? [];
assert.ok(steps.length > 0, 'the move produced no steps');
/**
* The first step delivered must be THIS seat's move — not a bot's, which is what a replayed
* opening turn would look like. The sequence does NOT restart at 0: `takeSteps` empties the
* collector without rewinding the counter, so the first thing a client sees may be seq 14. That
* is fine and deliberate — what 0.8.1's gap detection needs is monotonic and dense, not
* zero-based.
*/
assert.equal(steps[0]!.player, 0, 'the first delivered step was not the move just made');
assert.equal(steps[0]!.cause, option.type);
steps.forEach((st, i) => {
if (i > 0) assert.equal(st.seq, steps[i - 1]!.seq + 1, 'sequence must stay dense');
});
});
it('every seat gets the same public steps, and a connect gets a baseline to merge onto', () => {
const session = createSession(1917398, config, ['Alice', 'Bob', 'Carol'], [1, 2]);
const connected = session.connect(0 as PlayerIndex);
assert.ok(connected.publicReset, 'a connecting client needs a baseline for its step queue');
let seen = 0;
for (let i = 0; i < 60; i++) {
const menu = session.connect(0 as PlayerIndex).menu;
const option = menu?.options[0];
if (!option) break;
const r = session.intent(0 as PlayerIndex, i, option);
if (!r.accepted) break;
const pushes = [...r.pushes.values()];
if (pushes.length === 0) continue;
const first = pushes[0]!.steps ?? [];
if (first.length === 0) continue;
seen += first.length;
for (const p of pushes) {
assert.deepEqual(p.steps, first, 'every seat must receive the identical public steps');
}
}
assert.ok(seen > 0, 'no steps reached a push, so this proved nothing');
});
});