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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.7.4",
|
||||
"version": "0.7.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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">
|
||||
<h2>Play solitaire</h2>
|
||||
<p>Play by yourself and run the entire division for five full days. Your goal is 20 Revenue.
|
||||
Your game data is saved in your browser — if you close the tab and reopen this site
|
||||
<p>Play by yourself and run the entire division for five full days. Clear the Revenue floor of
|
||||
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.
|
||||
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 class="door" href="./replays.html">
|
||||
|
||||
+202
-112
@@ -37,6 +37,7 @@ import {
|
||||
} from './presets.ts';
|
||||
import type { GameType, PresetName } from './presets.ts';
|
||||
import { settingsForm } from './settings-form.ts';
|
||||
import type { SettingsForm } from './settings-form.ts';
|
||||
|
||||
const SAVE_KEY = 'station-master.save.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)
|
||||
* and `#gameui` (the board, whether local or remote). Both start `hidden` in the markup so neither
|
||||
* ever flashes before `start()` decides which one this load actually needs. */
|
||||
function showScreen(which: 'lobby' | 'gameui'): void {
|
||||
/** Toggles the three mutually-exclusive top-level screens `play.html` defines — `#lobby` (Phase 4),
|
||||
* `#gameui` (the board, whether local or remote), and `#solitairesetup` (asked before the first
|
||||
* solitaire deal, the same way `#lobby` is asked before the first multiplayer one — Jesse,
|
||||
* 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('gameui')!.hidden = which !== 'gameui';
|
||||
document.getElementById('solitairesetup')!.hidden = which !== 'solitairesetup';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -739,16 +743,35 @@ function start(): void {
|
||||
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');
|
||||
|
||||
/**
|
||||
* 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.
|
||||
const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
|
||||
const local = createLocalSession(seed, solitaireDefaults(gameOptionsFromUrl(params)));
|
||||
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);
|
||||
|
||||
applyCapabilities();
|
||||
@@ -1895,58 +1918,77 @@ if (leaveBtn) {
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
const ngForm = settingsForm('ng-');
|
||||
/**
|
||||
* ONE GAME-TYPE BLOCK, WIRED — the type radios, the shared rules form beneath them, and the small
|
||||
* glue between them (which type is currently selected, what its note says, how Days feeds the
|
||||
* floor). The in-game "New game" dialog (`ng-`) and the pre-game setup screen (`ss-`, Gitea
|
||||
* "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.
|
||||
*
|
||||
* PREFILLING IS DELIBERATELY LEFT TO THE CALLER. The dialog opens on the game CURRENTLY IN PLAY
|
||||
* (so redealing to compare keeps comparing); the setup screen opens on the plain Solitaire
|
||||
* defaults, because there is no game yet to read. `setBase` plus a direct `form.write(...)` is the
|
||||
* seam that lets each caller do its own version of "what do these fields show at first paint"
|
||||
* without this function having to guess which one it is wiring.
|
||||
*/
|
||||
type WiredGameType = {
|
||||
form: SettingsForm;
|
||||
days(): number;
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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. Both screens now read `presets.ts` and drive their 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.
|
||||
*/
|
||||
let ngBase: PresetName = 'solitaire';
|
||||
let ngType: GameType = 'solitaire';
|
||||
function wireGameTypeBlock(prefix: string, root: ParentNode): WiredGameType {
|
||||
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 ngFloorTyped = false;
|
||||
let floorTyped = false;
|
||||
|
||||
const ngDays = (): number => {
|
||||
const raw = Number(field<HTMLInputElement>('ng-days').value);
|
||||
const days = (): number => {
|
||||
const raw = Number(field<HTMLInputElement>('days').value);
|
||||
return Number.isFinite(raw) && raw >= 1 ? Math.round(raw) : 5;
|
||||
};
|
||||
|
||||
const ngTypeRadios = (): HTMLInputElement[] =>
|
||||
Array.from(dlg.querySelectorAll<HTMLInputElement>('input[name="ng-type"]'));
|
||||
const typeRadios = (): HTMLInputElement[] =>
|
||||
Array.from(root.querySelectorAll<HTMLInputElement>(`input[name="${prefix}type"]`));
|
||||
|
||||
function ngRefresh(): void {
|
||||
const differing = ngForm.mark(ngBase, 1, ngDays());
|
||||
if (differing.length > 0) ngType = 'custom';
|
||||
else if (ngType === 'custom') ngType = ngBase;
|
||||
for (const r of ngTypeRadios()) r.checked = r.value === ngType;
|
||||
const note = field<HTMLElement>('ng-type-note');
|
||||
function refresh(): void {
|
||||
const differing = form.mark(base, 1, days());
|
||||
if (differing.length > 0) type = 'custom';
|
||||
else if (type === 'custom') type = base;
|
||||
for (const r of typeRadios()) r.checked = r.value === type;
|
||||
const note = field<HTMLElement>('type-note');
|
||||
note.textContent =
|
||||
ngType === 'custom'
|
||||
? `${gameTypeLabel('custom', preset(ngBase).scoring)} · ${differing.length} ` +
|
||||
`${differing.length === 1 ? 'setting differs' : 'settings differ'} from ${preset(ngBase).label}.`
|
||||
: preset(ngType as PresetName).blurb;
|
||||
type === 'custom'
|
||||
? `${gameTypeLabel('custom', preset(base).scoring)} · ${differing.length} ` +
|
||||
`${differing.length === 1 ? 'setting differs' : 'settings differ'} from ${preset(base).label}.`
|
||||
: preset(type as PresetName).blurb;
|
||||
}
|
||||
|
||||
function ngSelectPreset(name: PresetName): void {
|
||||
ngBase = name;
|
||||
ngType = name;
|
||||
ngFloorTyped = false;
|
||||
const values = presetSettings(name, 1, ngDays());
|
||||
ngForm.write(values, values);
|
||||
ngRefresh();
|
||||
function selectPreset(name: PresetName): void {
|
||||
base = name;
|
||||
type = name;
|
||||
floorTyped = false;
|
||||
const values = presetSettings(name, 1, days());
|
||||
form.write(values, values);
|
||||
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
|
||||
// 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).
|
||||
@@ -1964,33 +2006,99 @@ if (newBtn && dlg) {
|
||||
r.onchange = () => {
|
||||
if (!r.checked) return;
|
||||
if (r.value === 'custom') {
|
||||
ngType = 'custom';
|
||||
ngRefresh();
|
||||
type = 'custom';
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
ngSelectPreset(r.value as PresetName);
|
||||
selectPreset(r.value as PresetName);
|
||||
};
|
||||
}
|
||||
|
||||
ngForm.onEdit((key) => {
|
||||
if (key === 'minCombinedRevenue') ngFloorTyped = true;
|
||||
ngType = 'custom';
|
||||
ngRefresh();
|
||||
form.onEdit((key) => {
|
||||
if (key === 'minCombinedRevenue') floorTyped = true;
|
||||
type = 'custom';
|
||||
refresh();
|
||||
});
|
||||
|
||||
// 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 = () => {
|
||||
if (!ngFloorTyped) {
|
||||
const values = ngForm.read();
|
||||
const want = presetSettings(ngBase, 1, ngDays());
|
||||
ngForm.write({ ...values, minCombinedRevenue: want.minCombinedRevenue }, want);
|
||||
field<HTMLInputElement>('days').oninput = () => {
|
||||
if (!floorTyped) {
|
||||
const values = form.read();
|
||||
const want = presetSettings(base, 1, days());
|
||||
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
|
||||
// block still visible, so the two screens read the same.
|
||||
ngForm.setEmployeeRotationAvailable(false);
|
||||
// block still visible, so every screen that offers it reads the same.
|
||||
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.
|
||||
@@ -2019,67 +2127,49 @@ if (newBtn && dlg) {
|
||||
|
||||
field<HTMLInputElement>('ng-seed').value = '';
|
||||
field<HTMLInputElement>('ng-days').value = String(f.days);
|
||||
ngBase = 'solitaire';
|
||||
ngType = 'solitaire';
|
||||
ngFloorTyped = false;
|
||||
ng.setBase('solitaire', 'solitaire');
|
||||
// The rules actually in play, then the comparison decides what to call them.
|
||||
ngForm.write(settingsOf(configFromFrame(f)), presetSettings('solitaire', 1, f.days));
|
||||
ngForm.setEmployeeRotationAvailable(false);
|
||||
ngRefresh();
|
||||
ng.form.write(settingsOf(configFromFrame(f)), presetSettings('solitaire', 1, f.days));
|
||||
ng.refresh();
|
||||
dlg.showModal();
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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', () => {
|
||||
if (dlg.returnValue !== 'deal') return;
|
||||
|
||||
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);
|
||||
// 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;
|
||||
commitNewGame(ng, field<HTMLInputElement>('ng-seed').value);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* THE PRE-GAME SETUP SCREEN — asked before the FIRST solitaire deal, the same way `#lobby` is
|
||||
* already asked before the first multiplayer one (Jesse, 2026-08-29: "let the user choose their
|
||||
* options like the start of a multiplayer game"; "asking first is the only path").
|
||||
*
|
||||
* Only reached for a genuinely fresh visit — `start()` is what decides that; by the time this runs,
|
||||
* 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 zoomInBtn = document.getElementById('zoomin') as HTMLButtonElement | null;
|
||||
const zoomLabel = document.getElementById('zoomlabel');
|
||||
|
||||
+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}}
|
||||
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
||||
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
|
||||
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}
|
||||
@@ -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:hover{background:none}
|
||||
.lb-why{color:#e0b060;font-size:11px}
|
||||
#lobby h2{margin-top:0}
|
||||
#lobby h3{margin-bottom:2px}
|
||||
#lobby h2,#solitairesetup h2{margin-top:0}
|
||||
#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:last-child{border-bottom:none}
|
||||
.lb-seat .who{flex:1}
|
||||
@@ -592,6 +592,185 @@ ul.blocked li{padding:2px 0}
|
||||
</section>
|
||||
</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 class="topbar">
|
||||
<header>
|
||||
|
||||
+27
-7
@@ -3815,18 +3815,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 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();
|
||||
for (const prefix of ['lb-', 'ng-']) {
|
||||
for (const prefix of ['lb-', 'ng-', '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 both screens', () => {
|
||||
it('offers all five game types on all three screens', () => {
|
||||
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']) {
|
||||
assert.ok(
|
||||
html.includes(`name="${prefix}type" value="${type}"`),
|
||||
@@ -3905,6 +3907,13 @@ describe('the New Game dialog', () => {
|
||||
'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'),
|
||||
};
|
||||
const matching = (sel: string): Radio[] => {
|
||||
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');
|
||||
});
|
||||
|
||||
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 () => {
|
||||
// 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/);
|
||||
// every sim measurement is taken against — deliberately did not move; this is what the setup
|
||||
// screen deals when nothing on it is touched, the same way the dialog always has.
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user