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. The same assumption left a crew's own cut standing when it pulled out through a leg, which is the "cars left behind" report we had failed to reproduce. Gitea#14 — every count is docs/Deck cards5.xlsx. Track halved, and the Q12 office doubling and Gap 12 industry tripling both come out with it: they were measured against a deck with twice the track, and keeping them at the sheet's track count wipes out the reefer chain entirely. 84 rows now match card for card; the ten Safety, Event and Inspection cards it adds are not built and are held out. Cards the sheet no longer lists are dealt zero copies rather than deleted, so their rules stay implemented. Gitea#15 — RAR reversed it: a rail may stop dead against its neighbour and the placement is legal. What must hold is that no train crosses the gap, which was already true and is now pinned against the reported board. Gitea#3 — the printed speeds are scenery. A card costs one Stage per printed region and where a train STARTS is what varies; Fast/Slow is read on Hilly alone. Entering a one-region card behind another is a collision now, which is what ABS exists to prevent, and ABS no longer holds trains silently. Gitea#18 — the Division draws as one row, west to east, with no office-area detail. East is finally always to the right. Closes #3 Closes #14 Closes #15 Closes #17 Closes #18
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. `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('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'] }),
|
|
);
|
|
});
|
|
});
|