v0.5.0 — multiplayer Phases 2 and 3: a server that runs a game and survives being restarted

Phases 0-1 shipped in v0.4.0 (seat/identity split, per-player turn state, the Session boundary).
This lands Phase 2 (server core, one game, no lobby) and Phase 3 (persistence and resumption) per
docs/architecture/multiplayer.md §12. Phases 4-6 (lobby/reconnection, the 22 opponent-directed
cards, StartOS packaging) are still ahead.

Phase 2: src/server/session.ts hosts a game in pure logic (no sockets) on top of game.ts's existing
Game/submit/currentActor/actionMenu; it verifies seat === currentActor(game) itself before calling
submit, since submit() trusts its caller and a server can't. src/server/http.ts and index.ts add
POST /api/game, GET /api/stream (SSE, per-seat), POST /api/intent, and static serving of dist/.
src/sim/frame-delta.ts is a purpose-built per-seat board delta for one live push at a time. Found
and fixed along the way: actionMenu(game, seat) only used seat for the hand field, so a server
computing every connected seat's Menu would have handed the acting player's legal moves to a
waiting seat. Verified with a live end-to-end smoke test (2-player game, two SSE streams, a
rejected intent from the wrong seat, an idempotent resend) plus test/server/session.test.ts and
test/redaction.test.ts. Not verified: an actual browser (none available in this environment).

