Files
station-master/test/public-delta.test.ts
T
Jesse.MarkowitzandClaude Opus 5 4adf149ba5 v0.8.0.10 — playtest fixes: clearance rulings, the log, the map, and a save file
From the first two multiplayer playtests of v0.8.0.9, each traced before fixing.

The engine:

- A train on a card BEHIND the one departing no longer triggers a clearance
  ruling or an opposite-direction bar (#26). Reproduced from the exported
  save: X15 was held over X18 behind it, and X18 then collided into the full
  Whistle Post. Games in progress holding a ruling the engine no longer asks
  for will not resume (28 of 40 recorded four-seat games); shipped as is at
  Jesse's call.
- `mainlineModified` carries the card's previous kind, so the log can say
  what a Realignment converted (#27).

The screen:

- The turn chart and the Division map name the player whose move is on
  screen while bot turns replay, not the live actor (#25).
- The owning player's name is no longer outlined by the turn arrow's stroke,
  which made it unreadable (#24).
- A Mainline card flashes on the map when a Realignment changes it (#28).
- The history is held back with the board and revealed step by step, instead
  of arriving whole while the board is still catching up (#29).
- A ruling made by holding the office reads "Superintendent Player X" (#30),
  and no line names a player twice (#31).
- A seated player can download their own game as a save file: the play
  page's Save replay button, fed by GET /api/save?token=… (#32). The StartOS
  action cannot do this — an action result is text only.

Closes #24
Closes #25
Closes #26
Closes #27
Closes #28
Closes #29
Closes #30
Closes #31
Closes #32

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
2026-09-15 22:44:20 -04:00

295 lines
14 KiB
TypeScript

/**
* 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, changedPiles, 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 };
describe('which piles a step moved', () => {
/**
* MEASURED FROM REAL PLAY, then pinned. The table in `changedPiles` claims what each action moves,
* and a claim in a comment is worth nothing unless something checks it — so this drives real games
* and asserts the mapping holds, action by action.
*/
it('maps each action to the piles it actually touches', () => {
const seen = new Map<string, Set<string>>();
/**
* TWO PASSES, because a single driver cannot reach every case. Left to itself the bot almost
* never takes a Department card, and a driver that prefers one then never draws from the deck —
* so each preference is played out separately and the assertions below require BOTH to have
* been observed rather than passing on whichever happened to occur.
*/
for (const prefer of ['draw.fromDepartment', 'draw.fromHomeOffice'] as const) {
for (const seed of [1917398, 191056, 4242]) {
const s = newState(seed);
for (let i = 0; i < 400; i++) {
const actor = currentActorOfState(s);
if (actor === null) break;
const options = legalActions(s, actor);
if (options.length === 0) break;
const chosen =
options.find((o) => o.type === prefer) ??
options.find((o) => o.type === 'card.discard') ??
options[i % options.length]!;
const before = publicSnapshot(s);
const r = applyIntent(s, actor, chosen);
if (!r.ok) break;
pump(s);
const piles = changedPiles(before, publicSnapshot(s)).map((p) => p.replace(/dept\d/, 'dept'));
if (!seen.has(chosen.type)) seen.set(chosen.type, new Set());
for (const p of piles) seen.get(chosen.type)!.add(p);
if (piles.length === 0) seen.get(chosen.type)!.add('(none)');
}
}
}
const of = (t: string): Set<string> => seen.get(t) ?? new Set();
// NOT VACUOUS: the four cases the mapping is actually about must all have happened.
for (const needed of ['draw.fromHomeOffice', 'draw.fromDepartment', 'card.discard', 'card.play']) {
assert.ok(of(needed).size > 0, `${needed} never occurred, so its rule proved nothing`);
}
// A HOME OFFICE DRAW MOVES THE COUNT AND NOTHING ELSE ON A PILE. The card is private; the deck
// getting shorter is not, and it is the only thing a watcher can be shown.
assert.deepEqual([...of('draw.fromHomeOffice')].sort(), ['home']);
// A DEPARTMENT DRAW touches that Department, and sometimes the deck too — the pile refills from
// it. Both are public, so both may light.
for (const p of of('draw.fromDepartment')) {
assert.ok(p === 'dept' || p === 'home', `a Department draw moved "${p}"`);
}
assert.ok(of('draw.fromDepartment').has('dept'), 'a Department draw must light its Department');
// A DISCARD lands face up on a Department, and which one is public.
assert.deepEqual([...of('card.discard')].sort(), ['dept']);
// A PLAYED CARD that does not stay on the board lands face up in the Salvage Yard.
assert.ok(of('card.play').has('salvage'), 'a played card must be able to light the Salvage Yard');
// ENDING A PHASE moves no card anywhere, so nothing should light for it.
for (const quiet of ['draw.end', 'loadUnload.end', 'switch.end', 'localOps.choose']) {
if (of(quiet).size > 0) assert.deepEqual([...of(quiet)], ['(none)'], `${quiet} lit a pile`);
}
});
it('lights nothing without a previous frame to compare against', () => {
// A reset has nothing to have watched arriving, so nothing on it is lit.
const s = newState(4242);
assert.deepEqual(changedPiles(null, publicSnapshot(s)), []);
});
});
describe('a Mainline card that changed under the players (Gitea#28)', () => {
it('names the node a Realignment converted, and nothing else', async () => {
const { changedDivisionCards } = await import('../src/sim/public-delta.ts');
const { REALIGNMENTS } = await import('../src/engine/content.ts');
const s = newState(4242);
const before = publicSnapshot(s);
// Realignment converts a card to another kind (`content.ts`'s table). Applied to the state directly:
// what is being tested is the DETECTOR, not the play that reaches it — which needs the card in hand,
// the draw option taken and no train on the card.
const at = s.division.nodes.findIndex((n) => n.kind === 'mainline' && REALIGNMENTS.some((r) => r.from === n.card));
assert.ok(at >= 0, 'no Mainline card in this Division can be realigned at all');
const node = s.division.nodes[at];
assert.equal(node?.kind, 'mainline');
if (node?.kind === 'mainline') {
node.card = REALIGNMENTS.find((r) => r.from === node.card)!.to;
}
const after = publicSnapshot(s);
assert.deepEqual(changedDivisionCards(before, after), [at], 'the realigned card was not the one reported');
assert.deepEqual(changedDivisionCards(after, after), [], 'an unchanged Division reported a change');
assert.deepEqual(changedDivisionCards(null, after), [], 'a first board has nothing to compare against');
});
it('says nothing when only the trains on a card moved', async () => {
const { changedDivisionCards } = await import('../src/sim/public-delta.ts');
const s = newState(1917398);
const before = publicSnapshot(s);
const at = s.division.nodes.findIndex((n) => n.kind === 'mainline');
const node = s.division.nodes[at];
if (node?.kind === 'mainline') {
node.transits.push({ tray: 'tray1', stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
}
assert.deepEqual(changedDivisionCards(before, publicSnapshot(s)), [], 'a train arriving flashed the card');
});
});