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
3e961496b0
+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');
|
||||
|
||||
Reference in New Issue
Block a user