Initial commit
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* 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 {
|
||||
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 { 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'] });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('card catalogue (component 1)', () => {
|
||||
it('composes a 52-card deck', () => {
|
||||
const total = deckComposition().reduce((n, c) => n + c.count, 0);
|
||||
assert.equal(total, DECK_SIZE);
|
||||
assert.equal(buildDeck().length, DECK_SIZE);
|
||||
});
|
||||
|
||||
it('matches card-reference.md deck composition exactly', () => {
|
||||
const byCategory = Object.fromEntries(
|
||||
deckComposition().map((c) => [c.category, c.count]),
|
||||
);
|
||||
assert.deepEqual(byCategory, {
|
||||
timetabledTrain: 12,
|
||||
extraTrain: 4,
|
||||
office: 9,
|
||||
freightFacility: 10,
|
||||
modifier: 5,
|
||||
track: 12,
|
||||
});
|
||||
});
|
||||
|
||||
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.count + (t.consist.requiresCaboose ? 1 : 0);
|
||||
assert.ok(slots <= MAX_CONSIST, `${t.className} ${t.number} uses ${slots} slots`);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives Extras low seniority (X9-X12)', () => {
|
||||
assert.deepEqual(EXTRA_TRAINS.map((t) => t.number).sort((a, b) => a - b), [9, 10, 11, 12]);
|
||||
});
|
||||
|
||||
it('identifies Freight Houses as the both-direction facilities', () => {
|
||||
// Gap 10d — not a card, a collective term for Grocer's Warehouse and Oil Refinery.
|
||||
const houses = FREIGHT_PROFILES.filter(isFreightHouse).map((f) => f.kind);
|
||||
assert.deepEqual(houses.sort(), ['grocersWarehouse', 'oilRefinery']);
|
||||
});
|
||||
|
||||
it('keeps every industry track within the four-car limit', () => {
|
||||
for (const f of FREIGHT_PROFILES) {
|
||||
assert.ok(f.industryTrackLength <= 4, `${f.name} track ${f.industryTrackLength}`);
|
||||
}
|
||||
});
|
||||
|
||||
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.outboundCapacity > 0, wantsOut, `${f.name} outbound`);
|
||||
assert.equal(f.inboundCapacity > 0, wantsIn, `${f.name} inbound`);
|
||||
}
|
||||
});
|
||||
|
||||
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]);
|
||||
for (const o of OFFICE_PROFILES) {
|
||||
if (o.tier === 'whistlePost') continue;
|
||||
assert.ok(o.greenSlots > o.porters, `${o.tier} green slots vs porters`);
|
||||
assert.ok(o.redSlots > o.porters, `${o.tier} red slots vs porters`);
|
||||
}
|
||||
});
|
||||
|
||||
it('forms an office pyramid so the strict upgrade sequence cannot stall', () => {
|
||||
// Gap 3b requires more Depots than Stations than Terminals.
|
||||
const inDeck = OFFICE_PROFILES.filter((o) => o.copiesInDeck > 0).map((o) => o.copiesInDeck);
|
||||
assert.deepEqual(inDeck, [4, 3, 2]);
|
||||
});
|
||||
|
||||
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 62 rolling stock pieces', () => {
|
||||
assert.equal(TOTAL_ROLLING_STOCK, 62);
|
||||
assert.equal(buildRollingStock().length, 62);
|
||||
});
|
||||
|
||||
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.outboundCapacity + f.inboundCapacity) * f.copies;
|
||||
demand.set(f.carType, (demand.get(f.carType) ?? 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('gives every mainline card two regions', () => {
|
||||
const g = newSolitaireGame();
|
||||
for (const node of g.division.nodes) {
|
||||
if (node.kind === 'mainline') assert.equal(node.regions.length, 2);
|
||||
}
|
||||
});
|
||||
|
||||
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 cards and turns three Department slots face up', () => {
|
||||
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((c) => c !== null));
|
||||
});
|
||||
|
||||
it('accounts for every card exactly once', () => {
|
||||
const g = newSolitaireGame();
|
||||
const all = [
|
||||
...g.decks.homeOffice,
|
||||
...g.decks.departments.filter((c): c is string => c !== null),
|
||||
...g.decks.salvageYard,
|
||||
...[...g.decks.hands.values()].flat(),
|
||||
];
|
||||
assert.equal(all.length, DECK_SIZE, 'cards lost or duplicated');
|
||||
assert.equal(new Set(all).size, DECK_SIZE, 'duplicate card ids');
|
||||
});
|
||||
|
||||
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 all 62 rolling stock pieces in the Division Yard', () => {
|
||||
const g = newSolitaireGame();
|
||||
assert.equal(g.yards.divisionYard.length, 62);
|
||||
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'] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user