"Many operations still occurred too fast for me to see", at 10x — where an action already holds the screen for seven seconds. So it was never duration: a bot drawing a card changes one number in a panel nobody is watching, and the board sits unchanged. Raising the dwell was the wrong lever and it had been pulled three times. f.deck has carried the face-down count since the Frame existed and nothing drew it — the display gap test/display-gaps.test.ts sweeps for, surviving in the one panel that draws every other pile. It is a tile now, first in the row, face down, because that is the order a card travels and not knowing what is on top is the point. And the piles a move touched are lit for as long as that move is on screen. Derived from the frames either side of a step rather than sent, so nothing joins the protocol and the 0.8.1 board gets it free. What lights follows what is public, and was measured across four seeds rather than reasoned about: a Home Office draw lights the deck and never names the card; a Department draw lights that pile, and the deck too when it refills; a discard lights the Department it lands on; a played card lights the Salvage Yard. Switching and new trains light nothing here — they move the board, which the district panel already follows. A state rather than a flash: the timetable's fixed 1.5s animation would be over long before a seven-second pause. Not for your own moves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
257 lines
12 KiB
TypeScript
257 lines
12 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)), []);
|
|
});
|
|
});
|