v0.3.0 — playtest fixes, two new rules, and §7 enforced
This commit is contained in:
+69
-22
@@ -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_CARDS, TRACK_IN_DECK } from '../src/engine/content.ts';
|
||||
import { MODIFIER_PROFILES, OPENING_OTHER, OPENING_TRACK, SOLITAIRE_DECK_SIZE, TRACK_CARDS, TRACK_IN_DECK } from '../src/engine/content.ts';
|
||||
import {
|
||||
DECK_SIZE,
|
||||
EXTRA_TRAINS,
|
||||
@@ -53,23 +53,26 @@ describe('card catalogue (component 1)', () => {
|
||||
// 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 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.
|
||||
// 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.
|
||||
//
|
||||
// 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, 243);
|
||||
// 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.
|
||||
assert.equal(DECK_SIZE, 235);
|
||||
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,
|
||||
// 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.
|
||||
@@ -87,20 +90,21 @@ 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 243 draws (9%) that do nothing.
|
||||
assert.equal(SOLITAIRE_DECK_SIZE, 221);
|
||||
// game they would be 22 of 235 draws (9%) that do nothing.
|
||||
assert.equal(SOLITAIRE_DECK_SIZE, 213);
|
||||
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. Poling is dealt no copies, so this is 243 and not 244.
|
||||
assert.equal(buildDeck('competitive').length, 243);
|
||||
// A competitive deck keeps them. Poling and the sharp curves are dealt none.
|
||||
assert.equal(buildDeck('competitive').length, 235);
|
||||
});
|
||||
|
||||
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);
|
||||
// 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(
|
||||
@@ -111,7 +115,7 @@ describe('card catalogue (component 1)', () => {
|
||||
});
|
||||
|
||||
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
|
||||
// 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;
|
||||
@@ -370,13 +374,34 @@ describe('game setup (component 2)', () => {
|
||||
assert.equal(area.adOccupancy.length, 0);
|
||||
});
|
||||
|
||||
it('deals three cards and starts three Department piles, one card each', () => {
|
||||
it('deals three track and three other cards, and starts three Department piles', () => {
|
||||
// The opening deal comes from two separately shuffled piles, so the district you can build is
|
||||
// dealt rather than waited for. Six against a hand limit of three is deliberate: the first turn
|
||||
// is spent choosing which of them to keep.
|
||||
const g = newSolitaireGame();
|
||||
assert.equal(g.decks.hands.get(0)!.length, 3);
|
||||
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');
|
||||
|
||||
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('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 = newSolitaireGame();
|
||||
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('accounts for every card exactly once', () => {
|
||||
const g = newSolitaireGame();
|
||||
const all = [
|
||||
@@ -390,6 +415,28 @@ describe('game setup (component 2)', () => {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user