adding classification and division yard updates, train collisions, engine and car updates, turnout improvements.

This commit is contained in:
Jesse
2026-08-04 21:03:17 -04:00
parent 4bba991f41
commit 821d3cdffa
26 changed files with 6876 additions and 2638 deletions
+165 -2
View File
@@ -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}`);
}
});
});