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
187 lines
8.3 KiB
TypeScript
187 lines
8.3 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, 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 };
|