adding classification and division yard updates, train collisions, engine and car updates, turnout improvements.
This commit is contained in:
@@ -257,7 +257,7 @@ describe('Mainline Phase (§8)', () => {
|
||||
id: `t${i}`,
|
||||
trainNumber: spec.n,
|
||||
trainIsExtra: spec.extra,
|
||||
engineFront: true,
|
||||
engineAt: 0,
|
||||
consist: [],
|
||||
direction: 'east',
|
||||
position: { at: 'divisionPoint', side: 'west' },
|
||||
@@ -287,7 +287,7 @@ describe('collisions are automatic (Gap 2)', () => {
|
||||
id,
|
||||
trainNumber: 2,
|
||||
trainIsExtra: false,
|
||||
engineFront: true,
|
||||
engineAt: 0,
|
||||
consist: [{ type: 'coach', loaded: true }],
|
||||
direction: 'east',
|
||||
position: { at: 'mainline', index: 1 },
|
||||
@@ -328,7 +328,7 @@ describe('collisions are automatic (Gap 2)', () => {
|
||||
id: 'x',
|
||||
trainNumber: 8,
|
||||
trainIsExtra: false,
|
||||
engineFront: true,
|
||||
engineAt: 0,
|
||||
consist: [
|
||||
{ type: 'hopper', loaded: true },
|
||||
{ type: 'caboose', loaded: true },
|
||||
@@ -359,7 +359,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
|
||||
id: 'ahead',
|
||||
trainNumber: 4,
|
||||
trainIsExtra: false,
|
||||
engineFront: true,
|
||||
engineAt: 0,
|
||||
consist: [],
|
||||
direction: 'east',
|
||||
position: { at: 'mainline', index: 1 },
|
||||
@@ -379,7 +379,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
|
||||
id: 'behind',
|
||||
trainNumber: 2,
|
||||
trainIsExtra: false,
|
||||
engineFront: true,
|
||||
engineAt: 0,
|
||||
consist: [],
|
||||
direction: 'east',
|
||||
position: { at: 'divisionPoint', side: 'west' },
|
||||
@@ -402,7 +402,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
|
||||
id: 'oncoming',
|
||||
trainNumber: 3,
|
||||
trainIsExtra: false,
|
||||
engineFront: true,
|
||||
engineAt: 0,
|
||||
consist: [],
|
||||
direction: 'west',
|
||||
position: { at: 'mainline', index: 1 },
|
||||
@@ -421,7 +421,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
|
||||
id: 'waiting',
|
||||
trainNumber: 2,
|
||||
trainIsExtra: false,
|
||||
engineFront: true,
|
||||
engineAt: 0,
|
||||
consist: [],
|
||||
direction: 'east',
|
||||
position: { at: 'divisionPoint', side: 'west' },
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ function placeTray(s: GameState, coord: GridCoord, consist: CrewTray['consist']
|
||||
id,
|
||||
trainNumber: null,
|
||||
trainIsExtra: false,
|
||||
engineFront: true,
|
||||
engineAt: 0,
|
||||
consist,
|
||||
direction: 'east',
|
||||
position: { at: 'grid', owner: 0, coord },
|
||||
|
||||
@@ -52,7 +52,7 @@ function handEnhancement(s: GameState, key: string): string {
|
||||
function placeTray(s: GameState, coord: GridCoord, consist: TrackCard['standing'] = []): string {
|
||||
const id = s.freeTrays.pop()!;
|
||||
s.trays.set(id, {
|
||||
id, trainNumber: 9, trainIsExtra: false, engineFront: true,
|
||||
id, trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
||||
consist, direction: 'east', position: { at: 'grid', owner: 0, coord }, movesUsed: 0,
|
||||
});
|
||||
return id;
|
||||
@@ -185,7 +185,7 @@ describe('Interlocking and Yard Office relieve the Office', () => {
|
||||
function inbound(s: GameState, consist: TrackCard['standing']) {
|
||||
const id = 'inbound';
|
||||
s.trays.set(id, {
|
||||
id, trainNumber: 9, trainIsExtra: false, engineFront: true,
|
||||
id, trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
||||
consist, direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
});
|
||||
const ml = s.division.nodes[1];
|
||||
@@ -264,11 +264,11 @@ describe('ABS Signals amend the collision rule', () => {
|
||||
ml.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
||||
}
|
||||
s.trays.set('ahead', {
|
||||
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineFront: true,
|
||||
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
});
|
||||
s.trays.set('behind', {
|
||||
id: 'behind', trainNumber: 2, trainIsExtra: false, engineFront: true,
|
||||
id: 'behind', trainNumber: 2, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
||||
});
|
||||
s.clock.phase = 'mainline';
|
||||
@@ -292,7 +292,7 @@ describe('Telegraph, Telephone and Radio dispatch meets', () => {
|
||||
}
|
||||
// An oncoming senior train — §8.1 makes this an absolute bar without a device.
|
||||
s.trays.set('oncoming', {
|
||||
id: 'oncoming', trainNumber: 3, trainIsExtra: false, engineFront: true,
|
||||
id: 'oncoming', trainNumber: 3, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'west', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
});
|
||||
const ml = s.division.nodes[1];
|
||||
@@ -304,7 +304,7 @@ describe('Telegraph, Telephone and Radio dispatch meets', () => {
|
||||
ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
|
||||
}
|
||||
s.trays.set('mine', {
|
||||
id: 'mine', trainNumber: 9, trainIsExtra: false, engineFront: true,
|
||||
id: 'mine', trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
||||
});
|
||||
s.clock.phase = 'mainline';
|
||||
|
||||
@@ -16,12 +16,15 @@ import { advance } from '../src/engine/advance.ts';
|
||||
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
||||
import {
|
||||
MAINLINE_MODIFIER_CARDS,
|
||||
MAINLINE_PROFILES,
|
||||
MANEUVER_CARDS,
|
||||
REALIGNMENTS,
|
||||
crossingStages,
|
||||
mainlineModifierRule,
|
||||
mainlineProfile,
|
||||
} from '../src/engine/content.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { GameEvent } from '../src/engine/events.ts';
|
||||
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
|
||||
import { coordKey } from '../src/engine/state.ts';
|
||||
import { snapshot } from '../src/sim/view.ts';
|
||||
@@ -210,11 +213,11 @@ describe('Red Flags protect a stopped train', () => {
|
||||
const node = pinned(s, 1, 'plains');
|
||||
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
||||
s.trays.set('ahead', {
|
||||
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineFront: true,
|
||||
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
});
|
||||
s.trays.set('behind', {
|
||||
id: 'behind', trainNumber: 2, trainIsExtra: false, engineFront: true,
|
||||
id: 'behind', trainNumber: 2, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
||||
});
|
||||
const dp = s.division.nodes[0];
|
||||
@@ -313,7 +316,7 @@ describe('Flying Switch rolls a cut into an industry', () => {
|
||||
area.grid.set(coordKey(at(-2, 0)), industry);
|
||||
|
||||
s.trays.set('crew', {
|
||||
id: 'crew', trainNumber: 1, trainIsExtra: false, engineFront: true,
|
||||
id: 'crew', trainNumber: 1, trainIsExtra: false, engineAt: 0,
|
||||
consist: [{ type: 'hopper', loaded: false }, { type: 'boxcar', loaded: false }],
|
||||
// Facing SOUTH, down the spur. `direction` only carries east/west, so a crew on north-south
|
||||
// track needs its actual port — without it the engine looks for an east exit the card has not
|
||||
@@ -405,12 +408,17 @@ describe('card coverage', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('Poling remains unimplemented, deliberately', () => {
|
||||
it('Poling stays unimplemented AND out of the deck, deliberately', () => {
|
||||
// Guard against someone "fixing" this by inventing an effect. The sheet says TBD; until the
|
||||
// source says otherwise, a silent no-op would be worse than a rejection.
|
||||
//
|
||||
// It is dealt zero copies rather than deleted: a card that cannot be played is worse in a
|
||||
// player's hand than absent from the deck, but the entry stays so the gap remains visible and
|
||||
// the card returns the moment its rule is known.
|
||||
const poling = MANEUVER_CARDS.find((c) => c.key === 'poling');
|
||||
assert.ok(poling, 'Poling should still be in the deck');
|
||||
assert.ok(poling, 'the Poling entry should stay, so the gap does not vanish');
|
||||
assert.match(poling!.effect, /TBD/i);
|
||||
assert.equal(poling!.copies, 0, 'Poling is dealt into the deck but cannot be played');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -495,7 +503,7 @@ describe("a train is made up to its card's consist (§8.2)", () => {
|
||||
const s = game();
|
||||
s.clock.phase = 'newTrain';
|
||||
s.trays.set('t', {
|
||||
id: 't', trainNumber: 9, trainIsExtra: false, engineFront: true,
|
||||
id: 't', trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'west', position: { at: 'divisionPoint', side: 'east' }, movesUsed: 0,
|
||||
});
|
||||
|
||||
@@ -525,7 +533,7 @@ describe("a train is made up to its card's consist (§8.2)", () => {
|
||||
const s = game();
|
||||
s.clock.phase = 'newTrain';
|
||||
s.trays.set('t', {
|
||||
id: 't', trainNumber: 9, trainIsExtra: false, engineFront: true,
|
||||
id: 't', trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
||||
consist: [{ type: 'caboose', loaded: true }],
|
||||
direction: 'west', position: { at: 'divisionPoint', side: 'east' }, movesUsed: 0,
|
||||
});
|
||||
@@ -595,3 +603,87 @@ describe('regions on a Mainline card (§2.1, §8.2)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Q13 — a train that catches the one ahead runs into it', () => {
|
||||
/**
|
||||
* Two trains on one card, same direction, the follower closing. `total 3 / remaining 2` puts the
|
||||
* leader in region 1 after it moves; `total 2 / remaining 2` puts the follower in region 0 and
|
||||
* then region 1 — so it catches up whichever order the phase happens to process them in.
|
||||
*/
|
||||
const twoTrains = (opts: { absSignals?: boolean } = {}): { s: GameState; events: GameEvent[] } => {
|
||||
const s = createGame({
|
||||
id: 'rear', seed: 3,
|
||||
config: {
|
||||
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
|
||||
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['bot'],
|
||||
});
|
||||
const index = s.division.nodes.findIndex(
|
||||
(n) => n.kind === 'mainline' && !mainlineProfile(n.card).trainsMayPass,
|
||||
);
|
||||
assert.ok(index >= 0, 'no single-track Mainline card in this Division');
|
||||
const node = s.division.nodes[index]!;
|
||||
assert.ok(node.kind === 'mainline');
|
||||
if (node.kind !== 'mainline') throw new Error('unreachable');
|
||||
if (opts.absSignals) node.absSignals = true;
|
||||
|
||||
const make = (n: number, total: number, left: number): string => {
|
||||
const id = s.freeTrays.pop()!;
|
||||
s.trays.set(id, {
|
||||
id, trainNumber: n, trainIsExtra: false, engineAt: 0,
|
||||
consist: [{ type: 'boxcar', loaded: false }],
|
||||
direction: 'east',
|
||||
position: { at: 'mainline', index },
|
||||
} as never);
|
||||
node.transits.push({ tray: id, stagesRemaining: left, stagesTotal: total, direction: 'east' });
|
||||
return id;
|
||||
};
|
||||
make(9, 3, 2); // the leader
|
||||
make(11, 2, 2); // the follower, closing
|
||||
|
||||
s.clock.phase = 'mainline';
|
||||
const events: GameEvent[] = [];
|
||||
for (let i = 0; i < 4; i++) events.push(...advance(s).events);
|
||||
return { s, events };
|
||||
};
|
||||
|
||||
it('destroys both trains, and it is the Superintendent\'s fault (§10)', () => {
|
||||
// REGRESSION against a rule that was never implemented. §10 makes a Mainline collision the
|
||||
// Superintendent's fault and removes both trains, and ABS Signals exists to prevent rear-enders
|
||||
// — but §8.3's trigger list never named one, so granting clearance was FREE: both trains
|
||||
// survived, no penalty, and ABS Signals protected against nothing.
|
||||
const { events } = twoTrains();
|
||||
const smash = events.find((e) => e.type === 'trainsDestroyed');
|
||||
assert.ok(smash, 'the follower ran into the leader and nothing happened');
|
||||
if (smash.type !== 'trainsDestroyed') throw new Error('unreachable');
|
||||
assert.match(smash.reason, /ran into the train ahead/);
|
||||
assert.equal(smash.trains.length, 2, 'a rear-ender removes BOTH trains');
|
||||
assert.ok(
|
||||
events.some((e) => e.type === 'revenueChanged' && e.delta < 0),
|
||||
'the collision cost nothing',
|
||||
);
|
||||
});
|
||||
|
||||
it('ABS Signals holds the follower short instead', () => {
|
||||
// "Trains on this card will not rear-end each other; they stop short of a collision."
|
||||
const { events } = twoTrains({ absSignals: true });
|
||||
assert.ok(
|
||||
!events.some((e) => e.type === 'trainsDestroyed'),
|
||||
'ABS Signals did not prevent the collision',
|
||||
);
|
||||
assert.ok(
|
||||
events.some((e) => e.type === 'trainHeld' && /ABS Signals/.test(e.reason)),
|
||||
'nothing was held short of the train ahead',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves trains alone on a card that prints "trains may pass"', () => {
|
||||
// Double Track and Uncontrolled Siding hold two trains because they HAVE two roads. Catching up
|
||||
// there means going past, which is what the card is for. Without this the mechanic fired 0.41
|
||||
// times a game while the bot never once granted clearance — the tell that they were all
|
||||
// passing cards.
|
||||
const passing = MAINLINE_PROFILES.filter((m) => m.trainsMayPass).map((m) => m.kind);
|
||||
assert.deepEqual(passing, ['doubleTrack', 'uncontrolledSiding']);
|
||||
});
|
||||
});
|
||||
|
||||
+12
-7
@@ -53,7 +53,11 @@ describe('card catalogue (component 1)', () => {
|
||||
// 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.
|
||||
assert.equal(DECK_SIZE, 140);
|
||||
//
|
||||
// 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
|
||||
// catalogue so the gap remains visible.
|
||||
assert.equal(DECK_SIZE, 139);
|
||||
assert.equal(buildDeck().length, DECK_SIZE);
|
||||
});
|
||||
|
||||
@@ -69,20 +73,21 @@ describe('card catalogue (component 1)', () => {
|
||||
spaceUse: 12,
|
||||
enhancement: 18,
|
||||
mainlineModifier: 7,
|
||||
maneuver: 7,
|
||||
// 6, not 7 — Poling is dealt no copies until its rule is known.
|
||||
maneuver: 6,
|
||||
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 140 draws (16%) that do nothing.
|
||||
assert.equal(SOLITAIRE_DECK_SIZE, 118);
|
||||
// game they would be 22 of 139 draws (16%) that do nothing.
|
||||
assert.equal(SOLITAIRE_DECK_SIZE, 117);
|
||||
const solo = buildDeck('solitaire');
|
||||
assert.equal(solo.length, 118);
|
||||
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.
|
||||
assert.equal(buildDeck('competitive').length, 140);
|
||||
// A competitive deck keeps them. 139, not 140 — Poling is dealt no copies.
|
||||
assert.equal(buildDeck('competitive').length, 139);
|
||||
});
|
||||
|
||||
it('keeps track OUT of the deck, as a per-player supply', () => {
|
||||
|
||||
+197
-36
@@ -8,8 +8,11 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import { pump } from '../src/engine/advance.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import { refillDivisionYardIfEmpty } from '../src/engine/apply.ts';
|
||||
import { TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
|
||||
import type { GameConfig, GameState, OfficeArea } from '../src/engine/state.ts';
|
||||
import { exitsFrom, hasPort, neighbour, opposite } from '../src/engine/track.ts';
|
||||
import type { Intent } from '../src/engine/intents.ts';
|
||||
import { connectionsFor, exitsFrom, hasPort, neighbour, opposite, variantsFor } from '../src/engine/track.ts';
|
||||
import type { Port } from '../src/engine/track.ts';
|
||||
import { developerBot, playGame, randomBot } from '../src/sim/bot.ts';
|
||||
import { simulate } from '../src/sim/harness.ts';
|
||||
@@ -236,8 +239,13 @@ describe('end-of-game statistics', () => {
|
||||
const report = simulate({
|
||||
games: 30, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
|
||||
});
|
||||
// Both halves of freight, matching `strategyBuckets`. This filter used to omit `freightUnload`
|
||||
// — the same omission the summary itself had — so a game that scored only by unloading was
|
||||
// bucketed but not counted here.
|
||||
const scored = report.perGame.filter(
|
||||
(g) => g.revenue.freightLoad + g.revenue.passengerBoard + g.revenue.passengerDetrain > 0,
|
||||
(g) =>
|
||||
g.revenue.freightLoad + g.revenue.freightUnload +
|
||||
g.revenue.passengerBoard + g.revenue.passengerDetrain > 0,
|
||||
);
|
||||
const bucketed = strategyBuckets(report.perGame).reduce((n, b) => n + b.games, 0);
|
||||
assert.equal(bucketed, scored.length, 'a scoring game fell outside every bucket');
|
||||
@@ -591,45 +599,49 @@ describe('Enhancements can reach the board at all (regression)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('rolling stock stays in circulation (regression)', () => {
|
||||
it('returns the Classification Yard to service overnight', () => {
|
||||
// REGRESSION, and the largest single revenue change measured on this bot: +2.32 ± 0.52 paired
|
||||
// over 400 seeds (t = 8.79).
|
||||
describe('rolling stock returns to service (regression)', () => {
|
||||
it('empties the Classification Yard into the Division Yard when the Division Yard runs out', () => {
|
||||
// §2 — used Rolling Stock is set out in the Classification Yard, used engines and cabooses go
|
||||
// straight back to the Division Yard, and the Classification Yard empties ONLY when the
|
||||
// Division Yard is bare. Then all of it returns at once.
|
||||
//
|
||||
// The Classification Yard was WRITE-ONLY. Seven places pushed cars into it — retired trains,
|
||||
// collisions, unjams, set-outs — and nothing in the engine ever read it. Rolling stock drained
|
||||
// one way out of the game: 30 cars a game dead by the end, 37% of the 80 dealt at setup.
|
||||
// REGRESSION on two counts. The Classification Yard was write-only to begin with — seven
|
||||
// writers, no readers — so 37% of the stock left the game. The first fix then guessed the wrong
|
||||
// trigger, returning cars at every DAY boundary, which keeps the yard topped up continuously
|
||||
// and is far more generous than the rule: worth +2.42 revenue a game that the game does not
|
||||
// actually grant.
|
||||
//
|
||||
// It was not obvious this mattered. The Division Yard does not run DRY in five Days (16.6
|
||||
// loaded freight cars left, empty in 2 games of 100), which is why this was ranked third and
|
||||
// predicted not to affect revenue. The aggregate was never the point: what starves freight is
|
||||
// not having the RIGHT commodity when a green box needs stocking, and returning classified
|
||||
// cars keeps the mix alive.
|
||||
const games = 30;
|
||||
let deadAtEnd = 0;
|
||||
let everReturned = 0;
|
||||
for (let i = 0; i < games; i++) {
|
||||
const s = createGame({
|
||||
id: `cy-${i}`,
|
||||
seed: 1000 + i * 7919,
|
||||
config: { ...config, length: 'standard' },
|
||||
playerNames: ['bot'],
|
||||
});
|
||||
const r = playGame(s, developerBot, pump);
|
||||
deadAtEnd += s.yards.classificationYard.length;
|
||||
// Cars must have passed THROUGH it, or the test proves nothing about the return path.
|
||||
if (r.events.some((e) => e.type === 'trainCompleted')) everReturned++;
|
||||
}
|
||||
// So this asserts the TRIGGER, not a tidy end state. The Division Yard legitimately still holds
|
||||
// cars at the end of a short game, and the Classification Yard legitimately holds a pile.
|
||||
const s = createGame({
|
||||
id: 'refill',
|
||||
seed: 4,
|
||||
config: { ...config, length: 'standard' },
|
||||
playerNames: ['bot'],
|
||||
});
|
||||
|
||||
assert.ok(everReturned > 0, 'no train ever completed — nothing reached the Classification Yard');
|
||||
// A Day's worth of arrivals may still be sitting there when the game ends; a whole game's worth
|
||||
// may not. Before the fix this was ~30 a game.
|
||||
assert.ok(
|
||||
deadAtEnd / games < 15,
|
||||
`${(deadAtEnd / games).toFixed(1)} cars a game are still stranded in the Classification ` +
|
||||
'Yard at the end — rolling stock is leaving the game and never coming back',
|
||||
// Drain the Division Yard and park a known car in Classification.
|
||||
const parked = s.yards.divisionYard.splice(0, s.yards.divisionYard.length);
|
||||
s.yards.classificationYard.push(...parked);
|
||||
assert.equal(s.yards.divisionYard.length, 0);
|
||||
const waiting = s.yards.classificationYard.length;
|
||||
assert.ok(waiting > 0, 'nothing was parked to return');
|
||||
|
||||
const moved = refillDivisionYardIfEmpty(s);
|
||||
assert.ok(moved, 'an empty Division Yard did not pull the Classification Yard back');
|
||||
assert.equal(moved!.count, waiting);
|
||||
assert.equal(s.yards.divisionYard.length, waiting, 'the cars did not come back');
|
||||
assert.equal(s.yards.classificationYard.length, 0, 'the Classification Yard was not emptied');
|
||||
|
||||
// And it must NOT fire while the Division Yard still has anything at all.
|
||||
s.yards.classificationYard.push({ type: 'boxcar', loaded: false });
|
||||
assert.equal(
|
||||
refillDivisionYardIfEmpty(s),
|
||||
null,
|
||||
'the Classification Yard returned while the Division Yard still had cars',
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('measurement discipline', () => {
|
||||
@@ -662,3 +674,152 @@ describe('measurement discipline', () => {
|
||||
assert.ok(upgrades > 0, 'no office upgrade was visible in the event log across 25 games');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the bot does not lay track that cannot work (regression)', () => {
|
||||
/** Drive a game with the developer bot, reporting every track piece it lays. */
|
||||
const laysIn = (seed: number, prepare?: (s: GameState) => void): Intent[] => {
|
||||
const s = createGame({
|
||||
id: `lay-${seed}`,
|
||||
seed,
|
||||
config: { ...config, length: 'standard' },
|
||||
playerNames: ['Solitaire'],
|
||||
});
|
||||
prepare?.(s);
|
||||
const laid: Intent[] = [];
|
||||
const spy = {
|
||||
name: 'spy',
|
||||
choose(st: GameState, p: 0, opts: Intent[]) {
|
||||
const pick = developerBot.choose(st, p, opts);
|
||||
if (pick.type === 'track.lay') laid.push(pick);
|
||||
return pick;
|
||||
},
|
||||
};
|
||||
playGame(s, spy as never, pump);
|
||||
return laid;
|
||||
};
|
||||
|
||||
it('will not cut a turnout with no arc left to hang beneath it', () => {
|
||||
// A turnout is a hole in the Running Track, worth cutting only for what hangs under it. This
|
||||
// does not happen naturally — measured at 0%, because the bot lays the arc immediately after
|
||||
// the turnout and never runs the supply dry first — so the situation is constructed. It guards
|
||||
// against the supply changing rather than against something happening today.
|
||||
const drained = laysIn(4242, (s) => {
|
||||
const area = s.officeAreas.get(0)!;
|
||||
for (const k of ['curved:left', 'curved:right', 'sharpCurved:left', 'sharpCurved:right']) {
|
||||
area.trackSupply.set(k, 0);
|
||||
}
|
||||
});
|
||||
const turnouts = drained.filter((i) => i.type === 'track.lay' && i.geometry === 'turnout');
|
||||
assert.equal(
|
||||
turnouts.length,
|
||||
0,
|
||||
`${turnouts.length} turnouts were cut with no arc left to build beneath them`,
|
||||
);
|
||||
|
||||
// And the guard must not be doing this by simply stopping the bot from building.
|
||||
const normal = laysIn(4242);
|
||||
assert.ok(normal.length > 5, `the bot laid only ${normal.length} pieces with a full supply`);
|
||||
});
|
||||
|
||||
it('rarely lays rail that butts a card which cannot accept it', () => {
|
||||
// A port facing an EMPTY square is a promise — something may be built there later. A port
|
||||
// butting an OCCUPIED square whose card has no matching port is not: that square is taken, so
|
||||
// the rail stops dead and always will. Reported from a replay, where an arc was dropped off a
|
||||
// turnout with its far end jammed into a curve.
|
||||
//
|
||||
// A TIE-BREAKER rather than a veto, so this is a rate and not a zero: forbidding it outright
|
||||
// measured WORSE (-0.62 revenue a game), while preferring the cleaner of two equally good
|
||||
// placements measured better and cut these from 28% of pieces to 7%.
|
||||
let laid = 0;
|
||||
let dead = 0;
|
||||
for (const seed of [1000, 8919, 16838, 24757, 32676]) {
|
||||
const s = createGame({
|
||||
id: `dp-${seed}`, seed,
|
||||
config: { ...config, length: 'standard' },
|
||||
playerNames: ['Solitaire'],
|
||||
});
|
||||
const spy = {
|
||||
name: 'spy',
|
||||
choose(st: GameState, p: 0, opts: Intent[]) {
|
||||
const pick = developerBot.choose(st, p, opts);
|
||||
if (pick.type === 'track.lay') {
|
||||
laid++;
|
||||
const area = st.officeAreas.get(p)!;
|
||||
const v = variantsFor(pick.geometry)[pick.variant ?? 0];
|
||||
const probe = {
|
||||
geometry: {
|
||||
kind: 'track', geometry: pick.geometry,
|
||||
...(v?.axis ? { axis: v.axis } : {}),
|
||||
...(v?.arc ? { arc: v.arc } : {}),
|
||||
...(v?.turnout ? { turnout: v.turnout } : {}),
|
||||
},
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
} as never;
|
||||
for (const port of new Set(connectionsFor(probe).flat())) {
|
||||
const n = neighbour(pick.placement, port);
|
||||
const nb = area.grid.get(`${n.row},${n.col}`);
|
||||
if (nb && !hasPort(nb, opposite(port))) dead++;
|
||||
}
|
||||
}
|
||||
return pick;
|
||||
},
|
||||
};
|
||||
playGame(s, spy as never, pump);
|
||||
}
|
||||
assert.ok(laid > 40, `only ${laid} pieces laid across five games`);
|
||||
const rate = dead / laid;
|
||||
assert.ok(
|
||||
rate < 0.15,
|
||||
`${(rate * 100).toFixed(0)}% of pieces were laid butting a card that cannot accept them`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the freight figures count both halves (regression)', () => {
|
||||
it('scores an unload as Revenue earned, not as an unload started', () => {
|
||||
// REGRESSION, against the instrument rather than the game. `rev.freightUnload` was assigned
|
||||
// `eventCounts['unloadBegan']` — unloads STARTED, not Revenue EARNED, which differ by every
|
||||
// unload that never finished — and `grossFreight` then used `freightLoad` alone, so
|
||||
// `freightShare` reported roughly 60% of the freight actually earned. That is the number the
|
||||
// "freight is only 13-18% of gross" finding was read from, and it drove a density change.
|
||||
//
|
||||
// Asserted against the ENGINE's own events, so the summary cannot drift from what happened.
|
||||
const report = simulate({
|
||||
games: 30, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
|
||||
});
|
||||
|
||||
let loads = 0;
|
||||
let unloads = 0;
|
||||
let begun = 0;
|
||||
for (const seed of [1000, 8919, 16838]) {
|
||||
const s = createGame({
|
||||
id: `fu-${seed}`, seed,
|
||||
config: { ...config, length: 'standard' },
|
||||
playerNames: ['bot'],
|
||||
});
|
||||
const r = playGame(s, developerBot, pump);
|
||||
for (const e of r.events) {
|
||||
if (e.type === 'unloadBegan') begun++;
|
||||
if (e.type !== 'revenueChanged') continue;
|
||||
if (e.reason === 'freightLoad') loads += e.delta;
|
||||
if (e.reason === 'freightUnload') unloads += e.delta;
|
||||
}
|
||||
}
|
||||
assert.ok(unloads > 0, 'no unload ever earned Revenue');
|
||||
// `begun` is what the summary used to count. It is an upper bound on what was EARNED, because
|
||||
// an unload can be started and never finished — so counting it was wrong whether or not the
|
||||
// two happen to agree on a given sample.
|
||||
assert.ok(begun >= unloads, 'more unloads earned Revenue than were ever begun');
|
||||
|
||||
// Every game in the report must count both halves toward its freight share.
|
||||
for (const g of report.perGame) {
|
||||
const gross = g.revenue.freightLoad + g.revenue.freightUnload + g.revenue.passengerBoard + g.revenue.passengerDetrain;
|
||||
if (gross === 0) continue;
|
||||
const expected = (g.revenue.freightLoad + g.revenue.freightUnload) / gross;
|
||||
assert.ok(
|
||||
Math.abs(g.freightShare - expected) < 1e-9,
|
||||
`freightShare ${g.freightShare.toFixed(3)} omits the unload half (expected ${expected.toFixed(3)})`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -545,7 +545,7 @@ describe('coupling lifts only the cars the crew ran over', () => {
|
||||
const area = areaFrom(grid, { row: 0, col: 0 });
|
||||
const s = gameWith(area);
|
||||
s.trays.set('crew', {
|
||||
id: 'crew', trainNumber: 1, trainIsExtra: false, engineFront: true, consist: [],
|
||||
id: 'crew', trainNumber: 1, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'west', facing: 'w', position: { at: 'grid', owner: 0, coord: { row: 0, col: 0 } }, movesUsed: 0,
|
||||
});
|
||||
s.clock.phase = 'localOps';
|
||||
|
||||
+165
-2
@@ -13,6 +13,7 @@ import { dirname, join, resolve } from 'node:path';
|
||||
|
||||
import { cardDescription, describeIntent } from '../src/sim/view.ts';
|
||||
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||
import { record, renderHtml } from '../src/sim/replay.ts';
|
||||
import { snapshot } from '../src/sim/view.ts';
|
||||
import { createGame as createEngineGame } from '../src/engine/setup.ts';
|
||||
import {
|
||||
@@ -529,11 +530,11 @@ describe('the page explains itself', () => {
|
||||
const game = newGame(5);
|
||||
const s = game.state;
|
||||
s.trays.set('tray2', {
|
||||
id: 'tray2', trainNumber: 4, trainIsExtra: false, engineFront: true,
|
||||
id: 'tray2', trainNumber: 4, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
});
|
||||
s.trays.set('tray3', {
|
||||
id: 'tray3', trainNumber: 7, trainIsExtra: false, engineFront: true,
|
||||
id: 'tray3', trainNumber: 7, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
});
|
||||
s.clock.pendingDecision = { train: 'tray2', occupiedBy: 'tray3' };
|
||||
@@ -1191,3 +1192,165 @@ describe('a train says what its card calls for', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the three places a game is drawn stay in step', () => {
|
||||
// A game is drawn on the playable page, in the standalone replay file, and in the website's
|
||||
// replay viewer. They had drifted: the viewer offered three speeds where the standalone offered
|
||||
// five, and had neither the sound nor the auto-hide the other two grew. Anything a player learns
|
||||
// on one screen should hold on the others.
|
||||
|
||||
it('offers the same playback speeds in both replay viewers', () => {
|
||||
const site = readFileSync(join(dist, 'replays.html'), 'utf8');
|
||||
const standalone = renderHtml(record(880009, 'standard', 600));
|
||||
const speeds = (html: string): string[] =>
|
||||
[...html.matchAll(/<option value="\d+"[^>]*>([a-z ]+)<\/option>/g)].map((m) => m[1]!.trim());
|
||||
|
||||
const a = speeds(site);
|
||||
const b = speeds(standalone);
|
||||
assert.ok(a.length >= 5, `the site viewer offers only ${a.length} speeds: ${a.join(', ')}`);
|
||||
assert.deepEqual(
|
||||
new Set(a),
|
||||
new Set(b),
|
||||
`the two viewers offer different speeds — site: ${a.join(', ')} / standalone: ${b.join(', ')}`,
|
||||
);
|
||||
for (const wanted of ['extra slow', 'very fast']) {
|
||||
assert.ok(a.includes(wanted), `the site viewer has no "${wanted}" speed`);
|
||||
}
|
||||
});
|
||||
|
||||
it('offers sound and auto-hide everywhere a game is drawn', () => {
|
||||
const pages: [string, string][] = [
|
||||
['play.html', readFileSync(join(dist, 'play.html'), 'utf8')],
|
||||
['replays.html', readFileSync(join(dist, 'replays.html'), 'utf8')],
|
||||
['the standalone replay', renderHtml(record(880009, 'standard', 600))],
|
||||
];
|
||||
for (const [name, html] of pages) {
|
||||
assert.match(html, /id="v?sound"/, `${name} has no sound control`);
|
||||
assert.match(html, /id="v?districttoggle"/, `${name} has no auto-hide control`);
|
||||
assert.match(html, /id="v?districtsummary"/, `${name} shows nothing when the district is folded`);
|
||||
assert.match(html, /district\.folded/, `${name} has no rule to fold the district away`);
|
||||
}
|
||||
});
|
||||
|
||||
it('asks the replay viewer for no element its page lacks', () => {
|
||||
// The same total check the playable page gets: a `$('vfoo')` left behind after removing #vfoo
|
||||
// throws on load, and the page simply never starts.
|
||||
const src = readFileSync(join(root, 'src/web/replays.ts'), 'utf8');
|
||||
const asked = new Set([...src.matchAll(/\$\('([a-zA-Z][\w-]*)'\)/g)].map((m) => m[1]!));
|
||||
const html = readFileSync(join(dist, 'replays.html'), 'utf8');
|
||||
const present = new Set([...html.matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!));
|
||||
for (const id of asked) {
|
||||
assert.ok(present.has(id), `replays.ts asks for #${id}, which replays.html does not contain`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('every file the build needs is actually in the repo (regression)', () => {
|
||||
it('does not gitignore a source page', () => {
|
||||
// REGRESSION. `.gitignore` carried `replay*.html` to catch the throwaway files generated at the
|
||||
// repo root — unanchored, so it also matched `src/web/replays.html`, which is a SOURCE file.
|
||||
// That page had therefore NEVER been committed: a fresh clone was missing it, and the build
|
||||
// copies it unconditionally. The pattern is anchored to the root now.
|
||||
//
|
||||
// Asked of git rather than read out of .gitignore: the question is whether the file survives a
|
||||
// clone, and only git can answer that. `check-ignore -q` exits 0 when ignored, 1 when not.
|
||||
for (const page of ['src/web/index.html', 'src/web/play.html', 'src/web/replays.html']) {
|
||||
let ignored: boolean;
|
||||
try {
|
||||
execFileSync('git', ['check-ignore', '-q', page], { cwd: root, stdio: 'pipe' });
|
||||
ignored = true;
|
||||
} catch {
|
||||
ignored = false;
|
||||
}
|
||||
assert.equal(ignored, false, `${page} is gitignored — it would be missing from a fresh clone`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the tray is an engine plus its Rolling Stock', () => {
|
||||
it('shows where the engine sits, because it may pull, push, or do both', () => {
|
||||
// A Crew Tray is an engine and its cars, and the engine may be PULLING (ahead of everything),
|
||||
// PUSHING (behind everything) or in the middle doing both — which decides which end cars couple
|
||||
// onto (§A.3) and which way the train can shove.
|
||||
//
|
||||
// REGRESSION on a dead field: `engineFront` was a boolean written in three places and read in
|
||||
// NONE, so the engine had a position the game recorded and never used, and every consist was
|
||||
// drawn as an anonymous row of cars. It is an index into the consist now.
|
||||
const s = createEngineGame({
|
||||
id: 'eng', seed: 1038389,
|
||||
config: {
|
||||
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
|
||||
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['Solitaire'],
|
||||
});
|
||||
|
||||
// A tray built by hand, so every position can be checked rather than hoping one turns up.
|
||||
// Trays are created when a train is made up, so a fresh game has none.
|
||||
const trayId = s.freeTrays.pop()!;
|
||||
const tray = {
|
||||
id: trayId,
|
||||
trainNumber: 9,
|
||||
trainIsExtra: false,
|
||||
engineAt: 0,
|
||||
consist: [
|
||||
{ type: 'boxcar', loaded: false },
|
||||
{ type: 'hopper', loaded: true },
|
||||
],
|
||||
direction: 'east',
|
||||
position: { at: 'divisionPoint', side: 'west' },
|
||||
} as never;
|
||||
s.trays.set(trayId, tray);
|
||||
const west = s.division.nodes.find((n) => n.kind === 'divisionPoint');
|
||||
if (west && west.kind === 'divisionPoint') west.holding.push(trayId);
|
||||
|
||||
const consistFor = (at: number): string[] => {
|
||||
(tray as unknown as { engineAt: number }).engineAt = at;
|
||||
const f = snapshot(s, [], null);
|
||||
for (const node of f.division) {
|
||||
for (const t of node.trains.flat()) {
|
||||
if (t.consist.includes('ENG')) return t.consist;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
assert.equal(consistFor(0)[0], 'ENG', 'an engine at 0 is not drawn pulling');
|
||||
assert.equal(consistFor(2)[2], 'ENG', 'an engine behind the cars is not drawn pushing');
|
||||
const middle = consistFor(1);
|
||||
assert.equal(middle[1], 'ENG', 'an engine between the cars is not drawn in the middle');
|
||||
assert.equal(middle.length, 3, 'the engine displaced a car instead of sitting between them');
|
||||
});
|
||||
|
||||
it('shows both yards, split loaded and empty', () => {
|
||||
// Rolling stock is finite and the Classification Yard returns to service only when the Division
|
||||
// Yard is BARE, so watching the Division Yard run down is real information — and neither yard
|
||||
// was shown anywhere.
|
||||
const s = createEngineGame({
|
||||
id: 'yards', seed: 1038389,
|
||||
config: {
|
||||
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
|
||||
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['Solitaire'],
|
||||
});
|
||||
const f = snapshot(s, [], null);
|
||||
|
||||
assert.ok(f.yards.divisionTotal > 0, 'the Division Yard is dealt nothing');
|
||||
assert.equal(
|
||||
f.yards.division.reduce((n, r) => n + r.loaded + r.empty, 0),
|
||||
f.yards.divisionTotal,
|
||||
'the Division Yard breakdown does not add up to its total',
|
||||
);
|
||||
assert.equal(
|
||||
f.yards.classification.reduce((n, r) => n + r.loaded + r.empty, 0),
|
||||
f.yards.classificationTotal,
|
||||
'the Classification Yard breakdown does not add up to its total',
|
||||
);
|
||||
// The page must ask for the elements it fills.
|
||||
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
||||
for (const id of ['divyard', 'clsyard', 'divtot', 'clstot']) {
|
||||
assert.ok(html.includes(`id="${id}"`), `play.html has no #${id}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user