Files
station-master/test/multiplayer.test.ts
T
Jesse.MarkowitzandClaude Opus 5 7f4e027258 v0.7.9.6 — three things the engine knew and the screen did not
Found by looking rather than by being told. Gitea#21, #22, #94 and #96
were four instances of one fault in a row — the engine gains something
that changes what a train may do, and nothing draws it — and every one
was found by a player hitting it. So instead of waiting for the fifth,
every field of GameState and its nested types was enumerated, checked for
a reader in sim/view.ts, src/web/ and sim/narrate.ts, and the survivors
verified BY RUNNING THE ENGINE rather than by trusting the grep.

Four fields had no reader. `movedThisPhase` lives and dies inside one
`advance` call and is nobody's business. The other three are below. What
was ruled out matters as much: `freightWorked`, `drawnThisTurn`,
`freightAgentUsed`, `switchedSince` and `movesUsed` are invisible on
purpose, their effect already showing as legality or as a complement
already on the Frame. A field is not a display gap merely because nothing
renders it.

#98 — the Crew Tray pool. §7 scarcity is called an explicit mechanic and
was explicit only in the engine. The blocked panel had one tray rule,
keyed off the train due out this Stage, so a player who spent a card on
an Extra or ordered a second section got an EMPTY panel while their train
sat behind an exhausted pool — both having been announced once in the log
in a line promising a future event that nothing then confirmed. The
shared table carries the pool and the queue now, so the common board gets
it too, and the panel reports all three with the count beside them.

#99 — a train held at the Limits vanished off the board, and this one had
shipped. The Interlocking stops an inbound train on the Limit Track
rather than colliding with a full Office. `arriveAtOffice` removes the
tray from the Mainline node's `transits` and the Interlocking branch
pushes it onto `heldAtLimits` without assigning `tray.position` — and the
map draws mainline nodes from `transits` and squares from
`position.at === 'grid'`, so between the two it was drawn in NEITHER. It
disappeared on arrival and reappeared in the Office some Stages later.
Fixed in the view: the engine is right, and `position` is left alone
deliberately so nothing treats the train as standing somewhere it could
be switched from.

#100 — the Campaign Train's speeches change its rules, and the card said
the same thing before and after. Worse, the "EXPEDITED ... costs 1
Revenue" warning prints only under `rules.expedite`, so X17 became
subject to a fault whose warning the game shows to every other expedited
train and never to it. `trainRules` reads `speechMade` now and borrows
`isExpedited` from advance.ts rather than restating the test.

#45 — the 0.7.9 dead-field audit, finished, and the answer was different
for each. `overHandLimit` is WIRED: its consumer existed all along and
was inferring the hand limit from the ABSENCE of `draw.end` in the menu,
which is sound only while `check` keeps refusing for exactly three
reasons. `viewerSeat` is DOCUMENTED, with a condition — Gitea#20's board
keys districts by seat, and the note says to delete it if step 2 ships
without using it.

The audit had missed a third limb. `game.mustPlayCard` was assigned on
every submit and read by nothing: deleted. Chasing it turned up the thing
worth fixing — the §6.2 hand-limit test existed in THREE places, all
agreeing, which is the state #96's disagreement started from. One
`overHandLimit(state, player)` in state.ts now, and the other two ask it.
`Session.overHandLimit()` is deleted rather than kept: the Frame already
carries the fact, so the method was a second path to it.

934 tests pass, up from 917. The 17 new ones were written red, and each
fix checked by mutation: reverting `speechMade` fails 2, dropping the
held-train projection fails 4, forgetting the tray queues fails 2. The
empty blocked panel is reported beside its positive control, since an
empty result from a broken function proves nothing.

NOT VERIFIED AT A TABLE. Engine and view work, checked by tests and by
running the engine. #39 and #35 still stand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5boPxP6JHRYMm8adXaF5R
2026-09-07 21:29:16 -04:00

977 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 } 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 } 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 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 });
const competitive: GameConfig = {
mode: 'competitive',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
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<typeof developerBot.choose>[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<typeof developerBot.choose>[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 class="bs-name([^"]*bs-owner[^"]*)"[^>]*>([^<]*)<\/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));
});
});