Track becomes a deck card, Departments become decks, and trains must be made up to leave, industry restrictions enforced, update display and add a new game button

This commit is contained in:
Jesse
2026-08-05 08:28:22 -04:00
parent 2d348e1ecf
commit 9c76b1a5e2
28 changed files with 1927 additions and 394 deletions
+42 -20
View File
@@ -7,7 +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 { MODIFIER_PROFILES, SOLITAIRE_DECK_SIZE, TRACK_CARDS, TRACK_IN_DECK } from '../src/engine/content.ts';
import {
DECK_SIZE,
EXTRA_TRAINS,
@@ -49,21 +49,27 @@ const newSolitaireGame = (seed = 1234) =>
// ---------------------------------------------------------------------------
describe('card catalogue (component 1)', () => {
it('composes the 140-card deck from the design', () => {
// Transcribed from docs/Deck cards2.xlsx, whose own total is 115 — plus 18 extra industry cards
// (Gap 12, industries 9 → 27) and 7 extra office cards (Q12, offices 7 → 14). Both are
// deliberate departures from the sheet and both are flagged provisional in content.ts.
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.
//
// 139, not 140: Poling is dealt ZERO copies. Its effect is "TBD in the source", so a card that
// cannot be played is worse in a hand than absent from the deck. The entry stays in the
// We are at 243 rather than 219 because of two deliberate departures, both flagged provisional
// in content.ts: 18 extra industry cards (Gap 12, industries 9 → 27) and 7 extra office cards
// (Q12, offices 7 → 14). Both were tuned against a deck that had NO track in it, so both are due
// a re-measurement now that 104 track cards share the draw.
//
// 243 and not 244: Poling is dealt ZERO copies. Its effect is "TBD in the source", so a card
// that cannot be played is worse in a hand than absent from the deck. The entry stays in the
// catalogue so the gap remains visible.
assert.equal(DECK_SIZE, 139);
assert.equal(DECK_SIZE, 243);
assert.equal(buildDeck().length, DECK_SIZE);
});
it('matches the design deck composition exactly', () => {
const byCategory = Object.fromEntries(deckComposition().map((c) => [c.category, c.count]));
assert.deepEqual(byCategory, {
// 104, straight from the sheet's column B — the largest category in the deck by some way.
track: 104,
// 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.
@@ -81,19 +87,35 @@ describe('card catalogue (component 1)', () => {
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 139 draws (16%) that do nothing.
assert.equal(SOLITAIRE_DECK_SIZE, 117);
// game they would be 22 of 243 draws (9%) that do nothing.
assert.equal(SOLITAIRE_DECK_SIZE, 221);
const solo = buildDeck('solitaire');
assert.equal(solo.length, SOLITAIRE_DECK_SIZE);
assert.ok(!solo.some((c) => c.kind.kind === 'spaceUse' || c.kind.kind === 'action'));
// A competitive deck keeps them. 139, not 140 — Poling is dealt no copies.
assert.equal(buildDeck('competitive').length, 139);
// A competitive deck keeps them. Poling is dealt no copies, so this is 243 and not 244.
assert.equal(buildDeck('competitive').length, 243);
});
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('deals track FROM the deck, at the sheet\'s counts', () => {
// Column B of Deck cards2.xlsx, "Number in Deck": 32 straights, 16+16 curves, 4+4 sharp curves,
// 16+16 turnouts. An earlier reading took the sheet's LAST column, "Track Per Player" (26), as a
// separate stack outside the deck — it is 104 shared out among four players, not a second pile.
assert.equal(TRACK_IN_DECK, 104);
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', () => {
// 104 of 243. 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', () => {
@@ -348,22 +370,22 @@ describe('game setup (component 2)', () => {
assert.equal(area.adOccupancy.length, 0);
});
it('deals three cards and turns three Department slots face up', () => {
it('deals three cards and starts three Department piles, one card each', () => {
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));
assert.ok(g.decks.departments.every((pile) => pile.length === 1), 'each Department starts with one face-up card');
});
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.departments.flat(),
...g.decks.salvageYard,
...[...g.decks.hands.values()].flat(),
];
// A solitaire deck omits the 22 opponent-directed cards (Q6), so it holds 118, not 140.
// 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');
});