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:
+333
-46
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user