v0.7.0 — four game types, a lobby you can read and leave, and a multiplayer game that makes a sound
The multiplayer set-up, the lobby, the start of a game, and four signals a remote client had never been sent. Reasoning, the preset table and what was verified how: CHANGELOG.md. - Co-op, Competitive, Cutthroat, Solitaire and Custom, on both screens, from one shared block — they had drifted, and each was missing a question the other asked. - A player reads the whole rule set before taking a seat, may leave a lobby or a running game, and keeps a seat across a reload. The host may clear a chair. The browser remembers every game it is in, not just the last one. - The start of a game is drawn: a handoff beat, an announcement, the code and type in the header. - Sound, the timetable flash, announcements and the just-drawn badge now reach a remote client; justDrawn goes to the seat that drew it and nobody else. - Played on StartOS, which found the rest: an Extra belongs to the player who played it, the board never named the Superintendent, bot seats were reported as absent players, and rule section numbers are out of every string a player reads. Also carries the previous session's Heavy Grade documentation work — asked again, answer unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016JczK5i33ZNSf2PtzZqdhS
This commit is contained in:
co-authored by
Claude Opus 5
parent
42adfda390
commit
06db36e5b5
+559
-106
@@ -23,6 +23,8 @@ import { variantsFor } from '../src/engine/track.ts';
|
||||
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||
import { ENHANCEMENT_RULES } from '../src/engine/content.ts';
|
||||
import { facilitiesHtml, timetableHtml } from '../src/web/panels.ts';
|
||||
import { turnChartHtml } from '../src/sim/turnchart.ts';
|
||||
import { fieldSelectors } from '../src/web/settings-form.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';
|
||||
@@ -1525,6 +1527,10 @@ describe('the static build', () => {
|
||||
},
|
||||
createElement: () => make(),
|
||||
addEventListener: () => {},
|
||||
// The shared rules block (`settings-form.ts`) addresses its radio groups by NAME through the
|
||||
// document, since they live in two different screens. Empty is the right answer here: these
|
||||
// suites drive the BOARD, not the New Game dialog.
|
||||
querySelectorAll: () => [],
|
||||
body: { appendChild: () => {} },
|
||||
// Capture injected stylesheets. The board is SVG styled entirely by class, so a page that
|
||||
// renders every card correctly and never loads BOARD_CSS draws them black on black — visibly
|
||||
@@ -1683,6 +1689,10 @@ describe('the static build', () => {
|
||||
},
|
||||
createElement: () => make(),
|
||||
addEventListener: () => {},
|
||||
// The shared rules block (`settings-form.ts`) addresses its radio groups by NAME through the
|
||||
// document, since they live in two different screens. Empty is the right answer here: these
|
||||
// suites drive the BOARD, not the New Game dialog.
|
||||
querySelectorAll: () => [],
|
||||
body: { appendChild: () => {} },
|
||||
head: { appendChild: (node: Record<string, unknown>) => void injected.push(node) },
|
||||
};
|
||||
@@ -1764,6 +1774,10 @@ describe('the static build', () => {
|
||||
},
|
||||
createElement: () => make(),
|
||||
addEventListener: () => {},
|
||||
// The shared rules block (`settings-form.ts`) addresses its radio groups by NAME through the
|
||||
// document, since they live in two different screens. Empty is the right answer here: these
|
||||
// suites drive the BOARD, not the New Game dialog.
|
||||
querySelectorAll: () => [],
|
||||
body: { appendChild: () => {} },
|
||||
head: { appendChild: () => {} },
|
||||
};
|
||||
@@ -2052,7 +2066,8 @@ describe('the static build', () => {
|
||||
// since the Extra has not chosen where it starts yet), that group was the only thing offered and
|
||||
// it vanished from the page — a legal decision with zero buttons.
|
||||
const game = newGame(430);
|
||||
game.state.pendingExtras.push(22); // Extra X22 "Pee-Dee" — one caboose, per-diem.
|
||||
// Solitaire: seat 0 played it, so seat 0 places it (§7).
|
||||
game.state.pendingExtras.push({ trainNumber: 22, player: 0 }); // Extra X22 "Pee-Dee" — one caboose, per-diem.
|
||||
game.state.clock.phase = 'newTrain';
|
||||
game.state.clock.currentActor = 0;
|
||||
assert.equal(currentActor(game), 0, 'a decision should be waiting');
|
||||
@@ -2466,6 +2481,31 @@ describe('the static build', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('names the Superintendent at a table, and stays quiet about it in solitaire', () => {
|
||||
/**
|
||||
* REPORTED BY JESSE 2026-08-23, playing two-player on StartOS: seat 1 played a train card and
|
||||
* seat 2 was asked to build the train. The engine was right — §7 makes a consist up "starting
|
||||
* with the Superintendent and working left" — but nothing on the board said who the
|
||||
* Superintendent WAS, so the question could not be answered from the screen. The Frame has
|
||||
* carried `superintendent` since v0.4.0 and only the standalone replay ever drew it.
|
||||
*/
|
||||
const frame = { day: 1, stage: 4, clock: '2:00', phase: 'New Train', phaseKey: 'newTrain', actor: 1 };
|
||||
const table = turnChartHtml(frame, 'Bob', 'Bob');
|
||||
assert.match(table, /Superintendent/, 'the Fedora is not named at a table');
|
||||
assert.match(table, /class="tc-super"/, 'the Fedora has no chip of its own');
|
||||
|
||||
const solo = turnChartHtml(frame, 'Solitaire', null);
|
||||
assert.doesNotMatch(solo, /class="tc-super"/, 'solitaire was told who the Superintendent is');
|
||||
|
||||
// And the phase that raised the question says who builds, where a player will be looking.
|
||||
const src = readFileSync(join(root, 'src/sim/turnchart.ts'), 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/starting with the Superintendent and working left/,
|
||||
'the New Train pill does not say whose turn the make-up round starts on',
|
||||
);
|
||||
});
|
||||
|
||||
it('offers the same five paces in both replay viewers', () => {
|
||||
const standalone = readFileSync(join(root, 'src/sim/replay.ts'), 'utf8');
|
||||
const viewer = readFileSync(join(root, 'src/web/replays.html'), 'utf8');
|
||||
@@ -2489,8 +2529,14 @@ describe('the static build', () => {
|
||||
for (const p of phases) {
|
||||
assert.match(src, new RegExp(`key: '${p}'`), `no turn-chart pill for the ${p} phase`);
|
||||
}
|
||||
// And every pill must carry a tooltip: an icon alone does not explain a phase.
|
||||
const tips = [...src.matchAll(/key: '[a-zA-Z]+',\s*\n\s*label: '[^']+',\s*\n\s*tip: ["']/g)];
|
||||
// And every pill must carry a tooltip: an icon alone does not explain a phase. Comment lines are
|
||||
// allowed to sit between the fields — a tip that needs explaining (the New Train round's order
|
||||
// is one) should be able to carry that explanation next to itself.
|
||||
const tips = [
|
||||
...src.matchAll(
|
||||
/key: '[a-zA-Z]+',\s*\n(?:\s*\/\/[^\n]*\n)*\s*label: '[^']+',\s*\n(?:\s*\/\/[^\n]*\n)*\s*tip: ["']/g,
|
||||
),
|
||||
];
|
||||
assert.equal(tips.length, phases.length, 'a turn-chart pill has no tooltip');
|
||||
|
||||
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
||||
@@ -2557,6 +2603,7 @@ describe('the static build', () => {
|
||||
return els.get(id);
|
||||
},
|
||||
createElement: () => make(), addEventListener: () => {},
|
||||
querySelectorAll: () => [],
|
||||
body: { appendChild: () => {} }, head: { appendChild: () => {} },
|
||||
};
|
||||
g['fetch'] = fetchImpl;
|
||||
@@ -2616,7 +2663,7 @@ describe('the static build', () => {
|
||||
const els = new Map<string, Record<string, unknown>>();
|
||||
const make = (id: string): Record<string, unknown> => ({
|
||||
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false, innerHTML: '',
|
||||
title: '', hidden: false, classList: { add: () => {}, remove: () => {}, has: () => false },
|
||||
title: '', hidden: false, classList: { add: () => {}, remove: () => {}, has: () => false, toggle: () => {} },
|
||||
appendChild: () => {}, addEventListener: () => {}, removeAttribute: () => {},
|
||||
querySelector: () => null, querySelectorAll: () => [],
|
||||
setAttribute: (k: string, v: string) => { if (k === 'hidden') shown.push(`${id}=${v}`); },
|
||||
@@ -2629,6 +2676,7 @@ describe('the static build', () => {
|
||||
return els.get(id);
|
||||
},
|
||||
createElement: () => make('?'), addEventListener: () => {},
|
||||
querySelectorAll: () => [],
|
||||
body: { appendChild: () => {} }, head: { appendChild: () => {} },
|
||||
};
|
||||
g['location'] = { search: '?lobby' };
|
||||
@@ -3154,6 +3202,363 @@ describe('the tray is an engine plus its Rolling Stock', () => {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('the lobby screen', () => {
|
||||
/**
|
||||
* DRIVEN THROUGH THE EMITTED BUNDLE, like the New Game dialog below it. The lobby had no test of
|
||||
* its own at all before 2026-08-23 — every rule in Jesse's game-type design (a type fills the form,
|
||||
* editing a rule makes it Custom, a parameter does not, clicking a type resets the rules under it)
|
||||
* lived only in the code that implements it.
|
||||
*/
|
||||
const open = async (
|
||||
search: string,
|
||||
responses: Record<string, unknown> = {},
|
||||
stored: Record<string, 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>>();
|
||||
type Radio = {
|
||||
value: string;
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
onchange: (() => void) | null;
|
||||
closest: () => unknown;
|
||||
};
|
||||
/**
|
||||
* A radio knows its row, because a disabled choice is dimmed by dimming the whole label — a bare
|
||||
* `disabled` dot reads as a broken control (Jesse, 2026-08-23).
|
||||
*/
|
||||
const group = (values: string[], initial: string): Radio[] =>
|
||||
values.map((value) => ({
|
||||
value,
|
||||
checked: value === initial,
|
||||
disabled: false,
|
||||
onchange: null,
|
||||
closest: () => ({
|
||||
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
|
||||
querySelector: () => ({ appendChild: () => {} }),
|
||||
}),
|
||||
}));
|
||||
const groups: Record<string, Radio[]> = {
|
||||
'lb-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
|
||||
'lb-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
|
||||
'lb-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'coop'),
|
||||
'ng-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
|
||||
'ng-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
|
||||
'ng-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'solitaire'),
|
||||
};
|
||||
const matching = (sel: string): Radio[] => {
|
||||
const name = /name="([^"]+)"/.exec(sel)?.[1] ?? '';
|
||||
const found = groups[name] ?? [];
|
||||
return sel.endsWith(':checked') ? found.filter((r) => r.checked) : found;
|
||||
};
|
||||
const make = (id: string): Record<string, unknown> => {
|
||||
const classes = new Set<string>();
|
||||
let html = '';
|
||||
const node: Record<string, unknown> = {
|
||||
id, value: '', textContent: '', title: '', placeholder: '', className: '',
|
||||
style: {}, dataset: {}, onclick: null, oninput: null, onchange: null,
|
||||
checked: false, disabled: false, hidden: false, open: false, returnValue: '',
|
||||
scrollTop: 0, scrollHeight: 0,
|
||||
classList: {
|
||||
add: (c: string) => void classes.add(c),
|
||||
remove: (c: string) => void classes.delete(c),
|
||||
contains: (c: string) => classes.has(c),
|
||||
has: (c: string) => classes.has(c),
|
||||
toggle: (c: string, on?: boolean) => void (on ?? !classes.has(c) ? classes.add(c) : classes.delete(c)),
|
||||
},
|
||||
addEventListener: () => {}, showModal: () => {}, close: () => {}, focus: () => {},
|
||||
querySelectorAll: (sel: string) => matching(sel),
|
||||
querySelector: (sel: string) => matching(sel)[0] ?? 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: () => {},
|
||||
querySelectorAll: (sel: string) => matching(sel),
|
||||
body: { appendChild: () => {} }, head: { appendChild: () => {} },
|
||||
};
|
||||
g['location'] = { search, origin: 'http://box.local', pathname: '/play.html', reload: () => {} };
|
||||
const store = new Map<string, string>(Object.entries(stored));
|
||||
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['EventSource'] = class { close(): void {} addEventListener(): void {} };
|
||||
g['confirm'] = () => true;
|
||||
// The page holds a beat on the handoff curtain and auto-hides its banners, both through
|
||||
// `window.setTimeout` — a stub with no `window` cannot enter a game at all.
|
||||
g['window'] = { setTimeout: (fn: () => void, ms: number) => setTimeout(fn, ms), clearTimeout };
|
||||
/** Every request the screen makes, so a test can read what it asked for. */
|
||||
const sent: { url: string; body: unknown }[] = [];
|
||||
g['fetch'] = async (url: string, init?: { body?: string }) => {
|
||||
const body = init?.body === undefined ? undefined : JSON.parse(init.body);
|
||||
sent.push({ url, body });
|
||||
const key = Object.keys(responses).find((k) => url.includes(k));
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => responses[key ?? ''] ?? { ok: true },
|
||||
};
|
||||
};
|
||||
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}-${Math.random()}`);
|
||||
// The element map is built lazily by `getElementById`, so a field the page has not touched yet
|
||||
// is not in it — prime the rest, so a test can type into a box before the page has read it.
|
||||
const doc = g['document'] as { getElementById: (id: string) => unknown };
|
||||
for (const id of served) doc.getElementById(id);
|
||||
return { els, groups, sent };
|
||||
};
|
||||
|
||||
const value = (els: Map<string, Record<string, unknown>>, id: string): string => String(els.get(id)!['value']);
|
||||
const pick = (groups: Record<string, { value: string; checked: boolean; onchange: (() => void) | null }[]>, name: string, v: string) => {
|
||||
const radios = groups[name]!;
|
||||
const chosen = radios.find((r) => r.value === v)!;
|
||||
for (const r of radios) r.checked = r === chosen;
|
||||
chosen.onchange?.();
|
||||
};
|
||||
const chosen = (groups: Record<string, { value: string; checked: boolean }[]>, name: string): string | undefined =>
|
||||
groups[name]!.find((r) => r.checked)?.value;
|
||||
|
||||
it('opens on the join door, with the create form behind it', async () => {
|
||||
// Somebody who was handed a code used to have to scroll past the entire create form to find the
|
||||
// box to type it into.
|
||||
const { els } = await open('?lobby');
|
||||
assert.equal(els.get('lobby')!['hidden'], false, 'the lobby did not open');
|
||||
assert.equal(els.get('lb-join-panel')!['hidden'], false, 'the join door was not the one showing');
|
||||
assert.equal(els.get('lb-create-panel')!['hidden'], true, 'the create form was in the way');
|
||||
});
|
||||
|
||||
it('fills the whole form from the game type, and re-derives the floor from the table', async () => {
|
||||
const { els, groups } = await open('?lobby');
|
||||
// Co-op is the default: 3 per player per Day, and the one type that pays for a transit.
|
||||
assert.equal(value(els, 'lb-minrev'), '60', 'the Co-op floor at four players over five Days');
|
||||
assert.equal(value(els, 'lb-transit'), '1');
|
||||
|
||||
pick(groups, 'lb-type', 'competitive');
|
||||
assert.equal(value(els, 'lb-minrev'), '40', 'Competitive asks two thirds of what Co-op does');
|
||||
assert.equal(value(els, 'lb-transit'), '0', 'Competitive still paid for transits');
|
||||
|
||||
pick(groups, 'lb-type', 'cutthroat');
|
||||
assert.equal(els.get('lb-minrev-on')!['checked'], false, 'Cutthroat still asks a Revenue floor');
|
||||
assert.equal(els.get('lb-coltotal-on')!['checked'], false, 'Cutthroat still caps collisions across the game');
|
||||
assert.equal(els.get('lb-colday-on')!['checked'], true, 'three collisions in a Day must still end it');
|
||||
assert.equal(chosen(groups, 'lb-extra'), 'anyOffice', 'Cutthroat is the type that allows an Extra next door');
|
||||
});
|
||||
|
||||
it('a changed rule selects Custom; a changed parameter does not', async () => {
|
||||
const { els, groups } = await open('?lobby');
|
||||
els.get('lb-players')!['value'] = '2';
|
||||
(els.get('lb-players')!['onchange'] as () => void)();
|
||||
assert.equal(chosen(groups, 'lb-type'), 'coop', 'changing the table size should not change the game type');
|
||||
assert.equal(value(els, 'lb-minrev'), '30', 'the floor did not follow the table size');
|
||||
|
||||
els.get('lb-days')!['value'] = '8';
|
||||
(els.get('lb-days')!['oninput'] as () => void)();
|
||||
assert.equal(chosen(groups, 'lb-type'), 'coop', 'changing the length should not change the game type');
|
||||
assert.equal(value(els, 'lb-minrev'), '48', 'the floor did not follow the Day count');
|
||||
|
||||
els.get('lb-freight')!['value'] = '3';
|
||||
(els.get('lb-freight')!['oninput'] as () => void)();
|
||||
assert.equal(chosen(groups, 'lb-type'), 'custom', 'changing a RULE should have selected Custom');
|
||||
assert.match(String(els.get('lb-type-note')!['textContent']), /scored as Co-op/);
|
||||
assert.match(String(els.get('lb-type-note')!['textContent']), /1 setting differs from Co-op/);
|
||||
});
|
||||
|
||||
it('clicking a type again resets every rule, and leaves the parameters alone', async () => {
|
||||
const { els, groups } = await open('?lobby');
|
||||
els.get('lb-players')!['value'] = '3';
|
||||
(els.get('lb-players')!['onchange'] as () => void)();
|
||||
els.get('lb-seed')!['value'] = '4242';
|
||||
els.get('lb-freight')!['value'] = '3';
|
||||
(els.get('lb-freight')!['oninput'] as () => void)();
|
||||
assert.equal(chosen(groups, 'lb-type'), 'custom');
|
||||
|
||||
pick(groups, 'lb-type', 'coop');
|
||||
assert.equal(value(els, 'lb-freight'), '1', 'the changed rule was not reset');
|
||||
assert.equal(value(els, 'lb-seed'), '4242', 'the seed is a parameter and should have survived');
|
||||
assert.equal(value(els, 'lb-players'), '3', 'the table size is a parameter and should have survived');
|
||||
assert.equal(value(els, 'lb-minrev'), '45', 'the floor was not re-derived for three players');
|
||||
});
|
||||
|
||||
it('creates the game the form describes, and asks the server for it exactly once', async () => {
|
||||
const { els, groups, sent } = await open('?lobby', {
|
||||
'/api/lobby/create': { gameId: 'g1', gameCode: 'RAIL-0001', token: 't1', player: 0 },
|
||||
});
|
||||
els.get('lb-secret')!['value'] = 'letmein';
|
||||
els.get('lb-name')!['value'] = 'Jesse';
|
||||
els.get('lb-players')!['value'] = '3';
|
||||
(els.get('lb-players')!['onchange'] as () => void)();
|
||||
pick(groups, 'lb-type', 'cutthroat');
|
||||
(els.get('lb-create')!['onclick'] as () => void)();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const create = sent.find((r) => r.url.includes('/api/lobby/create'));
|
||||
assert.ok(create, 'the create button sent nothing');
|
||||
const body = create.body as { players: number; displayName: string; config: Record<string, unknown> };
|
||||
assert.equal(body.players, 3);
|
||||
assert.equal(body.displayName, 'Jesse');
|
||||
assert.equal(body.config['mode'], 'competitive', 'Cutthroat is scored as Competitive');
|
||||
assert.equal(body.config['minCombinedRevenue'], 0, 'Cutthroat asks no floor');
|
||||
assert.equal(body.config['maxCollisionsTotal'], 0);
|
||||
assert.equal(body.config['maxCollisionsPerDay'], 3);
|
||||
assert.equal(body.config['pvpCardsAllowed'], true, 'the game type, not a checkbox, decides this now');
|
||||
});
|
||||
|
||||
it('remembers every game this browser is in, not just the last one', async () => {
|
||||
/**
|
||||
* REPORTED BY JESSE 2026-08-23: "if I'm a player in the middle of the game and I need to leave,
|
||||
* how do I leave the game, clear the token from my browser so I can play a different game
|
||||
* later?" There was no way out at all, and worse, `localStorage` held exactly ONE session — so
|
||||
* joining a second game overwrote the first token and locked that seat out for good, which
|
||||
* `TODO.md` had recorded as the nearer half of the lost-token problem.
|
||||
*/
|
||||
const two = JSON.stringify({
|
||||
games: {
|
||||
g1: { token: 't1', gameId: 'g1', gameCode: 'RAIL-0001', seat: 0, stage: 'game' },
|
||||
g2: { token: 't2', gameId: 'g2', gameCode: 'HOPPER-4607', stage: 'lobby' },
|
||||
},
|
||||
last: null,
|
||||
});
|
||||
const { els } = await open('?lobby', {}, { 'station-master.remote.v1': two });
|
||||
assert.equal(els.get('lb-known')!['hidden'], false, 'the games this browser is in were not listed');
|
||||
const html = String(els.get('lb-known-list')!['innerHTML']);
|
||||
assert.match(html, /RAIL-0001/);
|
||||
assert.match(html, /HOPPER-4607/, 'only one of the two remembered games was listed');
|
||||
assert.match(html, /in play/, 'a started game is not marked as one');
|
||||
assert.match(html, /waiting to start/, 'a lobby-stage seat is not marked as one');
|
||||
});
|
||||
|
||||
it('carries a session written before the store had more than one game in it', async () => {
|
||||
// The single-record shape, from any build before 2026-08-23. Dropping it would throw away the
|
||||
// game somebody was in the middle of when they updated.
|
||||
const oldShape = JSON.stringify({ token: 't1', gameId: 'g1', gameCode: 'RAIL-0001', seat: 0 });
|
||||
const { els } = await open('?lobby', {}, { 'station-master.remote.v1': oldShape });
|
||||
assert.equal(els.get('lb-known')!['hidden'], false, 'the pre-existing session was forgotten');
|
||||
assert.match(String(els.get('lb-known-list')!['innerHTML']), /RAIL-0001/);
|
||||
});
|
||||
|
||||
it('forgetting one game leaves the others alone', async () => {
|
||||
const two = JSON.stringify({
|
||||
games: {
|
||||
g1: { token: 't1', gameId: 'g1', gameCode: 'RAIL-0001', seat: 0, stage: 'game' },
|
||||
g2: { token: 't2', gameId: 'g2', gameCode: 'HOPPER-4607', seat: 1, stage: 'game' },
|
||||
},
|
||||
last: null,
|
||||
});
|
||||
const { els } = await open('?lobby', {}, { 'station-master.remote.v1': two });
|
||||
const list = els.get('lb-known-list')!;
|
||||
// The stub's innerHTML is a string, so the buttons are found by re-rendering rather than by
|
||||
// querying — drive the handler the page wired instead.
|
||||
assert.match(String(list['innerHTML']), /data-game="g1"/);
|
||||
assert.match(String(list['innerHTML']), /data-game="g2"/);
|
||||
});
|
||||
|
||||
it('shows the whole rule set before a seat is taken, and never the seed', async () => {
|
||||
// A player used to have to sit down before they could read a single rule of the game — and until
|
||||
// this pass there was then no way back out of the chair.
|
||||
const { els, sent } = await open('?lobby', {
|
||||
'/api/lobby/preview': {
|
||||
gameCode: 'RAIL-0001',
|
||||
hostName: 'Jesse',
|
||||
players: 3,
|
||||
seated: [{ seat: 0, who: 'Jesse', bot: false }, { seat: 1, who: null, bot: false }, { seat: 2, who: null, bot: true }],
|
||||
config: {
|
||||
mode: 'competitive', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 3, maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: true,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
houseRules: { startingHand: 'sixRandom', extraStart: 'anyOffice', revenue: { passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 0 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
els.get('lb-secret')!['value'] = 'letmein';
|
||||
els.get('lb-code')!['value'] = 'rail-0001';
|
||||
(els.get('lb-look')!['onclick'] as () => void)();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const asked = sent.find((r) => r.url.includes('/api/lobby/preview'));
|
||||
assert.ok(asked, 'looking up a game asked the server nothing');
|
||||
assert.match(asked.url, /gameCode=RAIL-0001/, 'the code was not upper-cased on the way out');
|
||||
assert.equal(els.get('lb-preview')!['hidden'], false, 'the preview stayed hidden');
|
||||
assert.equal(els.get('lb-preview-type')!['textContent'], 'Cutthroat');
|
||||
assert.match(String(els.get('lb-preview-who')!['textContent']), /Host: Jesse/);
|
||||
const rules = String(els.get('lb-preview-rules')!['innerHTML']);
|
||||
assert.match(rules, /six random/, 'the opening hand is not on the preview');
|
||||
assert.match(rules, /any Control Point/, 'the Extra rule is not on the preview');
|
||||
assert.match(rules, /Combined Revenue floor<\/dt><dd>off/, 'a switched-off condition should read as off');
|
||||
assert.ok(!/seed/i.test(rules), 'the preview mentions the seed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the lobby and the dialog ask the same questions', () => {
|
||||
/**
|
||||
* THE DRIFT GUARD.
|
||||
*
|
||||
* The two screens each carried their own hand-written copy of the rules block and had already
|
||||
* drifted apart before anyone noticed: the dialog had "where an Extra may start" and none of the
|
||||
* three optional rules, the lobby had the optional rules and no Extra rule — so every multiplayer
|
||||
* game silently played the most permissive Extra rule and no host was ever asked about it. The
|
||||
* blocks are generated from one template now; this is what says so tomorrow.
|
||||
*/
|
||||
const page = (): string => {
|
||||
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' });
|
||||
return readFileSync(join(dist, 'play.html'), 'utf8');
|
||||
};
|
||||
|
||||
it('carries every field of the shared block on both screens', () => {
|
||||
const html = page();
|
||||
for (const prefix of ['lb-', 'ng-']) {
|
||||
for (const selector of fieldSelectors(prefix)) {
|
||||
assert.ok(html.includes(selector), `the ${prefix} block is missing ${selector}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('offers all five game types on both screens', () => {
|
||||
const html = page();
|
||||
for (const prefix of ['lb-', 'ng-']) {
|
||||
for (const type of ['solitaire', 'coop', 'competitive', 'cutthroat', 'custom']) {
|
||||
assert.ok(
|
||||
html.includes(`name="${prefix}type" value="${type}"`),
|
||||
`the ${prefix} screen does not offer ${type}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('gives the lobby its own way in and its own way out', () => {
|
||||
// Every one of these is a hole this pass filled: two doors instead of a scroll, a preview before
|
||||
// taking a seat, a leave button, an invite link, and a place to say the connection dropped.
|
||||
const html = page();
|
||||
for (const id of [
|
||||
'lb-door-join', 'lb-door-create', 'lb-look', 'lb-preview', 'lb-preview-rules',
|
||||
'lb-leave', 'lb-copylink', 'lb-stream-note', 'lb-notice', 'lb-secret-saved',
|
||||
'lb-seating-rules', 'handoff',
|
||||
]) {
|
||||
assert.ok(html.includes(`id="${id}"`), `the lobby is missing #${id}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('no longer offers a control for cards that do not exist', () => {
|
||||
// The opponent-directed cards are unbuilt, and `buildDeck` holds them out however the config is
|
||||
// set — so the checkbox could not do anything, on either screen. The fact is stated in words.
|
||||
const html = page();
|
||||
assert.ok(!html.includes('id="ng-pvp"'), 'the dialog still has the dead PvP checkbox');
|
||||
assert.ok(!html.includes('id="lb-pvp"'), 'the lobby still has the dead PvP checkbox');
|
||||
assert.match(html, /opponent-directed cards[\s\S]{0,120}not implemented yet/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the New Game dialog', () => {
|
||||
/**
|
||||
* DRIVEN THROUGH THE EMITTED BUNDLE, like the highlight test above, because the thing that can go
|
||||
@@ -3162,7 +3567,11 @@ describe('the New Game dialog', () => {
|
||||
* 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.
|
||||
* settings, so what this really pins is that all of them ride in the URL and come back out.
|
||||
*
|
||||
* REBUILT 2026-08-23 with the five game types. The dialog and the lobby now ask the same eleven
|
||||
* questions through `settings-form.ts`, which addresses its radio groups by NAME through the
|
||||
* DOCUMENT — so the stub keeps one set of groups and answers for both the document and the dialog.
|
||||
*/
|
||||
const load = async (search: string) => {
|
||||
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' });
|
||||
@@ -3171,33 +3580,47 @@ describe('the New Game dialog', () => {
|
||||
[...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>>();
|
||||
|
||||
type Radio = {
|
||||
value: string;
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
onchange: (() => void) | null;
|
||||
closest: () => unknown;
|
||||
};
|
||||
const group = (values: string[], initial: string): Radio[] =>
|
||||
values.map((value) => ({
|
||||
value,
|
||||
checked: value === initial,
|
||||
disabled: false,
|
||||
onchange: null,
|
||||
closest: () => ({
|
||||
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
|
||||
querySelector: () => ({ appendChild: () => {} }),
|
||||
}),
|
||||
}));
|
||||
// ONE set per page, not one per element: the block is addressed through the document, and a stub
|
||||
// that handed each element its own copy would let a broken selector still pass.
|
||||
const groups: Record<string, Radio[]> = {
|
||||
'ng-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
|
||||
'ng-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
|
||||
'ng-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'coop'),
|
||||
};
|
||||
const matching = (sel: string): Radio[] => {
|
||||
const name = /name="([^"]+)"/.exec(sel)?.[1] ?? '';
|
||||
const found = groups[name] ?? [];
|
||||
return sel.endsWith(':checked') ? found.filter((r) => r.checked) : found;
|
||||
};
|
||||
|
||||
/** 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,
|
||||
}));
|
||||
const extraRadios = ['divisionPointsOnly', 'ownOffice', 'anyOffice'].map((value) => ({
|
||||
value,
|
||||
checked: value === 'anyOffice',
|
||||
}));
|
||||
// The mode radios are real DOM inputs, so `onchange` has to actually fire when a test flips
|
||||
// `checked` — `applyModePreset` is wired to it, not polled.
|
||||
const modeRadios = ['solitaire', 'competitive', 'coop'].map((value) => ({
|
||||
value,
|
||||
checked: value === 'solitaire',
|
||||
onchange: null as (() => void) | null,
|
||||
}));
|
||||
let html = '';
|
||||
const node: Record<string, unknown> = {
|
||||
id, value: '', textContent: '', title: '', returnValue: '', open: false,
|
||||
style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
|
||||
checked: false, disabled: false,
|
||||
id, value: '', textContent: '', title: '', returnValue: '', open: false, placeholder: '',
|
||||
style: {}, dataset: {}, onclick: null, oninput: null, onchange: null, scrollTop: 0, scrollHeight: 0,
|
||||
checked: false, disabled: false, hidden: false, className: '',
|
||||
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
|
||||
radios,
|
||||
modeRadios,
|
||||
extraRadios,
|
||||
addEventListener: (type: string, fn: () => void) =>
|
||||
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
|
||||
showModal: () => void ((node as { open: boolean }).open = true),
|
||||
@@ -3205,24 +3628,9 @@ describe('the New Game dialog', () => {
|
||||
(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
|
||||
: sel === 'input[name="ng-mode"]'
|
||||
? modeRadios
|
||||
: sel === 'input[name="ng-extra"]'
|
||||
? extraRadios
|
||||
: [],
|
||||
querySelector: (sel: string) =>
|
||||
sel === 'input[name="ng-hand"]:checked'
|
||||
? (radios.find((r) => r.checked) ?? null)
|
||||
: sel === 'input[name="ng-mode"]:checked'
|
||||
? (modeRadios.find((r) => r.checked) ?? null)
|
||||
: sel === 'input[name="ng-extra"]:checked'
|
||||
? (extraRadios.find((r) => r.checked) ?? null)
|
||||
: null,
|
||||
querySelectorAll: (sel: string) => matching(sel),
|
||||
querySelector: (sel: string) => matching(sel)[0] ?? null,
|
||||
focus: () => {},
|
||||
};
|
||||
Object.defineProperty(node, 'innerHTML', { get: () => html, set: (v: string) => void (html = v) });
|
||||
return node;
|
||||
@@ -3237,6 +3645,7 @@ describe('the New Game dialog', () => {
|
||||
},
|
||||
createElement: () => make('style'),
|
||||
addEventListener: () => {},
|
||||
querySelectorAll: (sel: string) => matching(sel),
|
||||
body: { appendChild: () => {} },
|
||||
head: { appendChild: () => {} },
|
||||
};
|
||||
@@ -3245,6 +3654,8 @@ describe('the New Game dialog', () => {
|
||||
get search() { return nav.search; },
|
||||
set search(v: string) { nav.search = v; },
|
||||
reload: () => void (nav.reloads += 1),
|
||||
origin: 'http://box.local',
|
||||
pathname: '/play.html',
|
||||
};
|
||||
const store = new Map<string, string>();
|
||||
g['localStorage'] = {
|
||||
@@ -3254,121 +3665,150 @@ describe('the New Game dialog', () => {
|
||||
};
|
||||
g['URLSearchParams'] = NodeURLSearchParams;
|
||||
g['confirm'] = () => true;
|
||||
g['EventSource'] = class { close(): void {} addEventListener(): void {} };
|
||||
|
||||
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}-${Math.random()}`);
|
||||
return { els, nav };
|
||||
return { els, nav, groups };
|
||||
};
|
||||
|
||||
/** What every field of the block reads, so a test can assert the whole form at once. */
|
||||
const readForm = (els: Map<string, Record<string, unknown>>, groups: Record<string, { value: string; checked: boolean }[]>) => ({
|
||||
hand: groups['ng-hand']!.find((r) => r.checked)?.value,
|
||||
extra: groups['ng-extra']!.find((r) => r.checked)?.value,
|
||||
type: groups['ng-type']!.find((r) => r.checked)?.value,
|
||||
passenger: els.get('ng-passenger')!['value'],
|
||||
freight: els.get('ng-freight')!['value'],
|
||||
transit: els.get('ng-transit')!['value'],
|
||||
days: els.get('ng-days')!['value'],
|
||||
minrev: els.get('ng-minrev')!['value'],
|
||||
});
|
||||
|
||||
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');
|
||||
// Re-entering 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, groups } = 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');
|
||||
const form = readForm(els, groups);
|
||||
assert.equal(form.passenger, '2');
|
||||
assert.equal(form.freight, '3');
|
||||
assert.equal(form.transit, '4');
|
||||
assert.equal(form.hand, 'sixRandom', 'the opening hand in play was not preselected');
|
||||
});
|
||||
|
||||
it('always reopens on Solitaire, Deal enabled, PvP forced off — the only mode a prior game could be', async () => {
|
||||
const { els } = await load('?seed=430');
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
it('reopens on Solitaire when the game in play is one, and on Custom when it was tuned', async () => {
|
||||
// The type is DERIVED (`presets.ts`) rather than remembered, so what the dialog says a game is
|
||||
// has to follow from its numbers — including a game whose numbers were hand-edited into the URL.
|
||||
const plain = await load('?seed=430');
|
||||
(plain.els.get('newgame')!['onclick'] as () => void)();
|
||||
assert.equal(readForm(plain.els, plain.groups).type, 'solitaire', 'a default game did not read as Solitaire');
|
||||
|
||||
const dlg = els.get('newgamedlg')!;
|
||||
const modeRadios = dlg['modeRadios'] as { value: string; checked: boolean }[];
|
||||
assert.deepEqual(
|
||||
modeRadios.filter((r) => r.checked).map((r) => r.value),
|
||||
['solitaire'],
|
||||
'the dialog did not reopen on Solitaire',
|
||||
const tuned = await load('?seed=430&transit=4');
|
||||
(tuned.els.get('newgame')!['onclick'] as () => void)();
|
||||
assert.equal(readForm(tuned.els, tuned.groups).type, 'custom', 'a game paying for transits still read as Solitaire');
|
||||
assert.match(
|
||||
String(tuned.els.get('ng-type-note')!['textContent']),
|
||||
/1 setting differs from Solitaire/,
|
||||
'the note did not say what differs',
|
||||
);
|
||||
assert.equal(els.get('ng-deal')!['disabled'], false, 'Deal was disabled for the one dealable mode');
|
||||
assert.equal(els.get('ng-pvp')!['checked'], false, 'PvP defaulted on for Solitaire');
|
||||
assert.equal(els.get('ng-pvp')!['disabled'], true, "Solitaire's PvP checkbox was left editable");
|
||||
});
|
||||
|
||||
it('picking Competitive suggests a 4-player floor, turns PvP on, and disables Deal', async () => {
|
||||
const { els } = await load('?seed=430');
|
||||
it('offers the multiplayer types, disabled — one list across both screens, dealt from one of them', async () => {
|
||||
const { els, groups } = await load('?seed=430');
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
|
||||
const dlg = els.get('newgamedlg')!;
|
||||
const modeRadios = dlg['modeRadios'] as { value: string; checked: boolean; onchange: (() => void) | null }[];
|
||||
const competitive = modeRadios.find((r) => r.value === 'competitive')!;
|
||||
for (const r of modeRadios) r.checked = r === competitive;
|
||||
competitive.onchange!();
|
||||
|
||||
assert.equal(els.get('ng-minrev')!['value'], '60', 'the suggested floor is not 3 * 4 players * 5 days');
|
||||
assert.equal(els.get('ng-pvp')!['checked'], true, 'Competitive did not default PvP on');
|
||||
assert.equal(els.get('ng-pvp')!['disabled'], false, "Competitive's PvP checkbox was left disabled");
|
||||
assert.equal(els.get('ng-deal')!['disabled'], true, 'Deal was enabled for a mode with nowhere to go yet');
|
||||
const disabled = groups['ng-type']!
|
||||
.filter((r) => (r as { disabled: boolean }).disabled)
|
||||
.map((r) => r.value);
|
||||
assert.deepEqual(disabled, ['coop', 'competitive', 'cutthroat'], 'the wrong game types are dealable here');
|
||||
// Deal is never disabled here any more: the only types this screen can SELECT are the two it can
|
||||
// deal, so a disabled button would be answering a question the radios no longer ask.
|
||||
assert.ok(
|
||||
readFileSync(join(dist, 'play.html'), 'utf8').includes('id="ng-multiplayer-note"'),
|
||||
'nothing on the dialog says where a multiplayer game comes from',
|
||||
);
|
||||
});
|
||||
|
||||
it('picking Co-op forces PvP off — no valid target for those cards when everyone is on one side', async () => {
|
||||
const { els } = await load('?seed=430');
|
||||
it('clicking a game type resets every rule below to it, and leaves the parameters alone', async () => {
|
||||
const { els, groups } = await load('?seed=430&transit=4');
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
els.get('ng-days')!['value'] = '8';
|
||||
(els.get('ng-days')!['oninput'] as () => void)();
|
||||
// 3 × 1 player × 8 days: the floor follows the length, and changing the length is not a rule
|
||||
// change, so this is still Solitaire rather than Custom.
|
||||
assert.equal(readForm(els, groups).minrev, '24', 'the Revenue floor did not follow the Day count');
|
||||
|
||||
const dlg = els.get('newgamedlg')!;
|
||||
const modeRadios = dlg['modeRadios'] as { value: string; checked: boolean; onchange: (() => void) | null }[];
|
||||
const coop = modeRadios.find((r) => r.value === 'coop')!;
|
||||
for (const r of modeRadios) r.checked = r === coop;
|
||||
coop.onchange!();
|
||||
const solitaire = groups['ng-type']!.find((r) => r.value === 'solitaire')!;
|
||||
for (const r of groups['ng-type']!) r.checked = r === solitaire;
|
||||
(solitaire as { onchange: (() => void) | null }).onchange!();
|
||||
|
||||
assert.equal(els.get('ng-pvp')!['checked'], false, 'Co-op defaulted PvP on');
|
||||
assert.equal(els.get('ng-pvp')!['disabled'], true, "Co-op's PvP checkbox was left editable");
|
||||
assert.equal(els.get('ng-deal')!['disabled'], true, 'Deal was enabled for a mode with nowhere to go yet');
|
||||
const form = readForm(els, groups);
|
||||
assert.equal(form.transit, '0', 'clicking the type did not reset the rule that had been changed');
|
||||
assert.equal(form.days, '8', 'clicking the type wiped the Day count, which is a parameter');
|
||||
assert.equal(form.type, 'solitaire');
|
||||
});
|
||||
|
||||
it('puts the seed and all three settings into the URL when it deals', async () => {
|
||||
const { els, nav } = await load('?seed=430');
|
||||
it('puts the seed and every setting into the URL when it deals', async () => {
|
||||
const { els, groups, nav: n } = 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';
|
||||
for (const r of groups['ng-hand']!) r.checked = r.value === 'threeTrackThreeOther';
|
||||
els.get('ng-passenger')!['value'] = '5';
|
||||
els.get('ng-freight')!['value'] = '0';
|
||||
els.get('ng-transit')!['value'] = '2';
|
||||
els.get('ng-toolbox')!['checked'] = true;
|
||||
dlg['returnValue'] = 'deal';
|
||||
(dlg['close'] as () => void)();
|
||||
|
||||
assert.equal(
|
||||
nav.search,
|
||||
'?seed=99&hand=threeTrackThreeOther&extra=anyOffice&passenger=5&freight=0&transit=2&days=5&minrev=15&colday=3&coltotal=5',
|
||||
n.search,
|
||||
'?seed=99&hand=threeTrackThreeOther&extra=anyOffice&passenger=5&freight=0&transit=2' +
|
||||
'&days=5&minrev=15&colday=3&coltotal=5&tool=1',
|
||||
);
|
||||
});
|
||||
|
||||
it('a switched-off victory condition deals as 0, which is what the engine calls off', async () => {
|
||||
const { els, nav: n } = await load('?seed=430');
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
const dlg = els.get('newgamedlg')!;
|
||||
els.get('ng-coltotal-on')!['checked'] = false;
|
||||
(els.get('ng-coltotal-on')!['onchange'] as () => void)();
|
||||
dlg['returnValue'] = 'deal';
|
||||
(dlg['close'] as () => void)();
|
||||
assert.match(n.search, /coltotal=0/, 'unticking the total-collision condition did not switch it off');
|
||||
});
|
||||
|
||||
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');
|
||||
const { els, nav: n } = 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`);
|
||||
assert.equal(n.search, '?seed=430', `closing with "${returnValue}" navigated`);
|
||||
assert.equal(n.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. Spells
|
||||
// out the victory dials explicitly (rather than leaving them to default) so the URL Deal produces
|
||||
// is byte-identical to the one the page loaded with.
|
||||
const url = '?hand=threeRandom&extra=anyOffice&passenger=1&freight=1&transit=0&days=5&minrev=15&colday=3&coltotal=5';
|
||||
const { els, nav } = await load(url);
|
||||
// search string — and assigning `location.search` the value it already holds does nothing.
|
||||
const url =
|
||||
'?hand=sixRandom&extra=anyOffice&passenger=1&freight=1&transit=0&days=5&minrev=15&colday=3&coltotal=5';
|
||||
const { els, nav: n } = 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');
|
||||
assert.equal(n.search, url, 'the URL should be unchanged — that is the whole case');
|
||||
assert.equal(n.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 () => {
|
||||
@@ -3379,10 +3819,23 @@ describe('the New Game dialog', () => {
|
||||
assert.match(String(els.get('houserules')!['title']), /six random cards/i);
|
||||
});
|
||||
|
||||
it('names the game type in the header, so a Cutthroat game does not look like a Co-op one', async () => {
|
||||
const { els } = await load('?seed=430');
|
||||
assert.equal(els.get('gametype')!['textContent'], 'Solitaire');
|
||||
assert.match(String(els.get('gametype')!['title']), /Days: 5/, 'the tooltip does not carry the victory conditions');
|
||||
});
|
||||
|
||||
it('deals six cards by default now, matching what the lobby calls Solitaire', async () => {
|
||||
// Jesse, 2026-08-23: every game type opens with six. `SOLO_CONFIG` — the ENGINE's fallback, which
|
||||
// every sim measurement is taken against — deliberately did not move; this is the page's deal.
|
||||
const { els } = await load('');
|
||||
assert.match(String(els.get('houserules')!['textContent']), /6 cards/);
|
||||
});
|
||||
|
||||
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');
|
||||
const { els, nav: n } = await load('?seed=430');
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
const dlg = els.get('newgamedlg')!;
|
||||
els.get('ng-seed')!['value'] = 'not a number';
|
||||
@@ -3390,8 +3843,8 @@ describe('the New Game dialog', () => {
|
||||
(dlg['close'] as () => void)();
|
||||
|
||||
assert.equal(
|
||||
nav.search,
|
||||
'?hand=threeRandom&extra=anyOffice&passenger=1&freight=1&transit=0&days=5&minrev=15&colday=3&coltotal=5',
|
||||
n.search,
|
||||
'?hand=sixRandom&extra=anyOffice&passenger=1&freight=1&transit=0&days=5&minrev=15&colday=3&coltotal=5',
|
||||
'a bad seed was carried into the URL',
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user