/** * 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 };