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:
Jesse.Markowitz
2026-08-23 05:46:42 -04:00
co-authored by Claude Opus 5
parent 42adfda390
commit 06db36e5b5
41 changed files with 4387 additions and 588 deletions
+2 -1
View File
@@ -1036,7 +1036,8 @@ describe('an Extra starts where the player puts it (Gitea#4)', () => {
*/
const pending = (trainNumber: number, tier?: 'depot' | 'station'): GameState => {
const s = game(11);
s.pendingExtras = [trainNumber];
// Queued by seat 0, who is therefore the one §7 lets place it.
s.pendingExtras = [{ trainNumber, player: 0 }];
s.timetable = s.timetable.map(() => null);
if (tier) s.officeAreas.get(0)!.tier = tier;
s.clock.phase = 'newTrain';
+101 -1
View File
@@ -11,9 +11,10 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { advance, pump } from '../src/engine/advance.ts';
import { applyIntent, areaAtSeat, areaOf } from '../src/engine/apply.ts';
import { applyIntent, areaAtSeat, areaOf, check } from '../src/engine/apply.ts';
import { STAGES_PER_DAY, STAGES_PER_SHIFT, crewTrayCount } from '../src/engine/content.ts';
import { createGame } from '../src/engine/setup.ts';
import { legalActions } from '../src/engine/legal.ts';
import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts';
import { coordKey, playerAtSeat, playerLeftOf, seatOf, subdivisions } from '../src/engine/state.ts';
import { developerBot, playGame } from '../src/sim/bot.ts';
@@ -675,6 +676,105 @@ describe('the New Train phase car-placement round rotates (§7, Gap 9)', () => {
});
});
describe('an Extra belongs to the player who played it (§7)', () => {
/**
* REPORTED BY JESSE 2026-08-23, from a two-player game on StartOS: one seat played a train card
* and the OTHER was asked to build the train. For a Timetabled train that is correct — the round
* above starts at the Superintendent — but §7 states the Extra rule a paragraph later and it is
* the opposite one: "the player who played the card may place the Crew Tray at either Division
* Point ... and may load the consist as he chooses."
*
* The engine could not honour it: `pendingExtras` was a bare `number[]`, so nothing recorded whose
* Extra it was and the phase asked whoever the acting order happened to be on. Invisible in
* solitaire, where that is always the same person.
*/
const withPendingExtra = (players: number, owner: PlayerIndex) => {
const s = game(players);
s.clock.phase = 'newTrain';
s.timetable = s.timetable.map(() => null);
// X22 "Pee-Dee" — a caboose-only Extra, so the consist is short and the round would be visible.
s.pendingExtras = [{ trainNumber: 22, player: owner }];
return s;
};
it('asks the player who played it where it starts — not whoever the round is on', () => {
// The owner is deliberately NOT the Superintendent, which is the case that used to go wrong.
const s = withPendingExtra(2, 1 as PlayerIndex);
const notSuper = playerLeftOf(s, s.clock.superintendent, 1);
s.pendingExtras = [{ trainNumber: 22, player: notSuper }];
const r = advance(s);
assert.equal(r.needsInput, true, 'the phase did not stop to place the Extra');
assert.equal(s.clock.currentActor, notSuper, 'the wrong player was asked where the Extra starts');
});
it('refuses another seat placing it, and offers it to nobody else', () => {
const s = withPendingExtra(2, 0 as PlayerIndex);
const intent = {
type: 'newTrain.startExtra' as const,
trainNumber: 22,
start: { kind: 'divisionPoint' as const, side: 'west' as const },
};
const theirs = applyIntent(s, 0 as PlayerIndex, intent);
assert.ok(theirs.ok, `the owner could not place their own Extra — ${theirs.ok ? '' : theirs.code}`);
/**
* TWO EXTRAS, TWO OWNERS — the case where the guard is actually reachable. The phase stops on
* the first pending Extra's owner, so seat 0 is legitimately the current actor; nothing but this
* rule stops them placing seat 1's train while they are there.
*/
const both = withPendingExtra(2, 0 as PlayerIndex);
both.pendingExtras = [
{ trainNumber: 22, player: 0 as PlayerIndex },
{ trainNumber: 24, player: 1 as PlayerIndex },
];
advance(both);
assert.equal(both.clock.currentActor, 0, 'the first pending Extra should have stopped on its owner');
assert.equal(check(both, 0 as PlayerIndex, { ...intent, trainNumber: 24 }), 'NOT_YOUR_EXTRA');
const notTheirs = applyIntent(both, 0 as PlayerIndex, { ...intent, trainNumber: 24 });
assert.equal(notTheirs.ok, false, 'a seat placed somebody else’s Extra while it was their turn');
// And it is not even offered: a menu that lists an action `check` will refuse is a menu lying.
assert.equal(
legalActions(both, 0 as PlayerIndex).some(
(i) => i.type === 'newTrain.startExtra' && i.trainNumber === 24,
),
false,
'somebody else’s Extra was offered a start point',
);
});
it('lets its player load the whole consist, rather than passing the round', () => {
// §7: "may load the consist as he chooses" — no going round the table for an Extra.
const s = withPendingExtra(2, 0 as PlayerIndex);
const owner = 0 as PlayerIndex;
const placed = applyIntent(s, owner, {
type: 'newTrain.startExtra',
trainNumber: 22,
start: { kind: 'divisionPoint', side: 'west' },
});
assert.ok(placed.ok, 'the Extra could not be placed');
s.yards.divisionYard.push({ type: 'caboose', loaded: false }, { type: 'caboose', loaded: false });
const asked: PlayerIndex[] = [];
for (let guard = 0; guard < 4; guard++) {
const r = advance(s);
if (!r.needsInput) break;
const actor = s.clock.currentActor!;
asked.push(actor);
const tray = [...s.trays.values()].find((t) => t.trainIsExtra);
assert.ok(tray, 'the Extra has no tray');
const result = applyIntent(s, actor, { type: 'newTrain.passCar', trayId: tray.id });
if (!result.ok) break;
}
assert.ok(asked.length > 0, 'nobody was asked to load the Extra');
assert.deepEqual(
[...new Set(asked)],
[owner],
`the Extra's consist went round the table (asked ${asked.join(', ')}) instead of staying with its player`,
);
});
});
describe('actionMenu is seat-safe (Phase 2 prep)', () => {
/**
* REGRESSION. `actionMenu(game, seat)` used `seat` only for the `hand` field — `options`/`direct`/
+179
View File
@@ -0,0 +1,179 @@
/**
* The four game types (`src/web/presets.ts`) — Jesse's design, 2026-08-23.
*
* These numbers are a DESIGN, not an implementation detail: they say what Co-op asks of a table and
* what Cutthroat refuses to. Pinned here so that changing one is a decision somebody makes on
* purpose rather than something a refactor can do quietly.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
PRESETS,
closestPreset,
configFromPreset,
configFromSettings,
differencesFrom,
gameTypeLabel,
preset,
presetOf,
presetSettings,
settingsOf,
} from '../src/web/presets.ts';
import type { PresetName } from '../src/web/presets.ts';
const NAMES: PresetName[] = ['solitaire', 'coop', 'competitive', 'cutthroat'];
describe('what each game type is', () => {
it('deals six cards in every type — the hand limit is three, so the first turn is a discard', () => {
for (const name of NAMES) {
assert.equal(presetSettings(name, 4, 5).startingHand, 'sixRandom', `${name} does not deal six`);
}
});
it('scores Cutthroat as Competitive, and Co-op as itself', () => {
assert.equal(preset('cutthroat').scoring, 'competitive');
assert.equal(preset('competitive').scoring, 'competitive');
assert.equal(preset('coop').scoring, 'coop');
assert.equal(preset('solitaire').scoring, 'solitaire');
});
it('lets the opponent-directed cards into Competitive and Cutthroat only', () => {
// Co-op has no opponent to point them at; solitaire has nobody at all. Inert either way until
// the cards are built (`setup.ts`), which is why no screen offers this as a control any more.
assert.deepEqual(
PRESETS.filter((p) => p.pvpCards).map((p) => p.name),
['competitive', 'cutthroat'],
);
});
it('pays for a transit in Co-op alone — the one economy that pays everybody at once', () => {
assert.equal(presetSettings('coop', 4, 5).trainPerTransit, 1);
for (const name of ['solitaire', 'competitive', 'cutthroat'] as PresetName[]) {
assert.equal(presetSettings(name, 4, 5).trainPerTransit, 0, `${name} pays for transits`);
}
});
it('lets an Extra be planted in another player’s district in Cutthroat only', () => {
assert.equal(presetSettings('cutthroat', 4, 5).extraStart, 'anyOffice');
assert.equal(presetSettings('coop', 4, 5).extraStart, 'ownOffice');
assert.equal(presetSettings('competitive', 4, 5).extraStart, 'ownOffice');
// At one player the two rules are the same rule.
assert.equal(presetSettings('solitaire', 1, 5).extraStart, 'anyOffice');
});
it('leaves every optional rule off, in every type', () => {
for (const name of NAMES) {
const s = presetSettings(name, 4, 5);
assert.deepEqual(
[s.reducedVisibility, s.employeeRotation, s.emergencyToolbox],
[false, false, false],
`${name} switches an optional rule on`,
);
}
});
});
describe('the Revenue floor is a formula, not a number', () => {
/**
* Jesse gave these at five Days — Co-op 15 × players, Competitive 10 × players — and they scale,
* because a ten-Day game with a five-Day target is not a target. The floor follows the table size
* AND the length, which is exactly why both sit above the type radios as parameters rather than
* below them as rules.
*/
it('asks 15 per player in Co-op at five Days, and 10 in Competitive', () => {
for (const players of [2, 3, 4]) {
assert.equal(presetSettings('coop', players, 5).minCombinedRevenue, 15 * players);
assert.equal(presetSettings('competitive', players, 5).minCombinedRevenue, 10 * players);
}
});
it('scales both ways with the Day count', () => {
assert.equal(presetSettings('coop', 3, 8).minCombinedRevenue, 3 * 3 * 8);
assert.equal(presetSettings('coop', 3, 3).minCombinedRevenue, 3 * 3 * 3);
assert.equal(presetSettings('competitive', 4, 10).minCombinedRevenue, 2 * 4 * 10);
assert.equal(presetSettings('competitive', 2, 3).minCombinedRevenue, 2 * 2 * 3);
});
it('asks nothing at all in Cutthroat, and leaves only the per-Day collision check standing', () => {
const s = presetSettings('cutthroat', 4, 5);
assert.equal(s.minCombinedRevenue, 0, 'Cutthroat has a Revenue floor');
assert.equal(s.maxCollisionsTotal, 0, 'Cutthroat caps collisions across the game');
assert.equal(s.maxCollisionsPerDay, 3, 'three collisions in one Day still ends a Cutthroat game');
});
});
describe('naming a game from its numbers', () => {
it('reads every type back as itself, at every table size and length', () => {
for (const name of NAMES) {
const players = name === 'solitaire' ? 1 : 3;
for (const days of [3, 5, 10]) {
const config = configFromPreset(name, players, days);
assert.equal(presetOf(config, players, days), name, `${name} at ${days} Days did not read back`);
}
}
});
it('tells Cutthroat and Competitive apart, though they are scored the same way', () => {
const cut = configFromPreset('cutthroat', 4, 5);
const comp = configFromPreset('competitive', 4, 5);
assert.equal(cut.mode, comp.mode, 'these two are meant to share a scoring mode');
assert.equal(presetOf(cut, 4, 5), 'cutthroat');
assert.equal(presetOf(comp, 4, 5), 'competitive');
});
it('calls a changed rule Custom, and says which rule', () => {
const base = configFromPreset('coop', 4, 5);
const settings = { ...settingsOf(base), emergencyToolbox: true };
const custom = configFromSettings(settings, 'coop', 5, false);
assert.equal(presetOf(custom, 4, 5), 'custom');
assert.deepEqual(differencesFrom('coop', settings, 4, 5), ['emergencyToolbox']);
});
it('does not call a Co-op game Competitive just because its dials line up', () => {
// The scoring mode is part of the comparison: the same numbers under a different mode are a
// different game, and the type is what a player reads to know which.
const settings = presetSettings('competitive', 4, 5);
const coopScored = configFromSettings(settings, 'coop', 5, false);
assert.notEqual(presetOf(coopScored, 4, 5), 'competitive');
assert.equal(presetOf(coopScored, 4, 5), 'custom');
});
it('measures a Custom game against the nearest type it is scored as', () => {
// A join preview is handed a finished config and nothing else — "Custom" alone would tell a
// player nothing about what they are sitting down to.
const settings = { ...presetSettings('cutthroat', 4, 5), freightPerLoad: 3 };
const config = configFromSettings(settings, 'competitive', 5, true);
const near = closestPreset(config, 4, 5);
assert.equal(near.name, 'cutthroat', 'a tuned Cutthroat game was measured against something else');
assert.deepEqual(near.differing, ['freightPerLoad']);
});
it('says what a Custom game is scored as, since the dials cannot', () => {
assert.match(gameTypeLabel('custom', 'coop'), /Co-op/);
assert.match(gameTypeLabel('custom', 'competitive'), /Competitive/);
assert.equal(gameTypeLabel('cutthroat', 'competitive'), 'Cutthroat');
});
});
describe('the config a form produces', () => {
it('carries the type’s scoring mode and its stance on the opponent cards', () => {
for (const name of NAMES) {
const config = configFromPreset(name, name === 'solitaire' ? 1 : 4, 5);
assert.equal(config.mode, preset(name).scoring);
assert.equal(config.pvpCardsAllowed, preset(name).pvpCards);
}
});
it('spells out every house rule rather than leaving one to a default somewhere else', () => {
const config = configFromPreset('competitive', 3, 5);
assert.ok(config.houseRules?.startingHand, 'the opening hand was left unnamed');
assert.ok(config.houseRules?.extraStart, 'the Extra rule was left unnamed');
assert.deepEqual(config.houseRules?.revenue, {
passengerPerCoach: 1,
freightPerLoad: 1,
trainPerTransit: 0,
});
});
});
+94
View File
@@ -10,6 +10,7 @@ import {
createLobby,
freshGameCode,
joinLobby,
leaveLobby,
playerCountAllowed,
reassignHost,
setBotSeat,
@@ -250,3 +251,96 @@ describe('game codes', () => {
assert.match(code, /^[A-Z]+-\d{4}$/);
});
});
describe('a name nobody else at the table is using', () => {
/**
* The display name is not decoration: it labels the district on the Division map, it is what the
* turn chart means by "waiting on Jesse", and `record()` puts it in front of every line that
* player causes. Two identical names make all three ambiguous, and the names lock at
* `Lobby.Start` — so the refusal has to happen at the door.
*/
it('refuses a second player using a name already at the table, whatever the case or spacing', () => {
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0001', 4);
for (const attempt of ['Alice', 'alice', ' ALICE ']) {
const result = joinLobby(lobby, attempt);
assert.equal(result.ok, false, `"${attempt}" was allowed alongside Alice`);
if (!result.ok) assert.equal(result.code, 'NAME_TAKEN');
}
});
it('frees the name again when that player leaves', () => {
const { lobby, session } = createLobby(competitive, 'Alice', 'RAIL-0001', 4);
const joined = joinLobby(lobby, 'Bob');
assert.ok(joined.ok);
if (!joined.ok) return;
const after = leaveLobby(joined.lobby, joined.session.token);
const again = joinLobby(after.lobby, 'Bob');
assert.equal(again.ok, true, 'a departed player’s name was still held against the table');
assert.equal(after.empty, false, 'the host is still seated');
assert.equal(after.lobby.hostToken, session.token, 'a non-host leaving moved the host chair');
});
});
describe('leaving a lobby', () => {
/**
* There was no way out at all before 2026-08-23. Start needs every chair filled and `setBotSeat`
* refuses to touch an occupied human seat, so a mis-join or a player who wandered off wedged the
* whole table.
*/
const table = () => {
const { lobby, session } = createLobby(competitive, 'Alice', 'RAIL-0001', 3);
const bob = joinLobby(lobby, 'Bob');
assert.ok(bob.ok);
if (!bob.ok) throw new Error('Bob could not sit down');
const carol = joinLobby(bob.lobby, 'Carol');
assert.ok(carol.ok);
if (!carol.ok) throw new Error('Carol could not sit down');
return { lobby: carol.lobby, alice: session, bob: bob.session, carol: carol.session };
};
it('empties the chair and forgets the token, leaving the rest of the table alone', () => {
const { lobby, bob } = table();
const after = leaveLobby(lobby, bob.token);
assert.equal(after.lobby.seats[1], null, 'the seat was not freed');
assert.equal(after.lobby.seats.length, 3, 'the table changed size');
assert.ok(!after.lobby.joinOrder.includes(bob.token), 'the departed token is still in the join order');
assert.equal((after.lobby.seats[0] as { displayName: string }).displayName, 'Alice');
assert.equal((after.lobby.seats[2] as { displayName: string }).displayName, 'Carol');
});
it('names a seat to clear somebody else — what the host’s "remove" does', () => {
const { lobby, alice, carol } = table();
const after = leaveLobby(lobby, alice.token, 2);
assert.equal(after.lobby.seats[2], null, 'the named seat was not cleared');
assert.ok(!after.lobby.joinOrder.includes(carol.token));
// Seat index IS player index (`lobby.ts`), so nothing may shuffle up to fill the hole.
assert.equal((after.lobby.seats[0] as { displayName: string }).displayName, 'Alice');
assert.equal((after.lobby.seats[1] as { displayName: string }).displayName, 'Bob');
});
it('passes host rights on when the host is the one who leaves', () => {
const { lobby, alice, bob } = table();
const after = leaveLobby(lobby, alice.token);
assert.equal(after.lobby.hostToken, bob.token, 'the earliest-joined remaining player did not become host');
assert.equal(after.empty, false);
});
it('reports the table empty when the last human goes, so the caller can drop it', () => {
const { lobby, alice, bob, carol } = table();
const one = leaveLobby(lobby, bob.token);
const two = leaveLobby(one.lobby, carol.token);
assert.equal(two.empty, false, 'Alice is still sitting there');
const three = leaveLobby(two.lobby, alice.token);
assert.equal(three.empty, true, 'a lobby with nobody human in it did not report itself empty');
});
it('leaves a bot seat to the bot controls, and ignores a token holding no seat', () => {
const { lobby, alice } = table();
const withBot = setBotSeat(leaveLobby(lobby, alice.token, 1).lobby, 1, true);
// `leaveLobby` is about people. A bot is removed with the button that put it there.
const after = leaveLobby(withBot, alice.token, 1);
assert.deepEqual(after.lobby.seats[1], { kind: 'bot' }, 'leaving cleared a bot seat');
const stranger = leaveLobby(withBot, 'not-a-token');
assert.equal(stranger.lobby, withBot, 'an unknown token changed the lobby');
});
});
+65
View File
@@ -347,3 +347,68 @@ describe('a save survives a release that did not change the rules', () => {
assert.equal(r.ok, false, 'a truncated replay was returned as a healthy session');
});
});
describe('the four transient signals (2026-08-23)', () => {
/**
* Multiplayer had none of these: `createRemoteSession` answered every one of them with an empty
* value, so a game on a server had no sound, no timetable flash, no announcement when a completed
* run paid the table, and no badge on the card you had just drawn. They ride on the push now — and
* `justDrawn` is the one that has to be careful, because `game.justDrawn` is ONE field for the
* whole game and does not say whose card it is.
*/
/** Drives the game until the current actor draws a card, and returns that turn's pushes. */
const drawSomething = (session: GameSession, players: number): { seat: PlayerIndex; pushes: Map<PlayerIndex, Push> } => {
for (let seat = 0 as PlayerIndex; seat < players; seat++) {
if (session.connect(seat).menu === null) continue;
// Two steps: §6's three options are exclusive, so the turn is spent on drawing before a card
// actually leaves the deck.
const chose = session.intent(seat, 1, { type: 'localOps.choose', option: 'draw' });
assert.equal(chose.accepted, true, 'the actor could not choose to draw');
const result = session.intent(seat, 2, { type: 'draw.fromHomeOffice' });
assert.equal(result.accepted, true, 'the actor could not draw from the Home Office deck');
if (!result.accepted) throw new Error('unreachable');
return { seat, pushes: result.pushes };
}
throw new Error('no seat was able to act');
};
it('sends the drawn card to the seat that drew it, and to nobody else', () => {
const session = createSession(42, config, ['Alice', 'Bob', 'Carol']);
const { seat, pushes } = drawSomething(session, 3);
const mine = pushes.get(seat);
assert.ok(mine?.justDrawn, 'the drawing seat was not told which card it drew');
for (const [other, push] of pushes) {
if (other === seat) continue;
assert.equal(
push.justDrawn,
undefined,
`seat ${other} was told which card seat ${seat} drew — that is a hand leak`,
);
}
});
it('keeps the badge across a reconnect, still only for its owner', () => {
const session = createSession(42, config, ['Alice', 'Bob', 'Carol']);
const { seat, pushes } = drawSomething(session, 3);
const drawn = pushes.get(seat)?.justDrawn;
assert.equal(session.connect(seat).justDrawn, drawn, 'a refresh lost the card the player just drew');
const other = ((seat + 1) % 3) as PlayerIndex;
assert.equal(session.connect(other).justDrawn, undefined, 'a reconnecting seat was told about someone else’s draw');
});
it('sends the shared signals to every seat, identically, and drains them', () => {
// A collision anywhere on the Division, the Stage bell, a train running off the end and paying
// everyone: these are the table's, not one player's.
const session = createSession(42, config, ['Alice', 'Bob', 'Carol']);
const { pushes } = drawSomething(session, 3);
const cues = [...pushes.values()].map((p) => JSON.stringify(p.cues ?? []));
assert.equal(new Set(cues).size, 1, 'seats were sent different sound cues for the same events');
// Drained: a signal marks a moment, so a later connect must not replay it.
const later = session.connect(0 as PlayerIndex);
assert.equal(later.cues, undefined, 'a reconnecting client was sent the sounds of what it missed');
assert.equal(later.announcement, undefined, 'a reconnecting client was re-sent an old announcement');
assert.equal(later.scheduled, undefined, 'a reconnecting client was re-sent an old timetable flash');
});
});
+559 -106
View File
@@ -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',
);
});