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:
+40
-2
@@ -21,6 +21,46 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 0.3.1 — 2026-08-13
|
||||
|
||||
### Groundwork for multiplayer
|
||||
|
||||
Two steps that hold whichever way the hotseat-or-server question goes, plus a deck change.
|
||||
|
||||
**The 22 opponent-directed cards are out of every deck, not just solitaire's.** Q6 removed them from
|
||||
solitaire because they have no legal target with one player; they are out of the competitive deck now
|
||||
too, because `checkPlay` answers both categories `NOT_IMPLEMENTED` and dealing them would make ~9% of
|
||||
draws reject outright. `buildDeck` returns 213 in both modes. `DECK_SIZE` (235) is now documented as
|
||||
the CATALOGUE rather than the size of any deck in play — the two had quietly become different numbers.
|
||||
Three Enhancements (Facing Point Locks, Water Column, Overpass) exist only to answer these cards and
|
||||
stay dormant until they return; recorded in `TODO.md` as multiplayer work.
|
||||
|
||||
**A net under the multi-player engine paths** — `test/multiplayer.test.ts`, 13 tests. Everything else
|
||||
in the suite is solitaire, so these had been running unwatched. They cover 2/3/4-player games playing
|
||||
to a finish; per-seat Office Areas and Crew Tray counts; the Fedora passing to the *next* seat, only on
|
||||
a shift boundary, reaching every seat over a game; Subdivisions splitting at the Offices that have
|
||||
upgraded and not at the ones that have not; one player's oncoming train barring another's departure,
|
||||
and no longer barring it once a Control Point puts them in different Subdivisions; and that
|
||||
`awardDeparture` pays the Office that ran the train rather than seat 0 — which with one player was
|
||||
unfalsifiable.
|
||||
|
||||
Two failed when first written and both times the TEST was wrong, not the engine: the Superintendent
|
||||
cases drove the clock by calling `advance` in a loop, which never moves it — `advance` stops and asks
|
||||
for input, so the game has to actually be played. **The engine's multi-player paths passed everything
|
||||
on the first honest run**, which is the useful result here.
|
||||
|
||||
**`snapshot` and `actionMenu` take a seat.** They were hardcoded to player 0 in eight places across
|
||||
`view.ts`, `game.ts` and `main.ts` — correct with one player, and a quiet disaster with more: every
|
||||
seat would have been shown player 0's railroad *including player 0's hand*, which is the one thing the
|
||||
state model calls secret. All eight now take a viewer, defaulting to 0 so solitaire and every replay
|
||||
are untouched, and `describeIntent` now describes an intent against the acting player's district
|
||||
rather than seat 0's. A test shows three seats getting three different boards and three different
|
||||
hands, so the parameter is exercised rather than merely present.
|
||||
|
||||
Bot unchanged at 7.0 — solitaire never dealt the opponent cards, so none of this moves it.
|
||||
|
||||
## 0.3.0 — 2026-08-12
|
||||
|
||||
### Sharp curves are out of the deck
|
||||
|
||||
Eight cards, dealt zero copies. The only thing that made a sharp curve different from an ordinary one
|
||||
@@ -95,8 +135,6 @@ game harder, which is the point — but it is worth knowing that enforcing §7 t
|
||||
of what the departure-Revenue rule gave. The train tooltip no longer says "NOT YET ENFORCED BY THE
|
||||
ENGINE"; it says what each rule does, because the restriction is the character of the card.
|
||||
|
||||
## 0.3.0 — 2026-08-12
|
||||
|
||||
### Two rule changes, both provisional, both measured
|
||||
|
||||
**The opening deal is now 3 track + 3 other, from two separately shuffled piles.** Track is shuffled
|
||||
|
||||
@@ -10,7 +10,7 @@ train into an occupied Subdivision. Get that wrong and two trains meet at speed.
|
||||
|
||||
## Status
|
||||
|
||||
**v0.3.0 — solitaire is playable in a browser.** The whole game runs client-side: the engine is pure,
|
||||
**v0.3.1 — solitaire is playable in a browser.** The whole game runs client-side: the engine is pure,
|
||||
imports nothing outside itself, and never touches `Math.random`, so a static host is all it needs.
|
||||
|
||||
- **Rules** — fully specified. Ten gaps in the original prototype rules found and resolved.
|
||||
|
||||
@@ -469,8 +469,15 @@ target is settled and freight carries its intended share.
|
||||
engine currently has no per-player turn within the New Train phase, so this is unbuilt rather
|
||||
than wrong.
|
||||
|
||||
- [ ] **Action cards (10) and Space-use cards (12).** Genuinely multiplayer-only — they are played AT
|
||||
an opponent. Rejected with `NOT_IMPLEMENTED`.
|
||||
- [ ] **THE 22 OPPONENT-DIRECTED CARDS — 10 Action, 12 Space-use — ARE OUT OF EVERY DECK UNTIL THEY
|
||||
ARE BUILT.** Jesse's call. They were already cut from solitaire (Q6, no legal target with one
|
||||
player); they are now cut from the competitive deck too, because `checkPlay` answers both
|
||||
categories `NOT_IMPLEMENTED` and dealing them would make ~9% of draws reject outright. Flip
|
||||
`opponentCardsInDeck` in `setup.ts` when they land. They are played AT another player —
|
||||
Watertower, Derail, Railroad Crossing and so on — so they are genuinely multiplayer work, and
|
||||
**three Enhancements are waiting on them**: Facing Point Locks, Water Column and Overpass are
|
||||
wired and read, and fire only against these cards. Until then those three are dormant by
|
||||
design rather than broken.
|
||||
- [ ] **Multiplayer proper.** The engine runs 2–5 player games and the bot plays them, but there is no
|
||||
server, no turn submission, and no per-player view.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
|
||||
+15
-3
@@ -60,7 +60,8 @@ export type TrackProfile = {
|
||||
/**
|
||||
* TRACK IS IN THE HOME OFFICE DECK, and is drawn and played like any other card.
|
||||
*
|
||||
* 104 cards, straight from column B of `docs/Deck cards2.xlsx`. An earlier reading made track a
|
||||
* 96 dealt of the sheet's 104 (column B of `docs/Deck cards2.xlsx`) — the 8 sharp curves are dealt
|
||||
* zero, see below. An earlier reading made track a
|
||||
* separate per-player supply of 26 pieces, sitting outside the deck and laid one a turn. That came
|
||||
* from misreading the sheet's LAST column, headed "Track Per Player" — 8/4/4/1/1/4/4 = 26, which is
|
||||
* a note about each player's likely share of 104 across four players, not a second stack of cards.
|
||||
@@ -102,7 +103,7 @@ export const TRACK_CARDS: readonly TrackProfile[] = [
|
||||
{ geometry: 'turnout', hand: 'left', name: 'Turnout (left)', copiesInDeck: 16, isOperationalRail: false, moveCost: 1 },
|
||||
];
|
||||
|
||||
/** 104 — the sheet's "Total track". */
|
||||
/** 96 — the sheet's "Total track" of 104, less the 8 sharp curves now dealt at zero. */
|
||||
export const TRACK_IN_DECK = TRACK_CARDS.reduce((n, t) => n + t.copiesInDeck, 0);
|
||||
|
||||
/** Start cards, placed at setup and never shuffled: 4 Whistle Posts, 8 Limits. */
|
||||
@@ -819,9 +820,20 @@ export function deckComposition(): { category: string; count: number }[] {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole CATALOGUE, including cards not currently dealt. Not the size of any deck in play — see
|
||||
* `DEALT_DECK_SIZE`, which is what `buildDeck` actually returns.
|
||||
*/
|
||||
export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0);
|
||||
|
||||
/** The solitaire deck drops the 22 opponent-directed cards. */
|
||||
/**
|
||||
* The deck actually dealt, in every mode: the catalogue less the 22 opponent-directed cards.
|
||||
*
|
||||
* Named for solitaire because Q6 dropped them there first, and kept under that name because the
|
||||
* number is the same either way. They are out of the competitive deck too until they are
|
||||
* implemented — `checkPlay` answers both categories NOT_IMPLEMENTED, so dealing them would make ~9%
|
||||
* of draws reject. See `buildDeck`.
|
||||
*/
|
||||
export const SOLITAIRE_DECK_SIZE = deckComposition()
|
||||
.filter((c) => !isOpponentOnly(c.category))
|
||||
.reduce((n, c) => n + c.count, 0);
|
||||
|
||||
+17
-6
@@ -53,9 +53,20 @@ export type SetupOptions = {
|
||||
|
||||
/** Builds the 52-card Home Office deck (§12.1). Unshuffled; caller shuffles with the seeded RNG. */
|
||||
export function buildDeck(mode: GameConfig['mode'] = 'competitive'): Card[] {
|
||||
// Q6 — the 22 opponent-directed cards have no legal target in a one-player game, so a solitaire
|
||||
// deck omits them entirely rather than leaving 19% of draws dead.
|
||||
const solitaire = mode === 'solitaire';
|
||||
/**
|
||||
* THE 22 OPPONENT-DIRECTED CARDS ARE OUT OF EVERY DECK FOR NOW, not just the solitaire one.
|
||||
*
|
||||
* Q6 took them out of solitaire because they have no legal target in a one-player game. They are
|
||||
* out of the competitive deck too because `checkPlay` answers both categories `NOT_IMPLEMENTED`:
|
||||
* leaving them in would make ~9% of draws reject outright, which is worse than not dealing them.
|
||||
* Three Enhancements — Facing Point Locks, Water Column and Overpass — exist only to answer these,
|
||||
* and stay dormant until they come back. Recorded in TODO.md as multiplayer work.
|
||||
*
|
||||
* `mode` is still taken so the signature does not move when they return; it is deliberately unused
|
||||
* for these two categories today.
|
||||
*/
|
||||
void mode;
|
||||
const opponentCardsInDeck = false;
|
||||
const cards: Card[] = [];
|
||||
let n = 0;
|
||||
const push = (kind: Card['kind']): void => {
|
||||
@@ -73,7 +84,7 @@ export function buildDeck(mode: GameConfig['mode'] = 'competitive'): Card[] {
|
||||
for (const m of MODIFIER_PROFILES) {
|
||||
for (let i = 0; i < m.copies; i++) push({ kind: 'modifier', modifier: m.kind });
|
||||
}
|
||||
if (!solitaire) {
|
||||
if (opponentCardsInDeck) {
|
||||
for (const c of SPACE_USE_CARDS) {
|
||||
for (let i = 0; i < c.copies; i++) push({ kind: 'spaceUse', key: c.key });
|
||||
}
|
||||
@@ -87,12 +98,12 @@ export function buildDeck(mode: GameConfig['mode'] = 'competitive'): Card[] {
|
||||
for (const c of MANEUVER_CARDS) {
|
||||
for (let i = 0; i < c.copies; i++) push({ kind: 'maneuver', key: c.key });
|
||||
}
|
||||
if (!solitaire) {
|
||||
if (opponentCardsInDeck) {
|
||||
for (const c of ACTION_CARDS) {
|
||||
for (let i = 0; i < c.copies; i++) push({ kind: 'action', key: c.key });
|
||||
}
|
||||
}
|
||||
// Track is IN the deck, 104 cards of it — the single largest category, and the reason building a
|
||||
// Track is IN the deck, 96 cards of it — the single largest category, and the reason building a
|
||||
// district costs you the industry or train you did not draw instead.
|
||||
for (const t of TRACK_CARDS) {
|
||||
for (let i = 0; i < t.copiesInDeck; i++) push({ kind: 'track', geometry: t.geometry, hand: t.hand });
|
||||
|
||||
+20
-8
@@ -547,6 +547,8 @@ function sampleDetail(s: GameState, kind: string, list: Intent[]): string {
|
||||
/** One readable line for a single intent. */
|
||||
export function describeIntent(s: GameState, i: Intent): string {
|
||||
const at = (c: { row: number; col: number }): string => `(${c.row},${c.col})`;
|
||||
// An intent belongs to whoever is acting, so it is described against THEIR district.
|
||||
const seat: PlayerIndex = s.clock.currentActor ?? 0;
|
||||
switch (i.type) {
|
||||
case 'localOps.choose':
|
||||
// The most consequential decision of the Stage, and it was labelled "choose switch". Say what
|
||||
@@ -575,10 +577,8 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
* moves — the second lifts a card already down — and calling both "play" hid the fact that the
|
||||
* square was not empty. The Limits sign is excluded: laying track there is ordinary growth.
|
||||
*/
|
||||
const actor = s.clock.currentActor;
|
||||
const over =
|
||||
i.placement && actor !== null
|
||||
? s.officeAreas.get(actor)?.grid.get(`${i.placement.row},${i.placement.col}`)
|
||||
const over = i.placement
|
||||
? s.officeAreas.get(seat)?.grid.get(`${i.placement.row},${i.placement.col}`)
|
||||
: undefined;
|
||||
const upgrade = over?.geometry.kind === 'track';
|
||||
return (
|
||||
@@ -651,7 +651,7 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
* waiting for a train that can carry it. For a passenger facility that load is passengers on
|
||||
* the platform.
|
||||
*/
|
||||
const f = areaOf(s, 0).grid.get(`${i.at.row},${i.at.col}`)?.facility ?? null;
|
||||
const f = areaOf(s, seat).grid.get(`${i.at.row},${i.at.col}`)?.facility ?? null;
|
||||
const where = f?.kind === 'passenger' ? 'onto the platform' : 'into the green Loading box';
|
||||
return i.carType === 'coach' && f?.kind === 'passenger'
|
||||
? `bring passengers ${where} at ${at(i.at)} — they wait there for a train with an empty coach`
|
||||
@@ -765,6 +765,17 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
* Build the view-model for a state. Shared with the playable web app so the live game and the
|
||||
* replay cannot drift into two different pictures of the same board.
|
||||
*/
|
||||
/**
|
||||
* The board as ONE SEAT sees it.
|
||||
*
|
||||
* `viewer` decides whose district, whose hand and whose facilities the Frame carries — everything
|
||||
* else (the Division, the timetable, the clock) is common to the table. It defaults to seat 0, which
|
||||
* is what solitaire and every replay want, so existing callers are unaffected.
|
||||
*
|
||||
* This was hardcoded to 0 throughout. That was correct while there was one player and would have
|
||||
* been a quiet disaster with more: every seat would have been shown player 0's railroad, including
|
||||
* player 0's hand, which is the one thing the state model calls secret.
|
||||
*/
|
||||
export function snapshot(
|
||||
s: GameState,
|
||||
lines: { text: string; tone: string }[],
|
||||
@@ -772,8 +783,9 @@ export function snapshot(
|
||||
whereFrom: { row: number; col: number } | null = null,
|
||||
decision: Decision | null = null,
|
||||
wasted = false,
|
||||
viewer: PlayerIndex = 0,
|
||||
): Frame {
|
||||
const area = areaOf(s, 0);
|
||||
const area = areaOf(s, viewer);
|
||||
const trayAt = new Map<string, string>();
|
||||
for (const [id, tray] of s.trays) {
|
||||
if (tray.position.at === 'grid') {
|
||||
@@ -972,8 +984,8 @@ export function snapshot(
|
||||
*
|
||||
* Both lines must reverse together or the descriptions come apart from the names.
|
||||
*/
|
||||
hand: [...(s.decks.hands.get(0) ?? [])].reverse().map((id) => cardName(s, id)),
|
||||
handWhat: [...(s.decks.hands.get(0) ?? [])].reverse().map((id) => cardDescription(s, id)),
|
||||
hand: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardName(s, id)),
|
||||
handWhat: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardDescription(s, id)),
|
||||
deck: s.decks.homeOffice.length,
|
||||
departments: s.decks.departments.map((pile) => {
|
||||
const top = pile[pile.length - 1];
|
||||
|
||||
+16
-11
@@ -314,7 +314,7 @@ export type Menu = {
|
||||
};
|
||||
|
||||
/** The action list as the page shows it: direct actions, plus subject-then-location for the rest. */
|
||||
export function actionMenu(game: Game): Menu {
|
||||
export function actionMenu(game: Game, seat: PlayerIndex = 0): Menu {
|
||||
const { options, groups } = actionGroups(game);
|
||||
const direct: ActionGroup[] = [];
|
||||
const placeableByTitle = new Map<string, Map<string, Placeable>>();
|
||||
@@ -367,7 +367,7 @@ export function actionMenu(game: Game): Menu {
|
||||
* generate options, so changing the stored order would reshuffle its tie-breaks and invalidate
|
||||
* every revenue measurement in TODO.md. `snapshot()` reverses identically for the replay viewers.
|
||||
*/
|
||||
const handIds = [...(game.state.decks.hands.get(0) ?? [])].reverse();
|
||||
const handIds = [...(game.state.decks.hands.get(seat) ?? [])].reverse();
|
||||
const hand: HandAction[] = handIds.map((cardId) => {
|
||||
const place = placeable.flatMap((g) => g.items).find((it) => it.subjectKey === `card:${cardId}`);
|
||||
let playNow: number | null = null;
|
||||
@@ -554,7 +554,8 @@ function joinsNote(
|
||||
): string {
|
||||
const v = variantsFor(geometry, hand)[variant ?? 0];
|
||||
if (!v) return '';
|
||||
const area = areaOf(game.state, 0);
|
||||
// The probe is against the district the placement would be made in, i.e. the actor's own.
|
||||
const area = areaOf(game.state, currentActor(game) ?? 0);
|
||||
const probe = {
|
||||
geometry: { kind: 'track', geometry, ...v, ...(hand !== 'none' ? { hand } : {}) },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
@@ -601,9 +602,9 @@ function consistTitle(game: Game, trayId: string): string | null {
|
||||
* held. The same test the engine applies to `draw.end`, asked here so the page can DISABLE the
|
||||
* button with a reason instead of hiding a move that has simply become illegal.
|
||||
*/
|
||||
export function overHandLimit(game: Game): boolean {
|
||||
const hand = game.state.decks.hands.get(0) ?? [];
|
||||
const limit = game.state.decks.redFlags.get(0) ? HAND_LIMIT + 1 : HAND_LIMIT;
|
||||
export function overHandLimit(game: Game, seat: PlayerIndex = 0): boolean {
|
||||
const hand = game.state.decks.hands.get(seat) ?? [];
|
||||
const limit = game.state.decks.redFlags.get(seat) ? HAND_LIMIT + 1 : HAND_LIMIT;
|
||||
return hand.length > limit;
|
||||
}
|
||||
|
||||
@@ -631,8 +632,8 @@ export function submit(game: Game, intent: Intent): boolean {
|
||||
* Modifier with no Facility to sit beside — looks identical to one where everything is available.
|
||||
* Derived from `legalActions`, so it cannot disagree with what the buttons offer.
|
||||
*/
|
||||
export function handPlayable(game: Game): boolean[] {
|
||||
const hand = game.state.decks.hands.get(0) ?? [];
|
||||
export function handPlayable(game: Game, seat: PlayerIndex = 0): boolean[] {
|
||||
const hand = game.state.decks.hands.get(seat) ?? [];
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) return hand.map(() => false);
|
||||
const playable = new Set(
|
||||
@@ -643,9 +644,13 @@ export function handPlayable(game: Game): boolean[] {
|
||||
return hand.map((id) => playable.has(id));
|
||||
}
|
||||
|
||||
/** The board as the replay draws it, so the live game and the replay agree. */
|
||||
export function view(game: Game): Frame {
|
||||
return snapshot(game.state, [], null);
|
||||
/**
|
||||
* The board as the replay draws it, so the live game and the replay agree.
|
||||
*
|
||||
* `seat` is whose railroad and whose hand to show. Solitaire has one seat and never passes it.
|
||||
*/
|
||||
export function view(game: Game, seat: PlayerIndex = 0): Frame {
|
||||
return snapshot(game.state, [], null, null, null, false, seat);
|
||||
}
|
||||
|
||||
function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = null): void {
|
||||
|
||||
+2
-1
@@ -722,7 +722,8 @@ function renderActions(
|
||||
game.state.turn.option === 'draw' &&
|
||||
!menu.options.some((i) => i.type === 'draw.end')
|
||||
) {
|
||||
const hand = (game.state.decks.hands.get(0) ?? []).length;
|
||||
// The hand being counted is the ACTOR's — they are the one who cannot end the turn.
|
||||
const hand = (game.state.decks.hands.get(currentActor(game) ?? 0) ?? []).length;
|
||||
html +=
|
||||
`<div class="grp"><button class="act blocked" disabled data-tip="§6.2 — you may not end a turn holding more than three cards (four with a Red Flag). Play one onto the board, or discard one face-up to a Department slot.">` +
|
||||
`End Local Operations — play or discard down to three first (holding ${hand})</button></div>`;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
+14
-8
@@ -62,8 +62,10 @@ describe('card catalogue (component 1)', () => {
|
||||
// Two entries are dealt ZERO copies and kept in the catalogue so the design stays visible:
|
||||
// Poling, whose effect is "TBD in the source", and the sharp curves, whose only difference from
|
||||
// an ordinary curve was a Move cost nothing ever charged.
|
||||
// DECK_SIZE is the CATALOGUE, 235. The deck actually dealt is smaller: the 22 opponent-directed
|
||||
// cards are held back in every mode until they are implemented, so `buildDeck` returns 213.
|
||||
assert.equal(DECK_SIZE, 235);
|
||||
assert.equal(buildDeck().length, DECK_SIZE);
|
||||
assert.equal(buildDeck().length, SOLITAIRE_DECK_SIZE);
|
||||
});
|
||||
|
||||
it('matches the design deck composition exactly', () => {
|
||||
@@ -89,14 +91,18 @@ describe('card catalogue (component 1)', () => {
|
||||
});
|
||||
|
||||
it('removes opponent-directed cards from a solitaire deck', () => {
|
||||
// Q6 — Space-use and Action cards can only be played AT another player, so in a one-player
|
||||
// game they would be 22 of 235 draws (9%) that do nothing.
|
||||
// Q6 took Space-use and Action cards out of solitaire, where they have no legal target. They are
|
||||
// now out of the COMPETITIVE deck too, until they are implemented: `checkPlay` answers both
|
||||
// categories NOT_IMPLEMENTED, so dealing them would make 22 of 235 draws (9%) reject outright.
|
||||
assert.equal(SOLITAIRE_DECK_SIZE, 213);
|
||||
const solo = buildDeck('solitaire');
|
||||
assert.equal(solo.length, SOLITAIRE_DECK_SIZE);
|
||||
assert.ok(!solo.some((c) => c.kind.kind === 'spaceUse' || c.kind.kind === 'action'));
|
||||
// A competitive deck keeps them. Poling and the sharp curves are dealt none.
|
||||
assert.equal(buildDeck('competitive').length, 235);
|
||||
for (const mode of ['solitaire', 'competitive'] as const) {
|
||||
const deck = buildDeck(mode);
|
||||
assert.equal(deck.length, SOLITAIRE_DECK_SIZE, `${mode} deck size`);
|
||||
assert.ok(
|
||||
!deck.some((c) => c.kind.kind === 'spaceUse' || c.kind.kind === 'action'),
|
||||
`${mode} deck still holds opponent-directed cards`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('deals track FROM the deck, at the sheet\'s counts', () => {
|
||||
|
||||
Reference in New Issue
Block a user