603 lines
27 KiB
TypeScript
603 lines
27 KiB
TypeScript
/**
|
||
* 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');
|
||
});
|
||
});
|