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
380 lines
18 KiB
TypeScript
380 lines
18 KiB
TypeScript
/**
|
|
* THE WATCHABLE TABLE — v0.8.0, Gitea#20 / TODO #13, #15, #18.
|
|
*
|
|
* One shared, ordered presentation of everyone else's turns, on a seated player's own screen. The
|
|
* design is `docs/plans/jitsi-common-board.md` § v0.8.0; this file is its tests.
|
|
*
|
|
* Starting with ATTRIBUTION, because the caption row and the history panel both read these lines
|
|
* and a line that does not say who acted is useless on a screen built to answer "what did they
|
|
* just do?".
|
|
*/
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { applyIntent } from '../src/engine/apply.ts';
|
|
import { legalActions } from '../src/engine/legal.ts';
|
|
import type { GameConfig, PlayerIndex } from '../src/engine/state.ts';
|
|
import { fromMultiplayerSave, newGame, newMultiplayerGame, submit } from '../src/web/game.ts';
|
|
import { currentActor } from '../src/web/game.ts';
|
|
import { applyPublicDelta } from '../src/sim/public-delta.ts';
|
|
import { publicSnapshot } from '../src/sim/view.ts';
|
|
import type { PublicFrame } from '../src/sim/view.ts';
|
|
import { takeSteps } from '../src/sim/display-step.ts';
|
|
import { createSession } from '../src/server/session.ts';
|
|
import { kindOf } from '../src/sim/pacing.ts';
|
|
|
|
const config: GameConfig = {
|
|
mode: 'competitive',
|
|
days: 5,
|
|
minCombinedRevenue: 0,
|
|
maxCollisionsPerDay: 0,
|
|
maxCollisionsTotal: 0,
|
|
pvpCardsAllowed: false,
|
|
optionalRules: {
|
|
reducedVisibility: false,
|
|
employeeRotation: false,
|
|
emergencyToolbox: false,
|
|
},
|
|
};
|
|
|
|
/**
|
|
* The four events a switching turn is made of. Every one of them used to arrive in the shared log
|
|
* unattributed: `record()` (`web/game.ts`) prefixes a line with the player's name only when the
|
|
* event itself carries `player`, and these four were the only events in their class that did not
|
|
* — `cardDrawn`, `cardPlayed`, `cardDiscarded`, `carPlacedOnTrain`, `loadStarted`, `loadCompleted`,
|
|
* `flyingSwitch` and `localOpsOptionChosen` all did. So a switching turn read as an attributed
|
|
* bracket around anonymous contents:
|
|
*
|
|
* Player Alice chose to switch ← attributed
|
|
* CREW moved (1,2) → (1,3) — 4 of 6 ← whose train?
|
|
* Player Alice finished Local Operations ← attributed
|
|
*
|
|
* Measured 2026-09-09 and fixed with the feature that reads them, not filed.
|
|
*/
|
|
const SWITCHING_EVENTS = ['trayMoved', 'carsCoupled', 'carsDropped', 'consistSorted'] as const;
|
|
|
|
/** How each of those four reads in the log, so the assertions can find them by text. */
|
|
const SWITCHING_LINE = /^Player .+ (moved (Train |the local crew)|coupled \d+ car|set out |used the SMALL YARD)/;
|
|
|
|
describe('switching is attributed — TODO #13', () => {
|
|
it('every switching event carries the player who acted', () => {
|
|
/**
|
|
* Driven by PREFERRING switch moves rather than taking the first legal action, because bot
|
|
* switching is clustered rather than spread: two of the three published replays contain no
|
|
* `switch.move` at all, so a game driven by `options[0]` can finish without ever exercising
|
|
* this. The counter below then guards against the test passing vacuously.
|
|
*/
|
|
let seen = 0;
|
|
for (const seed of [1917398, 191056, 4242]) {
|
|
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
|
|
for (let i = 0; i < 800; i++) {
|
|
const actor = currentActor(game);
|
|
if (actor === null) break;
|
|
const options = legalActions(game.state, actor);
|
|
if (options.length === 0) break;
|
|
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]!;
|
|
|
|
// Read the events this intent produces before applying it for real, so the assertion sees
|
|
// exactly what `record()` will be handed.
|
|
const preview = applyIntent(structuredClone(game.state), actor, chosen);
|
|
if (preview.ok) {
|
|
for (const e of preview.events) {
|
|
if ((SWITCHING_EVENTS as readonly string[]).includes(e.type)) {
|
|
assert.ok(
|
|
'player' in e,
|
|
`${e.type} carries no player, so the log cannot say whose crew it was`,
|
|
);
|
|
assert.equal(
|
|
(e as { player: PlayerIndex }).player,
|
|
actor,
|
|
`${e.type} names the wrong player`,
|
|
);
|
|
seen++;
|
|
}
|
|
}
|
|
}
|
|
if (!submit(game, chosen)) break;
|
|
}
|
|
}
|
|
assert.ok(seen > 0, 'no switching event was produced, so this test proved nothing');
|
|
});
|
|
|
|
it('reads as a player action in the log, not as anonymous plain text', () => {
|
|
let lines = 0;
|
|
for (const seed of [1917398, 4242]) {
|
|
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
|
|
for (let i = 0; i < 800; i++) {
|
|
const actor = currentActor(game);
|
|
if (actor === null) break;
|
|
const options = legalActions(game.state, actor);
|
|
if (options.length === 0) break;
|
|
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
|
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
|
}
|
|
|
|
for (const line of game.log) {
|
|
// The old wording. `uncapitalise` deliberately leaves an acronym alone (`^[A-Z][a-z]` only),
|
|
// so "CREW moved" and "SMALL YARD —" would have survived the prefix and read as
|
|
// "Player Alice CREW moved …". Both were reworded to compose.
|
|
assert.doesNotMatch(
|
|
line.text,
|
|
/^CREW moved|^SMALL YARD —/,
|
|
`an unattributed switching line survived: ${line.text}`,
|
|
);
|
|
if (SWITCHING_LINE.test(line.text)) {
|
|
assert.equal(line.tone, 'act', `a switching line must read as somebody's move: ${line.text}`);
|
|
lines++;
|
|
}
|
|
}
|
|
}
|
|
assert.ok(lines > 0, 'no switching line reached the log, so this test proved nothing');
|
|
});
|
|
});
|
|
|
|
describe('the display-step collector — TODO #13', () => {
|
|
it('emits one step per accepted intent plus one per automatic phase, in order', () => {
|
|
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
|
|
let accepted = 0;
|
|
for (let i = 0; i < 120; i++) {
|
|
const actor = currentActor(game);
|
|
if (actor === null) break;
|
|
const options = legalActions(game.state, actor);
|
|
if (options.length === 0) break;
|
|
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
|
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
|
accepted++;
|
|
}
|
|
assert.ok(accepted > 30, `only ${accepted} intents accepted — this proved little`);
|
|
|
|
const steps = takeSteps(game.display);
|
|
/**
|
|
* TWO KINDS OF STEP SINCE TODO #18: one per accepted intent, and one per automatic phase that
|
|
* did anything. So the count is no longer `accepted` — but every intent must still have exactly
|
|
* one step, which is the invariant that matters.
|
|
*/
|
|
const byIntent = steps.filter((s) => s.cause !== 'phase');
|
|
const byPhase = steps.filter((s) => s.cause === 'phase');
|
|
assert.equal(byIntent.length, accepted, 'one step per accepted intent, no more and no fewer');
|
|
assert.ok(byPhase.length > 0, 'no phase produced a step — TODO #18 is not being served');
|
|
steps.forEach((s, i) => {
|
|
assert.equal(s.seq, i, 'sequence numbers must be dense and in order');
|
|
assert.equal(s.protocolVersion, 1);
|
|
assert.ok(kindOf(s.cause), `step ${i} carries a cause pacing cannot classify`);
|
|
// A phase is nobody's move; an intent is always somebody's.
|
|
assert.equal(s.player === null, s.cause === 'phase', `step ${i} disagrees about who acted`);
|
|
assert.equal(s.seat === null, s.cause === 'phase');
|
|
});
|
|
assert.equal(takeSteps(game.display).length, 0, 'draining must empty the collector');
|
|
});
|
|
|
|
it('a rejected intent produces no step', () => {
|
|
const game = newMultiplayerGame(4242, config, ['Alice', 'Bob', 'Carol']);
|
|
takeSteps(game.display);
|
|
// Somebody else's turn: refused before the engine is touched, so nothing to present.
|
|
const notMyTurn = ((currentActor(game) ?? 0) + 1) % 3;
|
|
assert.equal(submit(game, { type: 'draw.end' }, notMyTurn as PlayerIndex), false);
|
|
assert.equal(takeSteps(game.display).length, 0, 'a refused intent must not be presented');
|
|
});
|
|
|
|
it('the step deltas reconstruct the public board exactly', () => {
|
|
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
|
|
let held: PublicFrame | null = null;
|
|
for (let i = 0; i < 150; i++) {
|
|
const actor = currentActor(game);
|
|
if (actor === null) break;
|
|
const options = legalActions(game.state, actor);
|
|
if (options.length === 0) break;
|
|
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
|
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
|
for (const s of takeSteps(game.display)) held = applyPublicDelta(held, s.frame);
|
|
}
|
|
assert.deepEqual(held, publicSnapshot(game.state), 'the animated board drifted from the real one');
|
|
});
|
|
|
|
/**
|
|
* THE PROPERTY THAT IS CURRENTLY FREE AND MUST STAY THAT WAY.
|
|
*
|
|
* `fromSave`/`fromMultiplayerSave` rebuild a game with `applyIntent` + `record` + `drain` rather
|
|
* than `submit`, so a resumed server does not re-emit the whole game as steps and burn the
|
|
* sequence. The plan expected this to need an explicit guard. It does not — but move a replay
|
|
* path onto `submit()` and it silently becomes a real bug, which is why this is pinned.
|
|
*/
|
|
it('replaying a save emits no steps at all', () => {
|
|
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
|
|
for (let i = 0; i < 80; i++) {
|
|
const actor = currentActor(game);
|
|
if (actor === null) break;
|
|
const options = legalActions(game.state, actor);
|
|
if (options.length === 0) break;
|
|
if (!submit(game, options[0]!)) break;
|
|
}
|
|
assert.ok(game.history.length > 20, 'need a real history to replay');
|
|
|
|
const rebuilt = fromMultiplayerSave(game.seed, config, ['Alice', 'Bob', 'Carol'], game.history);
|
|
assert.equal(
|
|
rebuilt.game.display.steps.length,
|
|
0,
|
|
'a replay re-emitted the whole game as display steps',
|
|
);
|
|
assert.equal(rebuilt.game.display.seq, 0, 'a replay burned display sequence numbers');
|
|
});
|
|
|
|
it('solitaire collects the same way multiplayer does', () => {
|
|
// The standing design direction: solitaire is a special case of multiplayer, not a second
|
|
// implementation. Both go through one `submit()`, so this needs no separate code path — and
|
|
// that is exactly what makes TODO #18 fall out of TODO #13's mechanism.
|
|
const game = newGame(4242);
|
|
let accepted = 0;
|
|
for (let i = 0; i < 60; i++) {
|
|
const actor = currentActor(game);
|
|
if (actor === null) break;
|
|
const options = legalActions(game.state, actor);
|
|
if (options.length === 0) break;
|
|
if (!submit(game, options[0]!)) break;
|
|
accepted++;
|
|
}
|
|
assert.ok(accepted > 10, 'the solitaire game did not get going');
|
|
const collected = takeSteps(game.display);
|
|
assert.equal(
|
|
collected.filter((s) => s.cause !== 'phase').length,
|
|
accepted,
|
|
'solitaire must collect a step per intent too',
|
|
);
|
|
// And solitaire is where TODO #18 lives — its phases must earn beats on the same path.
|
|
assert.ok(collected.some((s) => s.cause === 'phase'), 'solitaire got no phase steps');
|
|
});
|
|
});
|
|
|
|
describe('steps reach a seated player — TODO #13', () => {
|
|
it('never replays the opening bot turns at the first client to connect', () => {
|
|
/**
|
|
* `buildSession` runs `driveBotTurns()` at construction, so with bots ahead of you in the order
|
|
* the game has already moved before anybody can connect. Those steps must be DROPPED, not
|
|
* queued: a connecting client's `publicReset` is the board as it stands after those very moves,
|
|
* so replaying them onto it would draw positions the game had already left.
|
|
*
|
|
* Found by review 2026-09-09 rather than by a failing test, which is why this one exists.
|
|
*/
|
|
const session = createSession(1917398, config, ['Alice', 'Bob', 'Carol'], [1, 2]);
|
|
const push = session.connect(0 as PlayerIndex);
|
|
assert.ok(push.publicReset, 'a connecting client needs a baseline');
|
|
assert.equal(push.steps, undefined, 'the connect push must carry no steps at all');
|
|
|
|
// And the first real broadcast must carry only what THIS move produced — nothing older.
|
|
const option = push.menu?.options[0];
|
|
assert.ok(option, 'seat 0 should have something to do');
|
|
const r = session.intent(0 as PlayerIndex, 1, option);
|
|
assert.ok(r.accepted);
|
|
const steps = [...r.pushes.values()][0]?.steps ?? [];
|
|
assert.ok(steps.length > 0, 'the move produced no steps');
|
|
/**
|
|
* The first step delivered must be THIS seat's move — not a bot's, which is what a replayed
|
|
* opening turn would look like. The sequence does NOT restart at 0: `takeSteps` empties the
|
|
* collector without rewinding the counter, so the first thing a client sees may be seq 14. That
|
|
* is fine and deliberate — what 0.8.1's gap detection needs is monotonic and dense, not
|
|
* zero-based.
|
|
*/
|
|
assert.equal(steps[0]!.player, 0, 'the first delivered step was not the move just made');
|
|
assert.equal(steps[0]!.cause, option.type);
|
|
steps.forEach((st, i) => {
|
|
if (i > 0) assert.equal(st.seq, steps[i - 1]!.seq + 1, 'sequence must stay dense');
|
|
});
|
|
});
|
|
|
|
|
|
it('every seat gets the same public steps, and a connect gets a baseline to merge onto', () => {
|
|
const session = createSession(1917398, config, ['Alice', 'Bob', 'Carol'], [1, 2]);
|
|
|
|
const connected = session.connect(0 as PlayerIndex);
|
|
assert.ok(connected.publicReset, 'a connecting client needs a baseline for its step queue');
|
|
|
|
let seen = 0;
|
|
for (let i = 0; i < 60; i++) {
|
|
const menu = session.connect(0 as PlayerIndex).menu;
|
|
const option = menu?.options[0];
|
|
if (!option) break;
|
|
const r = session.intent(0 as PlayerIndex, i, option);
|
|
if (!r.accepted) break;
|
|
const pushes = [...r.pushes.values()];
|
|
if (pushes.length === 0) continue;
|
|
const first = pushes[0]!.steps ?? [];
|
|
if (first.length === 0) continue;
|
|
seen += first.length;
|
|
for (const p of pushes) {
|
|
assert.deepEqual(p.steps, first, 'every seat must receive the identical public steps');
|
|
}
|
|
}
|
|
assert.ok(seen > 0, 'no steps reached a push, so this proved nothing');
|
|
});
|
|
});
|
|
|
|
describe('the log says who acted, once, and in what capacity (Gitea#30, #31)', () => {
|
|
it('never names a player twice — no "Player Jesse player 0 finished …"', async () => {
|
|
const { newMultiplayerGame, currentActor, submit } = await import('../src/web/game.ts');
|
|
const { legalActions } = await import('../src/engine/legal.ts');
|
|
|
|
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
|
|
for (let i = 0; i < 400; i++) {
|
|
const actor = currentActor(game);
|
|
if (actor === null) break;
|
|
const options = legalActions(game.state, actor);
|
|
if (options.length === 0) break;
|
|
if (!submit(game, options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
|
}
|
|
|
|
assert.ok(game.log.length > 50, 'the game barely ran, so this proved little');
|
|
for (const line of game.log) {
|
|
// `record()` prefixes the acting player's NAME; a narration that also named them read
|
|
// "Player Alice player 0 finished Local Operations" (playtest, 2026-09-15).
|
|
assert.doesNotMatch(
|
|
line.text,
|
|
/\bplayer \d+\b/i,
|
|
`a line still carries a bare player index: ${line.text}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
it('attributes a clearance ruling to the office, not to the seat\'s own turn', async () => {
|
|
const { newMultiplayerGame, drain, submit } = await import('../src/web/game.ts');
|
|
const { areaOf } = await import('../src/engine/apply.ts');
|
|
|
|
const game = newMultiplayerGame(7, config, ['Alice', 'Bob', 'Carol']);
|
|
const s = game.state;
|
|
const area = areaOf(s, 0);
|
|
|
|
// A westbound train at seat 0's Office, and another westbound AHEAD of it — west of the Office —
|
|
// which is §8.1's fourth condition and the Superintendent's to rule on (see Gitea#26).
|
|
s.trays.set('departing', {
|
|
id: 'departing', trainNumber: 15, trainIsExtra: true, engineAt: 0, consist: [],
|
|
direction: 'west', facing: 'w',
|
|
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
|
} as never);
|
|
area.adOccupancy.push('departing');
|
|
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
|
const card = s.division.nodes.findIndex((n, i) => i < office && n.kind === 'mainline');
|
|
const node = s.division.nodes[card];
|
|
assert.equal(node?.kind, 'mainline');
|
|
s.trays.set('ahead', {
|
|
id: 'ahead', trainNumber: 9, trainIsExtra: false, engineAt: 0, consist: [],
|
|
direction: 'west', facing: 'w', position: { at: 'mainline', index: card }, movesUsed: 0,
|
|
} as never);
|
|
if (node?.kind === 'mainline') {
|
|
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
|
|
}
|
|
|
|
s.clock.phase = 'mainline';
|
|
drain(game);
|
|
assert.equal(s.clock.pendingDecision?.kind, 'clearance', 'no ruling was called for, so nothing was tested');
|
|
|
|
const before = game.log.length;
|
|
assert.ok(submit(game, { type: 'mainline.clearance', allow: false }, s.clock.superintendent));
|
|
const said = game.log.slice(before).map((l) => l.text);
|
|
assert.ok(
|
|
said.some((text) => text.startsWith('Superintendent Player ')),
|
|
`a ruling did not read as the office's: ${said.join(' | ')}`,
|
|
);
|
|
});
|
|
});
|