asks what game you want Three queued items. The last matters most. A RELEASE NO LONGER DESTROYS EVERY GAME IN PROGRESS. Four consecutive releases killed every game on the box, one of them a release that changed only how the board is drawn. The reasoning behind the refusal was always right — a move legal under old rules may not be legal under new ones, and half-replaying a save is worse than refusing it. The TEST was wrong: it compared engineVersion for exact equality, and that stamp is the package version, which moves for a CSS fix. Whether a save still replays has an exact answer, so it is now asked directly. loadGame reads the file and judges nothing; tryResumeSession replays the intents and reports the first one the engine refuses. A save stamped with a version this server has never run resumes fine provided its moves replay — verified against a file hand-stamped 0.4.9-ancient. One that genuinely does not replay is still refused, but the log names the move rather than two version strings: "move 3 of 8 (localOps.choose) is rejected by the current rules with OPTION_ALREADY_CHOSEN". fromMultiplayerSave had to stop lying first. It has always stopped at the first unacceptable intent and done so in silence, which was survivable only because the version gate meant a doomed replay was never attempted. Now that the replay IS the check, it returns where it stopped and why. Deliberately not done: resuming a partly-replayable game at its last good move. That silently rewinds a game to a position nobody played to while every browser holding a later Frame carries on unaware. Refusing leaves the file intact, so putting the previous version back still recovers it. EMPLOYEE ROTATION IS IMPLEMENTED, SISTER TRAINS IS DELETED. Two of the four optional-rule flags were read by nothing at all. Employee Rotation is four lines in advance.ts, because the seat/player split (D9) exists for precisely this rule: seating is the only thing that moves, so Revenue, hands, the Superintendent and whose turn it is travel with the player, and the Office, district, grid and any trains standing in it stay with the chair. Inheriting the district you move into is the point of the rule, not a side effect. "Left" is seat + 1, matching playerLeftOf. Sister Trains is deleted rather than built: Q9 records that the Second Section card supersedes it, and that card exists, so the flag was a toggle for a rule the game no longer has. THE LOBBY ASKS WHAT GAME YOU WANT TO PLAY. Creating a game asked for a name, a mode and a table size; every other dial was hardcoded. A Game settings block now carries the same set the solitaire dialog does — seed, starting hand, the three revenue rates, Days, the combined-Revenue floor, both collision caps, the opponent-card toggle — plus the three surviving optional rules. Mode and table size set the defaults and everything stays editable. The seed is honoured, so a game can be reproduced or compared. Verified: 682 tests pass (679 + 3). The rotation tests were mutation-checked both ways — disabling the rotation and turning the table the wrong way each fail the suite. Live: a save stamped 0.4.9-ancient resumed, an injected illegal move was refused by name, and a create with every dial set to a non-default value came back out of game.json with all of them intact, including seed 777. Two of my own assertions were wrong on the way and the tests caught them: the Fedora legitimately passes at Stage 12 (§5) so it cannot be compared against its own earlier value, and dispatchUsedToday is cleared at every Day boundary so it cannot mark a district.
542 lines
24 KiB
TypeScript
542 lines
24 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,
|
|
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<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('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'] }),
|
|
);
|
|
});
|
|
});
|