adding classification and division yard updates, train collisions, engine and car updates, turnout improvements.
This commit is contained in:
+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