All four reported from a table on Day 1 of v0.8.0.16, and all the same shape. ABS SIGNALS COULD ONLY BE PLAYED ON ONE MAINLINE CARD, while its tooltip said "any Mainline card". The engine was never wrong: check accepts any node whose kind is mainline and legalActions filters by check, so all of them were legal. The failure was the LABEL — describeIntent named i.placement and never i.node, so every placement described itself as plain "play ABS Signals", and the action list drops duplicate labels. All but the lowest-index node were discarded before the menu saw them. This is the THIRD time that trap has fired and the file documents the other two three lines apart: a turnout's two rotations, and three Department discards. Same fix — name what distinguishes them. The card is also called what the card face calls it. prettyKey rendered absSignals as "Abs Signals" beside a tooltip saying ABS, an acronym no key-splitter can recover, so the authored names now win. Three of those names were transcribed in sentence case and were CORRECTED rather than adopted: the repository says "Yard Office" 36 times against "Yard office" twice. A lookup that imports its own source's typos is the drift it exists to prevent. NOTHING ON A MAINLINE CARD SHOWED WHAT WAS STANDING ON IT. Played, ABS left no mark and you found out by hovering — the same complaint the Heavy Grade wedge answered, and it matters more here because ABS decides whether a second train on that card is safe. It draws a signal mast with a lit lamp now; a signal is the literal object and needs no room for words, which is what lets it sit clear of a name as long as "Uncontrolled Siding" on a 152px cell. The Mainline modifiers draw as BRK, AIR and HLP. Realignment is deliberately not among them: reduce takes the `became` branch and changes node.card, so a realigned Trestle IS an Uncontrolled Siding afterwards. Asserted, so the absence reads as a finding. A FREIGHT AGENT TURN SAID A CAR MOVED WHEN NONE HAD. Three faults behind one line. It asserted an outcome, where §6.3 requires no action and the bot declines deliberately — unjamming a healthy box destroys a load that cost a whole action to stock. An idle Agent was then silent, which read as a dropped turn; a new freightAgentIdled event says so and why, reducing to nothing exactly like switchingEnded. And the work named a coordinate rather than the industry, though a `place` helper has existed for precisely that since the switching lines moved to it. "Loaded a loaded boxcar INTO the green Outbound box at the Freight House", with the direction in capitals because to-or-from was the question asked. THE LOG AND THE ACTION MENU SPELLED THE SAME SQUARE DIFFERENTLY. view.ts wrote (col,row) — X,Y, east/west then north/south — with a comment saying why; narrate.ts wrote the internal storage order with no comment at all. So the menu offered a move to "(1,-1)" and the log reported it at "(-1,1)", side by side. Pinned by a test that renders one square through BOTH describers and compares them to each other: a test written against either file alone would have passed. THE DOCUMENTATION IS REACHABLE FROM A RUNNING GAME, AND ALL OF IT IS PUBLISHED. v0.8.0.16 published the Quickstart and nothing it points at — its §8 links five documents by relative path and every one 404'd on the package, verified against the running container. The build publishes the full set, and the test reads the links OUT OF the guide rather than listing them. They are linked from the This Game card, where reference already lives, rather than the header that must not wrap; no mode awareness is needed, because solitaire and multiplayer are the same page on the same origin. THE REFERENCES DROPPED THE VERSION FROM THEIR NAMES. Four described v0.8.0.16 and had since the v0.8.0.15 audit; the v0.4.5 was the prototype edition they were first written against, kept only because 36 citations pointed at it — and it read as documentation five minor versions stale. They are quickstart.md, rules.md, home-deck.md, mainline-deck.md and components.md now, kept current with each release rather than published as editions. Two errors surfaced while checking them against this release, which is the argument for doing it: home-deck.md filed ABS Signals under Enhancements "played into your district" that "change what a square does" — it does neither, this release's bug written down — and mainline-deck.md, which lists everything playable onto a Mainline card, never mentioned it at all. 1010 fast tests and 35 sim tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
619 lines
28 KiB
TypeScript
619 lines
28 KiB
TypeScript
/**
|
|
* Build order step 1 — "Card data loads; a game state can be constructed and seeded."
|
|
* See architecture/components.md §4.
|
|
*/
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import type { CarType } from '../src/engine/content.ts';
|
|
import { DEFENCE_ONLY_CARDS, DEFENCE_ONLY_COPIES, MODIFIER_PROFILES, OPENING_OTHER, OPENING_TRACK, SOLITAIRE_DECK_SIZE, TRACK_CARDS, TRACK_IN_DECK } from '../src/engine/content.ts';
|
|
import {
|
|
DECK_SIZE,
|
|
EXTRA_TRAINS,
|
|
FREIGHT_PROFILES,
|
|
LENGTH_PROFILES,
|
|
MAX_CONSIST,
|
|
OFFICE_PROFILES,
|
|
ROLLING_STOCK_SUPPLY,
|
|
TIMETABLED_TRAINS,
|
|
TOTAL_ROLLING_STOCK,
|
|
crewTrayCount,
|
|
deckComposition,
|
|
isFreightHouse,
|
|
lengthProfile,
|
|
MAINLINE_DECK,
|
|
mainlineCardCount,
|
|
nextOfficeTier,
|
|
officeProfile,
|
|
} from '../src/engine/content.ts';
|
|
import { createRng } from '../src/engine/rng.ts';
|
|
import { buildDeck, buildRollingStock, createGame } from '../src/engine/setup.ts';
|
|
import type { StartingHand } from '../src/engine/content.ts';
|
|
import type { GameConfig } from '../src/engine/state.ts';
|
|
import { subdivisions } from '../src/engine/state.ts';
|
|
|
|
const solitaireConfig: GameConfig = {
|
|
mode: 'solitaire',
|
|
days: 5,
|
|
minCombinedRevenue: 0,
|
|
maxCollisionsPerDay: 0,
|
|
maxCollisionsTotal: 0,
|
|
pvpCardsAllowed: false,
|
|
optionalRules: {
|
|
reducedVisibility: false,
|
|
employeeRotation: false,
|
|
emergencyToolbox: false,
|
|
},
|
|
};
|
|
|
|
const newSolitaireGame = (seed = 1234) =>
|
|
createGame({ id: 'g1', seed, config: solitaireConfig, playerNames: ['Jesse'] });
|
|
|
|
/** A game dealt under one named `StartingHand`, for the tests that are about the deal itself. */
|
|
const gameDealtWith = (startingHand: StartingHand, seed = 1234) =>
|
|
createGame({
|
|
id: 'g1',
|
|
seed,
|
|
config: { ...solitaireConfig, houseRules: { startingHand } },
|
|
playerNames: ['Jesse'],
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('card catalogue (component 1)', () => {
|
|
it('composes the deck from the design', () => {
|
|
// THE WHOLE CATALOGUE IS docs/Deck cards5.xlsx NOW (Gitea#14). Sheet 5's own totals are
|
|
// "Total track 48" and "Total other (in play) 107", i.e. 155 shuffled, plus 12 start cards for
|
|
// its grand total of 167. Card for card, 84 rows agree with it exactly and the only ones that
|
|
// do not are listed below — every one of them a deliberate hold, in one direction or the other.
|
|
//
|
|
// EVERY COUNT IN THE CATALOGUE IS NOW THE SHEET'S. The two deliberate departures that used to
|
|
// sit here are gone with Gitea#14 — the Q12 office doubling (offices 14 → 7) and the Gap 12
|
|
// industry tripling (27 → 9) — because both were measured against a deck holding 96 track
|
|
// cards, and sheet 5 halves that. content.ts carries the measurements that decided it.
|
|
//
|
|
// We are at 143 rather than the sheet's 155 for ONE reason: the ten Safety, Event, Inspection
|
|
// and Space-use cards sheet 5 adds are not built, and stay out until they are (Jesse,
|
|
// 2026-08-26) — Cargo Theft, Civic Improvement, Civilian angel, Delayed Clearance, Flares 2,
|
|
// Robbery, Service Delays, Shipper complaints, Strike, Union Hall 2. Twelve copies in all.
|
|
//
|
|
// NOTHING RUNS THE OTHER WAY ANY MORE. Every card sheet 5 does not list is dealt ZERO copies
|
|
// rather than deleted, so the design stays visible and the rules stay implemented: the
|
|
// Telegraph/Telephone/Radio ladder, Facing Point Locks (both), Flying Switch, Section House and
|
|
// Vandalism, all removed from the design on purpose; Poling, whose effect the source records as
|
|
// "TBD"; and the sharp curves, whose only difference from an ordinary curve was a Move cost
|
|
// nothing ever charged — sheet 5 deals those zero too, so the catalogue and the design agree.
|
|
//
|
|
// DECK_SIZE is the CATALOGUE, 143. The deck actually dealt is smaller: the 20 opponent-directed
|
|
// cards are held back in every mode until they are implemented, so `buildDeck` returns 123.
|
|
assert.equal(DECK_SIZE, 143);
|
|
assert.equal(buildDeck().length, SOLITAIRE_DECK_SIZE);
|
|
});
|
|
|
|
it('matches the design deck composition exactly', () => {
|
|
const byCategory = Object.fromEntries(deckComposition().map((c) => [c.category, c.count]));
|
|
assert.deepEqual(byCategory, {
|
|
// Sheet 5's track counts exactly (Gitea#14): 16 straights, 8+8 curves, 8+8 turnouts, and the
|
|
// sharp curves dealt none — which is where the catalogue already had them, and where sheet 5
|
|
// now puts them too.
|
|
track: 48,
|
|
// The sheet's 7 — the Q12 doubling came out in Gitea#14; see OFFICE_PROFILES.
|
|
office: 7,
|
|
// The sheet's 9 — the Gap 12 tripling came out in Gitea#14; see INDUSTRY_PROFILES.
|
|
industry: 9,
|
|
modifier: 23,
|
|
train: 22,
|
|
spaceUse: 11,
|
|
// 6 — the dispatching ladder and Facing Point Locks are dealt 0 copies (see
|
|
// ENHANCEMENT_CARDS), and Interlocking, Water column and ABS Signals came down to the sheet's
|
|
// single copies. What is left is the sheet's Enhancements exactly, bar Railroad crossing,
|
|
// which sheet 5 moved here from the Action cards and which is still counted there below.
|
|
enhancement: 6,
|
|
// 5 — Facing Point Locks came out of the Mainline modifiers too.
|
|
mainlineModifier: 5,
|
|
// 3 — Red Flags is the sheet's 3; Flying Switch and Poling are both dealt none.
|
|
maneuver: 3,
|
|
// 9 — Vandalism is dealt none. The rest are opponent-directed and held out of every deck.
|
|
action: 9,
|
|
});
|
|
});
|
|
|
|
it('removes opponent-directed cards from a solitaire deck', () => {
|
|
// 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 be a dead draw.
|
|
// 121, not 123: the 20 opponent-directed cards come out, and so do the TWO that exist only to
|
|
// answer them — one Water Column and one Overpass. A defence with nothing to defend against is
|
|
// the same dead draw as the attack would be. `SimpleCard.answers` names the pairing, so they
|
|
// return together. It was seven until Gitea#14 dealt Facing Point Locks zero copies: a card at
|
|
// zero is already out, so it no longer needs holding back.
|
|
assert.equal(SOLITAIRE_DECK_SIZE, 121);
|
|
assert.equal(DEFENCE_ONLY_COPIES, 2);
|
|
for (const c of DEFENCE_ONLY_CARDS) {
|
|
assert.ok(c.answers, `${c.name} is held back without saying what it answers`);
|
|
assert.ok(
|
|
!buildDeck().some((x) => (x.kind as { key?: string }).key === c.key),
|
|
`${c.name} is still dealt`,
|
|
);
|
|
}
|
|
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', () => {
|
|
// Column B of Deck cards5.xlsx, "Number in Deck": 16 straights, 8+8 curves, 8+8 turnouts, and
|
|
// 0+0 sharp curves. Sheet 2 had each of those at double, which is what the deck dealt until
|
|
// Gitea#14. An earlier reading took the sheet's LAST column, "Track Per Player" (26), as a
|
|
// separate stack outside the deck; it is the sheet's total shared out among four players, not a
|
|
// second pile.
|
|
assert.equal(TRACK_IN_DECK, 48);
|
|
const deck = buildDeck();
|
|
for (const t of TRACK_CARDS) {
|
|
const n = deck.filter(
|
|
(c) => c.kind.kind === 'track' && c.kind.geometry === t.geometry && c.kind.hand === t.hand,
|
|
).length;
|
|
assert.equal(n, t.copiesInDeck, `${t.name}: ${n} in the deck, expected ${t.copiesInDeck}`);
|
|
}
|
|
});
|
|
|
|
it('makes track the largest category in the deck', () => {
|
|
// 48 of 121. Building a district is paid for in the industry or train you did not draw, which
|
|
// is the whole reason it matters that track is a card rather than a private supply.
|
|
//
|
|
// This asked for a THIRD of the deck until Gitea#14, which was only ever a rule of thumb. It
|
|
// asks its own question now — is track still the biggest single thing you can draw — plus a
|
|
// loose band, because the exact share is not settled yet and should not be pinned as though it
|
|
// were. Sheet 5 puts track at 48 of the 155 cards it would have you shuffle, i.e. 31%; we read
|
|
// 40% because the Space-use, Safety, Event and Inspection cards are held out, which concentrates
|
|
// everything that is left. The share falls TOWARDS the sheet as those land, so the band is set
|
|
// to hold across that whole journey rather than to be re-edited at each step.
|
|
const deck = buildDeck();
|
|
const counts = new Map<string, number>();
|
|
for (const c of deck) counts.set(c.kind.kind, (counts.get(c.kind.kind) ?? 0) + 1);
|
|
const track = counts.get('track') ?? 0;
|
|
for (const [kind, n] of counts) {
|
|
if (kind === 'track') continue;
|
|
assert.ok(track > n, `${kind} has ${n} cards against track's ${track}`);
|
|
}
|
|
const share = track / deck.length;
|
|
assert.ok(share > 0.28 && share < 0.45, `track is ${(share * 100).toFixed(1)}% of the deck`);
|
|
});
|
|
|
|
it('has 12 timetabled trains, odd westbound and even eastbound', () => {
|
|
assert.equal(TIMETABLED_TRAINS.length, 12);
|
|
for (const t of TIMETABLED_TRAINS) {
|
|
assert.equal(t.direction, t.number % 2 === 1 ? 'west' : 'east', `train ${t.number}`);
|
|
}
|
|
});
|
|
|
|
it('never lets a consist exceed four slots, caboose included', () => {
|
|
// §A.4 + Gap 4c — the most likely off-by-one in the whole model.
|
|
for (const t of [...TIMETABLED_TRAINS, ...EXTRA_TRAINS]) {
|
|
const slots = t.consist.freight + t.consist.coach + t.consist.caboose;
|
|
assert.ok(slots <= MAX_CONSIST, `${t.name} ${t.number} uses ${slots} slots`);
|
|
}
|
|
});
|
|
|
|
it('numbers Extras X13-X22, all junior to every timetabled train', () => {
|
|
// This is why Gap 5's Extra-vs-Timetabled tie can no longer occur.
|
|
assert.deepEqual(
|
|
EXTRA_TRAINS.map((t) => t.number).sort((a, b) => a - b),
|
|
[13, 14, 15, 16, 17, 18, 19, 20, 21, 22],
|
|
);
|
|
assert.ok(EXTRA_TRAINS.every((e) => TIMETABLED_TRAINS.every((t) => t.number < e.number)));
|
|
});
|
|
|
|
it('gives every train a name and a speed class', () => {
|
|
for (const t of [...TIMETABLED_TRAINS, ...EXTRA_TRAINS]) {
|
|
assert.ok(t.name.length > 2, `train ${t.number} has no name`);
|
|
assert.ok(t.speed === 'fast' || t.speed === 'slow', `train ${t.number} has no speed`);
|
|
}
|
|
});
|
|
|
|
it('names the Freight House and nothing else as the two-way industry', () => {
|
|
/**
|
|
* §9.3 — "Passenger Facilities and Freight Houses permit cars to move each direction". ONE card
|
|
* answers to that.
|
|
*
|
|
* This briefly asserted three. `card-reference.md` reads "'Freight House' is not a card. It is
|
|
* the collective term for a freight facility that loads *and* unloads — the Grocer's Warehouse
|
|
* and the Oil Refinery", and on that premise the Refinery and the Grocer's were both made
|
|
* `flow: 'both'`. The premise is dead: `glossary.md` and `rules-v0.2.md` corrected the Freight
|
|
* House to a card of its own, dealt like any other industry, so §9.3 names it and the table's
|
|
* "Both" column loses its only argument.
|
|
*
|
|
* Reported from playtesting v0.4.9d and confirmed by Jesse: the Refinery only ships tanks out,
|
|
* the Grocer's Warehouse only receives. `home-deck.md` prints both that way,
|
|
* and so does the modifier set — all three Refinery modifiers grant outbound.
|
|
*/
|
|
const houses = FREIGHT_PROFILES.filter(isFreightHouse).map((f) => f.kind);
|
|
assert.deepEqual(houses.sort(), ['freightHouse']);
|
|
const refinery = FREIGHT_PROFILES.find((f) => f.kind === 'refinery')!;
|
|
assert.equal(refinery.flow, 'outbound');
|
|
assert.deepEqual([refinery.baseOut, refinery.baseIn], [1, 0]);
|
|
const grocers = FREIGHT_PROFILES.find((f) => f.kind === 'grocersWarehouse')!;
|
|
assert.equal(grocers.flow, 'inbound');
|
|
assert.deepEqual([grocers.baseOut, grocers.baseIn], [0, 1]);
|
|
});
|
|
|
|
it('starts every industry at one car out and one loader', () => {
|
|
// The design is far leaner than the placeholder: capacity grows via modifier cards.
|
|
for (const f of FREIGHT_PROFILES) {
|
|
assert.ok(f.baseOut <= 1, `${f.name} baseOut ${f.baseOut}`);
|
|
assert.ok(f.baseIn <= 1, `${f.name} baseIn ${f.baseIn}`);
|
|
assert.equal(f.baseLoaders, 1, `${f.name} loaders`);
|
|
}
|
|
});
|
|
|
|
it('gives a facility capacity only in the directions it allows', () => {
|
|
for (const f of FREIGHT_PROFILES) {
|
|
const wantsOut = f.flow === 'outbound' || f.flow === 'both';
|
|
const wantsIn = f.flow === 'inbound' || f.flow === 'both';
|
|
assert.equal(f.baseOut > 0, wantsOut, `${f.name} outbound`);
|
|
assert.equal(f.baseIn > 0, wantsIn, `${f.name} inbound`);
|
|
}
|
|
});
|
|
|
|
it('records lockouts symmetrically', () => {
|
|
// Column E of the sheet. Semantics are still open, but the data must be consistent.
|
|
for (const f of FREIGHT_PROFILES) {
|
|
for (const other of f.lockouts) {
|
|
const o = FREIGHT_PROFILES.find((x) => x.kind === other)!;
|
|
assert.ok(o.lockouts.includes(f.kind), `${f.kind} locks out ${other} but not vice versa`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('ties every modifier to at least one real host', () => {
|
|
for (const m of MODIFIER_PROFILES) {
|
|
assert.ok(m.hosts.length > 0, `${m.name} has no host`);
|
|
for (const h of m.hosts) {
|
|
if (h === 'office') continue;
|
|
assert.ok(FREIGHT_PROFILES.some((f) => f.kind === h), `${m.name} names unknown host ${h}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('makes only the Whistle Post a non-Control-Point', () => {
|
|
for (const o of OFFICE_PROFILES) {
|
|
assert.equal(o.isControlPoint, o.tier !== 'whistlePost', o.tier);
|
|
assert.equal(o.isPassengerFacility, o.tier !== 'whistlePost', o.tier);
|
|
}
|
|
});
|
|
|
|
it('scales A/D tracks 1/2/3/4 and slots above porter count', () => {
|
|
assert.deepEqual(OFFICE_PROFILES.map((o) => o.adTracks), [1, 2, 3, 4]);
|
|
// The design gives slots EQUAL to porters; the earlier guess was one too generous.
|
|
for (const o of OFFICE_PROFILES) {
|
|
if (o.tier === 'whistlePost') continue;
|
|
assert.equal(o.passengerOut, o.porters, `${o.tier} green slots`);
|
|
assert.equal(o.passengerIn, o.porters, `${o.tier} red slots`);
|
|
}
|
|
});
|
|
|
|
it('forms an office pyramid so the strict upgrade sequence cannot stall', () => {
|
|
// Assert the PROPERTY, not the literal counts. Upgrades are strictly sequential (Gap 3b), so
|
|
// every tier must be at least as common as the one above it — otherwise players reach a rung
|
|
// whose next card is scarcer than the one that got them there. Densities are provisional (Q12)
|
|
// and expected to move again; the pyramid shape is what must survive.
|
|
const inDeck = OFFICE_PROFILES.filter((o) => o.copiesInDeck > 0).map((o) => o.copiesInDeck);
|
|
assert.ok(inDeck.length >= 2, 'there must be an upgrade ladder at all');
|
|
for (let i = 1; i < inDeck.length; i++) {
|
|
assert.ok(
|
|
inDeck[i]! <= inDeck[i - 1]!,
|
|
`tier ${i} has ${inDeck[i]} copies but the tier below it has ${inDeck[i - 1]}`,
|
|
);
|
|
}
|
|
// The Whistle Post is the starting state, never a card.
|
|
assert.equal(OFFICE_PROFILES.find((o) => o.tier === 'whistlePost')!.copiesInDeck, 0);
|
|
});
|
|
|
|
it('walks the upgrade sequence without skipping', () => {
|
|
assert.equal(nextOfficeTier('whistlePost'), 'depot');
|
|
assert.equal(nextOfficeTier('depot'), 'station');
|
|
assert.equal(nextOfficeTier('station'), 'terminal');
|
|
assert.equal(nextOfficeTier('terminal'), null);
|
|
});
|
|
|
|
it('supplies the full rolling stock roster', () => {
|
|
// 80 pieces after the Gap 12 supply scale-up. Asserted against the constant rather than a
|
|
// literal so the invariant is "the yard holds exactly the roster", not a number to re-edit.
|
|
assert.equal(TOTAL_ROLLING_STOCK, 80);
|
|
assert.equal(buildRollingStock().length, TOTAL_ROLLING_STOCK);
|
|
});
|
|
|
|
it('never demands more cars than the supply can furnish', () => {
|
|
// card-reference.md §8 supply check, as an executable assertion.
|
|
const supply = new Map(ROLLING_STOCK_SUPPLY.map((s) => [s.type, s.loaded + s.empty]));
|
|
const demand = new Map<CarType, number>();
|
|
for (const f of FREIGHT_PROFILES) {
|
|
const need = (f.baseOut + f.baseIn) * f.copies;
|
|
for (const t of f.carTypes) demand.set(t, (demand.get(t) ?? 0) + need);
|
|
}
|
|
for (const [type, need] of demand) {
|
|
assert.ok(need <= supply.get(type)!, `${type}: demand ${need} > supply ${supply.get(type)}`);
|
|
}
|
|
});
|
|
|
|
it('keeps the three day-count presets sim/CLI tooling still relies on', () => {
|
|
// Gap 10e's fixed targets retired 2026-08-20 — `minCombinedRevenue` is a free `GameConfig`
|
|
// variable now, not a `length`-keyed lookup. `LENGTH_PROFILES` survives only as a convenience
|
|
// preset for tools that still want a short/standard/campaign argument (`harness.ts`, `compare.ts`,
|
|
// `replay.ts`).
|
|
assert.deepEqual(
|
|
LENGTH_PROFILES.map((p) => [p.length, p.days]),
|
|
[['short', 3], ['standard', 5], ['campaign', 10]],
|
|
);
|
|
assert.equal(lengthProfile('standard').days, 5);
|
|
});
|
|
|
|
it('scales fixed supplies with player count', () => {
|
|
assert.equal(mainlineCardCount(1), 2);
|
|
assert.equal(mainlineCardCount(3), 4);
|
|
assert.equal(crewTrayCount(1), 4);
|
|
assert.equal(crewTrayCount(3), 6);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('seeded RNG (component 7)', () => {
|
|
it('is deterministic for a given seed', () => {
|
|
const a = createRng(42);
|
|
const b = createRng(42);
|
|
const drawsA = Array.from({ length: 50 }, () => a.nextInt(1000));
|
|
const drawsB = Array.from({ length: 50 }, () => b.nextInt(1000));
|
|
assert.deepEqual(drawsA, drawsB);
|
|
});
|
|
|
|
it('differs between seeds', () => {
|
|
const a = Array.from({ length: 20 }, ((r) => () => r.nextInt(1000))(createRng(1)));
|
|
const b = Array.from({ length: 20 }, ((r) => () => r.nextInt(1000))(createRng(2)));
|
|
assert.notDeepEqual(a, b);
|
|
});
|
|
|
|
it('rolls a D12 strictly within 1..12', () => {
|
|
const rng = createRng(7);
|
|
const seen = new Set<number>();
|
|
for (let i = 0; i < 5000; i++) {
|
|
const roll = rng.d12();
|
|
assert.ok(roll >= 1 && roll <= 12, `roll out of range: ${roll}`);
|
|
seen.add(roll);
|
|
}
|
|
assert.equal(seen.size, 12, 'every face should appear over 5000 rolls');
|
|
});
|
|
|
|
it('shuffles without mutating or losing elements', () => {
|
|
const rng = createRng(99);
|
|
const input = Array.from({ length: 52 }, (_, i) => i);
|
|
const out = rng.shuffle(input);
|
|
assert.deepEqual(input, Array.from({ length: 52 }, (_, i) => i), 'input mutated');
|
|
assert.deepEqual([...out].sort((x, y) => x - y), input, 'elements lost');
|
|
assert.notDeepEqual(out, input, 'shuffle was a no-op');
|
|
});
|
|
|
|
it('rejects an invalid bound', () => {
|
|
const rng = createRng(1);
|
|
assert.throws(() => rng.nextInt(0));
|
|
assert.throws(() => rng.nextInt(-3));
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('game setup (component 2)', () => {
|
|
it('constructs a solitaire game from a seed', () => {
|
|
const g = newSolitaireGame();
|
|
assert.equal(g.status, 'active');
|
|
assert.equal(g.players.length, 1);
|
|
assert.equal(g.clock.day, 1);
|
|
assert.equal(g.clock.stage, 1);
|
|
assert.equal(g.clock.phase, 'localOps');
|
|
assert.equal(g.clock.superintendent, 0);
|
|
assert.equal(g.clock.currentActor, 0);
|
|
});
|
|
|
|
it('is reproducible from the same seed and differs on another', () => {
|
|
assert.deepEqual(newSolitaireGame(5).decks.homeOffice, newSolitaireGame(5).decks.homeOffice);
|
|
assert.notDeepEqual(
|
|
newSolitaireGame(5).decks.homeOffice,
|
|
newSolitaireGame(6).decks.homeOffice,
|
|
);
|
|
});
|
|
|
|
it('builds the MVP division: DP - ML - Office - ML - DP', () => {
|
|
const g = newSolitaireGame();
|
|
assert.deepEqual(
|
|
g.division.nodes.map((n) => n.kind),
|
|
['divisionPoint', 'mainline', 'office', 'mainline', 'divisionPoint'],
|
|
);
|
|
});
|
|
|
|
it('deals each Mainline card a terrain type', () => {
|
|
// Terrain is what sets crossing time (Q1), so every Mainline node must carry a card kind.
|
|
const g = newSolitaireGame();
|
|
for (const node of g.division.nodes) {
|
|
if (node.kind === 'mainline') {
|
|
assert.ok(node.card, 'mainline node has no card type');
|
|
assert.deepEqual(node.transits, []);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('deals the Mainline cards from the printed deck, without replacement', () => {
|
|
/**
|
|
* `buildDivision` drew uniformly from the nine card TYPES with replacement, so a Division could
|
|
* be dealt two Interchanges (or two Tunnels), and Plains — printed twice in the deck — carried
|
|
* the same weight as cards printed once. That became a rules question rather than a flavour one
|
|
* when an Extra gained the right to start "at the Interchange if one is on the board" (§7): the
|
|
* board has to hold at most one for that to mean anything.
|
|
*
|
|
* Swept over many seeds because a single deal cannot tell a deck from a die.
|
|
*/
|
|
const seen = new Map<string, number>();
|
|
for (let seed = 0; seed < 400; seed++) {
|
|
for (const players of [1, 2, 3, 4]) {
|
|
const g = createGame({
|
|
id: 'deck', seed,
|
|
config: players === 1 ? solitaireConfig : { ...solitaireConfig, mode: 'competitive' },
|
|
playerNames: Array.from({ length: players }, (_, i) => `P${i}`),
|
|
});
|
|
const cards = g.division.nodes.flatMap((n) => (n.kind === 'mainline' ? [n.card] : []));
|
|
assert.equal(cards.length, mainlineCardCount(players));
|
|
const counts = new Map<string, number>();
|
|
for (const c of cards) {
|
|
const n = (counts.get(c) ?? 0) + 1;
|
|
counts.set(c, n);
|
|
seen.set(c, (seen.get(c) ?? 0) + 1);
|
|
// Plains is the one card printed twice; nothing else may be dealt twice at all.
|
|
assert.ok(n <= (c === 'plains' ? 2 : 1), `${c} dealt ${n} times at seed ${seed}`);
|
|
}
|
|
}
|
|
}
|
|
// Every card in the deck reachable, so the deal is not quietly missing one.
|
|
for (const kind of new Set(MAINLINE_DECK)) {
|
|
assert.ok((seen.get(kind) ?? 0) > 0, `${kind} was never dealt in 400 seeds`);
|
|
}
|
|
});
|
|
|
|
it('opens with the whole railroad as one Subdivision', () => {
|
|
// §8 — every Office is a Whistle Post, which is not a Control Point.
|
|
const g = newSolitaireGame();
|
|
const subs = subdivisions(g);
|
|
assert.equal(subs.length, 1, 'expected exactly one Subdivision at game start');
|
|
});
|
|
|
|
it('starts each player on a Whistle Post with three cards in a row', () => {
|
|
const g = newSolitaireGame();
|
|
const area = g.officeAreas.get(0)!;
|
|
assert.equal(area.tier, 'whistlePost');
|
|
assert.equal(area.grid.size, 3, 'Limits, Whistle Post, Limits');
|
|
assert.equal(officeProfile(area.tier).adTracks, 1);
|
|
assert.equal(area.adOccupancy.length, 0);
|
|
});
|
|
|
|
it('deals three random cards by default, and starts three Department piles', () => {
|
|
// The prototype rule, and the New Game dialog's default: three off one deck, already at the hand
|
|
// limit, with no guarantee of anything. The other two shapes are below.
|
|
const g = newSolitaireGame();
|
|
assert.equal(g.decks.hands.get(0)!.length, 3);
|
|
|
|
assert.equal(g.decks.departments.length, 3);
|
|
assert.ok(g.decks.departments.every((pile) => pile.length === 1), 'each Department starts with one face-up card');
|
|
});
|
|
|
|
it('deals six random cards when that is the rule, over the hand limit on purpose', () => {
|
|
// Six against a hand limit of three is deliberate: the first turn is spent choosing which of
|
|
// them to keep. What it does NOT do is guarantee track, which is the difference from the split
|
|
// deal below.
|
|
const g = gameDealtWith('sixRandom');
|
|
assert.equal(g.decks.hands.get(0)!.length, 6);
|
|
});
|
|
|
|
it('deals three track and three other cards when that is the rule', () => {
|
|
// From two separately shuffled piles, so the district you can build is dealt rather than waited
|
|
// for — a run-around needs five specific pieces, and drawing for them took eight games.
|
|
const g = gameDealtWith('threeTrackThreeOther');
|
|
const hand = g.decks.hands.get(0)!;
|
|
assert.equal(hand.length, OPENING_TRACK + OPENING_OTHER);
|
|
|
|
const track = hand.filter((id) => g.cards.get(id)!.kind.kind === 'track');
|
|
assert.equal(track.length, OPENING_TRACK, 'the opening hand is not three track cards');
|
|
assert.equal(hand.length - track.length, OPENING_OTHER, 'the opening hand is not three other cards');
|
|
});
|
|
|
|
it('shuffles the leftover track back into one deck for the rest of the game', () => {
|
|
// The split is an opening-deal device only. If the leftover track stayed out, every draw after
|
|
// the first turn would be drawn from a deck with no track in it at all.
|
|
const g = gameDealtWith('threeTrackThreeOther');
|
|
const rest = [...g.decks.homeOffice, ...g.decks.departments.flat()];
|
|
const track = rest.filter((id) => g.cards.get(id)!.kind.kind === 'track');
|
|
assert.equal(
|
|
track.length, TRACK_IN_DECK - OPENING_TRACK,
|
|
'the track left over after the deal is not back in the deck',
|
|
);
|
|
});
|
|
|
|
it('leaves every card accounted for whichever shape it was dealt in', () => {
|
|
// The single-deck path and the two-pile path deal from different piles into the same game; a
|
|
// card lost or duplicated by either would be a deck that quietly runs short mid-game.
|
|
for (const shape of ['threeRandom', 'sixRandom', 'threeTrackThreeOther'] as const) {
|
|
const g = gameDealtWith(shape);
|
|
const all = [
|
|
...g.decks.homeOffice,
|
|
...g.decks.departments.flat(),
|
|
...g.decks.salvageYard,
|
|
...[...g.decks.hands.values()].flat(),
|
|
];
|
|
assert.equal(all.length, SOLITAIRE_DECK_SIZE, `cards lost or duplicated dealing ${shape}`);
|
|
assert.equal(new Set(all).size, all.length, `duplicate card dealing ${shape}`);
|
|
}
|
|
});
|
|
|
|
it('accounts for every card exactly once', () => {
|
|
const g = newSolitaireGame();
|
|
const all = [
|
|
...g.decks.homeOffice,
|
|
...g.decks.departments.flat(),
|
|
...g.decks.salvageYard,
|
|
...[...g.decks.hands.values()].flat(),
|
|
];
|
|
// A solitaire deck omits the 22 opponent-directed cards (Q6).
|
|
assert.equal(all.length, SOLITAIRE_DECK_SIZE, 'cards lost or duplicated');
|
|
assert.equal(new Set(all).size, SOLITAIRE_DECK_SIZE, 'duplicate card ids');
|
|
});
|
|
|
|
it('flanks every Office with Mainline cards, never a Division Point', () => {
|
|
// The layout is always DP · Mainline · Office · Mainline · … · Mainline · DP, at every player
|
|
// count. `moveTrain` carries a branch for departing an Office straight onto a Division Point
|
|
// which this makes UNREACHABLE — it is kept correct rather than deleted, and this is the
|
|
// invariant that says why. If the layout ever changes, that branch wakes up and wants checking.
|
|
for (const players of [1, 2, 3, 4]) {
|
|
const g = createGame({
|
|
id: 'g', seed: 1234,
|
|
// Solitaire is a one-player mode by construction, so the wider counts run competitive.
|
|
config: players === 1 ? solitaireConfig : { ...solitaireConfig, mode: 'competitive' },
|
|
playerNames: Array.from({ length: players }, (_, i) => `p${i}`),
|
|
});
|
|
const nodes = g.division.nodes;
|
|
nodes.forEach((n, i) => {
|
|
if (n.kind !== 'office') return;
|
|
for (const j of [i - 1, i + 1]) {
|
|
assert.equal(nodes[j]?.kind, 'mainline', `office at ${i} is not flanked by Mainline at ${j}`);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
it('starts with no trains scheduled and none running', () => {
|
|
// §4 — "no trains are officially running yet". This is the Day-1 revenue ramp (Gap 10e).
|
|
const g = newSolitaireGame();
|
|
assert.equal(g.timetable.length, 12);
|
|
assert.ok(g.timetable.every((slot) => slot === null));
|
|
assert.equal(g.trays.size, 0);
|
|
});
|
|
|
|
it('provides player-count + 3 crew trays', () => {
|
|
assert.equal(newSolitaireGame().freeTrays.length, 4);
|
|
});
|
|
|
|
it('puts every rolling stock piece in the Division Yard', () => {
|
|
const g = newSolitaireGame();
|
|
assert.equal(g.yards.divisionYard.length, TOTAL_ROLLING_STOCK);
|
|
assert.equal(g.yards.classificationYard.length, 0);
|
|
});
|
|
|
|
it('rejects a solitaire game with more than one player', () => {
|
|
assert.throws(() =>
|
|
createGame({ id: 'x', seed: 1, config: solitaireConfig, playerNames: ['a', 'b'] }),
|
|
);
|
|
});
|
|
});
|