Two setup screens, not three — the in-game dialog is deleted

Jesse: "it should not go to a separate screen. We should reuse the
Solitaire New Game Screen… in general we should reuse what we already
have."

#newgamedlg was a third copy of the same questions and the one that
drifted: shown only to a solitaire player, it asked "Everyone loses if
COMBINED Revenue…" and explained Employee Rotation in full multiplayer
terms beside a control it had disabled. Both were on the list to
re-word; deleting the screen removes the drift instead of restating it.

New game opens the setup screen IN PLACE rather than navigating, so the
live session stays in memory: the fields open on the rules actually
being played (what the dialog was good for), and Continue Existing Saved
Game puts the board back with no reload. render() calls save() every
frame, so nothing is at risk either way.

The two remaining screens now match below their headers — same three
parameters in the same order, same seed note, same chair note. Solitaire
shows Players at the table locked at 1 rather than omitting it: a fixed
control says "same form, table of one", a missing one made it a
different form sharing a rules block.

Drift guard drops to two prefixes and now fails if any ng- id returns.

Stays in the unshipped v0.7.9. 874 tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
This commit is contained in:
Jesse.Markowitz
2026-08-30 07:58:56 -04:00
co-authored by Claude Sonnet 5
parent cf018b4a5f
commit 131538dc7c
4 changed files with 173 additions and 316 deletions
+30
View File
@@ -124,6 +124,36 @@ orientation**, 276 east / 259 west. `setup.ts` is the only place a Mainline node
rolls the direction from the seed for every grade, so the `?? 'east'` fallbacks in `view.ts` and
`advance.ts` are unreachable.
### Two setup screens, not three — the in-game dialog is deleted
Jesse, 2026-08-30: "If you are in the Solitaire game and you click the new game dialog, it should not
go to a separate screen. We should reuse the Solitaire New Game Screen… in general we should reuse
what we already have."
`#newgamedlg` was a third copy of the same questions and the one that drifted. It was shown ONLY to
a solitaire player, and it asked "Everyone loses if **combined** Revenue at the end is under" — a
table's question, put to one person — and explained Employee Rotation in full, in multiplayer terms,
beside a control it had itself disabled. Both were on the list to re-word. Deleting the screen
removes the drift instead of restating it, which is the cheaper fix and the one that cannot drift
again.
**New game now opens the setup screen in place.** Not a navigation: the live session stays in memory,
so the fields open on the rules actually being played — which is what the dialog was good for, and
losing "change one dial, redeal, compare" would have been a real loss — and **Continue Existing Saved
Game** puts the board straight back with no reload and no replay. Nothing is at risk either way:
`render()` calls `save()` every frame, so the game in progress is always on disk.
**And the two remaining screens now match below their headers.** Each keeps its own opening — the
lobby's join/secret section and "Create a new game" are multiplayer's alone — but from the parameters
down they are one form: the same three fields in the same order (seed, players at the table, days),
the same note about a seed and settings dealing the same railroad, and the same note about every
chair being taken. Solitaire shows **Players at the table** too, locked at one, rather than omitting
it — a control that is present and fixed says "this is the same form, at a table of one", where a
missing one just made it a different form that happened to share a rules block.
The drift guard that had been checking three prefixes now checks two, and a new assertion fails if
any `ng-` id ever reappears.
### Three wording and layout fixes
- **The collision entries** on all three screens now read "The game ends immediately and results in
+48 -64
View File
@@ -2137,65 +2137,26 @@ function commitNewGame(wired: WiredGameType, seedFieldValue: string): void {
else location.search = next;
}
/**
* THE IN-GAME "NEW GAME" BUTTON GOES TO THE SETUP SCREEN (Jesse, 2026-08-30 — "it should not go to
* a separate screen. We should reuse the Solitaire New Game Screen").
*
* `#newgamedlg` used to be a third copy of the same questions and the one that drifted: it carried
* multiplayer wording on a screen only a solitaire player ever sees. It is deleted; this navigates
* to the screen that already asks these questions properly.
*
* Nothing is lost on the way: `render()` calls `save()` every frame, so the game in progress is
* always on disk, and the setup screen offers "Continue Existing Saved Game" to come back to it.
*/
const newBtn = document.getElementById('newgame');
const dlg = document.getElementById('newgamedlg') as HTMLDialogElement | null;
if (newBtn && dlg) {
const field = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
/**
* THE SAME FIVE GAME TYPES THE LOBBY OFFERS, and the same shared rules block under them.
*
* The dialog used to carry its own copy of the questions and its own idea of the defaults, which
* is how it ended up with "where an Extra may start" that the lobby did not have and none of the
* three optional rules that it did. Every screen now reads `presets.ts` and drives its block
* through `settings-form.ts`; only Solitaire can actually be DEALT here, so the three multiplayer
* types are shown disabled rather than hidden — what this screen offers and what the lobby offers
* should read as one list, not two.
*/
const ng = wireGameTypeBlock('ng-', dlg);
/**
* ASK FOR ALL OF IT, rather than documenting URL parameters in the title bar.
*
* It asked for the seed alone, through `prompt()`. The opening hand and the three revenue rates
* were constants in the source, so trying a variation meant an edit and a rebuild — and balance is
* the open question this game has (`TODO.md`). A dialog is what lets a playtest be a playtest.
*
* The dialog OPENS ON THE RULES IN PLAY rather than on the defaults: dealing a second game to
* compare against the first is the common case, and re-entering settings each time is how a
* comparison silently stops comparing. Which TYPE that is comes out of the comparison — a game
* dealt at the Solitaire defaults reopens on Solitaire, and one that was tuned reopens on Custom
* with every changed field marked.
*/
if (newBtn) {
newBtn.onclick = () => {
// The button itself is hidden for a session that cannot deal (`applyCapabilities`), but the
// dialog's whole answer-reading/URL-navigating flow below assumes a LocalSession throughout, so
// the guard is repeated — and `local` is captured as a `const` so the narrowing survives the
// closures below it (see `renderUndo`'s identical note on why `session` itself cannot be).
if (!isLocal(session)) return;
const local = session;
const f = local.view();
const day = f.day;
const started = f.status === 'active' && (day > 1 || f.stage > 1);
if (started && !confirm(`Forget this game (seed ${local.seed()}, Day ${day}) and deal a new one?`)) return;
field<HTMLInputElement>('ng-seed').value = '';
field<HTMLInputElement>('ng-days').value = String(f.days);
ng.setBase('solitaire', 'solitaire');
// The rules actually in play, then the comparison decides what to call them.
ng.form.write(settingsOf(configFromFrame(f)), presetSettings('solitaire', 1, f.days));
ng.refresh();
dlg.showModal();
showScreen('solitairesetup');
// IN PLACE, not a navigation: the live session stays in memory, so the fields can open on the
// rules actually being played and "Continue Existing Saved Game" is just showing the board
// again rather than a reload and a replay.
runSolitaireSetup(new URLSearchParams(), true, session.view());
};
/**
* One handler for every way the dialog can close — the Deal button, the Cancel button, and Esc,
* which `<dialog>` answers with an empty `returnValue` and no submit event at all.
*/
dlg.addEventListener('close', () => {
if (dlg.returnValue !== 'deal') return;
commitNewGame(ng, field<HTMLInputElement>('ng-seed').value);
});
}
/**
@@ -2208,7 +2169,7 @@ if (newBtn && dlg) {
* Solitaire defaults, since there is no live game to compare against yet, and reuses the identical
* `wireGameTypeBlock`/`commitNewGame` pair the in-game dialog uses — the two are one design, not two.
*/
function runSolitaireSetup(params: URLSearchParams, hasSave = false): void {
function runSolitaireSetup(params: URLSearchParams, hasSave = false, live: Frame | null = null): void {
const screen = document.getElementById('solitairesetup');
const dealBtn = document.getElementById('ss-deal');
if (!screen || !dealBtn) return;
@@ -2220,25 +2181,48 @@ function runSolitaireSetup(params: URLSearchParams, hasSave = false): void {
const seedField = document.getElementById('ss-seed') as HTMLInputElement | null;
if (seedField) seedField.value = params.get('seed') ?? '';
/**
* WHAT THE FIELDS OPEN ON, and it is not the same question in both directions.
*
* Reached mid-game from "New game", this opens on the rules CURRENTLY IN PLAY — that is what the
* deleted dialog was good for, and losing it would make "change one dial and redeal to compare"
* impossible. Reached from the splash, there is no game to read, so it opens on the plain
* Solitaire defaults.
*/
if (live) {
const daysField = document.getElementById('ss-days') as HTMLInputElement | null;
if (daysField) daysField.value = String(live.days);
ss.setBase('solitaire', 'solitaire');
ss.form.write(settingsOf(configFromFrame(live)), presetSettings('solitaire', 1, live.days));
ss.refresh();
} else {
ss.selectPreset('solitaire');
}
/**
* THE WAY BACK TO A GAME IN PROGRESS, and the reason the door is allowed to outrank a save at all.
* Dealing from here calls `clearSave()`, so a player who reached this screen from the splash — by
* clicking "Play solitaire", which nobody reads as "throw away what I was playing" — needs their
* game one button away and needs to be told what Deal costs.
*
* Resuming is a navigation to the BARE url rather than a session built here: `start()` already
* resumes a save on a bare load, and routing both paths through it keeps one place that turns a
* URL into a game.
* Mid-game the game is still in memory, so going back is just showing it again. From the splash
* there is nothing loaded yet, so it is a navigation to the bare URL and `start()` restores the
* save — one place that turns a URL into a game, either way.
*/
const resumeBtn = document.getElementById('ss-resume');
const savedNote = document.getElementById('ss-saved-note');
const canResume = hasSave || live !== null;
if (resumeBtn) {
resumeBtn.hidden = !hasSave;
resumeBtn.onclick = () => void (location.search = '');
resumeBtn.hidden = !canResume;
resumeBtn.onclick = live
? () => {
showScreen('gameui');
render();
}
: () => void (location.search = '');
}
if (savedNote) savedNote.hidden = !hasSave;
if (savedNote) savedNote.hidden = !canResume;
ss.selectPreset('solitaire');
dealBtn.onclick = () => commitNewGame(ss, seedField?.value ?? '');
}
+27 -181
View File
@@ -318,9 +318,10 @@ ul.blocked li{padding:2px 0}
<!-- THE LOBBY (Phase 4) — shown instead of the game UI whenever there is no game yet to play: no
stored session token, or a token whose game hasn't started. `lobby.ts` owns everything in here;
`main.ts` only decides whether THIS div or `#gameui` below is the one currently visible.
`#newgamedlg` at the very end of the body is solitaire-only, and asks the same questions through
the same shared module (`settings-form.ts`) — the two blocks are generated from one template. -->
`main.ts` only decides whether THIS div, `#solitairesetup` or `#gameui` is the one visible.
`#solitairesetup` asks the same questions of a solitaire player, through the same shared module
(`settings-form.ts`) — the two blocks are generated from one template, and since 2026-08-30
they are the ONLY two: the in-game dialog that was a third copy is gone. -->
<div id="lobby" hidden>
<header><b><a href="./index.html" class="home">Station Master</a></b> — <span class="dim">Multiplayer</span></header>
@@ -627,14 +628,26 @@ ul.blocked li{padding:2px 0}
<p class="ng-note">One railroad, one player, five full days by default — everything below is
yours to change before you deal. Clearing the Revenue floor wins; falling short loses.</p>
<!-- THE SAME THREE PARAMETERS THE LOBBY ASKS, in the same order, with the same note under them
(Jesse, 2026-08-30: "everything beneath that should be the same"). The table size is here
rather than hidden because it is one of the three things that describe a game, and leaving
it out made this screen a different form that happened to share a rules block. It is LOCKED
at one: a `LocalSession` runs the engine in this browser and a table needs a server, which
is the same reason the four multiplayer game types are shown disabled below. -->
<div class="lb-params">
<label class="ng-num"><span>Seed</span>
<input id="ss-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed"></label>
<label class="ng-num"><span>Players at the table</span>
<select id="ss-players" disabled>
<option value="1" selected>1</option>
</select></label>
<label class="ng-num"><span>Days</span>
<input id="ss-days" type="number" min="1" max="20" step="1" value="5"></label>
</div>
<p class="ng-note">The same seed and the same settings always deal the same railroad, so a game
can be shared, compared or replayed. Leave it blank for a random one.</p>
<p class="ng-note">Every chair has to be taken before the game can start — here that is the one
chair, and it is yours. To play with other people, use the Multiplayer door instead.</p>
<h3>Game type</h3>
<div class="set-row" id="ss-type-row">
@@ -821,7 +834,7 @@ ul.blocked li{padding:2px 0}
</span>
<button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button>
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
<button id="newgame" title="Deal a fresh game. You choose the seed, the opening hand and what the three economies pay. Undo steps back one action at a time; this throws the whole game away, so download the replay first if you want to keep it.">New game</button>
<button id="newgame" title="Set up a fresh game — the seed, the table, the opening hand and what the three economies pay. Opens the same screen a new solitaire game starts from, with your current rules filled in; your game in progress is kept until you press Deal, and Continue puts it straight back.">New game</button>
<button id="multiplayer" title="Create or join a Competitive or Co-op game on this server, with other players.">Multiplayer</button>
<!-- LEAVING A RUNNING GAME. 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
@@ -902,187 +915,20 @@ ul.blocked li{padding:2px 0}
</div>
</main>
<!-- ===================================================================
NEW GAME — the seed, the opening hand, and what the three economies pay.
<!-- THE IN-GAME "NEW GAME" BUTTON GOES TO THE SOLITAIRE SETUP SCREEN — there is no second
dialog any more (Jesse, 2026-08-30: "it should not go to a separate screen. We should reuse the
Solitaire New Game Screen… in general we should reuse what we already have").
It was a `prompt()` asking for a seed. Two of the three things that decide what kind of game
you are about to play had no way in at all: the opening hand had been changed twice with no
way back to the earlier rule, and the revenue rates were constants in the source. Balance is
the open question in this game (`TODO.md`), and the way to settle it is to deal several games
at different settings — which needs a dialog, not a rebuild.
`#newgamedlg` was a third copy of the same questions, and the one that drifted: it kept the
multiplayer wording ("Everyone loses if COMBINED Revenue…") on a screen only ever shown to a
solitaire player, and explained Employee Rotation in full beside a control it had disabled.
Deleting it removes the drift rather than re-wording it.
Every control has a default that is the recommended answer, so DEAL with nothing touched is a
complete, sensible game. The settings ride in the URL alongside the seed, because a seed alone
no longer names a game: `?seed=430` with a different opening hand is a different railroad.
==================================================================== -->
Nothing is lost by navigating away mid-game: `render()` calls `save()` on every frame, so the
game in progress is always on disk, and the setup screen offers "Continue Existing Saved Game"
to come straight back to it. -->
</div><!-- /gameui -->
<dialog id="newgamedlg" aria-labelledby="ng-title">
<form method="dialog" id="newgameform">
<h2 class="big" id="ng-title">New game</h2>
<!-- THE SAME BLOCK THE LOBBY USES, same shared module, same order — the two screens are one
design. Only Solitaire can be dealt here; the multiplayer types are shown disabled rather
than hidden, so what this screen offers and what the lobby offers read as one list. -->
<div class="lb-params">
<label class="ng-num"><span>Seed</span>
<input id="ng-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed"></label>
<label class="ng-num"><span>Days</span>
<input id="ng-days" type="number" min="1" max="20" step="1" value="5"></label>
</div>
<p class="ng-note">The same seed and the same settings always deal the same railroad, so a game
can be shared, compared or replayed. Leave it blank for a random one.</p>
<h3>Game type</h3>
<div class="set-row" id="ng-type-row">
<label class="ng-radio"><input type="radio" name="ng-type" value="solitaire">
<span><b>Solitaire</b><br><span class="dim">One railroad, one player. The whole Division is yours to run.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-type" value="coop" checked>
<span><b>Co-op</b><br><span class="dim">Everyone&#8217;s Revenue is one table score. You win together or lose together.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-type" value="competitive">
<span><b>Competitive</b><br><span class="dim">Highest Revenue wins — unless the table misses its combined minimum, and then everyone loses.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-type" value="cutthroat">
<span><b>Cutthroat</b><br><span class="dim">Highest Revenue wins, and nothing is shared — the only way everyone loses is three collisions in one Day.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-type" value="custom">
<span><b>Custom</b><br><span class="dim">Whatever you set below. Selected for you the moment you change a rule; it is scored as the type you started from.</span></span></label>
</div>
<p class="ng-note" id="ng-type-note"></p>
<details id="ng-settings" open>
<summary>Game settings</summary>
<p class="ng-note">Every rule the game type sets, and every one of them yours to change.
Changing any of them selects <b>Custom</b>; clicking a type again resets all of them back
to it.</p>
<div class="set-groups">
<div class="set-group">
<h3>Starting hand</h3>
<p class="ng-note">What each player is dealt before the first turn. The hand limit is three
either way — deal six and the first turn is spent choosing which of them to keep.</p>
<div class="set-row" id="ng-hand-row">
<label class="ng-radio"><input type="radio" name="ng-hand" value="threeRandom">
<span><b>Three random cards</b><br><span class="dim">The original rule. At the hand limit already, and no guarantee of track.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-hand" value="sixRandom" checked>
<span><b>Six random cards</b><br><span class="dim">Twice the choice, still no guaranteed track — the first turn is a discard.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-hand" value="threeTrackThreeOther">
<span><b>Three random track and three random non-track cards</b><br><span class="dim">Dealt from two piles, so the district you can build is dealt rather than waited for.</span></span></label>
<span class="set-hint" id="ng-hand-hint"></span>
</div>
</div>
<div class="set-group">
<h3>Where an Extra may start</h3>
<p class="ng-note">The player who plays an Extra Train card chooses where its Crew Tray goes,
and the place decides which way it runs — a Division Point sends it away from itself; in the
middle of the railroad the player picks east or west. The Division Points and the Interchange
belong to nobody and are always available. Starting one inside a district is the part that
favours a seat, so it is set here. An Office must be a Control Point whatever this says: a
Whistle Post never qualifies.</p>
<div class="set-row" id="ng-extra-row">
<label class="ng-radio"><input type="radio" name="ng-extra" value="divisionPointsOnly">
<span><b>Division Points and the Interchange only</b><br><span class="dim">The strictest reading. Every Extra begins on shared ground.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-extra" value="ownOffice">
<span><b>Also the playing player&#8217;s own Control Point</b><br><span class="dim">You may start one at home, but not in somebody else&#8217;s district.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-extra" value="anyOffice">
<span><b>Also any player&#8217;s Control Point</b><br><span class="dim">The most permissive — an Extra may be planted in another player&#8217;s district.</span></span></label>
<span class="set-hint" id="ng-extra-hint"></span>
</div>
</div>
<div class="set-group">
<h3>Revenue</h3>
<p class="ng-note">What each piece of work pays, 0 to 5. A coach pays when it is boarded and
again when it is detrained; a load pays when it is made up and again when it is broken. Zero
switches an economy off so the others can be read.</p>
<div class="set-row" id="ng-passenger-row">
<label class="ng-num"><span>Passenger revenue per coach</span>
<input id="ng-passenger" type="number" min="0" max="5" step="1" value="1"></label>
<span class="set-hint" id="ng-passenger-hint"></span>
</div>
<div class="set-row" id="ng-freight-row">
<label class="ng-num"><span>Freight revenue per load</span>
<input id="ng-freight" type="number" min="0" max="5" step="1" value="1"></label>
<span class="set-hint" id="ng-freight-hint"></span>
</div>
<div class="set-row" id="ng-transit-row">
<label class="ng-num"><span>Train revenue per transit</span>
<input id="ng-transit" type="number" min="0" max="5" step="1" value="0"></label>
<span class="set-hint" id="ng-transit-hint"></span>
</div>
<p class="ng-note">A transit pays every player, once, when a train runs off the end of the
Division — the one thing nobody has to work for.</p>
</div>
<div class="set-group">
<h3>Victory conditions</h3>
<p class="ng-note">The ways this game can end badly. Each one is switched on or off in its own
right; how long the game runs is set above, with the table size.</p>
<div class="set-row" id="ng-minrev-row">
<label class="ng-gate"><input type="checkbox" id="ng-minrev-on" checked>
<span>Everyone loses if combined Revenue at the end is under</span>
<input id="ng-minrev" type="number" min="0" step="1" class="gate-num"></label>
<span class="set-hint" id="ng-minrev-hint"></span>
</div>
<div class="set-row" id="ng-colday-row">
<label class="ng-gate"><input type="checkbox" id="ng-colday-on" checked>
<span>The game ends immediately and results in a loss if collisions in one Day reach</span>
<input id="ng-colday" type="number" min="0" step="1" class="gate-num"></label>
<span class="set-hint" id="ng-colday-hint"></span>
</div>
<div class="set-row" id="ng-coltotal-row">
<label class="ng-gate"><input type="checkbox" id="ng-coltotal-on" checked>
<span>The game ends immediately and results in a loss after this many collisions in the whole game</span>
<input id="ng-coltotal" type="number" min="0" step="1" class="gate-num"></label>
<span class="set-hint" id="ng-coltotal-hint"></span>
</div>
<p class="ng-note">The opponent-directed cards — Derail, Watertower, Hobo Jungle and the
nineteen others, along with the seven that answer them — are not implemented yet, so no game
type deals them whatever else is set here.</p>
</div>
<div class="set-group">
<h3>Optional rules</h3>
<p class="ng-note">Off in every game type; each one changes how the game plays.</p>
<div class="set-row" id="ng-visibility-row">
<label class="ng-num"><span>Reduced Visibility — five switching Moves instead of six in the
night Stages (1&ndash;3 and 11&ndash;12)</span>
<input id="ng-visibility" type="checkbox"></label>
<span class="set-hint" id="ng-visibility-hint"></span>
</div>
<div class="set-row" id="ng-rotation-row">
<label class="ng-num"><span>Employee Rotation — at the end of each Day everyone moves one
chair left and takes over the next station up the line. Your Revenue and the Fedora go with
you; the district stays where it is</span>
<input id="ng-rotation" type="checkbox"></label>
<span class="set-hint" id="ng-rotation-hint"></span>
</div>
<div class="set-row" id="ng-toolbox-row">
<label class="ng-num"><span>Emergency Toolbox — everyone starts holding a Red Flag, so a hand
of four; play or discard down to three on the first turn</span>
<input id="ng-toolbox" type="checkbox"></label>
<span class="set-hint" id="ng-toolbox-hint"></span>
</div>
<div class="set-row" id="ng-tossloco-row">
<label class="ng-num"><span>A Timetabled train may be discarded — toss it face-up to a
Department slot, where a rival may pick it up. Turn this off and a train card can only ever
be played onto the timetable. An Extra is never discardable either way</span>
<input id="ng-tossloco" type="checkbox"></label>
<span class="set-hint" id="ng-tossloco-hint"></span>
</div>
</div>
</div>
</details>
<menu class="ng-buttons">
<span class="ng-note" id="ng-multiplayer-note" style="margin:0 auto 0 0">Use the <b>Multiplayer</b> button instead — it creates or joins a game on this server.</span>
<button value="cancel" id="ng-cancel" type="submit" formnovalidate>Cancel</button>
<button value="deal" id="ng-deal" type="submit">Deal</button>
</menu>
</form>
</dialog>
<!-- THE DAY ROLLING OVER (Gitea#10). A Day turns inside the automatic phases, so it happens
between one click and the next; the phase banner and the announcement flash both fade before
someone reading the board notices them. A modal stops and waits, which is the whole request:
+68 -71
View File
@@ -3739,9 +3739,9 @@ describe('the lobby screen', () => {
'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'),
'ss-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
'ss-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
'ss-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'solitaire'),
};
const matching = (sel: string): Radio[] => {
const name = /name="([^"]+)"/.exec(sel)?.[1] ?? '';
@@ -3994,7 +3994,7 @@ describe('the lobby screen', () => {
});
});
describe('the lobby and the dialog ask the same questions', () => {
describe('the lobby and the setup screen ask the same questions', () => {
/**
* THE DRIFT GUARD.
*
@@ -4009,20 +4009,20 @@ describe('the lobby and the dialog ask the same questions', () => {
return readFileSync(join(dist, 'play.html'), 'utf8');
};
it('carries every field of the shared block on all three screens', () => {
it('carries every field of the shared block on both screens', () => {
// `ss-` joined `lb-`/`ng-` 2026-08-29: the pre-game solitaire setup screen drives the identical
// block ("asking first is the only path"). Same drift guard, one more prefix.
const html = page();
for (const prefix of ['lb-', 'ng-', 'ss-']) {
for (const prefix of ['lb-', 'ss-']) {
for (const selector of fieldSelectors(prefix)) {
assert.ok(html.includes(selector), `the ${prefix} block is missing ${selector}`);
}
}
});
it('offers all five game types on all three screens', () => {
it('offers all five game types on both screens', () => {
const html = page();
for (const prefix of ['lb-', 'ng-', 'ss-']) {
for (const prefix of ['lb-', 'ss-']) {
for (const type of ['solitaire', 'coop', 'competitive', 'cutthroat', 'custom']) {
assert.ok(
html.includes(`name="${prefix}type" value="${type}"`),
@@ -4049,13 +4049,17 @@ describe('the lobby and the dialog ask the same questions', () => {
// 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.ok(!html.includes('id="ss-pvp"'), 'the setup screen still has the dead PvP checkbox');
// The in-game dialog was a THIRD copy of this block and the one that drifted — it kept the
// multiplayer wording on a solitaire-only screen. Deleted 2026-08-30; nothing may reintroduce
// a prefix that no screen owns.
assert.ok(!/id="ng-/.test(html), 'the deleted in-game dialog has come back');
assert.match(html, /opponent-directed cards[\s\S]{0,120}not implemented yet/i);
});
});
describe('the New Game dialog', () => {
describe('the solitaire setup screen', () => {
/**
* DRIVEN THROUGH THE EMITTED BUNDLE, like the highlight test above, because the thing that can go
* wrong here is wiring rather than logic: an id that does not match the HTML, a handler on the
@@ -4097,14 +4101,10 @@ describe('the New Game dialog', () => {
}));
// 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.
// ONE set, matching the markup's own `checked` defaults. There were two until 2026-08-30, one
// per screen, because the in-game dialog was a second copy of this block — it is deleted, and
// the setup screen it folded into is the only thing on the page driving these radios now.
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'),
// The pre-game setup screen (Gitea, "asking first is the only path", 2026-08-29) drives the
// same shared block under the `ss-` prefix — one set here too, matching the markup's own
// `checked` defaults rather than the dialog's (Solitaire, not Co-op: there is no live game to
// reopen on, so the static default IS the Solitaire default).
'ss-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
'ss-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
'ss-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'solitaire'),
@@ -4176,15 +4176,15 @@ describe('the New Game dialog', () => {
/** 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'],
tossloco: els.get('ng-tossloco')!['checked'],
hand: groups['ss-hand']!.find((r) => r.checked)?.value,
extra: groups['ss-extra']!.find((r) => r.checked)?.value,
type: groups['ss-type']!.find((r) => r.checked)?.value,
passenger: els.get('ss-passenger')!['value'],
freight: els.get('ss-freight')!['value'],
transit: els.get('ss-transit')!['value'],
days: els.get('ss-days')!['value'],
minrev: els.get('ss-minrev')!['value'],
tossloco: els.get('ss-tossloco')!['checked'],
});
it('opens on the rules in play, so a second game can be dealt to compare with the first', async () => {
@@ -4193,9 +4193,9 @@ describe('the New Game dialog', () => {
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('solitairesetup')!['hidden'], false, 'the New game button did not open the setup screen');
assert.equal(els.get('gameui')!['hidden'], true, 'the board is still showing over the setup screen');
assert.equal(els.get('ss-seed')!['value'], '', 'the seed box kept the last game’s seed');
const form = readForm(els, groups);
assert.equal(form.passenger, '2');
assert.equal(form.freight, '3');
@@ -4230,7 +4230,7 @@ describe('the New Game dialog', () => {
(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']),
String(tuned.els.get('ss-type-note')!['textContent']),
/1 setting differs from Solitaire/,
'the note did not say what differs',
);
@@ -4239,29 +4239,30 @@ describe('the New Game dialog', () => {
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 disabled = groups['ng-type']!
const disabled = groups['ss-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',
assert.match(
readFileSync(join(dist, 'play.html'), 'utf8'),
/use the Multiplayer door instead/,
'nothing on the setup screen says where a multiplayer game comes from',
);
});
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)();
els.get('ss-days')!['value'] = '8';
(els.get('ss-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 solitaire = groups['ng-type']!.find((r) => r.value === 'solitaire')!;
for (const r of groups['ng-type']!) r.checked = r === solitaire;
const solitaire = groups['ss-type']!.find((r) => r.value === 'solitaire')!;
for (const r of groups['ss-type']!) r.checked = r === solitaire;
(solitaire as { onchange: (() => void) | null }).onchange!();
const form = readForm(els, groups);
@@ -4274,15 +4275,13 @@ describe('the New Game dialog', () => {
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 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)();
els.get('ss-seed')!['value'] = '99';
for (const r of groups['ss-hand']!) r.checked = r.value === 'threeTrackThreeOther';
els.get('ss-passenger')!['value'] = '5';
els.get('ss-freight')!['value'] = '0';
els.get('ss-transit')!['value'] = '2';
els.get('ss-toolbox')!['checked'] = true;
(els.get('ss-deal')!['onclick'] as () => void)();
assert.equal(
n.search,
@@ -4294,26 +4293,28 @@ describe('the New Game dialog', () => {
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)();
els.get('ss-coltotal-on')!['checked'] = false;
(els.get('ss-coltotal-on')!['onchange'] as () => void)();
(els.get('ss-deal')!['onclick'] 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: 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(n.search, '?seed=430', `closing with "${returnValue}" navigated`);
assert.equal(n.reloads, 0, `closing with "${returnValue}" reloaded`);
}
it('backing out deals nothing and puts the same game back on screen', async () => {
/**
* WHAT CANCEL USED TO BE. The dialog had a Cancel button and an Esc key, and this pinned that
* neither dealt. The screen that replaced it has neither — it has "Continue Existing Saved
* Game", which has to do the same job and one more besides: the game was never navigated away
* from, so going back is showing the board again rather than reloading and replaying it.
*/
const { els, nav: n } = await load('?seed=430');
(els.get('newgame')!['onclick'] as () => void)();
assert.equal(els.get('ss-resume')!['hidden'], false, 'mid-game there is no way back to the game');
(els.get('ss-resume')!['onclick'] as () => void)();
assert.equal(els.get('gameui')!['hidden'], false, 'backing out did not return to the board');
assert.equal(els.get('solitairesetup')!['hidden'], true, 'the setup screen stayed up');
assert.equal(n.search, '?seed=430', 'backing out navigated');
assert.equal(n.reloads, 0, 'backing out reloaded, losing the game in memory');
});
it('reloads when the answers are the URL the page already has, so a re-deal is not a no-op', async () => {
@@ -4323,9 +4324,7 @@ describe('the New Game dialog', () => {
'?hand=sixRandom&extra=ownOffice&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)();
(els.get('ss-deal')!['onclick'] as () => void)();
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');
@@ -4451,10 +4450,8 @@ describe('the New Game dialog', () => {
// optional box is not worth writing.
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';
dlg['returnValue'] = 'deal';
(dlg['close'] as () => void)();
els.get('ss-seed')!['value'] = 'not a number';
(els.get('ss-deal')!['onclick'] as () => void)();
assert.equal(
n.search,