/** * WHAT THE ENGINE KNOWS AND THE SCREEN DOES NOT — the 2026-09-07 sweep. * * Gitea#21, Gitea#22, #94 and #96 were four instances of one fault in a row: the engine gained a * thing that changes what a train may do, and nothing drew it. Each was found by a player hitting * it. So rather than wait for the fifth, every field of `GameState` and its nested types was * enumerated and checked for a reader in `sim/view.ts`, `src/web/` and `sim/narrate.ts`, and the * survivors were then verified by running the engine rather than by trusting the grep. * * Four fields had no reader anywhere. `movedThisPhase` is set and cleared inside one `advance` call * and is genuinely nobody's business. The other three are these tests. The bookkeeping fields whose * EFFECT is already visible as legality — `freightWorked`, `drawnThisTurn`, `freightAgentUsed`, * `movesUsed` (its complement `movesRemaining` is on the Frame), `switchedSince` — are deliberately * not here: a field is not a display gap merely because no one renders it. * * The method is worth more than the three fixes, and is written down in TODO Reference · #98. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { createGame } from '../src/engine/setup.ts'; import { crewTrayCount } from '../src/engine/content.ts'; import { areaOf } from '../src/engine/apply.ts'; import { impediments } from '../src/sim/narrate.ts'; import { projectDistrict, projectSharedTable, publicSnapshot, trainRules } from '../src/sim/view.ts'; import type { CrewTray, GameConfig, GameState, PlayerIndex, SeatIndex } from '../src/engine/state.ts'; import { seatOf } from '../src/engine/state.ts'; const config: GameConfig = { mode: 'competitive', days: 3, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false }, }; const game = (): GameState => { const s = createGame({ id: 'g', seed: 4242, config, playerNames: ['Ann', 'Bob'] }); s.status = 'active'; return s; }; /** * THE CREW TRAY POOL IS A MECHANIC, AND IT WAS INVISIBLE (#98). * * `state.ts` calls tray scarcity "an explicit mechanic": there are fewer trays than there are trains * wanting one, and which trains get held is the whole of §7. The engine knows three things about it * — how many trays are free, which Extras are queued for one, and which second sections are — and * the view read none of them. * * The panel that answers "why is nothing moving?" had exactly one tray rule, keyed off the train due * out THIS Stage (`s.timetable[stage - 1]`). So a player who spent a card on an Extra, or ordered a * second section, got a blocked panel that was completely EMPTY while their train sat behind an * exhausted pool — and both had been announced once in the log, in a line that promised a future * event ("as soon as a Crew Tray frees up") which nothing then confirmed. */ describe('the Crew Tray pool is a mechanic the player can see (#98)', () => { /** A table whose trays are all out, with one Extra and one second section queued behind them. */ const jammed = (): GameState => { const s = game(); s.freeTrays = []; s.pendingExtras.push({ trainNumber: 17, player: 0 as PlayerIndex }); s.pendingSecondSections.push(8); return s; }; it('reports how many Crew Trays are free, and how many there are', () => { const s = game(); const total = crewTrayCount(2); assert.equal(s.freeTrays.length, total, 'the premise is gone: trays were already out at setup'); assert.deepEqual(projectSharedTable(s).crewTrays, { free: total, total }); s.freeTrays = s.freeTrays.slice(0, 1); assert.deepEqual( projectSharedTable(s).crewTrays, { free: 1, total }, 'the pool emptied and the shared table did not notice', ); }); it('names the trains queued for a tray, so a promise made in the log is kept on the board', () => { const q = projectSharedTable(jammed()).queued; assert.deepEqual(q.extras, [{ trainNumber: 17, player: 0 }], 'a played Extra is waiting nowhere visible'); assert.deepEqual(q.secondSections, [8], 'an ordered second section is waiting nowhere visible'); }); it('tells the player who played the Extra that it is held for want of a crew', () => { const blocked = impediments(jammed(), 0 as PlayerIndex); const extra = blocked.find((b) => /X17/.test(b.where)); assert.ok(extra, 'the blocked panel said nothing about an Extra held for want of a Crew Tray'); assert.match(extra.why, /Crew Tray/, 'it was listed without naming the thing it is waiting for'); assert.equal(extra.severity, 'stuck'); // The POOL SIZE, not a second derivation of it. Written as `trays.size + freeTrays.length` // first, which reads 0 of 0 for any state where a tray is neither free nor carrying a train. assert.match( extra.why, new RegExp(`0 of ${crewTrayCount(2)} Crew Trays free`), 'the panel reported the wrong pool size', ); }); it('says the same for a second section, which waits on the identical pool', () => { const blocked = impediments(jammed(), 0 as PlayerIndex); const second = blocked.find((b) => /Train 8\b/.test(b.where) && /second section/i.test(b.why)); assert.ok(second, 'an ordered second section was queued invisibly'); assert.match(second.why, /Crew Tray/); }); it('says none of it once a tray is free, because then nothing is being waited on', () => { const s = jammed(); s.freeTrays = ['tray0']; const blocked = impediments(s, 0 as PlayerIndex); assert.equal( blocked.filter((b) => /Crew Tray/.test(b.why)).length, 0, 'a free tray still reported trains held for want of one', ); }); it('is on the common board too — the pool is on the table, not in a hand', () => { const pub = publicSnapshot(jammed()) as unknown as Record; assert.ok('crewTrays' in pub, 'a spectator cannot see the scarcity everyone at the table can'); assert.ok('queued' in pub); }); }); /** * A TRAIN HELD AT THE LIMITS IS STILL ON THE BOARD (#99). * * The Interlocking enhancement is the designed answer to a full Office: instead of the automatic * collision of Gap 2d, "may stop an inbound train on the Limit Track" — the train is held inside the * player's Limits, and takes the first A/D track that frees, ahead of any newcomer. * * It was drawn NOWHERE. `arriveAtOffice` removes the tray from the Mainline node's `transits` * (advance.ts) and the Interlocking branch pushes it onto `area.heldAtLimits` without assigning * `tray.position` — so the map, which draws mainline nodes from `transits` and district squares from * `position.at === 'grid'`, has nothing to draw it from in either place. The train vanished off the * board on arrival and reappeared in the Office some Stages later. * * Measured before fixing: with the tray in `transits` the Interchange node carries its chip; with * the tray moved to `heldAtLimits` exactly as the engine moves it, the node's `trains` is empty and * no grid square has gained it. * * The fix is in the VIEW, not the engine. The engine's state is right — a held train is inside the * Limits and not on an A/D track, which is what `heldAtLimits` says — and `position` is left alone * deliberately, so nothing may treat the train as standing on a square it could be switched from. */ describe('a train held at the Limits is drawn at the Limits (#99)', () => { const held = (direction: 'east' | 'west') => { const s = game(); const seat = seatOf(s, 0 as PlayerIndex); const area = areaOf(s, 0 as PlayerIndex); const trayId = s.freeTrays.pop()!; const tray: CrewTray = { id: trayId, trainNumber: 5, trainIsExtra: false, engineAt: 0, consist: [], direction, movesUsed: 0, position: { at: 'mainline', index: 1 }, } as CrewTray; s.trays.set(trayId, tray); area.heldAtLimits.push(trayId); return { s, seat, trayId, area }; }; it('draws it on the Limits square it is standing at, not nowhere', () => { const { s, seat, trayId, area } = held('east'); const { cells } = projectDistrict(s, seat as SeatIndex); const on = cells.filter((c) => c.trains.some((t) => t.trayId === trayId)); assert.equal(on.length, 1, 'a train held at the Limits is drawn on no square at all'); assert.equal(on[0]!.row, area.limitsWest.row, 'drawn at the wrong Limits'); assert.equal(on[0]!.col, area.limitsWest.col); }); it('holds an EASTBOUND train at the western Limits, because that is the end it came in by', () => { const { s, seat, area } = held('east'); const { cells } = projectDistrict(s, seat as SeatIndex); const on = cells.find((c) => c.trains.length > 0)!; assert.deepEqual({ row: on.row, col: on.col }, { row: area.limitsWest.row, col: area.limitsWest.col }); }); it('and a WESTBOUND train at the eastern Limits', () => { const { s, seat, area } = held('west'); const { cells } = projectDistrict(s, seat as SeatIndex); const on = cells.find((c) => c.trains.length > 0)!; assert.deepEqual({ row: on.row, col: on.col }, { row: area.limitsEast.row, col: area.limitsEast.col }); }); it('says on the chip that it is HELD, so it is not read as a train free to switch', () => { const { s, seat, trayId } = held('east'); const { cells } = projectDistrict(s, seat as SeatIndex); const chip = cells.flatMap((c) => c.trains).find((t) => t.trayId === trayId)!; assert.equal(chip.heldAtLimits, true, 'a held train looked exactly like one standing on the square'); assert.match(chip.what, /Interlocking|held/i, 'the chip does not say why it is standing there'); }); it('tells the district owner it is waiting, and what for', () => { const { s } = held('east'); const blocked = impediments(s, 0 as PlayerIndex); const b = blocked.find((x) => /Train 5/.test(x.where) && /Limits/i.test(x.why)); assert.ok(b, 'the blocked panel said nothing about a train held at the Limits'); }); it('does not invent a train on a square when nothing is held', () => { const s = game(); const { cells } = projectDistrict(s, seatOf(s, 0 as PlayerIndex) as SeatIndex); assert.equal(cells.flatMap((c) => c.trains).length, 0, 'a district with no trains drew one'); }); }); /** * THE CAMPAIGN TRAIN'S SPEECHES CHANGE ITS RULES, AND THE CARD HAS TO SAY WHICH HALF IT IS IN (#100). * * X17 is "one turn at station (speeches) then expedite". Its first Office arrival is an ordinary * stop; every arrival after that runs EXPEDITED, which means that if it is not back on the Office * square when the next Mainline Phase begins it is a Station Master fault costing 1 Revenue. * * `trainRules()` took `{ trainNumber, trainIsExtra }` — it could not see `speechMade` even though * both of its tray-side callers pass a whole `CrewTray` that has it. So the chip read identically * before and after, and worse: the "EXPEDITED — must be kept ready to highball… costs 1 Revenue" * warning is printed only under `rules.expedite`, so X17 became subject to a fault whose warning the * game shows to other trains and never to it. */ describe('the Campaign Train says whether its speeches are made (#100)', () => { const X17 = { trainNumber: 17, trainIsExtra: true } as const; it('before the speeches, says they are still to come and does not claim it is expedited yet', () => { const t = trainRules({ ...X17 }); assert.match(t, /SPEECHES/i); assert.doesNotMatch(t, /costs 1 Revenue/, 'it warned of a fault the train is not yet subject to'); }); it('after the speeches, says it is expedited NOW and carries the fault it is now subject to', () => { const t = trainRules({ ...X17, speechMade: true }); assert.match(t, /EXPEDITED/, 'a train that is now expedited did not say so'); assert.match(t, /costs 1 Revenue/, 'the fault warning is shown to other trains and not to this one'); }); it('reads differently before and after — the whole of the bug was that it did not', () => { assert.notEqual(trainRules({ ...X17 }), trainRules({ ...X17, speechMade: true })); }); it('still warns a permanently expedited train, which must not regress', () => { const fast = trainRules({ trainNumber: 5, trainIsExtra: false }); assert.match(fast, /EXPEDITED/); assert.match(fast, /costs 1 Revenue/); }); it('says nothing about speeches for a train that has no such rule', () => { assert.doesNotMatch(trainRules({ trainNumber: 5, trainIsExtra: false }), /SPEECHES/i); }); });