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

This commit is contained in:
Jesse
2026-08-14 22:39:50 -04:00
parent d1314066bd
commit 6655e20ea8
29 changed files with 5450 additions and 3902 deletions
+186 -4
View File
@@ -11,6 +11,10 @@ import { turnOf } from '../src/engine/state.ts';
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
// The REAL one. An earlier test in this file replaces `globalThis.URLSearchParams` with a two-line
// stub, and the dialog needs `set` as well as `get` — reading the global here would hand the page
// whichever stub happened to run last.
import { URLSearchParams as NodeURLSearchParams } from 'node:url';
import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts';
import { variantsFor } from '../src/engine/track.ts';
@@ -1324,8 +1328,23 @@ describe('the static build', () => {
// it, and a stub without one throws on load — which is a page that never starts, not a
// cosmetic gap.
const ownClasses = new Set<string>();
// The New Game dialog is a native <dialog>: the page listens for `close` on it at load, and
// opens it with `showModal`. Without these the page throws before it draws anything — which
// is a page that never starts, exactly what this stub exists to catch.
const listeners = new Map<string, ((e?: unknown) => void)[]>();
const node: Record<string, unknown> = {
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
title: '', returnValue: '', open: false,
addEventListener: (type: string, fn: (e?: unknown) => void) =>
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
showModal() {
(node as { open: boolean }).open = true;
},
close(value?: string) {
(node as { open: boolean; returnValue: string }).open = false;
if (value !== undefined) (node as { returnValue: string }).returnValue = value;
for (const fn of listeners.get('close') ?? []) fn();
},
classList: {
add: (c: string) => void ownClasses.add(c),
remove: (c: string) => void ownClasses.delete(c),
@@ -1740,10 +1759,11 @@ describe('the static build', () => {
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
}
// Raised 8 -> 10: the widest group is Switching, and the opening deal grew districts from 17.9
// to 20.3 cards, so a crew simply has more squares it can legally reach. That is the list getting
// longer for a good reason rather than the cross-products this test was written to kill.
assert.ok(worst <= 10, `the action list still reaches ${worst} buttons`);
// Raised 8 -> 10 -> 13. The widest group is Switching every time, which is a crew's reachable
// squares — the list getting longer for a good reason rather than the cross-products this test
// was written to kill. Re-measured over five seeds (430, 99, 1234, 880009, 202) in all three
// opening deals: 13 under `threeRandom` and `sixRandom`, 10 under `threeTrackThreeOther`.
assert.ok(worst <= 13, `the action list still reaches ${worst} buttons`);
});
it('makes up ONE train at a time, and names that train', () => {
@@ -2494,3 +2514,165 @@ describe('the tray is an engine plus its Rolling Stock', () => {
}
});
});
// ---------------------------------------------------------------------------
describe('the New Game dialog', () => {
/**
* DRIVEN THROUGH THE EMITTED BUNDLE, like the highlight test above, because the thing that can go
* wrong here is wiring rather than logic: an id that does not match the HTML, a handler on the
* wrong element, or a navigation that assigns the search string the page already has and so does
* nothing at all. None of that is visible to a test of `rulesFromUrl` in isolation.
*
* A seed alone stopped naming a game the moment the opening hand and the revenue rates became
* settings, so what this really pins is that all four ride in the URL and come back out.
*/
const load = async (search: string) => {
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' });
const served = new Set(
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!),
);
const els = new Map<string, Record<string, unknown>>();
/** Enough of an element for the page to start: the dialog's own API, and a settable `value`. */
const make = (id: string): Record<string, unknown> => {
const listeners = new Map<string, (() => void)[]>();
const radios = ['threeRandom', 'sixRandom', 'threeTrackThreeOther'].map((value) => ({
value,
checked: false,
}));
let html = '';
const node: Record<string, unknown> = {
id, value: '', textContent: '', title: '', returnValue: '', open: false,
style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
radios,
addEventListener: (type: string, fn: () => void) =>
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
showModal: () => void ((node as { open: boolean }).open = true),
close: () => {
(node as { open: boolean }).open = false;
for (const fn of listeners.get('close') ?? []) fn();
},
// Only the radio-group selectors the dialog actually uses; anything else is not this
// element's business and answering it with a guess would hide a typo in the real selector.
querySelectorAll: (sel: string) => (sel === 'input[name="ng-hand"]' ? radios : []),
querySelector: (sel: string) =>
sel === 'input[name="ng-hand"]:checked' ? (radios.find((r) => r.checked) ?? null) : null,
};
Object.defineProperty(node, 'innerHTML', { get: () => html, set: (v: string) => void (html = v) });
return node;
};
const g = globalThis as Record<string, unknown>;
g['document'] = {
getElementById: (id: string) => {
if (!served.has(id)) return null;
if (!els.has(id)) els.set(id, make(id));
return els.get(id);
},
createElement: () => make('style'),
addEventListener: () => {},
body: { appendChild: () => {} },
head: { appendChild: () => {} },
};
const nav: { search: string; reloads: number } = { search, reloads: 0 };
g['location'] = {
get search() { return nav.search; },
set search(v: string) { nav.search = v; },
reload: () => void (nav.reloads += 1),
};
const store = new Map<string, string>();
g['localStorage'] = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
};
g['URLSearchParams'] = NodeURLSearchParams;
g['confirm'] = () => true;
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}-${Math.random()}`);
return { els, nav };
};
it('opens on the rules in play, so a second game can be dealt to compare with the first', async () => {
// Re-entering four settings for every comparison game is how a comparison silently stops
// comparing. The seed is the one field that clears: the same seed twice is not a second sample.
const { els } = await load('?seed=430&hand=sixRandom&passenger=2&freight=3&transit=4');
(els.get('newgame')!['onclick'] as () => void)();
const dlg = els.get('newgamedlg')!;
assert.equal(dlg['open'], true, 'the New game button did not open the dialog');
assert.equal(els.get('ng-seed')!['value'], '', 'the seed box kept the last game’s seed');
assert.equal(els.get('ng-passenger')!['value'], '2');
assert.equal(els.get('ng-freight')!['value'], '3');
assert.equal(els.get('ng-transit')!['value'], '4');
const checked = (dlg['radios'] as { value: string; checked: boolean }[]).filter((r) => r.checked);
assert.deepEqual(checked.map((r) => r.value), ['sixRandom'], 'the opening hand in play was not preselected');
});
it('puts the seed and all three settings into the URL when it deals', async () => {
const { els, nav } = await load('?seed=430');
(els.get('newgame')!['onclick'] as () => void)();
const dlg = els.get('newgamedlg')!;
els.get('ng-seed')!['value'] = '99';
for (const r of dlg['radios'] as { value: string; checked: boolean }[]) r.checked = r.value === 'threeTrackThreeOther';
els.get('ng-passenger')!['value'] = '5';
els.get('ng-freight')!['value'] = '0';
els.get('ng-transit')!['value'] = '2';
dlg['returnValue'] = 'deal';
(dlg['close'] as () => void)();
assert.equal(nav.search, '?seed=99&hand=threeTrackThreeOther&passenger=5&freight=0&transit=2');
});
it('deals nothing on cancel, and nothing on Esc', async () => {
// Esc closes a <dialog> with an empty returnValue and fires no submit at all, so "not deal" has
// to be the test rather than "cancel" — the two arrive identically.
for (const returnValue of ['cancel', '']) {
const { els, nav } = await load('?seed=430');
(els.get('newgame')!['onclick'] as () => void)();
const dlg = els.get('newgamedlg')!;
dlg['returnValue'] = returnValue;
(dlg['close'] as () => void)();
assert.equal(nav.search, '?seed=430', `closing with "${returnValue}" navigated`);
assert.equal(nav.reloads, 0, `closing with "${returnValue}" reloaded`);
}
});
it('reloads when the answers are the URL the page already has, so a re-deal is not a no-op', async () => {
// Dealing a random seed, disliking it and dealing again at the same settings produces the same
// search string — and assigning `location.search` the value it already holds does nothing.
const url = '?hand=threeRandom&passenger=1&freight=1&transit=0';
const { els, nav } = await load(url);
(els.get('newgame')!['onclick'] as () => void)();
const dlg = els.get('newgamedlg')!;
dlg['returnValue'] = 'deal';
(dlg['close'] as () => void)();
assert.equal(nav.search, url, 'the URL should be unchanged — that is the whole case');
assert.equal(nav.reloads, 1, 'a re-deal at the same settings did nothing at all');
});
it('deals the game the URL describes, and says so in the header', async () => {
// The other half of the round trip: the dialog wrote those parameters, and this is the page
// reading them back. Without this the two halves can drift and each still pass its own test.
const { els } = await load('?seed=430&hand=sixRandom&passenger=4&freight=2&transit=1');
assert.match(String(els.get('houserules')!['textContent']), /6 cards.*4\/2\/1/);
assert.match(String(els.get('houserules')!['title']), /six random cards/i);
});
it('ignores a seed the browser cannot parse rather than refusing to deal', async () => {
// Blank and unparseable both plainly mean "surprise me"; an error dialog over a typo in an
// optional box is not worth writing.
const { els, nav } = await load('?seed=430');
(els.get('newgame')!['onclick'] as () => void)();
const dlg = els.get('newgamedlg')!;
els.get('ng-seed')!['value'] = 'not a number';
dlg['returnValue'] = 'deal';
(dlg['close'] as () => void)();
assert.equal(nav.search, '?hand=threeRandom&passenger=1&freight=1&transit=0', 'a bad seed was carried into the URL');
});
});