Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af68aac78d | ||
|
|
b4f09f05cb | ||
|
|
3e961496b0 | ||
|
|
a02d1fcffe |
+105
@@ -19,6 +19,111 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
||||
|
||||
---
|
||||
|
||||
## 0.7.7 — 2026-08-30
|
||||
|
||||
### Two releases shipped to a browser that never received them
|
||||
|
||||
Jesse installed v0.7.5, clicked **Play solitaire**, and landed in a dealt game instead of the new
|
||||
setup screen. v0.7.6 diagnosed that as a routing bug, fixed it, installed, verified — and it happened
|
||||
again, identically. The second report is what made the real cause findable: the fix was correct both
|
||||
times and neither one ever reached the browser.
|
||||
|
||||
**`buildStamp()`'s no-git fallback was the literal `nogit`, and the `.s9pk` build has no git.** The
|
||||
Dockerfile copies the working tree in without `.git`, so `git rev-parse` fails there on every
|
||||
packaged build — and that string is not only the visible stamp, it is the cache-bust key every module
|
||||
URL carries. So v0.7.4, v0.7.5 and v0.7.6 all published `./web/main.js?v=nogit`, byte-identical, and
|
||||
a returning player's browser correctly concluded it had the file already. The fallback is now the
|
||||
package version plus the build's own timestamp, which is always distinct and needs nothing from the
|
||||
environment. Proven rather than assumed: two builds of an identical git-less tree now stamp
|
||||
`0.7.6-mtf6l8rm` and `0.7.6-mtf6lant`.
|
||||
|
||||
**And the server sent no `Cache-Control` at all**, which is the other half — the pages are the one
|
||||
thing that cannot be versioned in their own URL, since a player types the address or follows a
|
||||
bookmark, so a cached `play.html` pins that player to the whole build it names including every `?v=`
|
||||
inside it. Fixed the exact way round that matters: a request carrying `?v=` may be stored for a year
|
||||
and marked `immutable`, and anything else is `no-cache`. `?v=` rather than "not HTML" because
|
||||
`build-web.ts` tags the modules and nothing else — a year of `immutable` on an untagged image or on
|
||||
the replay manifest would outlive several releases of it.
|
||||
|
||||
Neither half is sufficient alone: without the varying tag there is nothing for a fresh page to point
|
||||
at, and without the header the fresh page is itself served from cache.
|
||||
|
||||
**What this says about the two releases before it.** v0.7.5's setup screen and v0.7.6's door fix were
|
||||
both real, both correct, and both verified on `phoenix.local` by reading what the server served —
|
||||
which was true, and was never the thing in doubt. What went unverified was the browser, and a
|
||||
hard-reload would have told us on the first report. Worth remembering the next time a fix "has had no
|
||||
effect": check that it arrived before re-diagnosing it.
|
||||
|
||||
864 tests pass, two of them new — one pinning the no-git fallback as something that varies per build,
|
||||
one pinning the header rule and that the `?v=` flag actually reaches `serveStatic`.
|
||||
|
||||
---
|
||||
|
||||
## 0.7.6 — 2026-08-29
|
||||
|
||||
### The solitaire door could not reach solitaire
|
||||
|
||||
Found by Jesse verifying v0.7.5 on `phoenix.local`: from a browser that had ever held a multiplayer
|
||||
seat, clicking **Play solitaire** on the splash landed straight in a Co-op, four-seat lobby left over
|
||||
from unrelated earlier testing — not the new setup screen v0.7.5 just shipped.
|
||||
|
||||
`start()` checks a browser-remembered multiplayer session (`station-master.remote.v1`) before it ever
|
||||
looks at solitaire's own state, and there was nothing distinguishing "clicked Play solitaire" from
|
||||
"reloaded mid multiplayer game" — a bare `./play.html` load means both. `?lobby` already solved the
|
||||
identical problem for the door on the other side (D11); the solitaire door had no equivalent marker.
|
||||
|
||||
The door now links to `./play.html?solitaire`, and `start()` treats that — along with an explicit
|
||||
`?seed=` or a `?hand=` the setup screen's own Deal button just wrote — as unambiguous proof this
|
||||
navigation means solitaire, checked ahead of the remembered-session lookup rather than only below it.
|
||||
The `hand` check matters on its own: without it, pressing Deal would work once and then bounce the
|
||||
very next load into the remembered game, since `commitNewGame`'s URL carries `hand=` but not
|
||||
`solitaire=`.
|
||||
|
||||
862 tests pass, three of them new: the door reaching solitaire past a remembered game, a bare reload
|
||||
still correctly resuming one (unchanged behaviour, pinned so the fix does not overreach), and Deal's
|
||||
own URL surviving the same bounce.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
@@ -243,12 +243,8 @@ Queued 2026-08-29, from building Gitea#11 and #16 (both shipped in v0.7.3, main
|
||||
**What is still untested is the part the item is named for: humans, at a table.** Nobody has sat
|
||||
down and played a game off the end of its timetable, and the multiplayer vote has never been
|
||||
driven through two browsers — what a second player sees while waiting on a first, and whether
|
||||
"waiting on Carol" is legible once Carol has closed her laptop, are still unanswered. v0.7.3 is tested — engine, server,
|
||||
replay, an all-bot regression — and compiled and exercised headlessly, but nobody has sat down,
|
||||
run a game off the end of its timetable and voted. The multiplayer vote in particular has only
|
||||
been driven through `session.intent`, never through two browsers: what a second player sees while
|
||||
waiting on a first, and whether "waiting on Carol" is legible when Carol has closed her laptop,
|
||||
are both unanswered. Worth being the first thing the next play session does.
|
||||
"waiting on Carol" is legible once Carol has closed her laptop, are still unanswered. Worth being
|
||||
the first thing the next play session does.
|
||||
|
||||
36. **There is no per-Stage "this train did not move" signal, so "longest an engine sat on a siding"
|
||||
cannot be answered.** Gitea#16 asks for it and the comment on that issue said `trainStoodStill`
|
||||
@@ -263,7 +259,7 @@ Queued 2026-08-29, from building Gitea#11 and #16 (both shipped in v0.7.3, main
|
||||
folds a batch of events AFTER `advance` has already mutated past the moment they describe. Worth
|
||||
settling with the badge pass (#33) rather than on its own, since that is the only consumer.
|
||||
|
||||
37. ~~**Bump the StartOS wrapper to 0.7.3.**~~ — done 2026-08-29 (`74aea24` in
|
||||
37. ~~**Bump the StartOS wrapper to 0.7.3, then 0.7.4.**~~ — 0.7.3 done 2026-08-29 (`74aea24` in
|
||||
`station-master-startos`). Submodule pinned to `v0.7.3` (`45580d8`), `current.ts` at `0.7.3:0`,
|
||||
release notes in all five locales, `README.md` and `instructions.md` updated. No new version file
|
||||
and no migration — the outgoing `0.7.2:0`'s `up` was empty, `versions.md`'s common case, so
|
||||
@@ -275,10 +271,71 @@ Queued 2026-08-29, from building Gitea#11 and #16 (both shipped in v0.7.3, main
|
||||
proven rather than assumed by `test/harness.test.ts`, which replays the three files in
|
||||
`public/replays` (all recorded under an older ruleset) and asserts every intent still applies.
|
||||
|
||||
**The v0.7.3 tag is made locally and signed, but not pushed** — agents cannot push to
|
||||
`origin`. Until Jesse pushes it, the tag exists only here and in the wrapper's submodule clone;
|
||||
the recorded pin is the commit SHA, which is already on `origin`, so the wrapper is not blocked
|
||||
on it.
|
||||
**Bumped again to `0.7.4:0` the same day** (`085b88b`), pinned to `v0.7.4`, and installed on
|
||||
`phoenix.local` — verified there, not merely packed: the resume log shows `WHISTLE-4086` coming
|
||||
back with its 7 intents and all five new engine code paths present in the served build. Both tags
|
||||
are signed and pushed.
|
||||
|
||||
**Keep doing the whole sequence.** Tag the app, fetch the tag into the wrapper's submodule,
|
||||
bump `current.ts` in place (the outgoing `up` has been empty every time, `versions.md`'s common
|
||||
case), rewrite the notes in all five locales, update `README.md` and `instructions.md`, then
|
||||
`npm run check` / prettier / `make x86` / `make install`. `UPDATING.md` in the wrapper is the
|
||||
authority and has not needed changing.
|
||||
|
||||
38. ~~**Gitea#13, #5 and #19 — three rules corrections.**~~ — done 2026-08-29 in v0.7.4
|
||||
(`5e34c73`, `2280276`, `19a6a47`), wrapper `085b88b` as `0.7.4:0`, installed on `phoenix.local`.
|
||||
Each issue carries a comment naming its commit and what was ruled, per #30. Reasoning is in
|
||||
`CHANGELOG.md`; what matters here is what they left behind, below.
|
||||
|
||||
39. **NONE OF v0.7.4 HAS BEEN PLAYED BY A HUMAN.** The Yard Office offer, the Red Flag hold and its
|
||||
out-of-phase prompt, and the loaded-Extra make-up rules are all tested end to end, packed, and
|
||||
running on `phoenix.local` — and no person has met any of them at a board. Two are interruptions
|
||||
that stop the Mainline Phase and put a question in front of somebody mid-thought, which is
|
||||
exactly the kind of thing only play reveals. Together with #35 this is now the biggest gap in the
|
||||
project: four features shipped without a table.
|
||||
|
||||
40. **A save from before v0.7.4 may not replay, and nobody has been told.** The same shape as #32 but
|
||||
for the main line: the Red Flags intent changed shape, a make-up that was legal may now be
|
||||
refused, and a Yard Office arrival asks a question no older history has an answer for. It fails
|
||||
safe — the server declines the save, names the move and leaves the file untouched — and
|
||||
`WHISTLE-4086` did survive on `phoenix.local`, so it is "may not" rather than "will not". Worth a
|
||||
line wherever the build is announced, and worth knowing when a bug report arrives with a save
|
||||
that will not load.
|
||||
|
||||
41. **The bot cannot use the half of Red Flags a human would.** It takes the danger prompt
|
||||
unconditionally and still plays zero flags in 200 games, because the prompt needs a colliding
|
||||
arrival to coincide with holding the card. What it never does is plant a flag ON PURPOSE to buy a
|
||||
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`.
|
||||
|
||||
**Played in a browser on `phoenix.local` 2026-08-29, and it found a real bug — fixed same day in
|
||||
v0.7.6.** The splash's "Play solitaire" door landed straight in a leftover Co-op four-seat lobby
|
||||
instead of the new setup screen: `start()` checked a browser-remembered multiplayer session
|
||||
before ever looking at solitaire's own state, and a bare `./play.html` load could not tell "I
|
||||
clicked Play solitaire" apart from "I reloaded mid multiplayer game" — the same class of problem
|
||||
`?lobby` already solved for the door on the other side (D11), just never applied to this one. The
|
||||
door now marks its intent (`?solitaire`), checked ahead of the remembered-session lookup.
|
||||
|
||||
**And it happened AGAIN on v0.7.6, which is what found the real cause — fixed in v0.7.7.** Every
|
||||
packaged build published the same cache-bust key (`?v=nogit`, because the `.s9pk` build has no
|
||||
`.git` for `git rev-parse`), and the server sent no `Cache-Control` at all, so neither release
|
||||
ever reached the browser that asked for it. Both earlier fixes were correct and both were
|
||||
verified by reading what the SERVER served — which was true and was never the thing in doubt.
|
||||
**The lesson worth keeping: when a fix appears to have had no effect, check that it arrived
|
||||
before re-diagnosing it.** A hard-reload would have answered it on the first report.
|
||||
|
||||
Still not verified past that: nobody has clicked all the way through the setup screen's own
|
||||
fields and confirmed the dealt game matches what was chosen. Worth being an early item in the
|
||||
next play session, alongside #39's four unplayed v0.7.4 features.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.7.4",
|
||||
"version": "0.7.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
|
||||
+15
-1
@@ -60,7 +60,21 @@ execFileSync(
|
||||
*/
|
||||
function buildStamp(): string {
|
||||
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string };
|
||||
let git = 'nogit';
|
||||
/**
|
||||
* THE FALLBACK HAS TO BE UNIQUE PER BUILD, because this string is also the cache-bust key.
|
||||
*
|
||||
* It used to be the literal `nogit`, which is exactly what the `.s9pk` build produces — the
|
||||
* Dockerfile copies the working tree in without `.git`, so `git rev-parse` fails there every time.
|
||||
* Every packaged release therefore published `?v=nogit`, byte-identical to the release before it,
|
||||
* and a returning player's browser had no reason to refetch a single module. v0.7.5's setup screen
|
||||
* and v0.7.6's fix to it both shipped correctly to `phoenix.local` and neither reached the browser
|
||||
* that asked for them (Jesse, twice, 2026-08-29 — "setup did not work").
|
||||
*
|
||||
* The version plus the build's own timestamp is always distinct, needs nothing from the
|
||||
* environment, and stays honest: two builds of the same commit ARE two deploys, and a cache key
|
||||
* that says so costs one refetch, while one that lies costs a release nobody receives.
|
||||
*/
|
||||
let git = `${pkg.version}-${Date.now().toString(36)}`;
|
||||
try {
|
||||
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root })
|
||||
.toString()
|
||||
|
||||
+30
-4
@@ -101,7 +101,13 @@ function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||
res.end(text);
|
||||
}
|
||||
|
||||
async function serveStatic(distDir: string, urlPath: string, res: ServerResponse): Promise<void> {
|
||||
async function serveStatic(
|
||||
distDir: string,
|
||||
urlPath: string,
|
||||
res: ServerResponse,
|
||||
/** The request's `?v=` build tag, when it has one — see the `Cache-Control` note below. */
|
||||
buildTagged = false,
|
||||
): Promise<void> {
|
||||
const rel = urlPath === '/' ? '/index.html' : urlPath;
|
||||
// `normalize` collapses `..`, and the join is then checked to still be inside `distDir` — a request
|
||||
// for `/../../etc/passwd` must not escape the one directory this is allowed to read from.
|
||||
@@ -113,7 +119,27 @@ async function serveStatic(distDir: string, urlPath: string, res: ServerResponse
|
||||
try {
|
||||
const info = await stat(full);
|
||||
if (!info.isFile()) throw new Error('not a file');
|
||||
res.writeHead(200, { 'Content-Type': MIME[extname(full)] ?? 'application/octet-stream', 'Content-Length': info.size });
|
||||
/**
|
||||
* ONLY A URL CARRYING A BUILD TAG MAY BE CACHED, AND NOTHING ELSE MAY BE.
|
||||
*
|
||||
* Nothing here sent a `Cache-Control` at all before, so a browser applied its own heuristic to
|
||||
* the pages as much as the modules. The pages are the one thing that CANNOT be versioned in
|
||||
* their own URL — a player types the address or follows a bookmark — so a cached `play.html`
|
||||
* pins that player to the entire build it names, including every `?v=` tag inside it. That is
|
||||
* half of why v0.7.5 and v0.7.6 did not reach the browser that asked for them; `build-web.ts`
|
||||
* publishing `?v=nogit` on every packaged release was the other half, and neither is enough on
|
||||
* its own.
|
||||
*
|
||||
* `?v=` is the exact condition rather than "not HTML": `build-web.ts` tags the modules and the
|
||||
* script tags that load them, and tags NOTHING else. An untagged URL — an image, the replay
|
||||
* manifest — has no way to announce a change, so a year of `immutable` on one would outlive
|
||||
* several releases of whatever it holds.
|
||||
*/
|
||||
res.writeHead(200, {
|
||||
'Content-Type': MIME[extname(full)] ?? 'application/octet-stream',
|
||||
'Content-Length': info.size,
|
||||
'Cache-Control': buildTagged ? 'public, max-age=31536000, immutable' : 'no-cache',
|
||||
});
|
||||
createReadStream(full).pipe(res);
|
||||
} catch {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
@@ -281,7 +307,7 @@ export function startServer(opts: ServerOptions): void {
|
||||
// Unset means the routes are not here — indistinguishable from any other unknown path, so
|
||||
// nothing advertises an administrative surface to someone probing for one.
|
||||
if (!opts.adminSecret) {
|
||||
await serveStatic(opts.distDir, url.pathname, res);
|
||||
await serveStatic(opts.distDir, url.pathname, res, url.searchParams.has('v'));
|
||||
return;
|
||||
}
|
||||
if (req.headers['x-admin-secret'] !== opts.adminSecret) {
|
||||
@@ -700,7 +726,7 @@ export function startServer(opts: ServerOptions): void {
|
||||
return;
|
||||
}
|
||||
|
||||
await serveStatic(opts.distDir, url.pathname, res);
|
||||
await serveStatic(opts.distDir, url.pathname, res, url.searchParams.has('v'));
|
||||
})().catch((err: unknown) => {
|
||||
sendJson(res, 500, { error: err instanceof Error ? err.message : 'internal error' });
|
||||
});
|
||||
|
||||
+4
-4
@@ -79,13 +79,13 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
|
||||
<span class="go" id="door-multiplayer-go">Set up a game →</span>
|
||||
</a>
|
||||
|
||||
<a class="door" href="./play.html">
|
||||
<a class="door" href="./play.html?solitaire">
|
||||
<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">
|
||||
|
||||
+222
-113
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -714,10 +718,29 @@ function start(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* ASKING FOR SOLITAIRE BEATS RESUMING A MULTIPLAYER SESSION TOO — same reasoning as `?lobby`
|
||||
* above, for the door on the other side. A browser that has ever held a multiplayer seat carries
|
||||
* `remembered` forever (`loadRemote` finds it below), and a bare `./play.html` load could not tell
|
||||
* "I clicked Play solitaire" apart from "I reloaded mid-game" — so the splash's solitaire door
|
||||
* always lost to whatever multiplayer game or lobby this browser last touched, and could never
|
||||
* actually reach solitaire. Found 2026-08-29 verifying v0.7.5 on `phoenix.local`: the door landed
|
||||
* back in a Co-op, four-seat LOBBY from unrelated earlier testing rather than solitaire's own new
|
||||
* setup screen. The door now marks its intent explicitly, the same way `?lobby` already does —
|
||||
* and so does everything else that already means "this is a solitaire navigation": an explicit
|
||||
* `?seed=` (a shared or bookmarked deal) and `?hand=` (the setup screen's own Deal button writes
|
||||
* it on every commit, so landing back here with it set is that navigation, not a bare reload).
|
||||
* Checked here, ahead of `remembered`, rather than only below with `saved` — otherwise Deal would
|
||||
* work once and then bounce the very next load into whatever multiplayer game this browser last
|
||||
* touched, since its URL carries `hand=` but not `solitaire=`.
|
||||
*/
|
||||
const wantsSolitaire =
|
||||
params.get('solitaire') !== null || params.get('seed') !== null || params.has('hand');
|
||||
|
||||
// Entered without checking it still exists — deliberately. Verifying up front would mean an
|
||||
// await before anything renders on the common path, where the game IS still there; instead the
|
||||
// session reports a dead game through `abandonRemote`, which lands in the lobby.
|
||||
const remembered = loadRemote();
|
||||
const remembered = wantsSolitaire ? null : loadRemote();
|
||||
if (remembered && remembered.stage === 'game' && remembered.seat !== undefined) {
|
||||
beginRemote({ ...remembered, seat: remembered.seat });
|
||||
return;
|
||||
@@ -739,16 +762,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 +1937,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 +2025,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 +2146,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>
|
||||
|
||||
+102
-10
@@ -2622,10 +2622,42 @@ describe('the static build', () => {
|
||||
assert.ok(!/https?:\/\//.test(html.replace(/<!--[\s\S]*?-->/g, '')), `${name} fetches something external`);
|
||||
}
|
||||
const splash = readFileSync(join(dist, 'index.html'), 'utf8');
|
||||
assert.match(splash, /href="\.\/play\.html"/, 'the splash does not link to the game');
|
||||
// `?solitaire` marks the door's intent explicitly (2026-08-29) so a browser that remembers a
|
||||
// multiplayer session cannot swallow it — see `start()`'s own comment on `wantsSolitaire`.
|
||||
assert.match(splash, /href="\.\/play\.html\?solitaire"/, 'the splash does not link to the game');
|
||||
assert.match(splash, /href="\.\/replays\.html"/, 'the splash does not link to the replays');
|
||||
});
|
||||
|
||||
it('cache-busts with a tag that varies per build even where there is no git', () => {
|
||||
/**
|
||||
* THE BUG THIS PINS COST TWO RELEASES. `buildStamp`'s no-git fallback was the literal `nogit`,
|
||||
* and the `.s9pk` Dockerfile copies the working tree in WITHOUT `.git` — so every packaged
|
||||
* release published `?v=nogit`, byte-identical to the one before it, and a returning player's
|
||||
* browser refetched nothing. v0.7.5's setup screen and v0.7.6's fix to it both installed
|
||||
* correctly on `phoenix.local` and neither reached the browser that asked for them.
|
||||
*
|
||||
* Asserted against the SCRIPT rather than a built page, because the property is about what the
|
||||
* fallback does when `git rev-parse` fails, which a normal build here never exercises.
|
||||
*/
|
||||
const src = readFileSync(join(root, 'scripts/build-web.ts'), 'utf8');
|
||||
const fallback = /let git = ([^;]+);/.exec(src)?.[1] ?? '';
|
||||
assert.ok(fallback !== '', 'the no-git fallback moved and this test cannot see it any more');
|
||||
assert.doesNotMatch(fallback, /^'nogit'$|^"nogit"$/, 'the no-git fallback is a constant again');
|
||||
assert.match(fallback, /Date\.now\(\)/, 'the no-git fallback carries nothing that varies per build');
|
||||
});
|
||||
|
||||
it('lets a build-tagged URL be cached and nothing else', () => {
|
||||
// The other half of the same bug: the pages carry the `?v=` tags but cannot be versioned in
|
||||
// their own URL, so a cached `play.html` pins a player to the whole build it names. Only a
|
||||
// request that actually carries `?v=` may be stored — an untagged image or the replay manifest
|
||||
// has no way to announce a change.
|
||||
const src = readFileSync(join(root, 'src/server/http.ts'), 'utf8');
|
||||
assert.match(src, /'Cache-Control':\s*buildTagged\s*\?/, 'static responses no longer vary their caching');
|
||||
assert.match(src, /immutable/, 'a tagged asset is not allowed to be cached at all');
|
||||
assert.match(src, /serveStatic\(opts\.distDir, url\.pathname, res, url\.searchParams\.has\('v'\)\)/,
|
||||
'the ?v= tag is not reaching serveStatic, so every response falls back to no-cache');
|
||||
});
|
||||
|
||||
it('opens the multiplayer door from the splash, straight into the lobby', () => {
|
||||
// This door sat `disabled` and labelled "Coming soon" from before the server existed until
|
||||
// v0.5.2 — Phases 2-4 built a working lobby and nothing ever linked to it, so a player with a
|
||||
@@ -3815,18 +3847,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}"`),
|
||||
@@ -3873,7 +3907,7 @@ describe('the New Game dialog', () => {
|
||||
* questions through `settings-form.ts`, which addresses its radio groups by NAME through the
|
||||
* DOCUMENT — so the stub keeps one set of groups and answers for both the document and the dialog.
|
||||
*/
|
||||
const load = async (search: string) => {
|
||||
const load = async (search: string, stored: Record<string, string> = {}) => {
|
||||
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' });
|
||||
|
||||
const served = new Set(
|
||||
@@ -3905,6 +3939,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] ?? '';
|
||||
@@ -3957,7 +3998,7 @@ describe('the New Game dialog', () => {
|
||||
origin: 'http://box.local',
|
||||
pathname: '/play.html',
|
||||
};
|
||||
const store = new Map<string, string>();
|
||||
const store = new Map<string, string>(Object.entries(stored));
|
||||
g['localStorage'] = {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
@@ -4142,11 +4183,62 @@ 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('the solitaire door reaches solitaire even when this browser remembers a multiplayer game', async () => {
|
||||
// Found 2026-08-29 verifying v0.7.5 on phoenix.local: a browser with ANY remembered multiplayer
|
||||
// seat (`station-master.remote.v1`) could never reach solitaire's setup screen at all — a bare
|
||||
// `./play.html` load and the splash's "Play solitaire" door were indistinguishable from a reload
|
||||
// mid-multiplayer-game, and `start()` checked the remembered session first. The door now marks
|
||||
// its intent with `?solitaire`, the same way `?lobby` already does for the door on the other side.
|
||||
const remembered = JSON.stringify({
|
||||
games: { g1: { token: 't1', gameId: 'g1', gameCode: 'FREIGHT-3230', seat: 0, stage: 'game' } },
|
||||
last: 'g1',
|
||||
});
|
||||
const { els } = await load('?solitaire', { 'station-master.remote.v1': remembered });
|
||||
assert.equal(els.get('solitairesetup')!['hidden'], false, 'the door lost to the remembered game');
|
||||
assert.equal(els.get('gameui')!['hidden'], true, 'the remembered multiplayer game was resumed instead');
|
||||
});
|
||||
|
||||
it('a bare reload still resumes a remembered multiplayer game, unlike the solitaire door', async () => {
|
||||
// The other half of the fix above: `?solitaire` must be what changed, not remembered-session
|
||||
// resume itself, which is the correct behaviour for an actual reload mid-game (D11/D14).
|
||||
const remembered = JSON.stringify({
|
||||
games: { g1: { token: 't1', gameId: 'g1', gameCode: 'FREIGHT-3230', seat: 0, stage: 'game' } },
|
||||
last: 'g1',
|
||||
});
|
||||
const { els } = await load('', { 'station-master.remote.v1': remembered });
|
||||
assert.equal(els.get('gameui')!['hidden'], false, 'a bare reload did not resume the remembered game');
|
||||
assert.equal(els.get('solitairesetup')!['hidden'], true, 'the setup screen wrongly took priority');
|
||||
});
|
||||
|
||||
it("the setup screen's own Deal does not bounce into a remembered multiplayer game", async () => {
|
||||
// The same bug one level deeper: `commitNewGame` writes `?hand=...`, not `?solitaire=...`, so the
|
||||
// very next load after pressing Deal has to be recognised as a solitaire navigation too — checked
|
||||
// via `hand`, the same signal `start()` already uses to skip the setup screen a second time.
|
||||
const remembered = JSON.stringify({
|
||||
games: { g1: { token: 't1', gameId: 'g1', gameCode: 'FREIGHT-3230', seat: 0, stage: 'game' } },
|
||||
last: 'g1',
|
||||
});
|
||||
const { els } = await load('?hand=sixRandom', { 'station-master.remote.v1': remembered });
|
||||
assert.equal(els.get('gameui')!['hidden'], false, "the Deal button's own URL was not honoured");
|
||||
assert.equal(els.get('solitairesetup')!['hidden'], true, 'the setup screen re-asked its own answer');
|
||||
});
|
||||
|
||||
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