/** * 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 { areaAtSeat, areaOf } from '../src/engine/apply.ts'; import { STAGES_PER_SHIFT, crewTrayCount } from '../src/engine/content.ts'; import { createGame } from '../src/engine/setup.ts'; import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts'; import { coordKey, playerAtSeat, seatOf, subdivisions } from '../src/engine/state.ts'; import { developerBot, playGame } from '../src/sim/bot.ts'; import { snapshot } from '../src/sim/view.ts'; import { impediments } from '../src/sim/narrate.ts'; import { readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; const competitive: GameConfig = { mode: 'competitive', victory: 'highestAfterDays', length: 'standard', optionalRules: { reducedVisibility: false, sisterTrains: 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); 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'); }); });