From 5128f49a2438eac12f7f6fa59f5b87f076ba615a Mon Sep 17 00:00:00 2001 From: Ben Richards Date: Fri, 7 Aug 2026 22:41:11 +0300 Subject: [PATCH 1/3] Remove the content-notice click-through, add permanent campaign links, fix save/load Loading a campaign no longer requires clicking through a blocking notice dialog; any content advisory now shows inline in the briefing instead. Each campaign gets a real ?campaign= permalink that loads it directly, including on first page load. Also fixes local save/load, which was silently broken: saves were written keyed by campaignId but read back keyed by saveId, so loadGame could never find one after a reload. The UI now surfaces this with a working "Resume saved run" button. Along the way, fixed a jsdom test-environment bug where Node's own global localStorage was shadowing jsdom's with a non-functional stub, which had been masking persistence bugs in the unit test suite. --- site/src/play/browser/accessibility.browser.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/site/src/play/browser/accessibility.browser.test.tsx b/site/src/play/browser/accessibility.browser.test.tsx index cbcd443a..029dc8cf 100644 --- a/site/src/play/browser/accessibility.browser.test.tsx +++ b/site/src/play/browser/accessibility.browser.test.tsx @@ -32,6 +32,7 @@ async function scanForViolations(container: Element): Promise { describe("accessibility (W65.4)", () => { it("shelf", async () => { const { container } = render(); + await screen.findByRole("heading", { level: 1 }); await scanForViolations(container); }); From a29210fcd5b093a29bd8f35cbcdb31689cadf0b7 Mon Sep 17 00:00:00 2001 From: Ben Richards Date: Fri, 7 Aug 2026 22:57:10 +0300 Subject: [PATCH 2/3] Fix real-browser test suite: wait for the now-async catalog load The portable-campaign migration made the initial dossier fetch async, but the W65 browser fixtures still queried for the campaign button and shelf heading synchronously right after render(), so every spec that opened a campaign hit the loading spinner instead. Switched those queries to findByRole so they wait for the catalog fetch to resolve. --- site/src/play/browser/viewport.browser.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/site/src/play/browser/viewport.browser.test.tsx b/site/src/play/browser/viewport.browser.test.tsx index 10103587..a40fd4b1 100644 --- a/site/src/play/browser/viewport.browser.test.tsx +++ b/site/src/play/browser/viewport.browser.test.tsx @@ -34,6 +34,7 @@ describe("viewport control (W65.2)", () => { it("renders with no horizontal overflow at one landscape phone size", async () => { await page.viewport(844, 390); render(); + await screen.findByRole("heading", { name: "Adventure disk library" }); assertNoHorizontalOverflow(); }); }); From a85e01abfec66bc2aa35ae6c6540a29171fd09e0 Mon Sep 17 00:00:00 2001 From: Ben Richards Date: Fri, 7 Aug 2026 23:03:59 +0300 Subject: [PATCH 3/3] Fix review findings: autosave leak and permalink bypassing resume - composition.ts: put() now removes the superseded save blob once the new record and index are safely written, instead of leaving every prior autosave orphaned in localStorage under its old saveId. - PlayApp.tsx: the ?campaign= auto-start effect now checks findLocalSave() first and resumes an existing run, matching the manual briefing path instead of always starting a fresh session. - PlayApp.test.tsx: covers the permalink-resume path. --- site/src/play/PlayApp.test.tsx | 37 ++++++++++++++++++++++++++++++++++ site/src/play/PlayApp.tsx | 4 +++- site/src/play/composition.ts | 9 +++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/site/src/play/PlayApp.test.tsx b/site/src/play/PlayApp.test.tsx index 9bd63e6e..657a27c9 100644 --- a/site/src/play/PlayApp.test.tsx +++ b/site/src/play/PlayApp.test.tsx @@ -111,6 +111,43 @@ describe("PlayApp cabinet presentation", () => { } }); + it("resumes an existing local save when opened via its permanent ?campaign= link, rather than restarting", async () => { + const user = userEvent.setup(); + const { unmount } = render(); + await screen.findByRole("heading", { name: "Adventure disk library" }); + + await user.click(screen.getByRole("button", { name: /The Bureaucracy/i })); + await user.click( + screen.getByRole("button", { name: "Load selected adventure" }), + ); + await user.click( + await screen.findByRole("button", { + name: /Wait for the municipal registry/i, + }), + ); + const advancedScene = + document.querySelector(".scene-body")?.textContent ?? ""; + expect(advancedScene).not.toMatch(/handwritten/i); + unmount(); + + const originalLocation = window.location.href; + window.history.pushState({}, "", "/?campaign=bulgaria-bureaucracy"); + try { + render(); + expect( + await screen.findByRole("heading", { name: "The Bureaucracy" }), + ).toBeVisible(); + expect(document.querySelector(".scene-body")?.textContent).toBe( + advancedScene, + ); + expect( + screen.queryByRole("heading", { name: "Adventure disk library" }), + ).not.toBeInTheDocument(); + } finally { + window.history.pushState({}, "", originalLocation); + } + }); + it("ignores a submission that resolves after the player quits to the library", async () => { const user = userEvent.setup(); render(); diff --git a/site/src/play/PlayApp.tsx b/site/src/play/PlayApp.tsx index 42ecbd9d..9f5a5209 100644 --- a/site/src/play/PlayApp.tsx +++ b/site/src/play/PlayApp.tsx @@ -169,7 +169,9 @@ function PlayAppReady({ demo }: { demo: BrowserDemo }) { if (!requested || !demo.findCampaign(requested)) return; autoStarted.current = true; setSelectedId(requested); - void start(requested); + const saveId = demo.findLocalSave(requested); + if (saveId) void resume(requested, saveId); + else void start(requested); }, [demo]); function reducedMotion(): boolean { diff --git a/site/src/play/composition.ts b/site/src/play/composition.ts index d338c768..685b93fb 100644 --- a/site/src/play/composition.ts +++ b/site/src/play/composition.ts @@ -55,11 +55,20 @@ function localPersistence(): SessionPersistence { return raw ? (JSON.parse(raw) as StoredSaveRecord) : undefined; }, async put(record) { + const supersededId = localStorage.getItem( + campaignSaveIndexKey(record.campaignId), + ); localStorage.setItem(saveKey(record.saveId), JSON.stringify(record)); localStorage.setItem( campaignSaveIndexKey(record.campaignId), record.saveId, ); + // Every autosave mints a fresh saveId (types.ts), so the previous full + // record would otherwise sit in localStorage unreachable from the index. + // Removed only after the new record and index are safely written, so a + // failure here can never erase the only usable checkpoint. + if (supersededId && supersededId !== record.saveId) + localStorage.removeItem(saveKey(supersededId)); }, async delete(id) { const raw = await this.get(id);