The 0.4.9 line's port of v0.7.2, minus Gitea#18: the Division map redraw is a multiplayer redesign and does not belong in a build people are mid-playtest on. Gitea#17 — a 45° leg is an end of the west-to-east row, so backing into a cut through a curve's south leg no longer couples it back to front, and a crew pulling out through a leg takes its own cut with it. Gitea#14 — every count is docs/Deck cards5.xlsx, including removing the Q12 office doubling and the Gap 12 industry tripling. Gitea#15 — RAR reversed it: the placement is legal; what must hold is that no train crosses the gap. Gitea#3 — the printed speeds are scenery. One Stage per printed region, and where a train starts is what varies.
613 lines
28 KiB
TypeScript
613 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',
|
|
victory: 'highestAfterDays',
|
|
length: 'standard',
|
|
optionalRules: {
|
|
reducedVisibility: false,
|
|
sisterTrains: 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. `StationMaster-Home-Deck-v0.4.5.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('uses the Gap 10e revised victory targets', () => {
|
|
assert.deepEqual(
|
|
LENGTH_PROFILES.map((p) => [p.target, p.days]),
|
|
[[10, 3], [20, 5], [45, 10]],
|
|
);
|
|
assert.equal(lengthProfile('standard').target, 20);
|
|
});
|
|
|
|
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'] }),
|
|
);
|
|
});
|
|
});
|