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
295 lines
14 KiB
TypeScript
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');
|
|
});
|
|
});
|