- {code} -
-- Keep this somewhere safe. Anyone with this code can open your company. -
- - > - ); - } else if (status.isPending) { - body =Checking…
; - } else if (status.data?.hasCode) { - body = ( - <> -- You already have a backup code. It is only shown when it is made, so if you lost it, make - a new one. -
-- Your old code will stop working as soon as you generate a new one. -
- - > - ); - } else { - body = ( - <> -- A backup code lets you open this company on any device, or get it back if this one is - lost. No email or password. -
- - > - ); - } - - return ( - <> - - {open && ( -- {errorMessage(create.error ?? status.error)} -
- )} - -- This device's current company will be left behind. Back it up first if you want - to keep it. -
- )} - -+ This code is your whole company. Keep it somewhere safe, or import it on another device. +
+ {code ? ( + <> + ++ {errorMessage(exported.error)} +
+ ) : ( +Packing up your company…
+ )} + ++ This replaces the company on this device. Export it first if you want to keep it. +
+ )} + +- Could not start a company. Check your connection and try again. + Could not start a company. Try again.
)} diff --git a/apps/web/test/about.test.tsx b/apps/web/test/about.test.tsx index eb7ba8c..0244f38 100644 --- a/apps/web/test/about.test.tsx +++ b/apps/web/test/about.test.tsx @@ -2,6 +2,7 @@ import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { useClock } from "../src/clock/clock"; +import { debugInfo } from "../src/screens/AboutSettings"; import { fakeApi, renderApp, samplePlayerState } from "./render"; describe("about and debug info (Step 10K.10)", () => { @@ -20,10 +21,18 @@ describe("about and debug info (Step 10K.10)", () => { const text = (writeText.mock.calls[0] as unknown as [string])[0]; expect(text).toContain("Version: 0.0.0-test"); expect(text).toContain("Build: test"); - expect(text).toContain("Player: player-1"); - expect(text).toMatch(/Server time: \d{4}-\d{2}-\d{2}T/); + // CR-014: the save, its format version and where it is stored; no player id. + expect(text).toContain("Save: player-1"); + expect(text).toContain("Save version: 1"); + expect(text).toContain("Storage: unknown"); + expect(text).not.toContain("Player:"); + expect(text).toMatch(/Game time: \d{4}-\d{2}-\d{2}T/); // The app syncs its own clock, so expect whatever offset it holds now. expect(text).toContain(`Clock offset: ${Math.round(useClock.getState().offsetMs)} ms`); expect(text).toContain("Screen: /fleet"); }); + + it("names the device's save storage", () => { + expect(debugInfo("save-1", "indexeddb")).toContain("Storage: indexeddb"); + }); }); diff --git a/apps/web/test/backup-ui.test.tsx b/apps/web/test/backup-ui.test.tsx deleted file mode 100644 index 950761e..0000000 --- a/apps/web/test/backup-ui.test.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { ApiError } from "../src/api/client"; -import { fakeApi, renderApp, samplePlayerState, unauthorized } from "./render"; - -const CODE = "ABCDE-FGHJK-MNPQR-STUVW"; -const owner = () => samplePlayerState({ tutorial: null }); -const restored = () => - samplePlayerState({ - tutorial: null, - company: { id: "c2", name: "Restored Freight", credits: 77_000, loanBalance: 0 }, - }); - -async function openRestore() { - await userEvent.click(await screen.findByRole("button", { name: "Restore a company" })); - return screen.getByRole("dialog", { name: "Restore a company" }); -} - -describe("backup and restore (Step 10K.9)", () => { - afterEach(() => vi.unstubAllGlobals()); - - it("shows a new code grouped, and Copy puts it on the clipboard", async () => { - const writeText = vi.fn(() => Promise.resolve()); - Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); - renderApp( - fakeApi({ - getPlayerState: owner, - getBackupCode: () => ({ hasCode: false, createdAt: null }), - createBackupCode: () => ({ code: CODE, createdAt: 1 }), - }), - "/settings", - ); - await userEvent.click(await screen.findByRole("button", { name: "Back up company" })); - const sheet = screen.getByRole("dialog", { name: "Back up your company" }); - await userEvent.click( - await within(sheet).findByRole("button", { name: "Create a backup code" }), - ); - expect(await within(sheet).findByLabelText("Backup code")).toHaveTextContent(CODE); - expect(sheet).toHaveTextContent("Anyone with this code can open your company."); - await userEvent.click(within(sheet).getByRole("button", { name: "Copy" })); - expect(writeText).toHaveBeenCalledWith(CODE); - expect(await within(sheet).findByRole("button", { name: "Copied" })).toBeInTheDocument(); - }); - - it("warns about an existing code before replacing it", async () => { - renderApp( - fakeApi({ - getPlayerState: owner, - getBackupCode: () => ({ hasCode: true, createdAt: 1 }), - }), - "/settings", - ); - await userEvent.click(await screen.findByRole("button", { name: "Back up company" })); - const sheet = screen.getByRole("dialog", { name: "Back up your company" }); - expect( - await within(sheet).findByRole("button", { name: "Generate a new code" }), - ).toBeInTheDocument(); - expect(sheet).toHaveTextContent("Your old code will stop working"); - }); - - it("warns that a company past the tutorial is left behind, and not during the tutorial", async () => { - const { unmount } = renderApp(fakeApi({ getPlayerState: owner }), "/settings"); - expect(await openRestore()).toHaveTextContent("current company will be left behind"); - unmount(); - renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }), "/settings"); - expect(await openRestore()).not.toHaveTextContent("left behind"); - }); - - it("restores and goes to HQ", async () => { - let done = false; - const api = fakeApi({ - getPlayerState: () => (done ? restored() : owner()), - restoreCompany: () => { - done = true; - return { playerId: "p2", state: restored() }; - }, - }); - renderApp(api, "/settings"); - const sheet = await openRestore(); - await userEvent.type(within(sheet).getByRole("textbox"), CODE.toLowerCase()); - await userEvent.click(within(sheet).getByRole("button", { name: "Restore" })); - await waitFor(() => - expect(api.call).toHaveBeenCalledWith("restoreCompany", { code: CODE.toLowerCase() }), - ); - expect(await screen.findByRole("heading", { level: 1, name: "HQ" })).toBeInTheDocument(); - expect(screen.getByLabelText("Credits")).toHaveTextContent("77,000 cr"); - }); - - it("shows a failed restore's error and stays put", async () => { - renderApp( - fakeApi({ - getPlayerState: owner, - restoreCompany: () => { - throw new ApiError(409, "restore-failed", "That code didn't open a company."); - }, - }), - "/settings", - ); - const sheet = await openRestore(); - await userEvent.type(within(sheet).getByRole("textbox"), "WRONG"); - await userEvent.click(within(sheet).getByRole("button", { name: "Restore" })); - expect(await within(sheet).findByRole("alert")).toHaveTextContent("didn't open a company"); - expect(screen.getByRole("dialog", { name: "Restore a company" })).toBeInTheDocument(); - }); - - it("offers restore on the start screen too", async () => { - let done = false; - renderApp( - fakeApi({ - getPlayerState: () => (done ? restored() : unauthorized()), - restoreCompany: () => { - done = true; - return { playerId: "p2", state: restored() }; - }, - }), - ); - const sheet = await openRestore(); - expect(sheet).not.toHaveTextContent("left behind"); - await userEvent.type(within(sheet).getByRole("textbox"), CODE); - await userEvent.click(within(sheet).getByRole("button", { name: "Restore" })); - expect(await screen.findByRole("heading", { level: 1, name: "HQ" })).toBeInTheDocument(); - }); -}); diff --git a/apps/web/test/save-ui.test.tsx b/apps/web/test/save-ui.test.tsx new file mode 100644 index 0000000..5c2c9bd --- /dev/null +++ b/apps/web/test/save-ui.test.tsx @@ -0,0 +1,104 @@ +import { DeviceClock, GameEngine, MemoryBackend, SaveStorage } from "@deadhead/engine"; +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createEngineClient } from "../src/api/engine-client"; +import { saveFile } from "../src/screens/SaveSettings"; +import { renderApp } from "./render"; + +/** A device with its own storage, running the real engine. */ +function device() { + const engine = new GameEngine({ + storage: new SaveStorage(new MemoryBackend()), + clock: new DeviceClock(Date.now), + }); + return { engine, api: createEngineClient(engine) }; +} + +async function exportFrom(path = "/settings") { + const home = device(); + await home.engine.call("createGuest", {}); + const writeText = vi.fn((_: string) => Promise.resolve()); + Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); + const view = renderApp(home.api, path); + await userEvent.click(await screen.findByRole("button", { name: "Export save" })); + const sheet = screen.getByRole("dialog", { name: "Export save" }); + const box = await within(sheet).findByLabelText("Save code"); + await userEvent.click(within(sheet).getByRole("button", { name: "Copy" })); + expect(await within(sheet).findByRole("button", { name: "Copied" })).toBeInTheDocument(); + const code = (box as HTMLTextAreaElement).value; + expect(writeText).toHaveBeenCalledWith(code); + view.unmount(); + return { home, code }; +} + +describe("export and import save (Step 14.10)", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("exports a code that opens the same company on a fresh device", async () => { + const { home, code } = await exportFrom(); + const before = await home.engine.call("getPlayerState", {}); + + const fresh = device(); + renderApp(fresh.api, "/"); + await userEvent.click(await screen.findByRole("button", { name: "Import save" })); + const sheet = screen.getByRole("dialog", { name: "Import save" }); + // Nothing to replace on a fresh device, so no warning. + expect(within(sheet).queryByRole("note")).toBeNull(); + await userEvent.click(within(sheet).getByRole("textbox")); + await userEvent.paste(code); + await userEvent.click(within(sheet).getByRole("button", { name: "Import" })); + await userEvent.click(within(sheet).getByRole("button", { name: "Open company" })); + + await waitFor(() => expect(screen.queryByRole("dialog", { name: "Import save" })).toBeNull()); + expect(await fresh.engine.call("getPlayerState", {})).toEqual(before); + }); + + it("opens a save file, warns before replacing, then replaces the company", async () => { + const { home, code } = await exportFrom(); + const other = device(); + await other.engine.call("createGuest", {}); + renderApp(other.api, "/settings"); + + await userEvent.click(await screen.findByRole("button", { name: "Import save" })); + const sheet = screen.getByRole("dialog", { name: "Import save" }); + expect(within(sheet).getByRole("note")).toHaveTextContent( + "This replaces the company on this device.", + ); + const input = sheet.querySelector('input[type="file"]') as HTMLInputElement; + await userEvent.upload(input, saveFile(code)); + await waitFor(() => expect(within(sheet).getByRole("textbox")).toHaveValue(code)); + await userEvent.click(within(sheet).getByRole("button", { name: "Import" })); + expect(sheet).toHaveTextContent("Replace your company with this save?"); + await userEvent.click(within(sheet).getByRole("button", { name: "Replace company" })); + + await waitFor(() => expect(screen.queryByRole("dialog", { name: "Import save" })).toBeNull()); + expect((await other.engine.call("getPlayerState", {})).company).toEqual( + (await home.engine.call("getPlayerState", {})).company, + ); + }); + + it("shows an error for garbage and changes nothing", async () => { + const own = device(); + await own.engine.call("createGuest", {}); + const before = await own.engine.call("getPlayerState", {}); + renderApp(own.api, "/settings"); + + await userEvent.click(await screen.findByRole("button", { name: "Import save" })); + const sheet = screen.getByRole("dialog", { name: "Import save" }); + await userEvent.type(within(sheet).getByRole("textbox"), "not a save at all"); + await userEvent.click(within(sheet).getByRole("button", { name: "Import" })); + await userEvent.click(within(sheet).getByRole("button", { name: "Replace company" })); + + expect(await within(sheet).findByRole("alert")).toHaveTextContent( + "That isn't a Deadhead save code.", + ); + expect(await own.engine.call("getPlayerState", {})).toEqual(before); + }); + + it("names the save file by date", () => { + expect(saveFile("x", new Date("2101-02-03T04:05:06Z")).name).toBe( + "deadhead-save-2101-02-03.txt", + ); + }); +}); diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 5464650..e3dfac0 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -66,6 +66,8 @@ The `routes` catalog is the single source for both server and client. Each entry | `getBackupCode` | `GET /api/backup-code` | — | `{ hasCode, createdAt }` | yes | | `createBackupCode` | `POST /api/backup-code` | — | `{ code, createdAt }` (shown once; replaces any old code) | yes | | `restoreCompany` | `POST /api/restore` | `code` (case, spaces and dashes ignored); header `x-device-id` for rate limiting | `{ playerId, state }` + session cookie | no | +| `exportSave` | engine only (CR-014) | — | `{ code }`: `DEADHEAD1.` + base64url(gzip(save JSON)) | yes | +| `importSave` | engine only (CR-014) | `code` (whitespace ignored) | `{ playerId, state }`; `invalid-save` if it isn't a good save | no | | `devAdvanceClock` | `POST /api/test/advance-clock` | `advanceMs` | `{ offsetMs, realTime }` | yes; non-production only | | `devAddCredits` | `POST /api/test/add-credits` | `amount` | `{ company }` | yes; non-production only | | `devPilot` | `POST /api/test/pilot` | `pilotId`, `action`: `level-up` (XP to the next level) / `clear-rest` (duty 0, rest ended) | `PlayerState` | yes; non-production only | diff --git a/memory-bank/progress.md b/memory-bank/progress.md index dcc5467..02646f5 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -350,6 +350,6 @@ Spec: `memory-bank/change-requests/CR-014-local-first.md` (received as "CR-011 - [x] **Step 14.7 — Switch the client to the engine.** `src/platform/game.ts` builds the game on the device: IndexedDB save storage (memory where IndexedDB is missing), the `DeviceClock`, the Web Locks `TabLock`, and the `GameEngine` (developer commands in development builds and in `vite build --mode e2e` test builds via `.env.e2e`; the developer clock offset is kept in local storage in those builds so a fast-forward survives a reload). `src/api/engine-client.ts`: the typed `ApiClient` over engine commands, refusals turned into the same `ApiError`s (status and code), so screens and TanStack Query are unchanged. `src/GameRoot.tsx`: takes the tab lock ("Deadhead is open in another tab" with "Use here" otherwise, and when another tab takes over), provides the engine client, runs the resolution loop (browser timer and `visibilitychange`; a change refetches state and boards), and exposes `window.deadhead.call` in developer builds for browser tests. The clock reads the engine clock instead of the server's. The Step 11.2 offline mode is gone (cached last state, offline banner, `needsServer` buttons, the connection store and their tests): the game is always offline-capable; a failed load now says "Could not load the game." Browser tests run against the web build alone: no API server in the Playwright config, helpers call engine commands through the hook and wait for it after each load, and wait for an acknowledgement to be saved before navigating; 12.4 reopens the app with the developer offset already moved past the arrival. CI builds with `build:e2e`. Tests: `test/on-device.test.tsx` (the engine client answers as the server did, refusals included; "open in another tab" until Use here takes over; the game runs at once when no other tab has it); `e2e/14-7-on-device.e2e.ts` (with every request outside the app's own files blocked from the first load: start a company, fly the tutorial, accept the deal, dispatch and land a contract, and nothing was blocked); the Phase 12 end-to-end tests pass on the engine. - [x] **Step 14.8 — Company creation on the device.** With no save, the app shows the start screen (no company answers `unauthorized`, as before); "Start your company" runs `createGuest`, which builds the CR-001 starting state in a new save with a fresh world seed from the platform's crypto source (`startingSave`, shared with "Start a new company"). Asking again when a company exists returns that company. Tests (`packages/engine/test/start.test.ts`, with fixed ids and seed): from empty storage the state is exactly the CR-001 start (starting credits, no loan, the avatar at Earth beside the loaner, Earth and the Moon discovered, four tutorial runs to go, no ships, bays, flights, summaries or goals, the seed's company name); a new engine on the same storage opens the same company; different seeds give different companies. - [x] **Step 14.9 — Local notifications.** `packages/engine/src/notifications.ts`: `upcomingEvents` (each future arrival "X delivered to Y" / "X arrived at Y", each pilot's rest ending, each upgrade finishing), `plannedNotifications` (events in the same minute share one notification, titled "Deadhead: N updates", at the minute's last event), and `NotificationSync`, which replaces the platform's whole schedule from the save after every saved change (the engine core's `afterSave` hook; a failed sync never fails a command), so moved or cancelled events and a new company clear themselves. `GameEngine` takes a `notifications` option and adds `resyncNotifications()` and `requestNotificationPermission()`. Web: `src/platform/notifications.ts` uses Capacitor `LocalNotifications` on iOS and Android (cancel pending, schedule if permission is granted) and the no-op scheduler on the web; `createGame` wires it with the new `notifications` preference (default on) and resyncs when it changes; the engine client asks for permission right after `acceptDeal` succeeds; Settings has a Notifications section (On/Off). Tests: `packages/engine/test/notifications.test.ts` (a dispatch schedules one notification at its arrival; two arrivals in the same minute make one combined notification; a new company cancels everything; with the toggle off nothing is scheduled, and turning it on catches up; the no-op scheduler never errors); `apps/web/test/notifications.test.ts` (the web uses the no-op scheduler and play works; the toggle clears and restores the schedule; permission is asked after the deal and not before). -- [ ] Step 14.10 — Settings changes +- [x] **Step 14.10 — Settings changes.** Export and import replace the backup code. `packages/engine/src/save-code.ts`: `encodeSave` (the save as JSON, gzip via `CompressionStream`, base64url, behind `DEADHEAD1.`) and `decodeSave` (whitespace from pasting ignored; a wrong prefix, a damaged code or a non-save each give a readable error; migrates through `migrateSave`). New engine commands in the catalog: `exportSave` (`{ code }`) and `importSave` (`{ code }` → `{ playerId, state }`, no company needed; due arrivals resolved on import; refusals are `invalid-save` and leave the save as it was; the replaced company stays in the storage backups). `GameEngine` takes a `migrate` option (tests). Web: `src/screens/SaveSettings.tsx` — **Export save** (the code, Copy, Share as file through the share sheet where the device has one, otherwise a download named `deadhead-save-YYYY-MM-DD.txt`) and **Import save** (paste or open a file, then confirm; the warning about replacing the company shows when there is one); both in Settings → Company, Import save also on the start screen. `BackupSettings.tsx` and its test are gone (the server's backup routes stay until 14.11). Copy debug info: `Save`, `Save version`, `Storage` (via a new `GameContext`), `Game time`; no player id. The start screen's error no longer mentions a connection. Tests: `packages/engine/test/save-code.test.ts` (round trip, pasted line breaks; the same state on a fresh device; replaces an existing company; an older save is migrated (simulated with an injected migration, since only version 1 exists); garbage, damaged codes and non-saves are refused and change nothing; nothing to export before a company); `apps/web/test/save-ui.test.tsx` (export, Copy, import on a fresh device from the start screen; open a save file, warning, Replace company; garbage shows the error and changes nothing; file name); `about.test.tsx` updated. Developer tools were already covered (engine refuses developer commands without `devTools`; Settings hides the section in production). - [ ] Step 14.11 — Retire the server - [ ] Step 14.12 — Plan amendments and regression diff --git a/packages/engine/src/game/engine.ts b/packages/engine/src/game/engine.ts index 2944ed6..bbc921e 100644 --- a/packages/engine/src/game/engine.ts +++ b/packages/engine/src/game/engine.ts @@ -5,11 +5,13 @@ import { type RouteRequest, type RouteResponse, type SaveDocument, + type SaveParse, } from "@deadhead/schema"; import { realToGame, type RealTime } from "@deadhead/sim"; import type { z } from "zod"; import { EngineCore, type CoreDeps, type RandomSource } from "../core"; import type { NotificationSync } from "../notifications"; +import { decodeSave, encodeSave } from "../save-code"; import { commandTable } from "./commands"; import { startingSave } from "./company"; import { CommandError, fail } from "./errors"; @@ -40,7 +42,8 @@ export type CommandHandler