Phase 3: src/server/persistence.ts writes game.json and turn-timings.json, atomic-rewrite-then-
rename. game.ts gained fromMultiplayerSave, fixing a narration-attribution bug found while testing
it (fromSave's replay loop drops the actor argument, invisible in solitaire, unreadable the moment
there's more than one seat — fromSave itself still has this gap, deliberately untouched). Verified
live: server killed and restarted mid-game, both seats reconnected exactly where they left off.

Two rules bugs found while building this: the New Train phase never implemented its car-placement
round (every car of every train was placed by the Superintendent alone, in every mode, all along —
now reads the round position off tray.consist.length); and victory conditions are now one shared,
configurable GameConfig set across solitaire/competitive/coop instead of a fixed length lookup and
a dead firstToTarget condition.

Also folds in the three fixes already released on the patch line as v0.4.9b/c/d: a switching
train's crew badge failing to draw once it left the Office square, an unload that always took the
westmost car regardless of which was picked, and a legal decision that could render with zero
buttons.

docs/testing/0.5.0-test-plan.md and three reported-bug save files (docs/station-master-seed*.json)
included for reproducibility. tools/jitsi-harness/ deliberately left untracked — unrelated
side-project work, not part of this release. 635 tests, 0 failures.
This commit is contained in:
Jesse
2026-08-20 23:50:38 -04:00
parent f9c4d9fa92
commit c3c5cbfeec
52 changed files with 5282 additions and 420 deletions
+333 -46
View File
@@ -42,7 +42,21 @@ import {
} from '../src/web/game.ts';
const root = join(import.meta.dirname, '..');
/**
* `dist/` is built once by the `pretest` npm script (`scripts/build-web.ts`), before `node --test`
* ever starts — every test below that reads `dist` assumes it is already complete, not that some
* other test in this file built it. `npm run test` directly (skipping `npm test`'s `pretest` hook)
* will not have run it.
*/
const dist = join(root, 'dist');
/**
* A directory the actual "run the build command" test below builds into, kept separate from the
* shared `dist/` above. Two suites racing to write and read the SAME `dist/` — the build command
* doing a full `rmSync` + rebuild while other tests in this file read it mid-run — used to fail 9
* times in one measured run and 0 the next. `dist/` is now single-writer (`pretest`, once, before
* anything reads it); this directory is the ONLY thing the build-command test itself may touch.
*/
const distTest = join(root, 'dist-test');
/** Play a whole game by always taking the first offered action. */
function playThrough(seed: number, maxTurns = 20_000) {
@@ -764,15 +778,30 @@ describe('the board draws the printed card (docs/tracks.png)', () => {
.map((m) => ({ x1: Number(m[1]), y1: Number(m[2]), x2: Number(m[3]), y2: Number(m[4]) }));
};
/** Undirected segment angles in degrees, deduplicated. 0 is horizontal. */
const angles = (links: string[]): number[] => [
...new Set(
draw(links).map((r) => {
const a = (Math.atan2(r.y2 - r.y1, r.x2 - r.x1) * 180) / Math.PI;
return Math.round((((a % 180) + 180) % 180) * 10) / 10;
/**
* A 45° leg is a curved `<path>` now (v0.5.0), not straight `<line>`s — see `curvedRail` in
* `board-svg.ts`. Each returned array is the sampled polyline for one of the leg's two rails, in
* order from the through edge to the diagonal edge.
*/
const drawCurve = (links: string[]): { x: number; y: number }[][] => {
const cell = {
row: 0, col: 0, kind: 'trk', label: 'x', running: true, enhancements: [], enhancementsWhat: [],
trains: [], adTracks: null, cars: [], standingWest: 0, facility: null, links, what: '',
};
const svg = officeSvg([cell] as never, 0);
return [...svg.matchAll(/<path class="bs-rail" fill="none" d="M([^"]+)"\/>/g)].map((m) =>
m[1]!.split(' L').map((pair) => {
const [x, y] = pair.trim().split(' ').map(Number);
return { x: x!, y: y! };
}),
),
].sort((a, b) => a - b);
);
};
/** Undirected angle in degrees between two points, 0–180. */
const angleOf = (a: { x: number; y: number }, b: { x: number; y: number }): number => {
const deg = (Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
return ((deg % 180) + 180) % 180;
};
it('runs the through track dead centre, edge to edge', () => {
// The measurement from the printed sheet: on every one of its nine rows the east-west rail sits
@@ -788,36 +817,46 @@ describe('the board draws the printed card (docs/tracks.png)', () => {
}
});
it('leaves at exactly 45°, through the middle of the north or south edge', () => {
it('leaves the through edge level and the diagonal edge at 45°, curved rather than kinked', () => {
// The TRUE tangent is exactly 0° at the through edge and exactly 45°/135° at the diagonal edge —
// that is what the curve's control points are chosen to hit (see `curvedRail`'s call site). What
// is rendered is a sampled polyline, so the first and last drawn segment only APPROXIMATE that
// limit; a few degrees off at the sample count in use, well inside the tolerance below.
for (const links of [['ne'], ['nw'], ['se'], ['sw'], ['ew', 'ws'], ['ew', 'en']]) {
const found = angles(links);
assert.ok(found.includes(0), `${links} has no level run along the centre line`);
assert.ok(
found.includes(45) || found.includes(135),
`${links} draws its leg at ${found.join('/')}° — the card prints 45°`,
);
assert.equal(found.length, 2, `${links} draws ${found.join('/')}° — a leg is a run and one 45° angle`);
const [rail] = drawCurve(links);
assert.ok(rail && rail.length > 2, `${links} drew no curved leg`);
const start = angleOf(rail[0]!, rail[1]!);
const end = angleOf(rail[rail.length - 2]!, rail[rail.length - 1]!);
assert.ok(start <= 6 || start >= 174, `${links} leaves the through edge at ${start}°, not level`);
const offDiagonal = Math.min(Math.abs(end - 45), Math.abs(end - 135));
assert.ok(offDiagonal <= 6, `${links} meets the diagonal edge at ${end}°, not 45°/135°`);
}
});
it('draws the two diagonals as two different diagonals', () => {
// The matching rule made visible. `ne` and `sw` lie on one line and `nw` and `se` on the other,
// so a stacked pair on the same diagonal reads as one unbroken rail and a mismatched pair reads
// as the V it is. Drawn alike, the picture would claim a join the engine refuses.
assert.deepEqual(angles(['ne']), angles(['sw']), 'ne and sw must lie on the same diagonal');
assert.deepEqual(angles(['nw']), angles(['se']), 'nw and se must lie on the same diagonal');
assert.notDeepEqual(angles(['ne']), angles(['nw']), 'the two diagonals must be distinguishable');
// so a stacked pair on the same diagonal reads as one continuous curve and a mismatched pair
// reads as the V it is. Drawn alike, the picture would claim a join the engine refuses.
const family = (links: string[]): 'rising' | 'falling' => {
const [rail] = drawCurve(links);
const end = angleOf(rail![rail!.length - 2]!, rail![rail!.length - 1]!);
return Math.abs(end - 45) < Math.abs(end - 135) ? 'rising' : 'falling';
};
assert.equal(family(['ne']), family(['sw']), 'ne and sw must lie on the same diagonal');
assert.equal(family(['nw']), family(['se']), 'nw and se must lie on the same diagonal');
assert.notEqual(family(['ne']), family(['nw']), 'the two diagonals must be distinguishable');
});
it('meets the card edge at its midpoint, so abutting cards line up', () => {
// A leg that met the edge anywhere else would join to nothing, however right its angle.
// A leg that met the edge anywhere else would join to nothing, however right its angle. Exact,
// not approximate: the curve's LAST sampled point is always the true edge point (`edge` in
// `curvedRail`'s call site, not a discretised approximation of it), so averaging the two rails'
// endpoints cancels their perpendicular offset exactly, the same way it did when the leg was two
// straight segments.
const W = 166;
const H = 96;
// Only the sloping segments, and only the end of each that lands on the south edge.
const atEdge = draw(['sw'])
.filter((r) => r.y1 !== r.y2)
.map((r) => (Math.abs(r.y1 - H) < Math.abs(r.y2 - H) ? r.x1 : r.x2));
assert.equal(atEdge.length, 2, 'the leg is two rails');
const rails = drawCurve(['sw']);
assert.equal(rails.length, 2, 'the leg is two rails');
const atEdge = rails.map((r) => r[r.length - 1]!.x);
const mid = (atEdge[0]! + atEdge[1]!) / 2;
assert.ok(Math.abs(mid - W / 2) < 0.5, `the leg crosses the south edge at x=${mid}, not the middle (${W / 2})`);
});
@@ -1171,10 +1210,12 @@ describe('the page explains itself', () => {
});
it('states the objective and whether you are keeping up', () => {
// SOLO_CONFIG's minCombinedRevenue is collectiveRevenueFloor(1, 5) = 15 (game.ts) — the old
// fixed target of 20 retired 2026-08-20 with the victory-condition redesign.
const f = view(newGame(430));
assert.equal(f.objective.target, 20);
assert.equal(f.objective.target, 15);
assert.equal(f.objective.days, 5);
assert.match(f.objective.note, /of 20/);
assert.match(f.objective.note, /of 15/);
assert.match(f.objective.note, /Days? left/);
});
@@ -1325,11 +1366,17 @@ describe('replays are saves', () => {
describe('the static build', () => {
it('builds, and needs nothing but a static host', () => {
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' });
// Its OWN directory (`dist-test/`), not the shared `dist/` every other test in this file reads
// — see the comment on `distTest` above.
execFileSync('node', ['scripts/build-web.ts'], {
cwd: root,
stdio: 'pipe',
env: { ...process.env, BUILD_DIST_DIR: 'dist-test' },
});
assert.ok(existsSync(join(dist, 'index.html')), 'no index.html');
assert.ok(existsSync(join(dist, 'web/main.js')), 'no entry script');
assert.ok(existsSync(join(dist, 'engine/apply.js')), 'the engine did not emit');
assert.ok(existsSync(join(distTest, 'index.html')), 'no index.html');
assert.ok(existsSync(join(distTest, 'web/main.js')), 'no entry script');
assert.ok(existsSync(join(distTest, 'engine/apply.js')), 'the engine did not emit');
});
it('emits no import that a browser cannot resolve', () => {
@@ -1579,6 +1626,173 @@ describe('the static build', () => {
);
});
it('remembers auto-focus, sound and zoom across a reload, separately from the save', async () => {
// A lighter DOM stub than the highlight test above — this never touches board highlighting or
// action buttons, only the settings-bearing elements and `localStorage` itself.
const els = new Map<string, Record<string, unknown>>();
const injected: Record<string, unknown>[] = [];
const make = (): Record<string, unknown> => {
let html = '';
const listeners = new Map<string, ((e?: unknown) => void)[]>();
const ownClasses = new Set<string>();
const node: Record<string, unknown> = {
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
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),
contains: (c: string) => ownClasses.has(c),
toggle: (c: string) => (ownClasses.has(c) ? ownClasses.delete(c) : ownClasses.add(c)),
},
// No real `<svg>` child: `applyZoom` looks for one, finds none on this stub, and no-ops —
// the same way it does on a board card the stub never renders. Sizing the actual SVG needs a
// real DOM (jsdom or a browser), which this test suite does not carry; what IS checked here —
// the persisted setting, the button labels, the click wiring — is everything observable
// without one. `querySelectorAll` empty rather than absent: several unrelated render steps
// (crew markers, hand buttons, department piles) iterate whatever their element returns.
querySelector: () => null,
querySelectorAll: () => [],
};
Object.defineProperty(node, 'innerHTML', {
get: () => html,
set: (v: string) => void (html = v),
});
return node;
};
const served = new Set(
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map(
(m) => m[1]!,
),
);
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());
return els.get(id);
},
createElement: () => make(),
addEventListener: () => {},
body: { appendChild: () => {} },
head: { appendChild: (node: Record<string, unknown>) => void injected.push(node) },
};
g['location'] = { search: '?seed=555' };
const store = new Map<string, string>();
// Seeded BEFORE import: settings load once, at module top level, from whatever is already there.
store.set(
'station-master.settings.v1',
JSON.stringify({ districtMode: 'open', soundOn: true, zoom: 1.25 }),
);
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'] = class {
search: string;
constructor(search: string) {
this.search = search;
}
get(k: string): string | null {
return new RegExp(`${k}=([^&]*)`).exec(this.search)?.[1] ?? null;
}
};
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}`);
// The seeded settings took effect on load, before any click.
assert.match(String(els.get('sound')!['textContent']), /sound/, 'sound started ON, as seeded');
assert.equal(els.get('zoomlabel')!['textContent'], '125%', 'zoom started at the seeded level');
// Zooming in from 125% goes to 150% and persists; the button disables at the top of the range.
(els.get('zoomin')!['onclick'] as () => void)();
assert.equal(els.get('zoomlabel')!['textContent'], '150%');
assert.equal(els.get('zoomin')!['disabled'], true, 'already at the highest preset');
assert.equal(
(JSON.parse(store.get('station-master.settings.v1')!) as { zoom: number }).zoom,
1.5,
'the new zoom level was written back to localStorage',
);
// Muting persists too, independently of zoom.
(els.get('sound')!['onclick'] as () => void)();
assert.match(String(els.get('sound')!['textContent']), /muted/);
assert.equal(
(JSON.parse(store.get('station-master.settings.v1')!) as { soundOn: boolean }).soundOn,
false,
);
});
it('falls back to the defaults when settings are missing or corrupt, rather than throwing', async () => {
const els = new Map<string, Record<string, unknown>>();
const make = (): Record<string, unknown> => {
const node: Record<string, unknown> = {
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
title: '', returnValue: '', open: false,
addEventListener: () => {},
showModal() {},
close() {},
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
querySelector: () => null,
querySelectorAll: () => [],
};
let html = '';
Object.defineProperty(node, 'innerHTML', { get: () => html, set: (v: string) => void (html = v) });
return node;
};
const served = new Set(
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map(
(m) => m[1]!,
),
);
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());
return els.get(id);
},
createElement: () => make(),
addEventListener: () => {},
body: { appendChild: () => {} },
head: { appendChild: () => {} },
};
g['location'] = { search: '?seed=556' };
g['localStorage'] = {
// Neither a JSON parse error nor a thrown access should reach the page — a full or disabled
// localStorage must not take the game down with it, the same guard the save already has.
getItem: () => {
throw new Error('storage disabled');
},
setItem: () => {},
removeItem: () => {},
};
g['URLSearchParams'] = class {
search: string;
constructor(search: string) {
this.search = search;
}
get(k: string): string | null {
return new RegExp(`${k}=([^&]*)`).exec(this.search)?.[1] ?? null;
}
};
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}`);
assert.match(String(els.get('sound')!['textContent']), /muted/, 'sound defaults OFF');
assert.equal(els.get('zoomlabel')!['textContent'], '100%', 'zoom defaults to 100%');
});
it('busts the cache on every module, so a deploy cannot half-load', () => {
// The first real deploy served a fresh index.html against a CACHED main.js: the HTML had
// dropped an element the old script still asked for, so the page threw `missing element:
@@ -2318,8 +2532,11 @@ describe('the Division map shows the whole route', () => {
seed: 7,
config: {
mode: players === 1 ? 'solitaire' : 'competitive',
victory: 'highestAfterDays',
length: 'standard',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['A', 'B', 'C', 'D'].slice(0, players),
@@ -2334,7 +2551,7 @@ describe('the Division map shows the whole route', () => {
id: 'div-run',
seed: 7,
config: {
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Solitaire'],
@@ -2387,7 +2604,7 @@ describe('the Division map shows the whole route', () => {
const s = createEngineGame({
id: 'div-chips', seed: 7,
config: {
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Solitaire'],
@@ -2716,7 +2933,7 @@ describe('the tray is an engine plus its Rolling Stock', () => {
const s = createEngineGame({
id: 'eng', seed: 1038389,
config: {
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Solitaire'],
@@ -2766,7 +2983,7 @@ describe('the tray is an engine plus its Rolling Stock', () => {
const s = createEngineGame({
id: 'yards', seed: 1038389,
config: {
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Solitaire'],
@@ -2818,12 +3035,21 @@ describe('the New Game dialog', () => {
value,
checked: false,
}));
// 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,
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
radios,
modeRadios,
addEventListener: (type: string, fn: () => void) =>
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
showModal: () => void ((node as { open: boolean }).open = true),
@@ -2833,9 +3059,14 @@ describe('the New Game dialog', () => {
},
// 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 : []),
querySelectorAll: (sel: string) =>
sel === 'input[name="ng-hand"]' ? radios : sel === 'input[name="ng-mode"]' ? modeRadios : [],
querySelector: (sel: string) =>
sel === 'input[name="ng-hand"]:checked' ? (radios.find((r) => r.checked) ?? null) : null,
sel === 'input[name="ng-hand"]:checked'
? (radios.find((r) => r.checked) ?? null)
: sel === 'input[name="ng-mode"]:checked'
? (modeRadios.find((r) => r.checked) ?? null)
: null,
};
Object.defineProperty(node, 'innerHTML', { get: () => html, set: (v: string) => void (html = v) });
return node;
@@ -2888,6 +3119,53 @@ describe('the New Game dialog', () => {
assert.deepEqual(checked.map((r) => r.value), ['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)();
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',
);
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');
(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');
});
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');
(els.get('newgame')!['onclick'] as () => void)();
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!();
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');
});
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)();
@@ -2901,7 +3179,10 @@ describe('the New Game dialog', () => {
dlg['returnValue'] = 'deal';
(dlg['close'] as () => void)();
assert.equal(nav.search, '?seed=99&hand=threeTrackThreeOther&passenger=5&freight=0&transit=2');
assert.equal(
nav.search,
'?seed=99&hand=threeTrackThreeOther&passenger=5&freight=0&transit=2&days=5&minrev=15&colday=3&coltotal=5',
);
});
it('deals nothing on cancel, and nothing on Esc', async () => {
@@ -2920,8 +3201,10 @@ describe('the New Game dialog', () => {
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';
// 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&passenger=1&freight=1&transit=0&days=5&minrev=15&colday=3&coltotal=5';
const { els, nav } = await load(url);
(els.get('newgame')!['onclick'] as () => void)();
const dlg = els.get('newgamedlg')!;
@@ -2950,7 +3233,11 @@ describe('the New Game dialog', () => {
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');
assert.equal(
nav.search,
'?hand=threeRandom&passenger=1&freight=1&transit=0&days=5&minrev=15&colday=3&coltotal=5',
'a bad seed was carried into the URL',
);
});
});