v0.4.4 — New Game dialog for the opening hand and the three revenue rates, Yard renamed Interchange, and two movement bugs behind a mirrored consist

This commit is contained in:
Jesse
2026-08-14 22:39:50 -04:00
parent d1314066bd
commit 6655e20ea8
29 changed files with 5450 additions and 3902 deletions
+69 -1
View File
@@ -12,7 +12,8 @@ import { applyIntent, areaOf } from '../src/engine/apply.ts';
import { HAND_LIMIT, STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState } from '../src/engine/state.ts';
import type { CrewTray, GameConfig, GameState } from '../src/engine/state.ts';
import { railFacingOf } from '../src/engine/state.ts';
const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
mode: 'solitaire',
@@ -230,6 +231,14 @@ describe('Mainline Phase (§8)', () => {
it('moves trains one region per Stage (§8.2)', () => {
const s = game();
/**
* PIN THE TERRAIN. This used to rely on whatever card the seed happened to lay down, and the
* moment the RNG stream moved — a different opening deal draws a different number of cards
* before the Division is built — seed 1 dealt a 60 card instead of a 30 and the train crossed in
* one Stage. What is under test is that crossing takes the card's time, so the card has to be
* the test's own: Curves is a 30, which is two Stages for a fast train.
*/
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'curves';
scheduleTrain(s, 1, 2);
s.clock.phase = 'newTrain';
pump(s);
@@ -612,3 +621,62 @@ describe('X18 Circus Train — a point for standing still', () => {
assert.ok(!paidAgain(), 'the Circus Train collected a second time for the same set-up');
});
});
// ---------------------------------------------------------------------------
describe('a train on the Division points the way it is running', () => {
/**
* Nothing reset `facing` when a train left a district, so a crew that had been shunted onto a
* north-south spur carried a compass port — 'n' or 's' — out onto the Division with it.
*
* The Division is east-west, and so is every Office card, so that port exists nowhere the train is
* about to be. `movesFor` explores from `facing` and from its opposite, and a card with neither
* yields nothing at all: the train arrived at the next Office **unable to switch at all**. It also
* drew a ▲ on the Division map, where there is no north.
*
* A train running the Division has its engine at one end of an east-west railroad, so this is not
* a repair applied after the fact — it is the only thing `facing` can mean out there.
*/
const runningOnTheDivision = (spurFacing: 'n' | 's', direction: 'east' | 'west'): CrewTray => {
const s = game();
// Straight onto the Mainline card west of the first Office, as a departure would.
const index = s.division.nodes.findIndex((n) => n.kind === 'mainline');
const node = s.division.nodes[index]!;
assert.equal(node.kind, 'mainline');
const id = 'shunted';
s.trays.set(id, {
id, trainNumber: 2, trainIsExtra: false, engineAt: 0, consist: [],
direction,
// What a Move round a district leaves behind: a real port on the card it was standing on.
facing: spurFacing,
railFacing: 'w',
position: { at: 'grid', seat: 0, coord: areaOf(s, 0).officeCoord },
movesUsed: 0,
});
const tray = s.trays.get(id)!;
// Depart it: the Mainline Phase's own path onto the card.
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
for (let i = 0; i < 3 && tray.position.at === 'grid'; i++) {
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
advance(s);
}
assert.notEqual(tray.position.at, 'grid', 'the train never left the district');
return tray;
};
it('drops the spur port the moment it reaches the Mainline', () => {
for (const spur of ['n', 's'] as const) {
for (const direction of ['east', 'west'] as const) {
const tray = runningOnTheDivision(spur, direction);
assert.equal(
tray.facing,
direction === 'east' ? 'e' : 'w',
`a ${direction}bound train left the district still facing ${spur}`,
);
assert.equal(railFacingOf(tray), direction === 'east' ? 'e' : 'w');
}
}
});
});
+175 -8
View File
@@ -6,13 +6,13 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { applyIntent, check, areaOf, facilityCarTypes, reduce } from '../src/engine/apply.ts';
import { HAND_LIMIT, INDUSTRY_PROFILES, MOVES_PER_LOCAL_OPS, OPENING_OTHER, OPENING_TRACK } from '../src/engine/content.ts';
import { applyIntent, check, areaOf, facilityCarTypes, movesFor, reduce } from '../src/engine/apply.ts';
import { HAND_LIMIT, INDUSTRY_PROFILES, MOVES_PER_LOCAL_OPS } from '../src/engine/content.ts';
import type { Intent } from '../src/engine/intents.ts';
import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts';
import type { CrewTray, GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
import { coordKey, turnOf } from '../src/engine/state.ts';
import { coordKey, railFacingOf, turnOf } from '../src/engine/state.ts';
import { cardDescription, snapshot } from '../src/sim/view.ts';
const config: GameConfig = {
@@ -115,13 +115,15 @@ describe('Local Operations: drawing (§6.2)', () => {
it('draws from the Home Office deck into the hand', () => {
const s = game();
const before = s.decks.homeOffice.length;
const dealt = s.decks.hands.get(0)!.length;
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const r = applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
assert.ok(r.ok);
assert.equal(s.decks.homeOffice.length, before - 1);
// The opening deal is 3 track + 3 other, so a draw takes the hand to seven — and §6.2's limit
// then has to be played down to three before the turn can end.
assert.equal(s.decks.hands.get(0)!.length, OPENING_TRACK + OPENING_OTHER + 1);
// Measured against the deal rather than against a constant: the opening hand is a house rule
// now (three random, six random, or three-and-three), and none of the three changes what a draw
// does — it adds one card, and §6.2's limit then has to be played down to three.
assert.equal(s.decks.hands.get(0)!.length, dealt + 1);
});
it('takes the TOP card from a Department pile, never one buried under it', () => {
@@ -239,13 +241,15 @@ describe('Local Operations: drawing (§6.2)', () => {
it('will not end the phase over the hand limit', () => {
// §6.2 — "must reduce his hand to no more than three cards".
const s = game();
const dealt = s.decks.hands.get(0)!.length;
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
assert.equal(s.decks.hands.get(0)!.length, OPENING_TRACK + OPENING_OTHER + 1);
assert.equal(s.decks.hands.get(0)!.length, dealt + 1);
assert.ok(dealt + 1 > HAND_LIMIT, 'a draw must put the player over the limit for this to test anything');
assert.equal(check(s, 0, { type: 'draw.end' }), 'HAND_LIMIT');
// And the way down is play or discard — there is no per-turn cap on either, which is what makes
// the six-card opening hand playable in one turn rather than a limit that cannot be met.
// a six-card opening hand playable in one turn rather than a limit that cannot be met.
const hand = () => s.decks.hands.get(0)!;
while (hand().length > HAND_LIMIT) {
const r = applyIntent(s, 0, { type: 'card.discard', cardId: hand()[0]!, toSlot: 0 });
@@ -1252,3 +1256,166 @@ describe('an Office upgrade keeps what Modifiers added (§9)', () => {
assert.ok(office().capacity.outbound > base.out, 'and the upgrade still raised the tier');
});
});
// ---------------------------------------------------------------------------
describe('the engine is drawn pointing east or west, whatever track it is standing on', () => {
/**
* Reported from play: a crew that turned onto a north-south spur drew ▲, and the same crew kept
* that ▲ after it departed onto the Division — where there is no north or south at all — because
* nothing resets `facing` when a train leaves a district. A Division runs east and west, and a
* player reads a train the way a railroader does: which END the engine is on.
*
* So `facing` stays a PORT (movement needs one) and `railFacingOf` is what the board draws.
*/
const moved = (id: string, facing: 'n' | 's' | 'e' | 'w') =>
({ type: 'trayMoved', trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, facing }) as const;
it('carries the east-west sense across north-south track', () => {
const s = game();
const id = placeTray(s, at(0, 0));
const tray = s.trays.get(id)!;
reduce(s, moved(id, 'w'));
assert.equal(railFacingOf(tray), 'w');
// Onto a curve, and away up a spur. The port is north; the engine is still on the west end.
reduce(s, moved(id, 'n'));
assert.equal(tray.facing, 'n', 'the movement port must still be the real one');
assert.equal(railFacingOf(tray), 'w', 'but the drawing must not turn the train on end');
});
it('follows the engine around 180° of curves, because that really is a turn', () => {
// Forward through two curves — west port to north, north to east — and the engine genuinely
// does come out pointing the other way. Held values are held, not frozen.
const s = game();
const id = placeTray(s, at(0, 0));
const tray = s.trays.get(id)!;
for (const f of ['w', 'n', 'e'] as const) reduce(s, moved(id, f));
assert.equal(railFacingOf(tray), 'e');
});
it('falls back to the direction of the run for a tray that has not moved yet', () => {
// A tray placed straight onto the board has no history to carry, and `direction` is the only
// east-west fact about it.
const s = game();
const tray = s.trays.get(placeTray(s, at(0, 0)))!;
assert.equal(tray.railFacing, undefined);
assert.equal(railFacingOf(tray), 'e', 'placed running east');
tray.direction = 'west';
assert.equal(railFacingOf(tray), 'w');
});
it('never reports north or south to the board', () => {
// The view type says 'e' | 'w'; this is the runtime half of that promise, and it is the one a
// north-south spur used to break.
const s = game();
const id = placeTray(s, at(0, 0), [{ type: 'boxcar', loaded: false }] as never);
addCard(s, at(0, 0), straight());
reduce(s, moved(id, 's'));
const cell = snapshot(s, [], null).cells.find((c) => c.row === 0 && c.col === 0);
assert.ok(cell?.train, 'the train should be on the card');
assert.ok(cell.train.facing === 'e' || cell.train.facing === 'w', `got ${cell.train.facing}`);
});
});
// ---------------------------------------------------------------------------
describe('a train that rounds a curve points where the curve took it', () => {
/**
* REPORTED from play: "a train reversed into a siding and the display of the cars was reversed."
*
* `facing` is the port the engine would leave by, and a move recorded it as `opposite(entry)` —
* which is only the far end of a STRAIGHT. A curve is an arc between two ADJACENT edges: enter a
* north-west curve through its west port and the far end is north, not east. So a train that
* rounded a curve was left facing a port the card does not have.
*
* Two things went wrong with that. Movement: the next Move explores from `facing`, and a port the
* card lacks yields nothing, so the crew could only ever back out the way it came. Display: the
* east-west sense is carried from `facing` (`railFacingOf`), so a curve that turned the engine
* west could leave the board still drawing it east — the consist mirrored, which is what was seen.
*/
const curve = (arc: 'ne' | 'nw' | 'se' | 'sw'): TrackCard => ({
geometry: { kind: 'track', geometry: 'curved', arc, hand: 'right' },
baseOperationalRail: true,
standing: [],
facility: null,
modifiers: [],
enhancements: [],
});
/**
* Runs a crew one Move and reports where the engine ended up pointing.
*
* The card it starts ON matters as much as the one it moves to: a crew facing north or south has
* to be standing on something with that port, and only a curve has one.
*/
const roundIt = (
from: GridCoord,
to: GridCoord,
origin: TrackCard,
dest: TrackCard,
start: { facing: 'n' | 's' | 'e' | 'w'; railFacing: 'e' | 'w' },
reverse = false,
): { facing?: string; drawn: string } => {
const s = game();
addCard(s, from, origin);
addCard(s, to, dest);
const id = placeTray(s, from, [{ type: 'boxcar', loaded: false }] as never);
const tray = s.trays.get(id)!;
tray.facing = start.facing;
tray.railFacing = start.railFacing;
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
turnOf(s, 0).option = 'switch';
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to, reverse });
assert.ok(r.ok, `the move was refused: ${r.ok ? '' : r.code}`);
return { facing: tray.facing, drawn: railFacingOf(tray) };
};
it('leaves by the curve’s own far end, not by the opposite of the way it came in', () => {
// East along the running track into a curve that turns north. The far end is NORTH.
const out = roundIt({ row: 0, col: 0 }, { row: 0, col: 1 }, straight(), curve('nw'), { facing: 'e', railFacing: 'e' });
assert.equal(out.facing, 'n', 'the engine was left facing a port the curve does not have');
});
it('does not strand the crew on the curve it just rounded', () => {
// The symptom the wrong port produces: `movesFor` explores from `facing`, and a card without
// that port yields nothing at all — so the only move left was backing out the way it came.
const s = game();
addCard(s, at(0, 0), straight());
addCard(s, at(0, 1), curve('nw')); // joins north and west
addCard(s, at(1, 1), curve('se')); // joins south and east — the track continues
addCard(s, at(1, 2), straight());
const id = placeTray(s, at(0, 0), []);
s.trays.get(id)!.facing = 'e';
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
turnOf(s, 0).option = 'switch';
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 1), reverse: false }).ok);
const { to } = movesFor(s, 0, id);
assert.ok(
to.some((c) => c.row === 1 && c.col === 1),
'the crew cannot carry on round the curve — it can only back out the way it came',
);
});
it('turns the DRAWN direction when the curve really does turn the engine round', () => {
// South down a spur into a curve that turns west. The engine genuinely now points west, and the
// board has to say so — this is the mirrored consist that was reported.
const out = roundIt({ row: 1, col: 1 }, { row: 0, col: 1 }, curve('se'), curve('nw'), { facing: 's', railFacing: 'e' });
assert.equal(out.facing, 'w', 'the curve turned the engine west');
assert.equal(out.drawn, 'w', 'the board would still have drawn the consist facing east');
});
it('still points back the way it came when it BACKS round a curve', () => {
// Backing up does not turn a train around, whatever the track does underneath it: the engine
// trails, pointing out through the port the train came in by. That is geometry-independent and
// was already right — this is here so the fix above cannot quietly change it.
const out = roundIt({ row: 1, col: 1 }, { row: 0, col: 1 }, curve('se'), curve('nw'), { facing: 'n', railFacing: 'e' }, true);
assert.equal(out.facing, 'n', 'backing up turned the train around');
assert.equal(out.drawn, 'e', 'backing up changed which end the engine is on');
});
});
+42 -3
View File
@@ -332,7 +332,20 @@ describe('ABS Signals amend the collision rule', () => {
// ---------------------------------------------------------------------------
describe('one Revenue to EVERY player when a train completes its run', () => {
describe('train revenue per transit — paid to EVERY player when a train completes its run', () => {
/**
* `trainPerTransit` is a dial now, and it defaults to ZERO: at 1 it was worth ~5.4 Revenue against
* a bot mean of 7.0, drowning out the freight and passenger economies the game is about. These
* tests are about the rule, so they set the rate; the last one is about the default.
*/
const paying = (rate = 1, seed = 5): GameState =>
createGame({
id: 'g',
seed,
config: { ...config, houseRules: { revenue: { trainPerTransit: rate } } },
playerNames: ['p'],
});
/** A train sitting on the A/D track, made up and ready to highball. */
const readyToLeave = (s: GameState, direction: 'east' | 'west' = 'east'): string => {
const id = 'leaving';
@@ -364,7 +377,7 @@ describe('one Revenue to EVERY player when a train completes its run', () => {
});
it('pays every player once the train runs off the end of the Division', () => {
const s = game();
const s = paying();
const before = s.players.map((p) => p.revenue);
readyToLeave(s, 'east');
@@ -395,7 +408,7 @@ describe('one Revenue to EVERY player when a train completes its run', () => {
});
it('pays nothing for a local crew, which is switching rather than running', () => {
const s = game();
const s = paying();
const before = s.players[0]!.revenue;
const id = readyToLeave(s, 'east');
s.trays.get(id)!.trainNumber = null;
@@ -404,6 +417,32 @@ describe('one Revenue to EVERY player when a train completes its run', () => {
assert.equal(s.players[0]!.revenue, before, 'a switching crew was paid as though it had completed a run');
});
it('pays at the rate the game was dealt with, and nothing at the default of zero', () => {
// Zero is not "one, suppressed": no revenue event is emitted at all, so the history does not
// fill with "+0 Revenue" for work that did not pay.
const run = (rate: number): { revenue: number; events: number } => {
const s = rate === 0 ? game() : paying(rate);
readyToLeave(s, 'east');
let events = 0;
let done = false;
for (let i = 0; i < 20 && !done; i++) {
s.movedThisPhase = new Set();
s.clock.phase = 'mainline';
const r = advance(s);
events += r.events.filter(
(e) => e.type === 'revenueChanged' && e.reason === 'a train completed its run',
).length;
done = r.events.some((e) => e.type === 'trainCompleted');
}
assert.ok(done, `the train never left the Division at rate ${rate}`);
return { revenue: s.players[0]!.revenue, events };
};
assert.deepEqual(run(0), { revenue: 0, events: 0 }, 'the default paid for a transit');
assert.deepEqual(run(1), { revenue: 1, events: 1 });
assert.deepEqual(run(5), { revenue: 5, events: 1 }, 'the top of the range pays 5 in one event');
});
});
// ---------------------------------------------------------------------------
+6 -2
View File
@@ -119,10 +119,14 @@ describe('the intents are what reconstructs a game', () => {
);
});
it('carries no state in the save beyond the seed and the intents', () => {
it('carries no POSITION in the save — the seed, the rules dealt under, and the intents', () => {
// If anything else ever creeps into `Save`, the claim above weakens: the game would no longer be
// reconstructible from decisions alone, and persistence would have a schema to migrate.
//
// `rules` is the one addition, and it is not position: it is the other half of the seed. A seed
// only names a game together with the rules it was dealt under, which is why two published
// replays went dead when the rules moved (`TODO.md`) — the intents were fine, the deal was not.
const game = newGame(7);
assert.deepEqual(Object.keys(toSave(game)).sort(), ['history', 'seed']);
assert.deepEqual(Object.keys(toSave(game)).sort(), ['history', 'rules', 'seed']);
});
});
+6 -1
View File
@@ -275,8 +275,13 @@ describe('every published replay actually replays', () => {
const save = JSON.parse(readFileSync(join(dir, f), 'utf8')) as {
seed: number;
history: unknown[];
rules?: unknown;
};
const back = fromSave({ seed: save.seed, history: save.history as never });
// The WHOLE save, `rules` included. Rebuilding it from seed and history alone threw away the
// one field that says which ruleset the file was recorded under, so this replayed every
// published file under the pre-dialog defaults no matter what it said — a test that would
// pass a genuinely dead replay the moment the defaults and the file disagreed.
const back = fromSave(save as never);
assert.equal(
back.history.length,
save.history.length,
+9 -1
View File
@@ -552,8 +552,16 @@ describe('scoring lands on the right seat', () => {
*
* With one player this is unfalsifiable, because "everybody" is one person. This is the test
* that says so with three.
*
* The rate is a dial now and defaults to zero, so this names it: what is under test is WHO gets
* paid, not whether the default pays at all (`enhancements.test.ts` covers that).
*/
const s = game(3);
const s = createGame({
id: 'm',
seed: 4242,
config: { ...competitive, houseRules: { revenue: { trainPerTransit: 1 } } },
playerNames: ['a', 'b', 'c'],
});
pinTerrain(s);
const before = s.players.map((p) => p.revenue);
+48 -8
View File
@@ -28,6 +28,7 @@ import {
} 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';
@@ -46,6 +47,15 @@ const solitaireConfig: GameConfig = {
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)', () => {
@@ -380,26 +390,40 @@ describe('game setup (component 2)', () => {
assert.equal(area.adOccupancy.length, 0);
});
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.
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');
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 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(
@@ -408,6 +432,22 @@ describe('game setup (component 2)', () => {
);
});
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 = [
+186 -4
View File
@@ -11,6 +11,10 @@ import { turnOf } from '../src/engine/state.ts';
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
// The REAL one. An earlier test in this file replaces `globalThis.URLSearchParams` with a two-line
// stub, and the dialog needs `set` as well as `get` — reading the global here would hand the page
// whichever stub happened to run last.
import { URLSearchParams as NodeURLSearchParams } from 'node:url';
import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts';
import { variantsFor } from '../src/engine/track.ts';
@@ -1324,8 +1328,23 @@ describe('the static build', () => {
// it, and a stub without one throws on load — which is a page that never starts, not a
// cosmetic gap.
const ownClasses = new Set<string>();
// The New Game dialog is a native <dialog>: the page listens for `close` on it at load, and
// opens it with `showModal`. Without these the page throws before it draws anything — which
// is a page that never starts, exactly what this stub exists to catch.
const listeners = new Map<string, ((e?: unknown) => void)[]>();
const node: Record<string, unknown> = {
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
title: '', returnValue: '', open: false,
addEventListener: (type: string, fn: (e?: unknown) => void) =>
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
showModal() {
(node as { open: boolean }).open = true;
},
close(value?: string) {
(node as { open: boolean; returnValue: string }).open = false;
if (value !== undefined) (node as { returnValue: string }).returnValue = value;
for (const fn of listeners.get('close') ?? []) fn();
},
classList: {
add: (c: string) => void ownClasses.add(c),
remove: (c: string) => void ownClasses.delete(c),
@@ -1740,10 +1759,11 @@ describe('the static build', () => {
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
}
// Raised 8 -> 10: the widest group is Switching, and the opening deal grew districts from 17.9
// to 20.3 cards, so a crew simply has more squares it can legally reach. That is the list getting
// longer for a good reason rather than the cross-products this test was written to kill.
assert.ok(worst <= 10, `the action list still reaches ${worst} buttons`);
// Raised 8 -> 10 -> 13. The widest group is Switching every time, which is a crew's reachable
// squares — the list getting longer for a good reason rather than the cross-products this test
// was written to kill. Re-measured over five seeds (430, 99, 1234, 880009, 202) in all three
// opening deals: 13 under `threeRandom` and `sixRandom`, 10 under `threeTrackThreeOther`.
assert.ok(worst <= 13, `the action list still reaches ${worst} buttons`);
});
it('makes up ONE train at a time, and names that train', () => {
@@ -2494,3 +2514,165 @@ describe('the tray is an engine plus its Rolling Stock', () => {
}
});
});
// ---------------------------------------------------------------------------
describe('the New Game dialog', () => {
/**
* DRIVEN THROUGH THE EMITTED BUNDLE, like the highlight test above, because the thing that can go
* wrong here is wiring rather than logic: an id that does not match the HTML, a handler on the
* wrong element, or a navigation that assigns the search string the page already has and so does
* nothing at all. None of that is visible to a test of `rulesFromUrl` in isolation.
*
* A seed alone stopped naming a game the moment the opening hand and the revenue rates became
* settings, so what this really pins is that all four ride in the URL and come back out.
*/
const load = async (search: string) => {
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' });
const served = new Set(
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!),
);
const els = new Map<string, Record<string, unknown>>();
/** Enough of an element for the page to start: the dialog's own API, and a settable `value`. */
const make = (id: string): Record<string, unknown> => {
const listeners = new Map<string, (() => void)[]>();
const radios = ['threeRandom', 'sixRandom', 'threeTrackThreeOther'].map((value) => ({
value,
checked: false,
}));
let html = '';
const node: Record<string, unknown> = {
id, value: '', textContent: '', title: '', returnValue: '', open: false,
style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
radios,
addEventListener: (type: string, fn: () => void) =>
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
showModal: () => void ((node as { open: boolean }).open = true),
close: () => {
(node as { open: boolean }).open = false;
for (const fn of listeners.get('close') ?? []) fn();
},
// Only the radio-group selectors the dialog actually uses; anything else is not this
// element's business and answering it with a guess would hide a typo in the real selector.
querySelectorAll: (sel: string) => (sel === 'input[name="ng-hand"]' ? radios : []),
querySelector: (sel: string) =>
sel === 'input[name="ng-hand"]:checked' ? (radios.find((r) => r.checked) ?? null) : null,
};
Object.defineProperty(node, 'innerHTML', { get: () => html, set: (v: string) => void (html = v) });
return node;
};
const g = globalThis as Record<string, unknown>;
g['document'] = {
getElementById: (id: string) => {
if (!served.has(id)) return null;
if (!els.has(id)) els.set(id, make(id));
return els.get(id);
},
createElement: () => make('style'),
addEventListener: () => {},
body: { appendChild: () => {} },
head: { appendChild: () => {} },
};
const nav: { search: string; reloads: number } = { search, reloads: 0 };
g['location'] = {
get search() { return nav.search; },
set search(v: string) { nav.search = v; },
reload: () => void (nav.reloads += 1),
};
const store = new Map<string, string>();
g['localStorage'] = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
};
g['URLSearchParams'] = NodeURLSearchParams;
g['confirm'] = () => true;
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}-${Math.random()}`);
return { els, nav };
};
it('opens on the rules in play, so a second game can be dealt to compare with the first', async () => {
// Re-entering four settings for every comparison game is how a comparison silently stops
// comparing. The seed is the one field that clears: the same seed twice is not a second sample.
const { els } = await load('?seed=430&hand=sixRandom&passenger=2&freight=3&transit=4');
(els.get('newgame')!['onclick'] as () => void)();
const dlg = els.get('newgamedlg')!;
assert.equal(dlg['open'], true, 'the New game button did not open the dialog');
assert.equal(els.get('ng-seed')!['value'], '', 'the seed box kept the last game’s seed');
assert.equal(els.get('ng-passenger')!['value'], '2');
assert.equal(els.get('ng-freight')!['value'], '3');
assert.equal(els.get('ng-transit')!['value'], '4');
const checked = (dlg['radios'] as { value: string; checked: boolean }[]).filter((r) => r.checked);
assert.deepEqual(checked.map((r) => r.value), ['sixRandom'], 'the opening hand in play was not preselected');
});
it('puts the seed and all three settings into the URL when it deals', async () => {
const { els, nav } = await load('?seed=430');
(els.get('newgame')!['onclick'] as () => void)();
const dlg = els.get('newgamedlg')!;
els.get('ng-seed')!['value'] = '99';
for (const r of dlg['radios'] as { value: string; checked: boolean }[]) r.checked = r.value === 'threeTrackThreeOther';
els.get('ng-passenger')!['value'] = '5';
els.get('ng-freight')!['value'] = '0';
els.get('ng-transit')!['value'] = '2';
dlg['returnValue'] = 'deal';
(dlg['close'] as () => void)();
assert.equal(nav.search, '?seed=99&hand=threeTrackThreeOther&passenger=5&freight=0&transit=2');
});
it('deals nothing on cancel, and nothing on Esc', async () => {
// Esc closes a <dialog> with an empty returnValue and fires no submit at all, so "not deal" has
// to be the test rather than "cancel" — the two arrive identically.
for (const returnValue of ['cancel', '']) {
const { els, nav } = await load('?seed=430');
(els.get('newgame')!['onclick'] as () => void)();
const dlg = els.get('newgamedlg')!;
dlg['returnValue'] = returnValue;
(dlg['close'] as () => void)();
assert.equal(nav.search, '?seed=430', `closing with "${returnValue}" navigated`);
assert.equal(nav.reloads, 0, `closing with "${returnValue}" reloaded`);
}
});
it('reloads when the answers are the URL the page already has, so a re-deal is not a no-op', async () => {
// Dealing a random seed, disliking it and dealing again at the same settings produces the same
// search string — and assigning `location.search` the value it already holds does nothing.
const url = '?hand=threeRandom&passenger=1&freight=1&transit=0';
const { els, nav } = await load(url);
(els.get('newgame')!['onclick'] as () => void)();
const dlg = els.get('newgamedlg')!;
dlg['returnValue'] = 'deal';
(dlg['close'] as () => void)();
assert.equal(nav.search, url, 'the URL should be unchanged — that is the whole case');
assert.equal(nav.reloads, 1, 'a re-deal at the same settings did nothing at all');
});
it('deals the game the URL describes, and says so in the header', async () => {
// The other half of the round trip: the dialog wrote those parameters, and this is the page
// reading them back. Without this the two halves can drift and each still pass its own test.
const { els } = await load('?seed=430&hand=sixRandom&passenger=4&freight=2&transit=1');
assert.match(String(els.get('houserules')!['textContent']), /6 cards.*4\/2\/1/);
assert.match(String(els.get('houserules')!['title']), /six random cards/i);
});
it('ignores a seed the browser cannot parse rather than refusing to deal', async () => {
// Blank and unparseable both plainly mean "surprise me"; an error dialog over a typo in an
// optional box is not worth writing.
const { els, nav } = await load('?seed=430');
(els.get('newgame')!['onclick'] as () => void)();
const dlg = els.get('newgamedlg')!;
els.get('ng-seed')!['value'] = 'not a number';
dlg['returnValue'] = 'deal';
(dlg['close'] as () => void)();
assert.equal(nav.search, '?hand=threeRandom&passenger=1&freight=1&transit=0', 'a bad seed was carried into the URL');
});
});