v0.7.8 — the setup screen was unreachable for anyone who had ever played
Third report of the same symptom, this time with the build confirmed current on screen, which ruled out v0.7.7's caching fault and left the real cause exposed. v0.7.5 skipped the setup screen whenever load() found a save, reasoned as "a saved game is a game to resume". A browser that has ever played solitaire always has one, so the door could never reach the screen again — and the fresh private window that appeared to vindicate v0.7.7 simply had no save. Two real faults were stacked; the caching one is fixed and had been masking this. The door outranks a saved game now: ?solitaire is a request to set one up, while a bare reload still resumes (pinned by its own test). Since Deal clears the save, the screen carries #ss-resume and says what Deal costs, so the door cannot destroy a game in progress. Also, per Jesse, riding along rather than taking its own release: the splash footer now names both ways to play. 868 tests pass, four new. The reproduction was a failing test written before the fix. 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
af68aac78d
commit
5210dbd5b4
@@ -19,6 +19,47 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 0.7.8 — 2026-08-30
|
||||||
|
|
||||||
|
### The setup screen was unreachable for anyone who had ever played
|
||||||
|
|
||||||
|
Third report of the same symptom, and this time the build was confirmed current on screen
|
||||||
|
(`0.7.7-mtf7hyxc`), which ruled out the caching fault v0.7.7 had just fixed and left the actual
|
||||||
|
cause with nowhere to hide.
|
||||||
|
|
||||||
|
**v0.7.5 skipped the setup screen whenever `load()` found a save**, reasoned in its own comment as
|
||||||
|
"a saved game is a game to resume". The consequence went unnoticed: a browser that has ever played
|
||||||
|
solitaire *always* has a save, so the door could never reach the screen again. Only a browser that
|
||||||
|
had never played would see it — which is exactly why a fresh private window appeared to prove
|
||||||
|
v0.7.7's cache fix. The private window had no save. Two genuine faults were stacked, the caching one
|
||||||
|
was real and is fixed, and it masked this one.
|
||||||
|
|
||||||
|
**The door now outranks a saved game.** `?solitaire` is an explicit request to set a game up;
|
||||||
|
clicking "Play solitaire" is not a request to resume. A BARE reload still resumes, which is the
|
||||||
|
zero-friction case D11 is about and is pinned by its own test.
|
||||||
|
|
||||||
|
**Dealing from the door would have destroyed a game in progress**, since `commitNewGame` calls
|
||||||
|
`clearSave()` — so the screen now carries `#ss-resume` ("Continue saved game") and states plainly
|
||||||
|
that dealing replaces the save. Resuming navigates to the bare URL rather than building a session
|
||||||
|
on the spot, so `start()` stays the only place that turns a URL into a game.
|
||||||
|
|
||||||
|
**Recorded because the failure was diagnostic, not technical.** The first two attempts each fixed
|
||||||
|
something real that was not this, and both were reported as verified. The routing fix in v0.7.6 was
|
||||||
|
verified by reading what the server served; v0.7.7's by the same. Neither ever exercised the actual
|
||||||
|
path with the actual state a returning player has. The reproduction here is a failing test asserting
|
||||||
|
the door with a save present — written before the fix, and it failed with "a saved game swallowed
|
||||||
|
the door".
|
||||||
|
|
||||||
|
### The splash footer names both ways to play
|
||||||
|
|
||||||
|
Was "solitaire runs entirely in your browser — no server code required", written when solitaire was
|
||||||
|
the only door. Now: "Multiplayer runs on StartOS server. Solitaire runs entirely in your browser."
|
||||||
|
(Jesse, 2026-08-30, asked to ride along with the next change rather than take a release of its own.)
|
||||||
|
|
||||||
|
868 tests pass, four of them new.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0.7.7 — 2026-08-30
|
## 0.7.7 — 2026-08-30
|
||||||
|
|
||||||
### Two releases shipped to a browser that never received them
|
### Two releases shipped to a browser that never received them
|
||||||
|
|||||||
@@ -333,6 +333,19 @@ Queued 2026-08-29, from building Gitea#11 and #16 (both shipped in v0.7.3, main
|
|||||||
**The lesson worth keeping: when a fix appears to have had no effect, check that it arrived
|
**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.
|
before re-diagnosing it.** A hard-reload would have answered it on the first report.
|
||||||
|
|
||||||
|
**And a THIRD report, 2026-08-30, with the build confirmed current on screen — which is what
|
||||||
|
finally found it. Fixed in v0.7.8.** v0.7.5 skipped the setup screen whenever a save existed
|
||||||
|
("a saved game is a game to resume"), so any browser that had ever played solitaire could never
|
||||||
|
reach it again; the private window that seemed to vindicate v0.7.7 simply had no save. The door
|
||||||
|
outranks a save now, a bare reload still resumes, and `#ss-resume` keeps the game in progress
|
||||||
|
one button away since Deal clears it.
|
||||||
|
|
||||||
|
**Three attempts, two of them fixing something real that was not the reported fault.** Each was
|
||||||
|
reported as verified, and each verification read what the SERVER served rather than exercising
|
||||||
|
the path with the state a returning player actually has. The thing that worked was a failing
|
||||||
|
test written before the fix. Worth remembering next time a report repeats: reproduce the user's
|
||||||
|
state first, and treat "I verified it" as unearned until something failed the way they described.
|
||||||
|
|
||||||
Still not verified past that: nobody has clicked all the way through the setup screen's own
|
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
|
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.
|
next play session, alongside #39's four unplayed v0.7.4 features.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "station-master",
|
"name": "station-master",
|
||||||
"version": "0.7.7",
|
"version": "0.7.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Station Master — a railroad operations game",
|
"description": "Station Master — a railroad operations game",
|
||||||
|
|||||||
+1
-1
@@ -100,7 +100,7 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
|
|||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<span>build <span id="build">__BUILD__</span></span>
|
<span>build <span id="build">__BUILD__</span></span>
|
||||||
<span>solitaire runs entirely in your browser — no server code required</span>
|
<span>Multiplayer runs on StartOS server. Solitaire runs entirely in your browser.</span>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
+35
-9
@@ -773,16 +773,24 @@ function start(): void {
|
|||||||
* (Jesse, 2026-08-29 — "let the user choose their options like the start of a 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").
|
* "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
|
* Three things answer the question and so skip the screen, in this order of precedence:
|
||||||
* a game to resume, and a seed names a specific deal someone already chose to share or bookmark,
|
* `hand` (every `commitNewGame` write sets it, so this navigation IS the Deal button landing back
|
||||||
* the same reasoning `?lobby` already uses to skip past the doors on an invite link. `hand` is the
|
* here to deal), `seed` (a specific deal someone chose to share or bookmark), and — only when the
|
||||||
* one field every `commitNewGame` write always sets (`rulesToUrl`), so its presence means this
|
* player did not explicitly ask to set one up — an existing save, which is a game to resume.
|
||||||
* 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.
|
* THE DOOR OUTRANKS A SAVED GAME, and getting that wrong is what made this feature unreachable
|
||||||
|
* for three releases. v0.7.5 skipped the screen whenever `load()` found ANYTHING, reasoned as "a
|
||||||
|
* saved game is a game to resume" — but a browser that has ever played solitaire always has one,
|
||||||
|
* so the door could never reach the screen again. Reported three times (Jesse, 2026-08-29 twice
|
||||||
|
* and 2026-08-30); a private window appeared to absolve it only because it had never played and
|
||||||
|
* so had no save. Clicking "Play solitaire" is a request to set a game up, not to resume one — a
|
||||||
|
* BARE reload is the resume case, and still is. `#ss-resume` is what keeps the save reachable, so
|
||||||
|
* this costs nobody the game they were playing.
|
||||||
*/
|
*/
|
||||||
if (!saved && requested === null && !params.has('hand')) {
|
const askedToSetUp = params.get('solitaire') !== null;
|
||||||
|
if (requested === null && !params.has('hand') && (askedToSetUp || !saved)) {
|
||||||
showScreen('solitairesetup');
|
showScreen('solitairesetup');
|
||||||
runSolitaireSetup(params);
|
runSolitaireSetup(params, saved !== null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2173,7 +2181,7 @@ if (newBtn && dlg) {
|
|||||||
* Solitaire defaults, since there is no live game to compare against yet, and reuses the identical
|
* 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.
|
* `wireGameTypeBlock`/`commitNewGame` pair the in-game dialog uses — the two are one design, not two.
|
||||||
*/
|
*/
|
||||||
function runSolitaireSetup(params: URLSearchParams): void {
|
function runSolitaireSetup(params: URLSearchParams, hasSave = false): void {
|
||||||
const screen = document.getElementById('solitairesetup');
|
const screen = document.getElementById('solitairesetup');
|
||||||
const dealBtn = document.getElementById('ss-deal');
|
const dealBtn = document.getElementById('ss-deal');
|
||||||
if (!screen || !dealBtn) return;
|
if (!screen || !dealBtn) return;
|
||||||
@@ -2185,6 +2193,24 @@ function runSolitaireSetup(params: URLSearchParams): void {
|
|||||||
const seedField = document.getElementById('ss-seed') as HTMLInputElement | null;
|
const seedField = document.getElementById('ss-seed') as HTMLInputElement | null;
|
||||||
if (seedField) seedField.value = params.get('seed') ?? '';
|
if (seedField) seedField.value = params.get('seed') ?? '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE WAY BACK TO A GAME IN PROGRESS, and the reason the door is allowed to outrank a save at all.
|
||||||
|
* Dealing from here calls `clearSave()`, so a player who reached this screen from the splash — by
|
||||||
|
* clicking "Play solitaire", which nobody reads as "throw away what I was playing" — needs their
|
||||||
|
* game one button away and needs to be told what Deal costs.
|
||||||
|
*
|
||||||
|
* Resuming is a navigation to the BARE url rather than a session built here: `start()` already
|
||||||
|
* resumes a save on a bare load, and routing both paths through it keeps one place that turns a
|
||||||
|
* URL into a game.
|
||||||
|
*/
|
||||||
|
const resumeBtn = document.getElementById('ss-resume');
|
||||||
|
const savedNote = document.getElementById('ss-saved-note');
|
||||||
|
if (resumeBtn) {
|
||||||
|
resumeBtn.hidden = !hasSave;
|
||||||
|
resumeBtn.onclick = () => void (location.search = '');
|
||||||
|
}
|
||||||
|
if (savedNote) savedNote.hidden = !hasSave;
|
||||||
|
|
||||||
ss.selectPreset('solitaire');
|
ss.selectPreset('solitaire');
|
||||||
dealBtn.onclick = () => commitNewGame(ss, seedField?.value ?? '');
|
dealBtn.onclick = () => commitNewGame(ss, seedField?.value ?? '');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -765,7 +765,15 @@ ul.blocked li{padding:2px 0}
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<!-- Shown only when `station-master.save.v1` holds a game. Dealing from this screen CLEARS that
|
||||||
|
save (`commitNewGame` calls `clearSave`), so without a way back the door would be a way to
|
||||||
|
lose a game in progress — and the door is reached by clicking "Play solitaire", which nobody
|
||||||
|
reads as "discard what I was playing". -->
|
||||||
|
<p class="ng-note" id="ss-saved-note" hidden>You have a solitaire game in progress. Dealing a new
|
||||||
|
one below replaces it — there is no undo for that.</p>
|
||||||
|
|
||||||
<menu class="ng-buttons">
|
<menu class="ng-buttons">
|
||||||
|
<button id="ss-resume" type="button" hidden>Continue saved game</button>
|
||||||
<button id="ss-deal" type="button">Deal</button>
|
<button id="ss-deal" type="button">Deal</button>
|
||||||
</menu>
|
</menu>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -4192,6 +4192,49 @@ describe('the New Game dialog', () => {
|
|||||||
assert.equal(els.get('gameui')!['hidden'], true, 'a game was dealt before anyone chose anything');
|
assert.equal(els.get('gameui')!['hidden'], true, 'a game was dealt before anyone chose anything');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('the solitaire door reaches the setup screen even when a solitaire game is saved', async () => {
|
||||||
|
/**
|
||||||
|
* REPORTED THREE TIMES BY JESSE (2026-08-29, twice, and 2026-08-30). v0.7.5 skipped the setup
|
||||||
|
* screen whenever `load()` found a save, reasoned as "a saved game is a game to resume" — but
|
||||||
|
* that means ANY browser that has ever played solitaire can never reach the setup screen from
|
||||||
|
* the door again, which is the whole feature. The private window that appeared to prove the
|
||||||
|
* caching fix had simply never played, so its `localStorage` was empty.
|
||||||
|
*
|
||||||
|
* The door is an explicit request to set a game up. A BARE reload still resumes (below).
|
||||||
|
*/
|
||||||
|
const save = JSON.stringify({ seed: 12345, history: [] });
|
||||||
|
const { els } = await load('?solitaire', { 'station-master.save.v1': save });
|
||||||
|
assert.equal(els.get('solitairesetup')!['hidden'], false, 'a saved game swallowed the door');
|
||||||
|
assert.equal(els.get('gameui')!['hidden'], true, 'the saved game was resumed instead of asking');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers a way back to the saved game, since dealing from the door destroys it', async () => {
|
||||||
|
// Deal calls `clearSave()`. The door is reached by clicking "Play solitaire", which nobody reads
|
||||||
|
// as "discard what I was playing" — so the save has to be one button away, and the cost of Deal
|
||||||
|
// has to be stated. Resuming navigates to the bare URL and lets `start()` do it.
|
||||||
|
const save = JSON.stringify({ seed: 12345, history: [] });
|
||||||
|
const { els, nav } = await load('?solitaire', { 'station-master.save.v1': save });
|
||||||
|
assert.equal(els.get('ss-resume')!['hidden'], false, 'no way back to the game in progress');
|
||||||
|
assert.equal(els.get('ss-saved-note')!['hidden'], false, "Deal's cost to the save is not stated");
|
||||||
|
(els.get('ss-resume')!['onclick'] as () => void)();
|
||||||
|
assert.equal(nav.search, '', 'resuming did not go back to the plain resume path');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the resume button when there is no saved game to go back to', async () => {
|
||||||
|
const { els } = await load('?solitaire');
|
||||||
|
assert.equal(els.get('ss-resume')!['hidden'], true, 'a resume button with nothing to resume');
|
||||||
|
assert.equal(els.get('ss-saved-note')!['hidden'], true, 'warns about replacing a save that does not exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a bare reload still resumes a saved solitaire game rather than asking again', async () => {
|
||||||
|
// The other half: `?solitaire` is what changed, not resuming itself. Reopening the tab must not
|
||||||
|
// put a question in front of somebody who just wants their game back (D11's zero-friction case).
|
||||||
|
const save = JSON.stringify({ seed: 12345, history: [] });
|
||||||
|
const { els } = await load('', { 'station-master.save.v1': save });
|
||||||
|
assert.equal(els.get('gameui')!['hidden'], false, 'a bare reload did not resume the saved game');
|
||||||
|
assert.equal(els.get('solitairesetup')!['hidden'], true, 'the setup screen interrupted a resume');
|
||||||
|
});
|
||||||
|
|
||||||
it('the solitaire door reaches solitaire even when this browser remembers a multiplayer game', async () => {
|
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
|
// 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
|
// seat (`station-master.remote.v1`) could never reach solitaire's setup screen at all — a bare
|
||||||
|
|||||||
Reference in New Issue
Block a user