v0.3.1 — multiplayer groundwork: 13 engine tests covering 2-4 player games, a seat parameter on snapshot and actionMenu so a player sees their own railroad rather than seat 0's, and the 22 opponent-directed cards held out of every deck until they are built
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* 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 { 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, subdivisions } from '../src/engine/state.ts';
|
||||
import { developerBot, playGame } from '../src/sim/bot.ts';
|
||||
import { snapshot } from '../src/sim/view.ts';
|
||||
|
||||
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', owner, coord: area.officeCoord }, movesUsed: 0,
|
||||
});
|
||||
area.adOccupancy.push(id);
|
||||
}
|
||||
|
||||
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`);
|
||||
for (let p = 0; p < players; p++) {
|
||||
assert.equal(areaOf(s, p).owner, p, 'an Office Area is owned by the wrong seat');
|
||||
}
|
||||
// §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.
|
||||
*/
|
||||
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 = 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');
|
||||
|
||||
areaOf(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 = (owner: number): number =>
|
||||
s.division.nodes.findIndex((n) => n.kind === 'office' && n.owner === owner);
|
||||
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 p = 0; p < 4; p++) areaOf(s, p).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('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('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 the departure Revenue to the Office the train left, not to seat 0', () => {
|
||||
/**
|
||||
* The rule reads "one Revenue for every train that clears YOUR section", and `awardDeparture`
|
||||
* takes the owner of the departing Office. With one player that is unfalsifiable: seat 0 is the
|
||||
* only seat there is. This is the test that says so with three.
|
||||
*/
|
||||
const s = game(3);
|
||||
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.equal(s.players[2]!.revenue, before[2]! + 1, 'seat 2 was not paid for its own departure');
|
||||
assert.equal(s.players[0]!.revenue, before[0]!, 'seat 0 was paid for a train it never ran');
|
||||
assert.equal(s.players[1]!.revenue, before[1]!, 'seat 1 was paid for a train it never ran');
|
||||
});
|
||||
|
||||
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 = areaOf(s, 1);
|
||||
const sizeBefore = s.officeAreas.get(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(s.officeAreas.get(2)!.grid.size, sizeBefore, "one player's track appeared in another's district");
|
||||
assert.notEqual(areaOf(s, 1), areaOf(s, 2), 'two seats share one Office Area object');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user