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:
co-authored by
Claude Opus 5
parent
312e0301e0
commit
02289e94b8
+4
-4
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user