/** * 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, 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', () => { // Transcribed from docs/Deck cards2.xlsx. The sheet's own totals are "Sum other 115" and // "Total track 104", i.e. 219, plus 12 start cards for its grand total of 231. // // We are at 235 rather than 219 because of three deliberate departures, all flagged in // content.ts: 18 extra industry cards (Gap 12, industries 9 → 27), 7 extra office cards (Q12, // offices 7 → 14), and the 8 sharp curves taken back OUT. The first two were tuned against a deck // that had NO track in it, so both are due a re-measurement now that 96 track cards share the // draw. // // 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, SOLITAIRE_DECK_SIZE); }); it('matches the design deck composition exactly', () => { const byCategory = Object.fromEntries(deckComposition().map((c) => [c.category, c.count])); assert.deepEqual(byCategory, { // 96, not the sheet's 104: the 8 SHARP CURVES are dealt zero copies. The only thing that made // one different from an ordinary curve was a Move cost that nothing ever charged, so they were // geometric duplicates taking 8 draws. Kept in the catalogue at zero, as Poling is. track: 96, // 14, not the sheet's 7 — Q12 office density; see OFFICE_PROFILES. office: 14, // 27, not the sheet's 9 — Gap 12 industry density; see INDUSTRY_PROFILES. industry: 27, modifier: 23, train: 22, spaceUse: 12, enhancement: 18, mainlineModifier: 7, // 6, not 7 — Poling is dealt no copies until its rule is known. maneuver: 6, action: 10, }); }); 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 make 22 of 235 draws (9%) reject outright. // 206, not 213: the 22 opponent-directed cards come out, and so do the SEVEN that exist only to // answer them — Facing Point Locks (both the Enhancement and the Mainline modifier, 2 each), two // Water Columns 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. assert.equal(SOLITAIRE_DECK_SIZE, 206); assert.equal(DEFENCE_ONLY_COPIES, 7); 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 cards2.xlsx, "Number in Deck": 32 straights, 16+16 curves, 16+16 turnouts — // and 4+4 sharp curves, which are dealt none. 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 104 shared out // among four players, not a second pile. assert.equal(TRACK_IN_DECK, 96); 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', () => { // 96 of 235. 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. const deck = buildDeck(); const track = deck.filter((c) => c.kind.kind === 'track').length; assert.ok(track > deck.length / 3, `track is only ${track} of ${deck.length} cards`); }); 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('identifies the both-direction industries the card reference names', () => { /** * `card-reference.md`: "'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." The table * agrees: both are "Both", and only the Power Plant is inbound-only. * * The engine had the Refinery as outbound-only and the Grocer's as inbound-only, so §9.3's * "Passenger Facilities and Freight Houses permit cars to move each direction" named neither of * them — and every Modifier grant on the missing direction was silently dropped, which is how * "grocer's warehouse didn't get extra outbound slot for truck dock" was reported. * * `freightHouse` is still in this list because the engine deals it as a CARD, which the rules say * it is not. That is a deck-composition question, recorded in TODO.md, not something to quietly * delete six cards over. */ const houses = FREIGHT_PROFILES.filter(isFreightHouse).map((f) => f.kind); assert.deepEqual(houses.sort(), ['freightHouse', 'grocersWarehouse', 'refinery']); }); 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(); 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(); 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('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'] }), ); }); });