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:
+69
-1
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user