/** * 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, areaOf } from '../src/engine/apply.ts'; import { legalActions } from '../src/engine/legal.ts'; import type { GameConfig, PlayerIndex } from '../src/engine/state.ts'; import { turnOf } 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 at |set out |used the SMALL YARD)/; /** * The two tones a switching line may carry, and why attribution matters in BOTH. * * Since 2026-09-17 a plain move along your own track is written `trace`: the line still exists, so * its display step has narration to caption the board with and a dwell to be watched for, but the * history panel does not draw it (`web/game.ts` § inHistory). What must never happen either way is * the line failing to say whose crew it was — the caption is read by the whole table while the move * goes up, which is if anything the more public of the two places. */ const SWITCHING_TONES = ['act', 'trace']; 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.ok( SWITCHING_TONES.includes(line.tone), `a switching line must read as somebody's move: ${line.text} (tone ${line.tone})`, ); 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 Fedora passing is visible (playtest 2026-09-16)', () => { it('names the new Superintendent in the history at the Stage it happens', async () => { const { newMultiplayerGame, currentActor, submit } = await import('../src/web/game.ts'); const { legalActions } = await import('../src/engine/legal.ts'); /** * It used to ride on `actorChanged`, which `record()` drops as turn bookkeeping — so the one * moment that event meant something never reached a player. Driven far enough to cross a shift * boundary (Stages 3, 6, 9, 12) rather than asserted on a hand-built event, because the point is * that a real game produces the line. */ const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']); for (let i = 0; i < 900; 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; if (game.state.clock.stage > 3 || game.state.clock.day > 1) break; } const handover = game.log.filter((l) => /SUPERINTENDENT — the Fedora passes to/.test(l.text)); assert.ok(handover.length > 0, 'the game crossed a shift change and the log never said so'); assert.match(handover[0]!.text, /Alice|Bob|Carol/, 'the handover did not name the new Superintendent'); }); }); 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(' | ')}`, ); }); }); // --------------------------------------------------------------------------- /** * HOW MUCH SWITCHING REACHES THE HISTORY PANEL — Jesse's ruling, 2026-09-17, asked as a question * from the table: *"Does switching show up in history at all? Should it? … I don't think I want all * six moves showing up… maybe dropping off or picking up cars in industries should be recorded."* * * It all showed up. A six-Move turn wrote a line per move, every one of them a pair of coordinates, * and two players shunting pushed everything else off the panel. What stays is the line saying * somebody switched, work at an INDUSTRY, and the Small Yard sort. * * THE LINE IS STILL WRITTEN, MARKED `trace`. `dwellForStep` gives a step no dwell when it produced * no narration, so dropping these outright stopped the board replaying switching at all — the first * attempt at this did exactly that and the step-queue suite caught it. The tone is the seam: the * caption still has its text, the panel filters the tone out. */ describe('the history panel keeps the switching that matters', () => { const play = (seed: number, steps: number) => { const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']); for (let i = 0; i < steps; 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; } return game; }; it('keeps the first move of a turn and traces the ones after it', () => { const game = play(1917398, 800); const moves = game.log.filter((l) => / moved (Train|the local crew)/.test(l.text)); assert.ok(moves.length > 0, 'no crew move reached the log, so this proved nothing'); // The opener of a turn is the move that leaves `movesAllowed - 1` behind it, which the line // prints — "5 of 6 Moves left". Those are drawn; everything after them is caption-only. const opening = moves.filter((l) => / 5 of 6 Moves left/.test(l.text)); const later = moves.filter((l) => !/ 5 of 6 Moves left/.test(l.text)); assert.ok(opening.length > 0, 'no turn opened with a move, so this proved nothing'); assert.ok(later.length > 0, 'no turn made a second move, so this proved nothing'); assert.ok( opening.every((l) => l.tone !== 'trace'), `the first move of a switching turn was hidden: ${opening.find((l) => l.tone === 'trace')?.text}`, ); assert.ok( later.every((l) => l.tone === 'trace'), `a move from the middle of a turn is still drawn: ${later.find((l) => l.tone !== 'trace')?.text}`, ); // Every one keeps its text, because that is what captions the board as the move goes up. assert.ok(moves.every((l) => /^Player /.test(l.text)), 'a trace line lost its attribution'); }); it('closes a switching turn with what it cost and where the crew was left', () => { /** * BUILT, NOT PLAYED — the driver above never submits `switch.end`: it finds a move or another * option every time, so 800 turns produced no closed switching turn at all and the assertion * would have been vacuous. */ const { game, trayId } = crewOnAnIndustry(); assert.ok(submit(game, { type: 'switch.move', trayId, to: { row: 1, col: 2 }, reverse: false })); assert.ok(submit(game, { type: 'switch.end' }), 'the turn would not end'); const closing = game.log.filter((l) => / finished switching/.test(l.text)); assert.equal(closing.length, 1, `expected one closing line, got ${closing.length}`); assert.notEqual(closing[0]!.tone, 'trace', 'the closing summary was hidden from the history'); assert.match( closing[0]!.text, /1 of 6 Moves used, leaving .* at /, `the closing line did not say what it cost and where the crew was left: ${closing[0]!.text}`, ); }); /** * A crew standing on a Freight House with a loaded boxcar, and plain track to its east. * * BUILT RATHER THAN PLAYED. The bot prefers moves over couplings, so 2400 driven turns across * three seeds produced not one set-out at an industry, and none of them ever ended a switching * turn — a driver that cannot reach the case cannot test it. */ const crewOnAnIndustry = (): { game: ReturnType; trayId: string } => { const game = newGame(77); const area = areaOf(game.state, 0); const spot = { row: 1, col: 1 }; const track = (over: object = {}) => ({ geometry: { kind: 'track', geometry: 'straight' }, baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [], ...over, }); area.grid.set('1,1', track({ geometry: { kind: 'facility', facility: 'freightHouse', axis: 'ew' }, facility: { kind: 'freight', subtype: 'freightHouse', allows: { outbound: true, inbound: true }, outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 1 }, menAtWork: [null, null, null], industryTrack: { cars: [] }, laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 }, }, }) as never); area.grid.set('1,2', track() as never); const trayId = game.state.freeTrays.pop()!; game.state.trays.set(trayId, { id: trayId, trainNumber: null, trainIsExtra: false, engineAt: 0, consist: [{ type: 'boxcar', loaded: true }], direction: 'east', facing: 'e', railFacing: 'e', position: { at: 'grid', seat: 0, coord: spot }, movesUsed: 0, } as never); game.state.clock.phase = 'localOps'; game.state.clock.currentActor = 0; turnOf(game.state, 0).option = 'switch'; return { game, trayId }; }; it('keeps work at an industry, and drops the same move on plain track', () => { const { game, trayId } = crewOnAnIndustry(); const plain = { row: 1, col: 2 }; assert.ok(submit(game, { type: 'switch.dropCars', trayId, count: 1 }), 'the set-out was refused'); const dropped = game.log.filter((l) => /set out/i.test(l.text)); assert.equal(dropped.length, 1, `expected one set-out line, got ${dropped.length}`); assert.notEqual( dropped[0]!.tone, 'trace', 'work at an industry was hidden from the history — it is the point of switching', ); assert.match( dropped[0]!.text, /at the Freight House/, `the line named a coordinate instead of the industry: ${dropped[0]!.text}`, ); // The same crew moving onto ordinary track is the noise this ruling was about. assert.ok(submit(game, { type: 'switch.move', trayId, to: plain, reverse: false }), 'the move was refused'); const moved = game.log.filter((l) => / moved the local crew/.test(l.text)); assert.equal(moved.length, 1, `expected one move line, got ${moved.length}`); /** * The FIRST move of a turn is kept, and this crew's first move is this one — so what is being * checked here is that it names the square by what stands on it rather than by its coordinates. * * The coordinate branch is `(2,1)` for `{ row: 1, col: 2 }`: X,Y, east/west then north/south, * which is how `view.ts` has always written a square and how `narrate.ts` writes one since * 2026-09-21. It read `(1,2)` here while the log still printed the internal storage order. */ assert.match(moved[0]!.text, /→ the Freight House|→ \(2,1\)/, `unexpected move line: ${moved[0]!.text}`); }); });