/** * The engine with more than one player. * * Everything else in this suite is solitaire, so the multi-player paths — Superintendent rotation, * Subdivisions split by SOME players' Control Points, one player's train blocking another's, and * scoring the right seat — have been running unwatched. The engine does play 2-5 players today; this * is the net under it before the presentation layer learns about seats. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { advance, pump } from '../src/engine/advance.ts'; import { applyIntent, areaAtSeat, areaOf, check, occupancyFor } from '../src/engine/apply.ts'; import { STAGES_PER_DAY, STAGES_PER_SHIFT, crewTrayCount } from '../src/engine/content.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 { coordKey, playerAtSeat, playerLeftOf, seatOf, subdivisions, turnOf } from '../src/engine/state.ts'; import { developerBot, playGame } from '../src/sim/bot.ts'; import { snapshot } from '../src/sim/view.ts'; import { divisionSvg } from '../src/sim/board-svg.ts'; import { impediments, narrate } from '../src/sim/narrate.ts'; import { readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { actionMenu } from '../src/web/game.ts'; import { newCollector } from '../src/sim/display-step.ts'; import type { Game } from '../src/web/game.ts'; /** The thin wrapper `actionMenu` expects, built directly around an already-created multi-player state * — `newGame` (game.ts) hardcodes one player, so it cannot construct this for a multi-seat game. */ const wrap = (s: GameState): Game => ({ state: s, seed: s.seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null, display: newCollector() }); const competitive: GameConfig = { mode: 'competitive', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, // These fixtures were written against a Whistle Post opening — one A/D track and no // Control Point — and several of them test exactly that. Named explicitly since the // default became a Depot. houseRules: { startingOffice: 'whistlePost' }, optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false }, }; const game = (players: number, seed = 4242): GameState => createGame({ id: `mp${players}`, seed, config: competitive, playerNames: Array.from({ length: players }, (_, i) => `p${i}`), }); /** Puts a made-up train on `owner`'s A/D track, ready to highball in `direction`. */ function readyToLeave(s: GameState, owner: PlayerIndex, id: string, direction: 'east' | 'west'): void { const area = areaOf(s, owner); s.trays.set(id, { id, trainNumber: 9, trainIsExtra: false, engineAt: 0, consist: [], direction, position: { at: 'grid', seat: seatOf(s, owner), coord: area.officeCoord }, movesUsed: 0, }); area.adOccupancy.push(id); } /** An industry with nothing in its green box — the simplest thing `impediments` reports. */ const idleIndustry = () => ({ geometry: { kind: 'facility', facility: 'mineTipple' }, baseOperationalRail: true, standing: [], modifiers: [], enhancements: [], facility: { kind: 'freight', subtype: 'mineTipple', allows: { outbound: true, inbound: false }, outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 0 }, menAtWork: [null, null, null], industryTrack: { cars: [] }, laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 }, }, }) as never; const pinTerrain = (s: GameState): void => { // Double Track and Uncontrolled Siding print "trains may pass", which would clear any departure. for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains'; }; // --------------------------------------------------------------------------- describe('multi-player games run at all', () => { it('plays 2, 3 and 4 players to a finish, scoring each seat separately', () => { for (const players of [2, 3, 4]) { const s = game(players); const r = playGame(s, developerBot, pump); assert.ok(r.finished, `${players}p did not finish`); assert.equal(s.players.length, players); // Each seat keeps its own score. A single shared counter would show identical revenue. assert.equal(new Set(s.players.map((p) => p.index)).size, players, 'seats are not distinct'); } }); it('gives every player an Office Area and the right number of Crew Trays', () => { for (const players of [2, 3, 4]) { const s = game(players); assert.equal(s.officeAreas.size, players, `${players}p office areas`); // Every seat is occupied by exactly one player, and that player's district is that seat's. // Which player sits where is decided by §4.4's D12, so it is a permutation, not the identity. assert.deepEqual([...s.seating].sort((a, b) => a - b), [...Array(players).keys()]); for (let seat = 0; seat < players; seat++) { assert.equal(areaOf(s, playerAtSeat(s, seat)).seat, seat, 'a seat holds the wrong district'); } // §7 — trays are scarce on purpose, and the count is per player count. assert.equal(s.freeTrays.length, crewTrayCount(players), `${players}p crew trays`); } }); }); describe('the Fedora goes round the table', () => { /** * Watches the Superintendent while the BOT plays the game. * * `advance` stops and asks for input rather than driving itself, so calling it in a loop never * moves the clock — the game has to actually be played. A spy policy records the SEAT holding the * Fedora each time a decision is asked for: §5 passes it round the table, and since §4.4's D12 * decides who sits where, the sequence of player indices is a permutation while the sequence of * seats is the plain 0, 1, 2, … that the rule describes. */ const superintendentsSeen = (players: number): number[] => { const s = game(players); const seen: number[] = []; const spy = { name: 'spy', choose(st: GameState, p: PlayerIndex, opts: Parameters[2]) { const who = seatOf(st, st.clock.superintendent); if (seen[seen.length - 1] !== who) seen.push(who); return developerBot.choose(st, p, opts); }, }; playGame(s, spy as never, pump); return seen; }; it('passes the Superintendent to the next seat, wrapping round the table', () => { // §5 — the Fedora passes every three Stages. With one player that is invisible, because the next // seat is always the same seat. for (const players of [2, 3, 4]) { const seen = superintendentsSeen(players); assert.ok(seen.length > players, `${players}p: the Fedora moved only ${seen.length - 1} times`); for (let i = 1; i < seen.length; i++) { assert.equal( seen[i], (seen[i - 1]! + 1) % players, `${players}p: the Fedora jumped from ${seen[i - 1]} to ${seen[i]}`, ); } } }); it('gives every seat the Fedora over a full game', () => { // Four Days of twelve Stages is sixteen shift changes, so nobody should be missed. for (const players of [2, 3, 4]) { const seen = new Set(superintendentsSeen(players)); assert.equal(seen.size, players, `${players}p: only ${seen.size} seats ever held the Fedora`); } }); it('changes shift on the Stage the rule names, not on some other count', () => { // The hand-over happens entering a Stage that is a multiple of three (§5). const s = game(3); const stages: number[] = []; let last = s.clock.superintendent; const spy = { name: 'spy', choose(st: GameState, p: PlayerIndex, opts: Parameters[2]) { if (st.clock.superintendent !== last) { stages.push(st.clock.stage); last = st.clock.superintendent; } return developerBot.choose(st, p, opts); }, }; playGame(s, spy as never, pump); assert.ok(stages.length > 0, 'the Superintendent never changed'); for (const stage of stages) { assert.equal( (stage - 1) % STAGES_PER_SHIFT, 0, `the shift changed inside Stage ${stage}, which is not a shift boundary`, ); } }); }); describe('Subdivisions are split by whoever is a Control Point', () => { it('splits only at the Offices that have upgraded', () => { // With four players the Division is DP · ML · O0 · ML · O1 · ML · O2 · ML · O3 · ML · DP. Every // Office starts a Whistle Post, so the whole railroad is ONE Subdivision; upgrading ONE office in // the middle should cut it in two and leave the others inside. const s = game(4); assert.equal(subdivisions(s).length, 1, 'four Whistle Posts should leave one Subdivision'); // BY SEAT: a Subdivision is a stretch of the physical chain, and §4.4's D12 decides which // player is sitting in which stretch. Upgrading "player 1's" Office would upgrade whichever // seat they happen to hold, which is not what this test is about. areaAtSeat(s, 1).tier = 'depot'; const split = subdivisions(s); assert.equal(split.length, 2, 'a Control Point should cut the Division in two'); // The upgraded Office is a BOUNDARY, so it appears in neither group; the others still sit inside. const officeIndex = (seat: number): number => s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === seat); const all = split.flat(); assert.ok(!all.includes(officeIndex(1)), 'the Control Point is still inside a Subdivision'); for (const other of [0, 2, 3]) { assert.ok(all.includes(officeIndex(other)), `a Whistle Post at seat ${other} became a boundary`); } }); it('gives every Office its own Subdivision once they are all Control Points', () => { const s = game(4); for (let seat = 0; seat < 4; seat++) areaAtSeat(s, seat).tier = 'terminal'; // Five Mainline cards, each now bounded by a Control Point or a Division Point. assert.equal(subdivisions(s).length, 5, 'each Mainline card should be its own Subdivision'); }); }); describe("one player's train blocks another's", () => { it('bars a departure into a Subdivision an opposing train occupies, whoever owns it', () => { // §8.1's absolute bar is about the SUBDIVISION, not about whose train it is. With two Whistle // Posts the whole railroad is one Subdivision, so seat 0 cannot leave into seat 1's oncoming // train even though they are nowhere near each other. const s = game(2); pinTerrain(s); assert.equal(subdivisions(s).length, 1, 'the fixture assumes one Subdivision'); // Seat 1's train is running WEST somewhere on the Division. const far = s.division.nodes.findIndex((n, i) => n.kind === 'mainline' && i > 2); const node = s.division.nodes[far]; if (node?.kind === 'mainline') { node.transits.push({ tray: 'theirs', stagesRemaining: 2, stagesTotal: 2, direction: 'west' }); } s.trays.set('theirs', { id: 'theirs', trainNumber: 3, trainIsExtra: false, engineAt: 0, consist: [], direction: 'west', position: { at: 'mainline', index: far }, movesUsed: 0, }); readyToLeave(s, 0, 'mine', 'east'); s.clock.phase = 'mainline'; s.movedThisPhase = new Set(); advance(s); assert.equal( s.trays.get('mine')!.position.at, 'grid', "a train departed into a Subdivision holding another player's oncoming train", ); }); it('lets it go once a Control Point puts them in different Subdivisions', () => { // The same position, with the middle Office upgraded: the two trains are no longer in the same // Subdivision, so seat 0's departure is nobody's business but its own. const s = game(2); pinTerrain(s); areaOf(s, 0).tier = 'depot'; const far = s.division.nodes.findIndex((n, i) => n.kind === 'mainline' && i > 2); const node = s.division.nodes[far]; if (node?.kind === 'mainline') { node.transits.push({ tray: 'theirs', stagesRemaining: 2, stagesTotal: 2, direction: 'west' }); } s.trays.set('theirs', { id: 'theirs', trainNumber: 3, trainIsExtra: false, engineAt: 0, consist: [], direction: 'west', position: { at: 'mainline', index: far }, movesUsed: 0, }); readyToLeave(s, 0, 'mine', 'west'); s.clock.phase = 'mainline'; s.movedThisPhase = new Set(); advance(s); assert.equal( s.trays.get('mine')!.position.at, 'mainline', 'a train was held for a train in a different Subdivision', ); }); }); describe('a seat is a place, a player is a person', () => { it('seats everyone exactly once, and seats them by the §4.4 roll', () => { /** * `seating` was the identity mapping until §4.4's D12 was wired up, which meant the whole * seat/player distinction was untested at runtime — every mix-up of the two was silently * correct. It is a real permutation now, and this is what says so. * * Solitaire is the exception and must stay one: with a single player there is one seat, the * permutation is trivially the identity, and every replay depends on that. */ for (const players of [2, 3, 4]) { const s = game(players); assert.deepEqual([...s.seating].sort((a, b) => a - b), [...Array(players).keys()], 'not a permutation'); for (let p = 0; p < players; p++) { assert.equal(playerAtSeat(s, seatOf(s, p)), p, 'seatOf and playerAtSeat disagree'); assert.equal(areaOf(s, p).seat, seatOf(s, p), 'a player is looking at the wrong district'); } } const solo = createGame({ id: 's', seed: 4242, config: { ...competitive, mode: 'solitaire' }, playerNames: ['a'] }); assert.deepEqual(solo.seating, [0], 'solitaire seating must stay the identity — replays depend on it'); }); it('seats the highest roller at the eastern end of the chain', () => { // §4.4 — "highest is the Eastern Division Point". `buildDivision` lays west-to-east, so the // eastern end is the LAST seat. Checked over many seeds rather than one, because a single deal // could satisfy this by luck. for (let seed = 1; seed <= 40; seed++) { const s = game(3, seed); const rolls = s.openingRolls.division; const east = s.seating[s.seating.length - 1]!; const best = Math.max(...rolls); assert.equal(rolls[east], best, `seed ${seed}: the easternmost seat is not the highest roll`); // And the chain runs low-to-high west to east, so nobody east of you rolled lower. for (let i = 1; i < s.seating.length; i++) { assert.ok(rolls[s.seating[i - 1]!]! <= rolls[s.seating[i]!]!, `seed ${seed}: the chain is not ordered`); } } }); it("follows a player to their new Office when the seating rotates", () => { /** * Employee Rotation (Appendix B) moves every player one seat left at the end of a Day while * their Revenue and the Fedora travel with them. The rule is not implemented, but the MODEL now * supports it: rotating `seating` is the whole operation, and every `areaOf(s, player)` caller * follows without changing. This is the test that the split actually bought something. */ const s = game(3); const officeOf = (p: PlayerIndex): number => areaOf(s, p).seat; const before = [0, 1, 2].map(officeOf); // Mark each Office so we can see which one a player is looking at. BY SEAT — the Offices are // the furniture, and the point of the test is that the furniture stays put. const tiers = ['depot', 'station', 'terminal'] as const; for (let seat = 0; seat < 3; seat++) areaAtSeat(s, seat).tier = tiers[seat]!; const tierOf = (p: PlayerIndex): string => areaOf(s, p).tier; assert.deepEqual([0, 1, 2].map(tierOf), before.map((seat) => tiers[seat]!)); // Everyone shuffles one chair along: whoever was at seat n is now at seat n+1. const rotated = [...s.seating]; rotated.unshift(rotated.pop()!); s.seating = rotated; assert.deepEqual([0, 1, 2].map(officeOf), before.map((seat) => (seat + 1) % 3), 'players did not move seats'); assert.deepEqual( [0, 1, 2].map(tierOf), before.map((seat) => tiers[(seat + 1) % 3]!), 'the Offices moved with them instead of staying put', ); // Revenue belongs to the person and must NOT have moved with the chair. assert.equal(s.players[0]!.index, 0, 'a player index changed when the seating rotated'); }); it('plays a whole game with the seating rotated', () => { /** * The abstraction is only worth something if the game still RUNS through it. Rotating `seating` * before the first move puts every seat/player confusion in the engine on the critical path at * once — the terminal check, the dispatch devices, the bot's own district lookups — where under * the identity mapping they were all silently correct. */ const s = game(3); s.seating = [2, 0, 1]; const r = playGame(s, developerBot, pump); assert.ok(r.finished, 'a rotated game did not finish'); assert.equal(new Set(s.players.map((p) => p.index)).size, 3, 'seats stopped being distinct'); // Each Office is still occupied by exactly one player, and by the one `seating` says. for (let seat = 0; seat < 3; seat++) { assert.equal(areaOf(s, playerAtSeat(s, seat)).seat, seat, `seat ${seat} lost its occupant`); } }); it('reports the impediments of the district a player is now sitting at', () => { /** * `impediments` took a PLAYER and used it as an `officeAreas` key, and `Frame.blocked` is built * from it — so after a rotation a player would have been shown the jams of whoever inherited * their old chair, naming squares that are not on the board in front of them. */ const s = game(3); // Put it in a SEAT, and work out who is sitting there. const area = areaAtSeat(s, 1); area.grid.set(coordKey({ row: area.runningRow - 1, col: 0 }), idleIndustry()); const occupant = playerAtSeat(s, 1); const withImpediments = (): PlayerIndex[] => [0, 1, 2].filter((p) => impediments(s, p).length > 0); assert.deepEqual(withImpediments(), [occupant], 'the industry is not reported to its own player'); // Everyone shuffles one chair along, so seat 1 changes hands. const rotated = [...s.seating]; rotated.unshift(rotated.pop()!); s.seating = rotated; const newOccupant = playerAtSeat(s, 1); assert.notEqual(newOccupant, occupant, 'the rotation did not move anybody into seat 1'); assert.deepEqual(withImpediments(), [newOccupant], 'the impediment did not follow the chair'); assert.deepEqual( [0, 1, 2].map((p) => snapshot(s, [], null, null, null, false, p).blocked.length > 0), [0, 1, 2].map((p) => p === newOccupant), 'the Frame disagrees with impediments after a rotation', ); }); }); describe('nothing keys an Office Area by player', () => { it('reaches officeAreas only through areaAtSeat', () => { /** * The class of bug, rather than the five instances of it that were found by hand. `officeAreas` * is keyed by SEAT; every one of these lookups was passing a PLAYER, which is right only while * seating is the identity mapping and wrong the moment Employee Rotation lands. Routing them all * through `areaOf`/`areaAtSeat` fixes them; this keeps them fixed. */ // `state.ts` and `apply.ts` hold the seat-typed accessors — `areaAtSeat`, `isControlPoint`, // `adTrackCount` — and are the only files allowed to touch the map directly. Everywhere else // goes through them, which is what makes the argument's meaning checkable at the call site. const allowed = ['engine/state.ts', 'engine/apply.ts']; const root = join(import.meta.dirname, '..', 'src'); const offenders: string[] = []; const walk = (dir: string): void => { for (const e of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, e.name); if (e.isDirectory()) walk(full); else if (e.name.endsWith('.ts')) { readFileSync(full, 'utf8').split('\n').forEach((line, i) => { // `areaAtSeat` is the one legal reader — it is what everything else goes through. if (line.includes('officeAreas.get(') && !allowed.some((a) => full.endsWith(a))) { offenders.push(`${full}:${i + 1}`); } }); } } }; walk(root); assert.deepEqual(offenders, [], 'officeAreas is being indexed outside areaAtSeat'); }); }); describe('the view shows one seat at a time', () => { it('gives each seat its own board and its own hand', () => { /** * `snapshot` was hardcoded to seat 0. That is correct with one player and would have shown every * player player 0's railroad — including player 0's HAND, which the state model calls secret. * This is the test that the parameter is real rather than decorative. */ const s = game(3); // Captured BEFORE the loop: seat 0's hand is about to be rewritten, and reading it afterwards // would hand every seat the same truncated list. const source = [...(s.decks.hands.get(0) ?? [])]; assert.ok(source.length >= 3, 'the opening deal should be six cards'); // Make the three districts visibly different, and give each seat a different hand. for (let p = 0; p < 3; p++) { const area = areaOf(s, p); for (let n = 0; n < p; n++) { area.grid.set(coordKey({ row: area.runningRow - 1, col: n }), { geometry: { kind: 'track', geometry: 'straight' }, baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [], }); } s.decks.hands.set(p, source.slice(0, p + 1)); } const frames = [0, 1, 2].map((p) => snapshot(s, [], null, null, null, false, p)); // Each seat sees its OWN district: seat 0 has none of the extra track, seat 2 has two pieces. const sizes = frames.map((f) => f.cells.length); assert.ok(sizes[0]! < sizes[1]! && sizes[1]! < sizes[2]!, `districts are not distinct: ${sizes.join(', ')}`); // And its own hand. assert.deepEqual(frames.map((f) => f.hand.length), [1, 2, 3], 'seats do not have distinct hands'); }); it('gives each seat its own Revenue, pace note, Moves and impediments', () => { /** * The board and the hand were seat-scoped; four scalar fields beside them were not, and each was * still reporting player 0. A player looking at their own railroad would have been shown someone * else's score, someone else's Moves left, and someone else's jammed facilities — the last of * which is a list of squares that do not exist on the board they are looking at. */ const s = game(3); // A real floor to pace against — the shared `competitive` fixture leaves it off (0) since most // tests in this file don't care, but pacing against "off" is trivially "always on pace" (view.ts). s.config.minCombinedRevenue = 10; s.players[0]!.revenue = 1; s.players[1]!.revenue = 9; s.players[2]!.revenue = 17; const frames = [0, 1, 2].map((p) => snapshot(s, [], null, null, null, false, p)); assert.deepEqual(frames.map((f) => f.revenue), [1, 9, 17], 'seats do not see their own Revenue'); // The pace note is computed from the viewer's score against the clock, so it must move with it. const pace = frames.map((f) => f.objective.onPace); assert.notDeepEqual(pace, [pace[0], pace[0], pace[0]], 'every seat got the same pace verdict'); assert.ok(frames[2]!.objective.note.startsWith('17 of '), `seat 2's note reads "${frames[2]!.objective.note}"`); // Stand an unstocked industry in ONE district — "green box empty" is the impediment. Only that // seat should be told about it. const area = areaOf(s, 1); const key = coordKey({ row: area.runningRow - 1, col: 0 }); area.grid.set(key, { geometry: { kind: 'facility', facility: 'mineTipple' }, baseOperationalRail: true, standing: [], modifiers: [], enhancements: [], facility: { kind: 'freight', subtype: 'mineTipple', allows: { outbound: true, inbound: false }, outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 0 }, menAtWork: [null, null, null], industryTrack: { cars: [] }, laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 }, }, } as never); const blocked = [0, 1, 2].map((p) => snapshot(s, [], null, null, null, false, p).blocked.length); assert.deepEqual([blocked[0], blocked[2]], [0, 0], 'an impediment in one district was reported to the others'); assert.ok(blocked[1]! > 0, 'the district with the idle industry was not told about it'); }); it('never draws one seat’s crew onto another seat’s board', () => { /** * REGRESSION. The tray lookup was keyed by `row,col` alone while every district uses the same * origin, so a crew standing at (0, 1) in one Office Area appeared at (0, 1) in ALL of them. The * cells were the viewer's own; the train drawn on them was whoever's happened to be there. */ const s = game(3); // Crews are made up during play, so the opening state has none — stand one in seat 1's district. readyToLeave(s, 1, 'crew-1', 'east'); const withTray = (p: PlayerIndex): number => snapshot(s, [], null, null, null, false, p).cells.filter((c) => c.trains.length > 0).length; assert.equal(withTray(1), 1, "the crew is not on its own seat's board"); assert.deepEqual([withTray(0), withTray(2)], [0, 0], "a crew was drawn onto another seat's board"); }); it('defaults to seat 0, so solitaire and every replay are unaffected', () => { const s = game(2); assert.deepEqual( snapshot(s, [], null).hand, snapshot(s, [], null, null, null, false, 0).hand, 'the default viewer is not seat 0', ); }); }); describe('scoring lands on the right seat', () => { it('pays EVERY player when a train completes its run, and nobody for merely departing', () => { /** * THE RULE CHANGED IN v0.4.2. It used to pay 1 to the Office a train departed — which on a * three-Office railroad paid three separate times for one train, and paid whichever Office the * train happened to pass first. It pays once now, when the train runs off the end of the * Division, and it pays everybody: getting a train the whole length is the shared achievement. * * With one player this is unfalsifiable, because "everybody" is one person. This is the test * that says so with three. * * The rate is a dial now and defaults to zero, so this names it: what is under test is WHO gets * paid, not whether the default pays at all (`enhancements.test.ts` covers that). */ const s = createGame({ id: 'm', seed: 4242, config: { ...competitive, houseRules: { revenue: { trainPerTransit: 1 } } }, playerNames: ['a', 'b', 'c'], }); pinTerrain(s); const before = s.players.map((p) => p.revenue); readyToLeave(s, 2, 'theirs', 'east'); s.clock.phase = 'mainline'; s.movedThisPhase = new Set(); advance(s); assert.deepEqual(s.players.map((p) => p.revenue), before, 'departing the Office paid somebody'); let completed; for (let i = 0; i < 25 && !completed; i++) { s.movedThisPhase = new Set(); s.clock.phase = 'mainline'; completed = advance(s).events.find((e) => e.type === 'trainCompleted'); } assert.ok(completed, 'the train never ran off the end of the Division'); assert.deepEqual( s.players.map((p) => p.revenue), before.map((r) => r + 1), 'a completed run did not pay every player exactly 1', ); }); it('keeps each seat’s Office Area to itself', () => { // A card laid in one player's district must not appear in another's — the areas are separate maps // and it would be easy for a shared reference to make every district the same district. const s = game(3); const mine = areaAtSeat(s, 1); const sizeBefore = areaAtSeat(s, 2).grid.size; mine.grid.set(coordKey({ row: mine.runningRow - 1, col: 0 }), { geometry: { kind: 'track', geometry: 'straight' }, baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [], }); assert.equal(areaAtSeat(s, 2).grid.size, sizeBefore, "one seat's track appeared in another's district"); assert.notEqual(areaAtSeat(s, 1), areaAtSeat(s, 2), 'two seats share one Office Area object'); }); }); describe('the New Train phase car-placement round rotates (§7, Gap 9)', () => { /** * REGRESSION. `newTrainPhase` used to hand the whole car-filling loop to a fixed actor — * `actorOffset` is reset to 0 entering the phase and was never incremented, so `actorAt(s, 0)` * always resolved to the Superintendent, who placed every car of every train alone. The written * rule (`rules-v0.2.md` §7, Gap 9) is explicit: "starting with the Superintendent and working * left, each player may place ONE car... the round repeats... until the consist is full," with a * worked example showing seats alternating. The fix reads the round position off * `tray.consist.length` instead, which is already exactly that counter and resets per train. */ it('cycles Superintendent-then-left, one car per player, wrapping as the round repeats', () => { // Train 5, "The Sparrow" — 3 coaches, no freight or caboose (content.ts) — small enough to // exercise both a player count that wraps (2p: seats 0,1,0) and one that doesn't (3p: 0,1,2). // It was Train 1 until Gitea#7 swapped the coach counts on 1/2 and 5/6; the test needs a // THREE-car consist and the Crack Limited now carries two, so it follows the three coaches. for (const players of [2, 3]) { const s = game(players); s.clock.phase = 'newTrain'; s.trays.set('t1', { id: 't1', trainNumber: 5, trainIsExtra: false, engineAt: 0, consist: [], direction: 'west', position: { at: 'divisionPoint', side: 'east' }, movesUsed: 0, }); s.yards.divisionYard.push( { type: 'coach', loaded: false }, { type: 'coach', loaded: false }, { type: 'coach', loaded: false }, ); const expected = [0, 1, 2].map((offset) => playerLeftOf(s, s.clock.superintendent, offset)); const seenActors: PlayerIndex[] = []; for (let guard = 0; guard < 10; guard++) { const r = advance(s); if (!r.needsInput) break; const actor = s.clock.currentActor!; seenActors.push(actor); const result = applyIntent(s, actor, { type: 'newTrain.placeCar', trayId: 't1', carType: 'coach', loaded: false, }); assert.ok(result.ok, `${players}p: placeCar rejected — ${result.ok ? '' : result.code}`); } const wanted = [0, 1, 2].map((i) => expected[i % players]!); assert.deepEqual( seenActors, wanted, `${players}p: actor sequence was [${seenActors}], wanted [${wanted}] (Superintendent-then-left)`, ); } }); }); describe('an Extra belongs to the player who played it (§7)', () => { /** * REPORTED BY JESSE 2026-08-23, from a two-player game on StartOS: one seat played a train card * and the OTHER was asked to build the train. For a Timetabled train that is correct — the round * above starts at the Superintendent — but §7 states the Extra rule a paragraph later and it is * the opposite one: "the player who played the card may place the Crew Tray at either Division * Point ... and may load the consist as he chooses." * * The engine could not honour it: `pendingExtras` was a bare `number[]`, so nothing recorded whose * Extra it was and the phase asked whoever the acting order happened to be on. Invisible in * solitaire, where that is always the same person. */ const withPendingExtra = (players: number, owner: PlayerIndex) => { const s = game(players); s.clock.phase = 'newTrain'; s.timetable = s.timetable.map(() => null); // X22 "Pee-Dee" — a caboose-only Extra, so the consist is short and the round would be visible. s.pendingExtras = [{ trainNumber: 22, player: owner }]; return s; }; it('asks the player who played it where it starts — not whoever the round is on', () => { // The owner is deliberately NOT the Superintendent, which is the case that used to go wrong. const s = withPendingExtra(2, 1 as PlayerIndex); const notSuper = playerLeftOf(s, s.clock.superintendent, 1); s.pendingExtras = [{ trainNumber: 22, player: notSuper }]; const r = advance(s); assert.equal(r.needsInput, true, 'the phase did not stop to place the Extra'); assert.equal(s.clock.currentActor, notSuper, 'the wrong player was asked where the Extra starts'); }); it('refuses another seat placing it, and offers it to nobody else', () => { const s = withPendingExtra(2, 0 as PlayerIndex); const intent = { type: 'newTrain.startExtra' as const, trainNumber: 22, start: { kind: 'divisionPoint' as const, side: 'west' as const }, }; const theirs = applyIntent(s, 0 as PlayerIndex, intent); assert.ok(theirs.ok, `the owner could not place their own Extra — ${theirs.ok ? '' : theirs.code}`); /** * TWO EXTRAS, TWO OWNERS — the case where the guard is actually reachable. The phase stops on * the first pending Extra's owner, so seat 0 is legitimately the current actor; nothing but this * rule stops them placing seat 1's train while they are there. */ const both = withPendingExtra(2, 0 as PlayerIndex); both.pendingExtras = [ { trainNumber: 22, player: 0 as PlayerIndex }, { trainNumber: 24, player: 1 as PlayerIndex }, ]; advance(both); assert.equal(both.clock.currentActor, 0, 'the first pending Extra should have stopped on its owner'); assert.equal(check(both, 0 as PlayerIndex, { ...intent, trainNumber: 24 }), 'NOT_YOUR_EXTRA'); const notTheirs = applyIntent(both, 0 as PlayerIndex, { ...intent, trainNumber: 24 }); assert.equal(notTheirs.ok, false, 'a seat placed somebody else’s Extra while it was their turn'); // And it is not even offered: a menu that lists an action `check` will refuse is a menu lying. assert.equal( legalActions(both, 0 as PlayerIndex).some( (i) => i.type === 'newTrain.startExtra' && i.trainNumber === 24, ), false, 'somebody else’s Extra was offered a start point', ); }); it('lets its player load the whole consist, rather than passing the round', () => { // §7: "may load the consist as he chooses" — no going round the table for an Extra. const s = withPendingExtra(2, 0 as PlayerIndex); const owner = 0 as PlayerIndex; const placed = applyIntent(s, owner, { type: 'newTrain.startExtra', trainNumber: 22, start: { kind: 'divisionPoint', side: 'west' }, }); assert.ok(placed.ok, 'the Extra could not be placed'); s.yards.divisionYard.push({ type: 'caboose', loaded: false }, { type: 'caboose', loaded: false }); const asked: PlayerIndex[] = []; for (let guard = 0; guard < 4; guard++) { const r = advance(s); if (!r.needsInput) break; const actor = s.clock.currentActor!; asked.push(actor); const tray = [...s.trays.values()].find((t) => t.trainIsExtra); assert.ok(tray, 'the Extra has no tray'); const result = applyIntent(s, actor, { type: 'newTrain.passCar', trayId: tray.id }); if (!result.ok) break; } assert.ok(asked.length > 0, 'nobody was asked to load the Extra'); assert.deepEqual( [...new Set(asked)], [owner], `the Extra's consist went round the table (asked ${asked.join(', ')}) instead of staying with its player`, ); }); }); describe('actionMenu is seat-safe (Phase 2 prep)', () => { /** * REGRESSION. `actionMenu(game, seat)` used `seat` only for the `hand` field — `options`/`direct`/ * `placeable`/`makeUp` all came from `currentActor(game)` regardless of which seat was asked. A * server computing every connected seat's Menu would have handed the acting player's legal moves to * a waiting seat, paired with the WRONG seat's hand. Found tracing Phase 2's per-seat Menu step. */ it('gives the acting seat its real options, and every other seat none at all', () => { const s = game(3); const g = wrap(s); const actorSeat = s.clock.currentActor!; const waiting = [0, 1, 2].filter((seat) => seat !== actorSeat); const actorMenu = actionMenu(g, actorSeat as PlayerIndex); assert.ok(actorMenu.options.length > 0, "the acting seat's Menu had no options at all"); for (const seat of waiting) { const menu = actionMenu(g, seat as PlayerIndex); assert.deepEqual(menu.options, [], `seat ${seat} (not acting) was given real options`); assert.deepEqual(menu.direct, [], `seat ${seat} (not acting) was given direct actions`); assert.deepEqual(menu.placeable, [], `seat ${seat} (not acting) was given placeable actions`); assert.equal(menu.makeUp, null, `seat ${seat} (not acting) was given a make-up panel`); } }); it('still shows a waiting seat its own hand, just nothing to do with it', () => { const s = game(3); const g = wrap(s); const actorSeat = s.clock.currentActor!; const waitingSeat = [0, 1, 2].find((seat) => seat !== actorSeat)! as PlayerIndex; const menu = actionMenu(g, waitingSeat); const expectedHand = s.decks.hands.get(waitingSeat) ?? []; assert.equal(menu.hand.length, expectedHand.length, "the waiting seat's own hand did not come through"); for (const card of menu.hand) { assert.equal(card.playNow, null, 'a waiting seat was offered a way to play a card'); assert.equal(card.spots, 0, 'a waiting seat was offered somewhere to place a card'); } }); }); describe('the map says whose railroad is whose', () => { it('the Frame names its own viewer, which nothing on it did before', () => { // Every private field is already scoped to one player — hand, Office Area, revenue, option — // but a page rendering that could not say WHICH player, so it could not tell you which of four // railroads was yours. const s = game(4); for (const viewer of [0, 1, 2, 3] as PlayerIndex[]) { const f = snapshot(s, [], null, null, null, false, viewer); assert.equal(f.viewer, viewer); assert.equal(f.viewerSeat, seatOf(s, viewer), 'viewerSeat must be the seat, not the player index'); } }); it('carries the opening D12 that decided the west-to-east chain', () => { const s = game(4); const f = snapshot(s, [], null, null, null, false, 0 as PlayerIndex); assert.equal(f.openingRolls.division.length, 4, 'one division roll per player'); assert.equal(f.openingRolls.superintendent.length, 4); // The rule the rolls implement: ascending by roll, west to east — so sorting the players by // their roll must reproduce the seating exactly (§4.4). const bySeat = [...f.players].sort((a, b) => a.seat - b.seat).map((p) => p.index); const byRoll = [...f.players] .map((p) => p.index) .sort((a, b) => f.openingRolls.division[a]! - f.openingRolls.division[b]! || b - a); assert.deepEqual(bySeat, byRoll, 'seating does not follow the opening rolls'); }); it('is not always the host at the eastern end — the roll decides', () => { // The question this answers: player 0 is the lobby host, and the eastern end is the LAST seat. // If the two were the same thing, every seed would put player 0 there. const easternPlayer = (seed: number): number => { const s = game(4, seed); const f = snapshot(s, [], null, null, null, false, 0 as PlayerIndex); return [...f.players].sort((a, b) => b.seat - a.seat)[0]!.index; }; const seen = new Set([101, 202, 303, 404, 505, 606].map(easternPlayer)); assert.ok(seen.size > 1, `the eastern end was always player ${[...seen][0]} across six seeds`); }); it('labels each Office with its owner, marking whose move it is and which one is yours', () => { const s = game(3); const viewer = 1 as PlayerIndex; const f = snapshot(s, [], null, null, null, false, viewer); const svg = divisionSvg(f.division, { players: f.players, actor: f.actor, viewer: f.viewer }); const owners = [...svg.matchAll(/]*>([^<]*)<\/text>/g)].map( (m) => ({ classes: m[1]!, text: m[2]! }), ); assert.equal(owners.length, 3, 'expected one owner-labelled Office per player'); // Every player is named somewhere, in seat order. const bySeat = [...f.players].sort((a, b) => a.seat - b.seat); assert.deepEqual( owners.map((o) => o.text.replace(' (you)', '')), bySeat.map((p) => p.name), ); const you = owners.find((o) => o.classes.includes('bs-you')); assert.ok(you, 'the viewer’s own Office is not marked'); assert.ok(you!.text.endsWith('(you)'), 'colour alone cannot say which railroad is the reader’s'); assert.equal( you!.text.replace(' (you)', ''), f.players.find((p) => p.index === viewer)!.name, 'the (you) mark is on the wrong Office', ); const turn = owners.filter((o) => o.classes.includes('bs-turn')); assert.equal(turn.length, f.actor === null ? 0 : 1, 'exactly one Office is the current actor’s'); if (f.actor !== null) { assert.equal(turn[0]!.text.replace(' (you)', ''), f.players.find((p) => p.index === f.actor)!.name); } }); it('draws no owner marks at all when given no roster, so the replay still renders', () => { const s = game(3); const f = snapshot(s, [], null, null, null, false, 0 as PlayerIndex); assert.equal(divisionSvg(f.division).includes('bs-owner'), false); }); }); describe('Employee Rotation (Appendix B)', () => { /** * Straight to the Day boundary, which is the only moment a rotation happens — the same shortcut * `advance.test.ts` uses to roll the clock over without playing twelve Stages of real turns. */ const atDayEnd = (on: boolean): GameState => { const s = createGame({ id: 'rot', seed: 4242, config: { ...competitive, optionalRules: { ...competitive.optionalRules, employeeRotation: on } }, playerNames: ['Alice', 'Bob', 'Carol'], }); s.clock.stage = STAGES_PER_DAY; s.clock.phase = 'shiftChange'; return s; }; it('is off unless asked for — the clock alone must not move anybody', () => { const s = atDayEnd(false); const before = [...s.seating]; advance(s); assert.equal(s.clock.day, 2, 'the clock did not roll over'); assert.deepEqual(s.seating, before, 'seats moved with the rule switched off'); }); it('moves every player one chair left at the Day boundary', () => { const s = atDayEnd(true); const before = [...s.seating]; advance(s); assert.equal(s.clock.day, 2); // "One chair to the left" is seat + 1, the direction `playerLeftOf` already turns the table. const expected = before.map((_, seat, all) => all[(seat - 1 + all.length) % all.length]!); assert.deepEqual(s.seating, expected); // Everyone moved, and nobody was lost or duplicated on the way round. assert.deepEqual([...s.seating].sort(), [...before].sort()); assert.notDeepEqual(s.seating, before); }); it('takes your points and the Fedora with you, and leaves the district behind', () => { const s = atDayEnd(true); const traveller = 1 as PlayerIndex; s.players[traveller]!.revenue = 17; const seatBefore = seatOf(s, traveller); /** * The Fedora is compared against the SAME game with the rule off, not against its own value * before the advance — Stage 12 is a shift change (§5), so it passes here anyway for reasons * that have nothing to do with rotation. What matters is that moving the chairs does not move * it: it names a player, and players are exactly what the rotation does not renumber. */ const control = atDayEnd(false); advance(control); // Identity, not a field: anything mutable is liable to be reset at a Day boundary anyway (the // once-a-Day dispatch reset clears `dispatchUsedToday` right there), and the claim under test // is about which OBJECT is attached to which chair. const districtLeftBehind = areaAtSeat(s, seatBefore); advance(s); assert.notEqual(seatOf(s, traveller), seatBefore, 'the traveller did not move'); assert.equal(s.players[traveller]!.revenue, 17, 'Revenue is keyed by player and must travel'); assert.equal(s.clock.superintendent, control.clock.superintendent, 'rotating the chairs moved the Fedora'); // The Office stayed exactly where it was, so whoever sits there now inherits it as they find // it. That is the rule rather than a side effect of it — you take over the next station up the // line, mess and all. assert.equal(areaAtSeat(s, seatBefore), districtLeftBehind, 'the district moved with the player'); assert.notEqual(areaOf(s, traveller), districtLeftBehind, 'the traveller kept their old district'); }); it('says who is now sitting where, by name', () => { const s = atDayEnd(true); const { events } = advance(s); const rotated = events.find((e) => e.type === 'seatsRotated'); assert.ok(rotated, 'no seatsRotated event was emitted'); const line = narrate(rotated, { playerName: (p) => s.players[p]!.name }).text; for (const name of ['Alice', 'Bob', 'Carol']) assert.match(line, new RegExp(name)); }); }); // --------------------------------------------------------------------------- // v0.8.3 — audit findings (2026-09-29): a player's switching stays in their own district // --------------------------------------------------------------------------- describe("switching is confined to the actor's own district (v0.8.3)", () => { /** * `check` resolved the tray with no seat test at all: the legal-move GENERATOR filtered trays by * seat, `check` did not, and the server validates with `check` alone. Every district opens on the * same coordinates, so a destination legal for a tray of your own at (0,0) was "legal" for a * rival's tray at THEIR (0,0) — and `trayMoved` then charged the Moves to the rival. */ const placeOwn = (s: GameState, owner: PlayerIndex, id: string): void => { const area = areaOf(s, owner); s.trays.set(id, { id, trainNumber: null, trainIsExtra: false, engineAt: 0, consist: [], direction: 'east', position: { at: 'grid', seat: seatOf(s, owner), coord: area.officeCoord }, movesUsed: 0, }); }; const switching = (s: GameState, player: PlayerIndex): void => { s.clock.phase = 'localOps'; s.clock.currentActor = player; applyIntent(s, player, { type: 'localOps.choose', option: 'switch' }); assert.equal(turnOf(s, player).option, 'switch', 'could not choose Switch'); }; it("refuses a switch.move on a rival's tray that would have been legal on your own", () => { const s = game(2); // Find a move the actor could make with a tray of THEIR OWN standing at the Office. placeOwn(s, 0, 'mine'); switching(s, 0); const own = legalActions(s, 0).find((i) => i.type === 'switch.move' && i.trayId === 'mine'); assert.ok(own && own.type === 'switch.move', 'no switching move to test with'); s.trays.delete('mine'); // Now the same move named against seat 1's tray, standing at seat 1's Office. placeOwn(s, 1, 'theirs'); const movesBefore = turnOf(s, 0).movesRemaining; const theirMovesBefore = turnOf(s, 1).movesRemaining; const code = check(s, 0, { ...own, trayId: 'theirs' }); assert.equal(code, 'NO_SUCH_TRAY', `a rival's tray was accepted (${code ?? 'null'})`); assert.ok(!applyIntent(s, 0, { ...own, trayId: 'theirs' }).ok, 'the move was applied'); assert.equal(turnOf(s, 0).movesRemaining, movesBefore); assert.equal(turnOf(s, 1).movesRemaining, theirMovesBefore, "the rival's Moves were charged"); assert.deepEqual(s.trays.get('theirs')!.position, { at: 'grid', seat: seatOf(s, 1), coord: areaOf(s, 1).officeCoord }); }); it("refuses dropCars and sortConsist on a rival's tray too", () => { const s = game(2); placeOwn(s, 0, 'mine'); // Switch is only on offer with a tray of your own to switch placeOwn(s, 1, 'theirs'); s.trays.get('theirs')!.consist.push({ type: 'boxcar', loaded: false }); switching(s, 0); assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 'theirs', count: 1 }), 'NO_SUCH_TRAY'); assert.equal(check(s, 0, { type: 'switch.sortConsist', trayId: 'theirs', order: [0] }), 'NO_SUCH_TRAY'); }); it("does not see a rival's crew as standing in your district", () => { /** * `occupancyFor().trayAt` matched on coordinates alone, so a crew at seat 0's (0,2) blocked * seat 1's (0,2) as "another train standing here". Invisible in solitaire. */ const s = game(2); const spot = { row: areaOf(s, 0).officeCoord.row, col: areaOf(s, 0).officeCoord.col + 2 }; s.trays.set('crew0', { id: 'crew0', trainNumber: null, trainIsExtra: false, engineAt: 0, consist: [], direction: 'east', position: { at: 'grid', seat: seatOf(s, 0), coord: spot }, movesUsed: 0, }); assert.equal(occupancyFor(s, 0, 'other').trayAt(spot), 'crew0', 'the owner cannot see their own crew'); assert.equal(occupancyFor(s, 1, 'other').trayAt(spot), null, "a rival's crew is standing in the wrong district"); }); });