Files
station-master/test/multiplayer.test.ts
T

603 lines
27 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 { 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: { length: 1, 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: [], 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: { length: 1, 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.tray != null).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: [], 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');
});
});