Expand game engine, replay, tests, and documentation
This commit is contained in:
+92
-34
@@ -7,6 +7,7 @@ import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { CarType } from '../src/engine/content.ts';
|
||||
import { MODIFIER_PROFILES, SOLITAIRE_DECK_SIZE, TRACK_PER_PLAYER } from '../src/engine/content.ts';
|
||||
import {
|
||||
DECK_SIZE,
|
||||
EXTRA_TRAINS,
|
||||
@@ -48,26 +49,44 @@ const newSolitaireGame = (seed = 1234) =>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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);
|
||||
it('composes the 115-card deck from the design', () => {
|
||||
// Transcribed from docs/Deck cards2.xlsx; the sheet's own total is 115.
|
||||
assert.equal(DECK_SIZE, 115);
|
||||
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]),
|
||||
);
|
||||
it('matches the design 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,
|
||||
office: 7,
|
||||
industry: 9,
|
||||
modifier: 23,
|
||||
train: 22,
|
||||
spaceUse: 12,
|
||||
enhancement: 18,
|
||||
mainlineModifier: 7,
|
||||
maneuver: 7,
|
||||
action: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('removes opponent-directed cards from a solitaire deck', () => {
|
||||
// Q6 — Space-use and Action cards can only be played AT another player, so in a one-player
|
||||
// game they would be 22 of 115 draws (19%) that do nothing.
|
||||
assert.equal(SOLITAIRE_DECK_SIZE, 93);
|
||||
const solo = buildDeck('solitaire');
|
||||
assert.equal(solo.length, 93);
|
||||
assert.ok(!solo.some((c) => c.kind.kind === 'spaceUse' || c.kind.kind === 'action'));
|
||||
// A competitive deck keeps them.
|
||||
assert.equal(buildDeck('competitive').length, 115);
|
||||
});
|
||||
|
||||
it('keeps track OUT of the deck, as a per-player supply', () => {
|
||||
// The single biggest structural change: 26 pieces per player, 104 for four.
|
||||
assert.equal(TRACK_PER_PLAYER, 26);
|
||||
assert.ok(!buildDeck().some((c) => c.kind.kind === 'track'), 'track leaked into the deck');
|
||||
});
|
||||
|
||||
it('has 12 timetabled trains, odd westbound and even eastbound', () => {
|
||||
assert.equal(TIMETABLED_TRAINS.length, 12);
|
||||
for (const t of TIMETABLED_TRAINS) {
|
||||
@@ -78,24 +97,38 @@ describe('card catalogue (component 1)', () => {
|
||||
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`);
|
||||
const slots = t.consist.freight + t.consist.coach + t.consist.caboose;
|
||||
assert.ok(slots <= MAX_CONSIST, `${t.name} ${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('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('identifies Freight Houses as the both-direction facilities', () => {
|
||||
// Gap 10d — not a card, a collective term for Grocer's Warehouse and Oil Refinery.
|
||||
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 industry', () => {
|
||||
const houses = FREIGHT_PROFILES.filter(isFreightHouse).map((f) => f.kind);
|
||||
assert.deepEqual(houses.sort(), ['grocersWarehouse', 'oilRefinery']);
|
||||
assert.deepEqual(houses.sort(), ['freightHouse']);
|
||||
});
|
||||
|
||||
it('keeps every industry track within the four-car limit', () => {
|
||||
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.industryTrackLength <= 4, `${f.name} track ${f.industryTrackLength}`);
|
||||
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`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -103,8 +136,28 @@ describe('card catalogue (component 1)', () => {
|
||||
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`);
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -117,17 +170,17 @@ describe('card catalogue (component 1)', () => {
|
||||
|
||||
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.ok(o.greenSlots > o.porters, `${o.tier} green slots vs porters`);
|
||||
assert.ok(o.redSlots > o.porters, `${o.tier} red slots vs porters`);
|
||||
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', () => {
|
||||
// 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]);
|
||||
assert.deepEqual(inDeck, [4, 2, 1]);
|
||||
});
|
||||
|
||||
it('walks the upgrade sequence without skipping', () => {
|
||||
@@ -147,8 +200,8 @@ describe('card catalogue (component 1)', () => {
|
||||
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);
|
||||
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)}`);
|
||||
@@ -245,10 +298,14 @@ describe('game setup (component 2)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('gives every mainline card two regions', () => {
|
||||
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.equal(node.regions.length, 2);
|
||||
if (node.kind === 'mainline') {
|
||||
assert.ok(node.card, 'mainline node has no card type');
|
||||
assert.deepEqual(node.transits, []);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -283,8 +340,9 @@ describe('game setup (component 2)', () => {
|
||||
...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');
|
||||
// A solitaire deck omits the 22 opponent-directed cards (Q6), so it holds 93, not 115.
|
||||
assert.equal(all.length, SOLITAIRE_DECK_SIZE, 'cards lost or duplicated');
|
||||
assert.equal(new Set(all).size, SOLITAIRE_DECK_SIZE, 'duplicate card ids');
|
||||
});
|
||||
|
||||
it('starts with no trains scheduled and none running', () => {
|
||||
|
||||
Reference in New Issue
Block a user