TODO #13, #15 and #18 — Gitea#20 steps 2-4 pointed at a seated player's own screen. Every accepted intent, and every automatic phase that does anything, becomes an ordered presentation step. A bot's whole switching turn used to land in one push; now it arrives as a run of steps, the district panel follows whoever is acting, and a [N behind] … [Skip] row says how far the board is from the game. Solitaire runs the same path — one collector inside submit(), which both session kinds already funnel through — which is where its automatic phases finally get a visible beat. Dwell is assigned by kind: switching holds the screen, turn bookkeeping costs nothing, and the clock turning over earns the beat. Tunable per viewer without a rebuild, and off entirely at pace 0. Also: switching was the one class of action logging unattributed, and now names its train. Reasoning, measurements and the three things that turned out wrong are in CHANGELOG.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
978 lines
45 KiB
TypeScript
978 lines
45 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 { 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 { newCollector } from '../src/sim/display-step.ts';
|
||
import type { Game } from '../src/web/game.ts';
|
||
|
||
/** The thin wrapper `actionMenu` expects, built directly around an already-created multi-player state
|
||
* — `newGame` (game.ts) hardcodes one player, so it cannot construct this for a multi-seat game. */
|
||
const wrap = (s: GameState): Game =>
|
||
({ state: s, seed: s.seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null, display: newCollector() });
|
||
|
||
const competitive: GameConfig = {
|
||
mode: 'competitive',
|
||
days: 5,
|
||
minCombinedRevenue: 0,
|
||
maxCollisionsPerDay: 0,
|
||
maxCollisionsTotal: 0,
|
||
pvpCardsAllowed: false,
|
||
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));
|
||
});
|
||
});
|