From 30aa4cd5bd9436d9e355a1433163552ee85dc8cd Mon Sep 17 00:00:00 2001 From: fireshaper Date: Fri, 9 Oct 2026 20:48:57 -0400 Subject: [PATCH] Step 14.10: export and import save Export save turns the whole save into one code (gzip + base64url behind a DEADHEAD1. prefix) to copy or share as a file; Import save takes a pasted code or a file, migrates and validates it, and replaces the company after confirmation. A bad code is refused and changes nothing. These replace the backup code. Debug info now gives the save id, save version and storage type. Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 11 ++ apps/web/src/GameRoot.tsx | 7 +- apps/web/src/platform/context.tsx | 10 ++ apps/web/src/screens/AboutSettings.tsx | 15 +- apps/web/src/screens/BackupSettings.tsx | 184 --------------------- apps/web/src/screens/SaveSettings.tsx | 207 ++++++++++++++++++++++++ apps/web/src/screens/Settings.tsx | 6 +- apps/web/src/screens/StartScreen.tsx | 6 +- apps/web/test/about.test.tsx | 13 +- apps/web/test/backup-ui.test.tsx | 124 -------------- apps/web/test/save-ui.test.tsx | 104 ++++++++++++ memory-bank/architecture.md | 2 + memory-bank/progress.md | 2 +- packages/engine/src/game/engine.ts | 23 ++- packages/engine/src/index.ts | 1 + packages/engine/src/save-code.ts | 61 +++++++ packages/engine/test/save-code.test.ts | 124 ++++++++++++++ packages/schema/src/errors.ts | 1 + packages/schema/src/requests.ts | 5 + packages/schema/src/routes.ts | 18 +++ packages/schema/test/routes.test.ts | 8 +- 21 files changed, 605 insertions(+), 327 deletions(-) create mode 100644 apps/web/src/platform/context.tsx delete mode 100644 apps/web/src/screens/BackupSettings.tsx create mode 100644 apps/web/src/screens/SaveSettings.tsx delete mode 100644 apps/web/test/backup-ui.test.tsx create mode 100644 apps/web/test/save-ui.test.tsx create mode 100644 packages/engine/src/save-code.ts create mode 100644 packages/engine/test/save-code.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 586aed7..f330faf 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1405,3 +1405,14 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **A combined notification fires at its minute's last event**, so "2 updates" never announces something that hasn't happened yet. - **Permission is asked by the engine client right after `acceptDeal` succeeds** (decision 0.5.91: not at first launch). The tutorial's loaner runs don't need notifications; owning the business is when waiting starts to matter. Without permission the native scheduler just schedules nothing. - **The toggle is a device preference** (default on), read by the engine through a callback; changing it resyncs at once. The section shows on the web too, labelled "iOS and Android apps only", so the setting carries over if the same preferences ever matter there. + +## Step 14.10 + +- **Export and import are catalog commands** (`exportSave`, `importSave`), so screens call them like everything else and the engine checks their schemas. The server never implemented them; its backup routes stay listed as server-only until it's removed in 14.11. +- **The code is `DEADHEAD1.` + base64url(gzip(JSON))**, using the platform's `CompressionStream`, so no compression library is added. The prefix makes "that isn't a save code" a clear error and leaves room for a different encoding later. Base64url has no characters a chat app or a file name would mangle; whitespace is ignored so a wrapped paste still works. +- **Import resolves due arrivals at once**, as opening the app would, and the save it replaces goes through the normal save path, so the old company is still in the storage backups. +- **A refused import changes nothing**: the code is decoded and checked before the write queue, and errors are `invalid-save` with the reason ("That isn't a Deadhead save code.", "The save code is incomplete or damaged.", or the schema's damage report). +- **Confirmation is a second press** ("Import" → "Replace company", or "Open company" on a fresh device). The warning shows only when a company would be replaced. +- **"Share as file"** uses the Web Share API with a file where the device supports it (phones, including the Capacitor web view), otherwise a download. No Capacitor share plugin. +- **Only save version 1 exists**, so the "older save migrates" test injects a migration through the engine's `migrate` option; `applyMigrations`'s stepping is tested in the schema package. +- **Debug info reads storage through a small `GameContext`** that `GameRoot` provides; outside it (component tests) storage shows as "unknown". diff --git a/apps/web/src/GameRoot.tsx b/apps/web/src/GameRoot.tsx index 39da091..4741aad 100644 --- a/apps/web/src/GameRoot.tsx +++ b/apps/web/src/GameRoot.tsx @@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useState, type ReactNode } from "react"; import { createEngineClient } from "./api/engine-client"; import { ApiProvider } from "./api/context"; +import { GameContext } from "./platform/context"; import { startResolutionLoop, type Game } from "./platform/game"; import { PLAYER_STATE_KEY } from "./state/player"; import { Button } from "./ui"; @@ -73,5 +74,9 @@ export function GameRoot({ game, children }: { game: Game; children: ReactNode } ); } - return {children}; + return ( + + {children} + + ); } diff --git a/apps/web/src/platform/context.tsx b/apps/web/src/platform/context.tsx new file mode 100644 index 0000000..2911f10 --- /dev/null +++ b/apps/web/src/platform/context.tsx @@ -0,0 +1,10 @@ +import { createContext, useContext } from "react"; +import type { Game } from "./game"; + +/** The game on this device, for the few screens that need more than commands (debug info). */ +export const GameContext = createContext(null); + +/** The game, or null outside `GameRoot` (component tests). */ +export function useGame(): Game | null { + return useContext(GameContext); +} diff --git a/apps/web/src/screens/AboutSettings.tsx b/apps/web/src/screens/AboutSettings.tsx index ac17a4b..f7fee69 100644 --- a/apps/web/src/screens/AboutSettings.tsx +++ b/apps/web/src/screens/AboutSettings.tsx @@ -1,18 +1,22 @@ +import { CURRENT_SAVE_VERSION } from "@deadhead/schema"; import { useState } from "react"; import { APP_BUILD, APP_VERSION } from "../build-info"; import { serverNow, useClock } from "../clock/clock"; import { lastScreenPath } from "../layout/lastScreen"; +import { useGame } from "../platform/context"; import { useLoadedPlayer } from "../state/actions"; import { Button } from "../ui"; -/** The text "Copy debug info" puts on the clipboard (CR-011). */ -export function debugInfo(playerId: string): string { +/** The text "Copy debug info" puts on the clipboard (CR-011; save details since CR-014). */ +export function debugInfo(saveId: string, storage: string): string { return [ "Deadhead: Space Freight Tycoon", `Version: ${APP_VERSION}`, `Build: ${APP_BUILD}`, - `Player: ${playerId}`, - `Server time: ${new Date(serverNow()).toISOString()}`, + `Save: ${saveId}`, + `Save version: ${CURRENT_SAVE_VERSION}`, + `Storage: ${storage}`, + `Game time: ${new Date(serverNow()).toISOString()}`, `Clock offset: ${Math.round(useClock.getState().offsetMs)} ms`, `Screen: ${lastScreenPath()}`, ].join("\n"); @@ -21,6 +25,7 @@ export function debugInfo(playerId: string): string { /** Settings → About: version and build, and debug info for bug reports. */ export function About() { const state = useLoadedPlayer(); + const storage = useGame()?.storage.kind ?? "unknown"; const [copied, setCopied] = useState(false); return (
@@ -35,7 +40,7 @@ export function About() { size="sm" onClick={() => { void navigator.clipboard - ?.writeText(debugInfo(state.playerId)) + ?.writeText(debugInfo(state.playerId, storage)) .then(() => setCopied(true)) .catch(() => setCopied(false)); }} diff --git a/apps/web/src/screens/BackupSettings.tsx b/apps/web/src/screens/BackupSettings.tsx deleted file mode 100644 index dcd6b29..0000000 --- a/apps/web/src/screens/BackupSettings.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; -import { useNavigate } from "react-router"; -import { useApi } from "../api/context"; -import { errorMessage } from "../state/actions"; -import { PLAYER_STATE_KEY } from "../state/player"; -import { Button, Sheet } from "../ui"; - -const BACKUP_KEY = ["backup-code"] as const; - -/** "Back up company": make a code (shown once), or replace the existing one (decision 0.5.72). */ -export function BackupCompany() { - const api = useApi(); - const queryClient = useQueryClient(); - const [open, setOpen] = useState(false); - const [copied, setCopied] = useState(false); - const status = useQuery({ - queryKey: BACKUP_KEY, - queryFn: () => api.call("getBackupCode", {}), - enabled: open, - }); - const create = useMutation({ - mutationFn: () => api.call("createBackupCode", {}), - onSuccess: () => void queryClient.invalidateQueries({ queryKey: BACKUP_KEY }), - }); - const code = create.data?.code; - - const close = () => { - setOpen(false); - setCopied(false); - create.reset(); - }; - const copy = (text: string) => { - void navigator.clipboard - ?.writeText(text) - .then(() => setCopied(true)) - .catch(() => setCopied(false)); - }; - - let body; - if (code) { - body = ( - <> -

- {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 && ( - - {body} - {(create.isError || status.isError) && ( -

- {errorMessage(create.error ?? status.error)} -

- )} - -
- )} - - ); -} - -/** - * "Restore a company": enter a backup code to open that company on this - * device. `pastTutorial` warns that this device's own company is left behind. - */ -export function RestoreCompany({ pastTutorial }: { pastTutorial: boolean }) { - const api = useApi(); - const queryClient = useQueryClient(); - const navigate = useNavigate(); - const [open, setOpen] = useState(false); - const [code, setCode] = useState(""); - const restore = useMutation({ - mutationFn: () => api.call("restoreCompany", { code }), - onSuccess: async ({ state }) => { - queryClient.setQueryData(PLAYER_STATE_KEY, state); - await queryClient.invalidateQueries({ queryKey: ["board"] }); - setOpen(false); - void navigate("/"); - }, - }); - - return ( - <> - - {open && ( - - {pastTutorial && ( -

- This device's current company will be left behind. Back it up first if you want - to keep it. -

- )} -
{ - e.preventDefault(); - if (code.trim()) restore.mutate(); - }} - > - - {restore.isError && ( -

- {errorMessage(restore.error)} -

- )} -
- - -
-
-
- )} - - ); -} diff --git a/apps/web/src/screens/SaveSettings.tsx b/apps/web/src/screens/SaveSettings.tsx new file mode 100644 index 0000000..6722439 --- /dev/null +++ b/apps/web/src/screens/SaveSettings.tsx @@ -0,0 +1,207 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { useNavigate } from "react-router"; +import { useApi } from "../api/context"; +import { errorMessage } from "../state/actions"; +import { PLAYER_STATE_KEY } from "../state/player"; +import { Button, Sheet } from "../ui"; + +/** The file a save is shared as: plain text, dated. */ +export function saveFile(code: string, now = new Date()): File { + const day = now.toISOString().slice(0, 10); + return new File([code], `deadhead-save-${day}.txt`, { type: "text/plain" }); +} + +/** Shares the file where the device can (the share sheet), otherwise downloads it. */ +async function shareFile(file: File): Promise { + if (navigator.canShare?.({ files: [file] })) { + await navigator.share({ files: [file], title: "Deadhead save" }); + return; + } + const url = URL.createObjectURL(file); + const link = document.createElement("a"); + link.href = url; + link.download = file.name; + link.click(); + URL.revokeObjectURL(url); +} + +const codeBox = + "mt-1 h-28 w-full resize-none rounded-lg border border-space-600 bg-space-950/70 px-3 py-2 font-mono text-xs break-all text-ink-100 focus:border-amber-400 focus:outline-none"; + +/** "Export save" (decision 0.5.92): the whole company as one code, to copy or share as a file. */ +export function ExportSave() { + const api = useApi(); + const [open, setOpen] = useState(false); + const [copied, setCopied] = useState(false); + const exported = useQuery({ + queryKey: ["export-save"], + queryFn: () => api.call("exportSave", {}), + enabled: open, + gcTime: 0, + staleTime: 0, + }); + const code = exported.data?.code; + + const close = () => { + setOpen(false); + setCopied(false); + }; + + return ( + <> + + {open && ( + +

+ This code is your whole company. Keep it somewhere safe, or import it on another device. +

+ {code ? ( + <> +