v0.7.5 — solitaire asks before it deals, the same way multiplayer already does
A new #solitairesetup screen in play.html asks the full shared game-options block — type, starting hand, Extra start, revenue, victory conditions, optional rules — before a genuinely fresh visit deals a game. A saved game, an explicit ?seed=, or a URL a Deal already wrote all skip past it, same as ?lobby already skips the front doors on an invite link. The in-game dialog, the lobby and this screen now share one wireGameTypeBlock()/commitNewGame() pair instead of the dialog carrying its own copy of the questions. 859 tests pass. Not yet played in a browser. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
a02d1fcffe
commit
8ec9af18a2
@@ -19,6 +19,45 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 0.7.5 — 2026-08-29
|
||||||
|
|
||||||
|
### Solitaire asks first, the same way multiplayer already does
|
||||||
|
|
||||||
|
Jesse: "let the user choose their options like the start of a multiplayer game"; "asking first is
|
||||||
|
the only path." A bare visit to `play.html` used to deal a game on the spot, at whatever defaults
|
||||||
|
`gameOptionsFromUrl` fell back to, and the only way to see or change a setting was to open the
|
||||||
|
in-game "New game" dialog after the fact — compare a hand you already have, not one you are about
|
||||||
|
to be dealt. The lobby has asked this question for every multiplayer game since v0.6.0; solitaire
|
||||||
|
never did.
|
||||||
|
|
||||||
|
A genuinely fresh visit now lands on a new `#solitairesetup` screen first: game type, starting hand,
|
||||||
|
where an Extra may start, the three revenue rates, victory conditions, and the three optional rules
|
||||||
|
— then a Deal button. A saved game, an explicit `?seed=`, or a URL a Deal already wrote (`hand` is
|
||||||
|
the field every write always sets, so its presence is what tells the difference) all skip straight
|
||||||
|
past it, the same way `?lobby` already skips the front doors on an invite link — those are not "no
|
||||||
|
plan yet", they are a choice already made, elsewhere.
|
||||||
|
|
||||||
|
**One shared block instead of two copies drifting apart.** The in-game dialog, the lobby, and now
|
||||||
|
this screen all drive the identical `settings-form.ts` block through one new function,
|
||||||
|
`wireGameTypeBlock()` — factored out of what used to be dialog-only code. Only Solitaire can be
|
||||||
|
dealt outside the lobby, so the setup screen shows the other four types exactly as the dialog always
|
||||||
|
has: present, disabled, with a note pointing at the Multiplayer door. Committing an answer — from
|
||||||
|
either the dialog or the setup screen — goes through one `commitNewGame()`, which builds the URL and
|
||||||
|
navigates; `start()` is still the only place that turns a URL into a game.
|
||||||
|
|
||||||
|
Prefilling is deliberately left to the caller rather than folded into `wireGameTypeBlock` itself:
|
||||||
|
the dialog opens on the game CURRENTLY IN PLAY, so redealing to compare keeps comparing against it,
|
||||||
|
while the setup screen opens on the plain Solitaire defaults, since there is no live game yet to
|
||||||
|
read.
|
||||||
|
|
||||||
|
`index.html`'s door copy changed to match: "Start a game" reads "Set up a game" now, and the blurb
|
||||||
|
states the floor (15, not "20 Revenue") since that is what a player is agreeing to before they deal.
|
||||||
|
|
||||||
|
859 tests pass. **Not yet played in a browser** — verified by `tsc --noEmit`, the full suite, and
|
||||||
|
reading the diff, not by loading `play.html` fresh and clicking through it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0.7.4 — 2026-08-29
|
## 0.7.4 — 2026-08-29
|
||||||
|
|
||||||
Three rules issues off the tracker, in the order Jesse asked for them: #13, #5, #19. All three are
|
Three rules issues off the tracker, in the order Jesse asked for them: #13, #5, #19. All three are
|
||||||
|
|||||||
@@ -308,6 +308,17 @@ Queued 2026-08-29, from building Gitea#11 and #16 (both shipped in v0.7.3, main
|
|||||||
Stage for switching, which needs it to know it wants time — a notion it does not have. Reasoning
|
Stage for switching, which needs it to know it wants time — a notion it does not have. Reasoning
|
||||||
and the measurement are under Bot Performance.
|
and the measurement are under Bot Performance.
|
||||||
|
|
||||||
|
42. ~~**Solitaire must ask before it deals, the same way multiplayer's lobby already does.**~~ — done
|
||||||
|
2026-08-29 in v0.7.5. Jesse: "let the user choose their options like the start of a multiplayer
|
||||||
|
game"; "asking first is the only path." A new `#solitairesetup` screen in `play.html` asks the
|
||||||
|
full shared block — game type, starting hand, Extra start, revenue, victory conditions, optional
|
||||||
|
rules — before a genuinely fresh visit deals anything; a saved game, an explicit `?seed=`, or a
|
||||||
|
URL a Deal already wrote all skip past it. The in-game dialog, the lobby and this screen now
|
||||||
|
share one `wireGameTypeBlock()`/`commitNewGame()` pair instead of the dialog carrying its own
|
||||||
|
copy. Reasoning in `CHANGELOG.md`. **Committed but not yet played in a browser** — verified by
|
||||||
|
`tsc --noEmit` and the full suite (859 pass), not by loading the page and clicking through it.
|
||||||
|
Worth being an early item in the next play session, alongside #39's four unplayed v0.7.4 features.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Replay / Save Games
|
## Replay / Save Games
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "station-master",
|
"name": "station-master",
|
||||||
"version": "0.7.4",
|
"version": "0.7.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Station Master — a railroad operations game",
|
"description": "Station Master — a railroad operations game",
|
||||||
|
|||||||
+3
-3
@@ -81,11 +81,11 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
|
|||||||
|
|
||||||
<a class="door" href="./play.html">
|
<a class="door" href="./play.html">
|
||||||
<h2>Play solitaire</h2>
|
<h2>Play solitaire</h2>
|
||||||
<p>Play by yourself and run the entire division for five full days. Your goal is 20 Revenue.
|
<p>Play by yourself and run the entire division for five full days. Clear the Revenue floor of
|
||||||
Your game data is saved in your browser — if you close the tab and reopen this site
|
15 by the end or the game is a loss. Your game data is saved in your browser — if you close the tab and reopen this site
|
||||||
without clearing your cache, your game is preserved and you can continue automatically.
|
without clearing your cache, your game is preserved and you can continue automatically.
|
||||||
During the game you can also explicitly save your progress for later replay.</p>
|
During the game you can also explicitly save your progress for later replay.</p>
|
||||||
<span class="go">Start a game →</span>
|
<span class="go">Set up a game →</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a class="door" href="./replays.html">
|
<a class="door" href="./replays.html">
|
||||||
|
|||||||
+201
-111
@@ -37,6 +37,7 @@ import {
|
|||||||
} from './presets.ts';
|
} from './presets.ts';
|
||||||
import type { GameType, PresetName } from './presets.ts';
|
import type { GameType, PresetName } from './presets.ts';
|
||||||
import { settingsForm } from './settings-form.ts';
|
import { settingsForm } from './settings-form.ts';
|
||||||
|
import type { SettingsForm } from './settings-form.ts';
|
||||||
|
|
||||||
const SAVE_KEY = 'station-master.save.v1';
|
const SAVE_KEY = 'station-master.save.v1';
|
||||||
const SETTINGS_KEY = 'station-master.settings.v1';
|
const SETTINGS_KEY = 'station-master.settings.v1';
|
||||||
@@ -628,12 +629,15 @@ function noteFirstFrame(f: Frame): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Toggles the two mutually-exclusive top-level screens `play.html` defines — `#lobby` (Phase 4)
|
/** Toggles the three mutually-exclusive top-level screens `play.html` defines — `#lobby` (Phase 4),
|
||||||
* and `#gameui` (the board, whether local or remote). Both start `hidden` in the markup so neither
|
* `#gameui` (the board, whether local or remote), and `#solitairesetup` (asked before the first
|
||||||
* ever flashes before `start()` decides which one this load actually needs. */
|
* solitaire deal, the same way `#lobby` is asked before the first multiplayer one — Jesse,
|
||||||
function showScreen(which: 'lobby' | 'gameui'): void {
|
* 2026-08-29). All three start `hidden` in the markup so none ever flashes before `start()` decides
|
||||||
|
* which one this load actually needs. */
|
||||||
|
function showScreen(which: 'lobby' | 'gameui' | 'solitairesetup'): void {
|
||||||
document.getElementById('lobby')!.hidden = which !== 'lobby';
|
document.getElementById('lobby')!.hidden = which !== 'lobby';
|
||||||
document.getElementById('gameui')!.hidden = which !== 'gameui';
|
document.getElementById('gameui')!.hidden = which !== 'gameui';
|
||||||
|
document.getElementById('solitairesetup')!.hidden = which !== 'solitairesetup';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -739,16 +743,35 @@ function start(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
showScreen('gameui');
|
// A saved game carries its OWN rules and re-deals itself under them, whatever the URL says — see
|
||||||
|
// `configFor`. Read once, here, so the same answer decides both whether to ask before dealing and
|
||||||
|
// (below) whether to restore.
|
||||||
|
const saved = load();
|
||||||
const requested = params.get('seed');
|
const requested = params.get('seed');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ASK BEFORE THE FIRST DEAL, THE SAME WAY THE LOBBY ASKS BEFORE THE FIRST MULTIPLAYER GAME
|
||||||
|
* (Jesse, 2026-08-29 — "let the user choose their options like the start of a multiplayer game";
|
||||||
|
* "asking first is the only path").
|
||||||
|
*
|
||||||
|
* A saved game or an explicit `seed=` both mean this visit is not "no plan yet" — a saved game is
|
||||||
|
* a game to resume, and a seed names a specific deal someone already chose to share or bookmark,
|
||||||
|
* the same reasoning `?lobby` already uses to skip past the doors on an invite link. `hand` is the
|
||||||
|
* one field every `commitNewGame` write always sets (`rulesToUrl`), so its presence means this
|
||||||
|
* navigation IS the setup screen's own Deal button, landing back here to actually deal — checking
|
||||||
|
* it is what stops the screen asking itself the question a second time.
|
||||||
|
*/
|
||||||
|
if (!saved && requested === null && !params.has('hand')) {
|
||||||
|
showScreen('solitairesetup');
|
||||||
|
runSolitaireSetup(params);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showScreen('gameui');
|
||||||
// A seed in the URL makes a game shareable and reproducible: same link, same deal.
|
// A seed in the URL makes a game shareable and reproducible: same link, same deal.
|
||||||
const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
|
const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
|
||||||
const local = createLocalSession(seed, solitaireDefaults(gameOptionsFromUrl(params)));
|
const local = createLocalSession(seed, solitaireDefaults(gameOptionsFromUrl(params)));
|
||||||
session = local;
|
session = local;
|
||||||
|
|
||||||
// A saved game carries its OWN rules and re-deals itself under them, whatever the URL says — see
|
|
||||||
// `configFor`. That is why the restore happens after the session is built rather than feeding it.
|
|
||||||
const saved = load();
|
|
||||||
if (saved && requested === null) local.restore(saved);
|
if (saved && requested === null) local.restore(saved);
|
||||||
|
|
||||||
applyCapabilities();
|
applyCapabilities();
|
||||||
@@ -1895,58 +1918,77 @@ if (leaveBtn) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const newBtn = document.getElementById('newgame');
|
/**
|
||||||
const dlg = document.getElementById('newgamedlg') as HTMLDialogElement | null;
|
* ONE GAME-TYPE BLOCK, WIRED — the type radios, the shared rules form beneath them, and the small
|
||||||
if (newBtn && dlg) {
|
* glue between them (which type is currently selected, what its note says, how Days feeds the
|
||||||
const field = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
|
* floor). The in-game "New game" dialog (`ng-`) and the pre-game setup screen (`ss-`, Gitea
|
||||||
const ngForm = settingsForm('ng-');
|
* "let the user choose their options like the start of a multiplayer game", 2026-08-29) both need
|
||||||
|
* an identical copy of this — factored out once so the two cannot drift apart the way the rules
|
||||||
/**
|
* block itself already had before `settings-form.ts` existed to stop it.
|
||||||
* 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
|
* PREFILLING IS DELIBERATELY LEFT TO THE CALLER. The dialog opens on the game CURRENTLY IN PLAY
|
||||||
* is how it ended up with "where an Extra may start" that the lobby did not have and none of the
|
* (so redealing to compare keeps comparing); the setup screen opens on the plain Solitaire
|
||||||
* three optional rules that it did. Both screens now read `presets.ts` and drive their block
|
* defaults, because there is no game yet to read. `setBase` plus a direct `form.write(...)` is the
|
||||||
* through `settings-form.ts`; only Solitaire can actually be DEALT here, so the three multiplayer
|
* seam that lets each caller do its own version of "what do these fields show at first paint"
|
||||||
* types are shown disabled rather than hidden — what this screen offers and what the lobby offers
|
* without this function having to guess which one it is wiring.
|
||||||
* should read as one list, not two.
|
|
||||||
*/
|
*/
|
||||||
let ngBase: PresetName = 'solitaire';
|
type WiredGameType = {
|
||||||
let ngType: GameType = 'solitaire';
|
form: SettingsForm;
|
||||||
/** As in the lobby: the floor is derived from the length until the player sets one themselves. */
|
days(): number;
|
||||||
let ngFloorTyped = false;
|
refresh(): void;
|
||||||
|
/** The common case: prefill straight from a named type's own defaults, then repaint. */
|
||||||
|
selectPreset(name: PresetName): void;
|
||||||
|
/** The dialog's case: the caller writes the form itself (from a live game), then calls `refresh`
|
||||||
|
* — this only sets which type that write should be compared against. */
|
||||||
|
setBase(name: PresetName, type: GameType): void;
|
||||||
|
};
|
||||||
|
|
||||||
const ngDays = (): number => {
|
function wireGameTypeBlock(prefix: string, root: ParentNode): WiredGameType {
|
||||||
const raw = Number(field<HTMLInputElement>('ng-days').value);
|
const field = <T extends HTMLElement>(id: string): T => document.getElementById(`${prefix}${id}`) as T;
|
||||||
|
const form = settingsForm(prefix);
|
||||||
|
|
||||||
|
let base: PresetName = 'solitaire';
|
||||||
|
let type: GameType = 'solitaire';
|
||||||
|
/** As in the lobby: the floor is derived from the length until the player sets one themselves. */
|
||||||
|
let floorTyped = false;
|
||||||
|
|
||||||
|
const days = (): number => {
|
||||||
|
const raw = Number(field<HTMLInputElement>('days').value);
|
||||||
return Number.isFinite(raw) && raw >= 1 ? Math.round(raw) : 5;
|
return Number.isFinite(raw) && raw >= 1 ? Math.round(raw) : 5;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ngTypeRadios = (): HTMLInputElement[] =>
|
const typeRadios = (): HTMLInputElement[] =>
|
||||||
Array.from(dlg.querySelectorAll<HTMLInputElement>('input[name="ng-type"]'));
|
Array.from(root.querySelectorAll<HTMLInputElement>(`input[name="${prefix}type"]`));
|
||||||
|
|
||||||
function ngRefresh(): void {
|
function refresh(): void {
|
||||||
const differing = ngForm.mark(ngBase, 1, ngDays());
|
const differing = form.mark(base, 1, days());
|
||||||
if (differing.length > 0) ngType = 'custom';
|
if (differing.length > 0) type = 'custom';
|
||||||
else if (ngType === 'custom') ngType = ngBase;
|
else if (type === 'custom') type = base;
|
||||||
for (const r of ngTypeRadios()) r.checked = r.value === ngType;
|
for (const r of typeRadios()) r.checked = r.value === type;
|
||||||
const note = field<HTMLElement>('ng-type-note');
|
const note = field<HTMLElement>('type-note');
|
||||||
note.textContent =
|
note.textContent =
|
||||||
ngType === 'custom'
|
type === 'custom'
|
||||||
? `${gameTypeLabel('custom', preset(ngBase).scoring)} · ${differing.length} ` +
|
? `${gameTypeLabel('custom', preset(base).scoring)} · ${differing.length} ` +
|
||||||
`${differing.length === 1 ? 'setting differs' : 'settings differ'} from ${preset(ngBase).label}.`
|
`${differing.length === 1 ? 'setting differs' : 'settings differ'} from ${preset(base).label}.`
|
||||||
: preset(ngType as PresetName).blurb;
|
: preset(type as PresetName).blurb;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ngSelectPreset(name: PresetName): void {
|
function selectPreset(name: PresetName): void {
|
||||||
ngBase = name;
|
base = name;
|
||||||
ngType = name;
|
type = name;
|
||||||
ngFloorTyped = false;
|
floorTyped = false;
|
||||||
const values = presetSettings(name, 1, ngDays());
|
const values = presetSettings(name, 1, days());
|
||||||
ngForm.write(values, values);
|
form.write(values, values);
|
||||||
ngRefresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const r of ngTypeRadios()) {
|
function setBase(name: PresetName, t: GameType): void {
|
||||||
|
base = name;
|
||||||
|
type = t;
|
||||||
|
floorTyped = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const r of typeRadios()) {
|
||||||
// Nothing here can deal a multiplayer game: a `LocalSession` runs the engine in this browser and
|
// Nothing here can deal a multiplayer game: a `LocalSession` runs the engine in this browser and
|
||||||
// a table needs a server. The lobby is the door, and the row says so rather than just refusing
|
// a table needs a server. The lobby is the door, and the row says so rather than just refusing
|
||||||
// the click (Jesse, 2026-08-23 — a disabled radio that looks enabled reads as a broken one).
|
// the click (Jesse, 2026-08-23 — a disabled radio that looks enabled reads as a broken one).
|
||||||
@@ -1964,33 +2006,99 @@ if (newBtn && dlg) {
|
|||||||
r.onchange = () => {
|
r.onchange = () => {
|
||||||
if (!r.checked) return;
|
if (!r.checked) return;
|
||||||
if (r.value === 'custom') {
|
if (r.value === 'custom') {
|
||||||
ngType = 'custom';
|
type = 'custom';
|
||||||
ngRefresh();
|
refresh();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ngSelectPreset(r.value as PresetName);
|
selectPreset(r.value as PresetName);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
ngForm.onEdit((key) => {
|
form.onEdit((key) => {
|
||||||
if (key === 'minCombinedRevenue') ngFloorTyped = true;
|
if (key === 'minCombinedRevenue') floorTyped = true;
|
||||||
ngType = 'custom';
|
type = 'custom';
|
||||||
ngRefresh();
|
refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Days is a parameter, not a rule: it re-derives the floor and never makes a game Custom by itself.
|
// Days is a parameter, not a rule: it re-derives the floor and never makes a game Custom by itself.
|
||||||
field<HTMLInputElement>('ng-days').oninput = () => {
|
field<HTMLInputElement>('days').oninput = () => {
|
||||||
if (!ngFloorTyped) {
|
if (!floorTyped) {
|
||||||
const values = ngForm.read();
|
const values = form.read();
|
||||||
const want = presetSettings(ngBase, 1, ngDays());
|
const want = presetSettings(base, 1, days());
|
||||||
ngForm.write({ ...values, minCombinedRevenue: want.minCombinedRevenue }, want);
|
form.write({ ...values, minCombinedRevenue: want.minCombinedRevenue }, want);
|
||||||
}
|
}
|
||||||
ngRefresh();
|
refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
// "Everyone moves one chair left" has no meaning at a table of one — disabled with the rest of the
|
// "Everyone moves one chair left" has no meaning at a table of one — disabled with the rest of the
|
||||||
// block still visible, so the two screens read the same.
|
// block still visible, so every screen that offers it reads the same.
|
||||||
ngForm.setEmployeeRotationAvailable(false);
|
form.setEmployeeRotationAvailable(false);
|
||||||
|
|
||||||
|
return { form, days, refresh, selectPreset, setBase };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE COMMIT — reads a wired block's answers and turns them into a URL, the same path `?seed=`
|
||||||
|
* already took: `start()` reads it back out, so there is exactly one place that turns a URL into a
|
||||||
|
* game, whichever screen produced it.
|
||||||
|
*/
|
||||||
|
function commitNewGame(wired: WiredGameType, seedFieldValue: string): void {
|
||||||
|
const asked = seedFieldValue.trim();
|
||||||
|
// A seed the browser cannot parse is not a reason to refuse to deal — blank and unparseable both
|
||||||
|
// mean "surprise me", which is what leaving the box alone plainly asks for.
|
||||||
|
const seed = asked === '' || !Number.isFinite(Number(asked)) ? '' : String(Math.trunc(Number(asked)));
|
||||||
|
const settings = wired.form.read();
|
||||||
|
const rules = houseRules({
|
||||||
|
houseRules: {
|
||||||
|
startingHand: settings.startingHand,
|
||||||
|
extraStart: settings.extraStart,
|
||||||
|
discardTimetabled: settings.discardTimetabled,
|
||||||
|
revenue: {
|
||||||
|
passengerPerCoach: settings.passengerPerCoach,
|
||||||
|
freightPerLoad: settings.freightPerLoad,
|
||||||
|
trainPerTransit: settings.trainPerTransit,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const victory: NewGameOptions = {
|
||||||
|
days: Math.max(1, wired.days()),
|
||||||
|
minCombinedRevenue: settings.minCombinedRevenue,
|
||||||
|
maxCollisionsPerDay: settings.maxCollisionsPerDay,
|
||||||
|
maxCollisionsTotal: settings.maxCollisionsTotal,
|
||||||
|
optionalRules: {
|
||||||
|
reducedVisibility: settings.reducedVisibility,
|
||||||
|
// Never on at a table of one, whatever the box says — the control is disabled for the same
|
||||||
|
// reason, and this is the half that reaches the engine.
|
||||||
|
employeeRotation: false,
|
||||||
|
emergencyToolbox: settings.emergencyToolbox,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
clearSave();
|
||||||
|
const next = rulesToUrl(rules, victory, seed);
|
||||||
|
// Assigning the search string the page ALREADY has does nothing at all, which reads as a button
|
||||||
|
// that did not work — and it is the common case: deal a random seed, decide it was a bad deal,
|
||||||
|
// deal another at the same settings. Reload instead, and `start()` rolls a fresh seed.
|
||||||
|
if (next === location.search) location.reload();
|
||||||
|
else location.search = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
* ASK FOR ALL OF IT, rather than documenting URL parameters in the title bar.
|
||||||
@@ -2019,65 +2127,47 @@ if (newBtn && dlg) {
|
|||||||
|
|
||||||
field<HTMLInputElement>('ng-seed').value = '';
|
field<HTMLInputElement>('ng-seed').value = '';
|
||||||
field<HTMLInputElement>('ng-days').value = String(f.days);
|
field<HTMLInputElement>('ng-days').value = String(f.days);
|
||||||
ngBase = 'solitaire';
|
ng.setBase('solitaire', 'solitaire');
|
||||||
ngType = 'solitaire';
|
|
||||||
ngFloorTyped = false;
|
|
||||||
// The rules actually in play, then the comparison decides what to call them.
|
// The rules actually in play, then the comparison decides what to call them.
|
||||||
ngForm.write(settingsOf(configFromFrame(f)), presetSettings('solitaire', 1, f.days));
|
ng.form.write(settingsOf(configFromFrame(f)), presetSettings('solitaire', 1, f.days));
|
||||||
ngForm.setEmployeeRotationAvailable(false);
|
ng.refresh();
|
||||||
ngRefresh();
|
|
||||||
dlg.showModal();
|
dlg.showModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One handler for every way the dialog can close — the Deal button, the Cancel button, and Esc,
|
* 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.
|
* which `<dialog>` answers with an empty `returnValue` and no submit event at all.
|
||||||
*
|
|
||||||
* The answers go into the URL and the page navigates, which is the same path `?seed=` already
|
|
||||||
* took: `start()` reads them back, so there is exactly one place that turns a URL into a game.
|
|
||||||
*/
|
*/
|
||||||
dlg.addEventListener('close', () => {
|
dlg.addEventListener('close', () => {
|
||||||
if (dlg.returnValue !== 'deal') return;
|
if (dlg.returnValue !== 'deal') return;
|
||||||
|
commitNewGame(ng, field<HTMLInputElement>('ng-seed').value);
|
||||||
const asked = field<HTMLInputElement>('ng-seed').value.trim();
|
|
||||||
// A seed the browser cannot parse is not a reason to refuse to deal — blank and unparseable
|
|
||||||
// both mean "surprise me", which is what leaving the box alone plainly asks for.
|
|
||||||
const seed = asked === '' || !Number.isFinite(Number(asked)) ? '' : String(Math.trunc(Number(asked)));
|
|
||||||
const settings = ngForm.read();
|
|
||||||
const rules = houseRules({
|
|
||||||
houseRules: {
|
|
||||||
startingHand: settings.startingHand,
|
|
||||||
extraStart: settings.extraStart,
|
|
||||||
discardTimetabled: settings.discardTimetabled,
|
|
||||||
revenue: {
|
|
||||||
passengerPerCoach: settings.passengerPerCoach,
|
|
||||||
freightPerLoad: settings.freightPerLoad,
|
|
||||||
trainPerTransit: settings.trainPerTransit,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const victory: NewGameOptions = {
|
}
|
||||||
days: Math.max(1, ngDays()),
|
|
||||||
minCombinedRevenue: settings.minCombinedRevenue,
|
|
||||||
maxCollisionsPerDay: settings.maxCollisionsPerDay,
|
|
||||||
maxCollisionsTotal: settings.maxCollisionsTotal,
|
|
||||||
optionalRules: {
|
|
||||||
reducedVisibility: settings.reducedVisibility,
|
|
||||||
// Never on at a table of one, whatever the box says — the control is disabled for the same
|
|
||||||
// reason, and this is the half that reaches the engine.
|
|
||||||
employeeRotation: false,
|
|
||||||
emergencyToolbox: settings.emergencyToolbox,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
clearSave();
|
/**
|
||||||
const next = rulesToUrl(rules, victory, seed);
|
* THE PRE-GAME SETUP SCREEN — asked before the FIRST solitaire deal, the same way `#lobby` is
|
||||||
// Assigning the search string the page ALREADY has does nothing at all, which reads as a button
|
* already asked before the first multiplayer one (Jesse, 2026-08-29: "let the user choose their
|
||||||
// that did not work — and it is the common case: deal a random seed, decide it was a bad deal,
|
* options like the start of a multiplayer game"; "asking first is the only path").
|
||||||
// deal another at the same settings. Reload instead, and `start()` rolls a fresh seed.
|
*
|
||||||
if (next === location.search) location.reload();
|
* Only reached for a genuinely fresh visit — `start()` is what decides that; by the time this runs,
|
||||||
else location.search = next;
|
* there is no saved game and no URL already carrying a deal's answers. It opens on the plain
|
||||||
});
|
* 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): void {
|
||||||
|
const screen = document.getElementById('solitairesetup');
|
||||||
|
const dealBtn = document.getElementById('ss-deal');
|
||||||
|
if (!screen || !dealBtn) return;
|
||||||
|
|
||||||
|
const ss = wireGameTypeBlock('ss-', screen);
|
||||||
|
// A `?seed=` with no other rules params still means SOMETHING — a shared or bookmarked link
|
||||||
|
// naming a specific deal — so it is honoured as a prefill rather than discarded because this
|
||||||
|
// visit happened to be routed through the screen that now asks first.
|
||||||
|
const seedField = document.getElementById('ss-seed') as HTMLInputElement | null;
|
||||||
|
if (seedField) seedField.value = params.get('seed') ?? '';
|
||||||
|
|
||||||
|
ss.selectPreset('solitaire');
|
||||||
|
dealBtn.onclick = () => commitNewGame(ss, seedField?.value ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
const zoomOutBtn = document.getElementById('zoomout') as HTMLButtonElement | null;
|
const zoomOutBtn = document.getElementById('zoomout') as HTMLButtonElement | null;
|
||||||
|
|||||||
+182
-3
@@ -80,7 +80,7 @@ main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14p
|
|||||||
@media(max-width:1100px){main{grid-template-columns:1fr}}
|
@media(max-width:1100px){main{grid-template-columns:1fr}}
|
||||||
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
||||||
padding:10px 12px;margin-bottom:12px}
|
padding:10px 12px;margin-bottom:12px}
|
||||||
#lobby{max-width:1040px;margin:0 auto;padding:14px}
|
#lobby,#solitairesetup{max-width:1040px;margin:0 auto;padding:14px}
|
||||||
/* The create form is two short lists, not one long one: what game this is on the left, what its
|
/* The create form is two short lists, not one long one: what game this is on the left, what its
|
||||||
rules are on the right. Collapses to one column where there is no room for two. */
|
rules are on the right. Collapses to one column where there is no room for two. */
|
||||||
.lb-two{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.1fr);gap:22px;align-items:start}
|
.lb-two{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.1fr);gap:22px;align-items:start}
|
||||||
@@ -96,8 +96,8 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
|||||||
.ng-radio.disabled{opacity:.45;cursor:not-allowed}
|
.ng-radio.disabled{opacity:.45;cursor:not-allowed}
|
||||||
.ng-radio.disabled:hover{background:none}
|
.ng-radio.disabled:hover{background:none}
|
||||||
.lb-why{color:#e0b060;font-size:11px}
|
.lb-why{color:#e0b060;font-size:11px}
|
||||||
#lobby h2{margin-top:0}
|
#lobby h2,#solitairesetup h2{margin-top:0}
|
||||||
#lobby h3{margin-bottom:2px}
|
#lobby h3,#solitairesetup h3{margin-bottom:2px}
|
||||||
.lb-seat{display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--line)}
|
.lb-seat{display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--line)}
|
||||||
.lb-seat:last-child{border-bottom:none}
|
.lb-seat:last-child{border-bottom:none}
|
||||||
.lb-seat .who{flex:1}
|
.lb-seat .who{flex:1}
|
||||||
@@ -592,6 +592,185 @@ ul.blocked li{padding:2px 0}
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================================================================
|
||||||
|
SOLITAIRE SETUP — the same question multiplayer already asks first,
|
||||||
|
now asked here too (Jesse, 2026-08-29): a genuinely fresh visit deals
|
||||||
|
nothing until this screen's own Deal button is pressed. A saved game,
|
||||||
|
an explicit `?seed=`, or a URL already carrying a Deal's answers (any
|
||||||
|
of the shared block's fields — `hand` names the one always written)
|
||||||
|
all skip straight past this screen, exactly as `?lobby` already skips
|
||||||
|
past it into the lobby: those are not "no plan yet", they are a
|
||||||
|
choice already made, elsewhere.
|
||||||
|
|
||||||
|
THE SAME BLOCK THE DIALOG AND THE LOBBY USE, same shared module
|
||||||
|
(`settings-form.ts`), same order — three screens are one design now
|
||||||
|
instead of two. Only Solitaire can be dealt from here, so the other
|
||||||
|
four types are shown exactly as the in-game dialog shows them: present,
|
||||||
|
disabled, with a note pointing at the Multiplayer door instead.
|
||||||
|
==================================================================== -->
|
||||||
|
<div id="solitairesetup" hidden>
|
||||||
|
<header><b><a href="./index.html" class="home">Station Master</a></b> — <span class="dim">Solitaire</span></header>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>New solitaire game</h2>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>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>
|
||||||
|
|
||||||
|
<h3>Game type</h3>
|
||||||
|
<div class="set-row" id="ss-type-row">
|
||||||
|
<label class="ng-radio"><input type="radio" name="ss-type" value="solitaire" checked>
|
||||||
|
<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="ss-type" value="coop">
|
||||||
|
<span><b>Co-op</b><br><span class="dim">Everyone’s Revenue is one table score. You win together or lose together.</span></span></label>
|
||||||
|
<label class="ng-radio"><input type="radio" name="ss-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="ss-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="ss-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="ss-type-note"></p>
|
||||||
|
|
||||||
|
<details id="ss-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 you are 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="ss-hand-row">
|
||||||
|
<label class="ng-radio"><input type="radio" name="ss-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="ss-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="ss-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="ss-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="ss-extra-row">
|
||||||
|
<label class="ng-radio"><input type="radio" name="ss-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="ss-extra" value="ownOffice">
|
||||||
|
<span><b>Also the playing player’s own Control Point</b><br><span class="dim">You may start one at home, but not in somebody else’s district.</span></span></label>
|
||||||
|
<label class="ng-radio"><input type="radio" name="ss-extra" value="anyOffice">
|
||||||
|
<span><b>Also any player’s Control Point</b><br><span class="dim">The most permissive — an Extra may be planted in another player’s district.</span></span></label>
|
||||||
|
<span class="set-hint" id="ss-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="ss-passenger-row">
|
||||||
|
<label class="ng-num"><span>Passenger revenue per coach</span>
|
||||||
|
<input id="ss-passenger" type="number" min="0" max="5" step="1" value="1"></label>
|
||||||
|
<span class="set-hint" id="ss-passenger-hint"></span>
|
||||||
|
</div>
|
||||||
|
<div class="set-row" id="ss-freight-row">
|
||||||
|
<label class="ng-num"><span>Freight revenue per load</span>
|
||||||
|
<input id="ss-freight" type="number" min="0" max="5" step="1" value="1"></label>
|
||||||
|
<span class="set-hint" id="ss-freight-hint"></span>
|
||||||
|
</div>
|
||||||
|
<div class="set-row" id="ss-transit-row">
|
||||||
|
<label class="ng-num"><span>Train revenue per transit</span>
|
||||||
|
<input id="ss-transit" type="number" min="0" max="5" step="1" value="0"></label>
|
||||||
|
<span class="set-hint" id="ss-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. How long it runs is set above, in Days.</p>
|
||||||
|
<div class="set-row" id="ss-minrev-row">
|
||||||
|
<label class="ng-gate"><input type="checkbox" id="ss-minrev-on" checked>
|
||||||
|
<span>You lose if Revenue at the end is under</span>
|
||||||
|
<input id="ss-minrev" type="number" min="0" step="1" class="gate-num"></label>
|
||||||
|
<span class="set-hint" id="ss-minrev-hint"></span>
|
||||||
|
</div>
|
||||||
|
<div class="set-row" id="ss-colday-row">
|
||||||
|
<label class="ng-gate"><input type="checkbox" id="ss-colday-on" checked>
|
||||||
|
<span>The game ends in a loss if collisions in one Day reach</span>
|
||||||
|
<input id="ss-colday" type="number" min="0" step="1" class="gate-num"></label>
|
||||||
|
<span class="set-hint" id="ss-colday-hint"></span>
|
||||||
|
</div>
|
||||||
|
<div class="set-row" id="ss-coltotal-row">
|
||||||
|
<label class="ng-gate"><input type="checkbox" id="ss-coltotal-on" checked>
|
||||||
|
<span>The game ends in a loss after this many collisions in the whole game</span>
|
||||||
|
<input id="ss-coltotal" type="number" min="0" step="1" class="gate-num"></label>
|
||||||
|
<span class="set-hint" id="ss-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="ss-visibility-row">
|
||||||
|
<label class="ng-num"><span>Reduced Visibility — five switching Moves instead of six in the
|
||||||
|
night Stages (1–3 and 11–12)</span>
|
||||||
|
<input id="ss-visibility" type="checkbox"></label>
|
||||||
|
<span class="set-hint" id="ss-visibility-hint"></span>
|
||||||
|
</div>
|
||||||
|
<div class="set-row" id="ss-rotation-row">
|
||||||
|
<label class="ng-num"><span>Employee Rotation — meaningless at a table of one, shown here so
|
||||||
|
this screen and the lobby read as one list</span>
|
||||||
|
<input id="ss-rotation" type="checkbox" disabled></label>
|
||||||
|
<span class="set-hint" id="ss-rotation-hint"></span>
|
||||||
|
</div>
|
||||||
|
<div class="set-row" id="ss-toolbox-row">
|
||||||
|
<label class="ng-num"><span>Emergency Toolbox — start holding a Red Flag, so a hand of four;
|
||||||
|
play or discard down to three on the first turn</span>
|
||||||
|
<input id="ss-toolbox" type="checkbox"></label>
|
||||||
|
<span class="set-hint" id="ss-toolbox-hint"></span>
|
||||||
|
</div>
|
||||||
|
<div class="set-row" id="ss-tossloco-row">
|
||||||
|
<label class="ng-num"><span>A Timetabled train may be discarded — toss it face-up to a
|
||||||
|
Department slot. 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="ss-tossloco" type="checkbox"></label>
|
||||||
|
<span class="set-hint" id="ss-tossloco-hint"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<menu class="ng-buttons">
|
||||||
|
<button id="ss-deal" type="button">Deal</button>
|
||||||
|
</menu>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="gameui" hidden>
|
<div id="gameui" hidden>
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<header>
|
<header>
|
||||||
|
|||||||
+27
-7
@@ -3815,18 +3815,20 @@ describe('the lobby and the dialog ask the same questions', () => {
|
|||||||
return readFileSync(join(dist, 'play.html'), 'utf8');
|
return readFileSync(join(dist, 'play.html'), 'utf8');
|
||||||
};
|
};
|
||||||
|
|
||||||
it('carries every field of the shared block on both screens', () => {
|
it('carries every field of the shared block on all three 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();
|
const html = page();
|
||||||
for (const prefix of ['lb-', 'ng-']) {
|
for (const prefix of ['lb-', 'ng-', 'ss-']) {
|
||||||
for (const selector of fieldSelectors(prefix)) {
|
for (const selector of fieldSelectors(prefix)) {
|
||||||
assert.ok(html.includes(selector), `the ${prefix} block is missing ${selector}`);
|
assert.ok(html.includes(selector), `the ${prefix} block is missing ${selector}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('offers all five game types on both screens', () => {
|
it('offers all five game types on all three screens', () => {
|
||||||
const html = page();
|
const html = page();
|
||||||
for (const prefix of ['lb-', 'ng-']) {
|
for (const prefix of ['lb-', 'ng-', 'ss-']) {
|
||||||
for (const type of ['solitaire', 'coop', 'competitive', 'cutthroat', 'custom']) {
|
for (const type of ['solitaire', 'coop', 'competitive', 'cutthroat', 'custom']) {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
html.includes(`name="${prefix}type" value="${type}"`),
|
html.includes(`name="${prefix}type" value="${type}"`),
|
||||||
@@ -3905,6 +3907,13 @@ describe('the New Game dialog', () => {
|
|||||||
'ng-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
|
'ng-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
|
||||||
'ng-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
|
'ng-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
|
||||||
'ng-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'coop'),
|
'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'),
|
||||||
};
|
};
|
||||||
const matching = (sel: string): Radio[] => {
|
const matching = (sel: string): Radio[] => {
|
||||||
const name = /name="([^"]+)"/.exec(sel)?.[1] ?? '';
|
const name = /name="([^"]+)"/.exec(sel)?.[1] ?? '';
|
||||||
@@ -4142,11 +4151,22 @@ describe('the New Game dialog', () => {
|
|||||||
assert.match(String(els.get('gametype')!['title']), /Days: 5/, 'the tooltip does not carry the victory conditions');
|
assert.match(String(els.get('gametype')!['title']), /Days: 5/, 'the tooltip does not carry the victory conditions');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('asks before the first deal — a bare visit shows the setup screen, not a dealt game', async () => {
|
||||||
|
// Jesse, 2026-08-29: "let the user choose their options like the start of a multiplayer game";
|
||||||
|
// "asking first is the only path". A saved game, an explicit seed, or a URL a Deal already wrote
|
||||||
|
// (checked via `hand`, below) all skip this screen — nothing else does.
|
||||||
|
const { els } = await load('');
|
||||||
|
assert.equal(els.get('solitairesetup')!['hidden'], false, 'the setup screen stayed hidden');
|
||||||
|
assert.equal(els.get('gameui')!['hidden'], true, 'a game was dealt before anyone chose anything');
|
||||||
|
});
|
||||||
|
|
||||||
it('deals six cards by default now, matching what the lobby calls Solitaire', async () => {
|
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
|
// 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.
|
// every sim measurement is taken against — deliberately did not move; this is what the setup
|
||||||
const { els } = await load('');
|
// screen deals when nothing on it is touched, the same way the dialog always has.
|
||||||
assert.match(String(els.get('houserules')!['textContent']), /6 cards/);
|
const { els, nav } = await load('');
|
||||||
|
(els.get('ss-deal')!['onclick'] as () => void)();
|
||||||
|
assert.match(nav.search, /hand=sixRandom/, "the setup screen's own default was not six cards");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores a seed the browser cannot parse rather than refusing to deal', async () => {
|
it('ignores a seed the browser cannot parse rather than refusing to deal', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user