Step 14.10: export and import save
CI / check (push) Successful in 7m40s
Details
CI / check (push) Successful in 7m40s
Details
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 <noreply@anthropic.com>
This commit is contained in:
parent
f53ed1657b
commit
30aa4cd5bd
11
DECISIONS.md
11
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.
|
- **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.
|
- **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.
|
- **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".
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
import { createEngineClient } from "./api/engine-client";
|
import { createEngineClient } from "./api/engine-client";
|
||||||
import { ApiProvider } from "./api/context";
|
import { ApiProvider } from "./api/context";
|
||||||
|
import { GameContext } from "./platform/context";
|
||||||
import { startResolutionLoop, type Game } from "./platform/game";
|
import { startResolutionLoop, type Game } from "./platform/game";
|
||||||
import { PLAYER_STATE_KEY } from "./state/player";
|
import { PLAYER_STATE_KEY } from "./state/player";
|
||||||
import { Button } from "./ui";
|
import { Button } from "./ui";
|
||||||
|
|
@ -73,5 +74,9 @@ export function GameRoot({ game, children }: { game: Game; children: ReactNode }
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <ApiProvider api={api}>{children}</ApiProvider>;
|
return (
|
||||||
|
<GameContext.Provider value={game}>
|
||||||
|
<ApiProvider api={api}>{children}</ApiProvider>
|
||||||
|
</GameContext.Provider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<Game | null>(null);
|
||||||
|
|
||||||
|
/** The game, or null outside `GameRoot` (component tests). */
|
||||||
|
export function useGame(): Game | null {
|
||||||
|
return useContext(GameContext);
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,22 @@
|
||||||
|
import { CURRENT_SAVE_VERSION } from "@deadhead/schema";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { APP_BUILD, APP_VERSION } from "../build-info";
|
import { APP_BUILD, APP_VERSION } from "../build-info";
|
||||||
import { serverNow, useClock } from "../clock/clock";
|
import { serverNow, useClock } from "../clock/clock";
|
||||||
import { lastScreenPath } from "../layout/lastScreen";
|
import { lastScreenPath } from "../layout/lastScreen";
|
||||||
|
import { useGame } from "../platform/context";
|
||||||
import { useLoadedPlayer } from "../state/actions";
|
import { useLoadedPlayer } from "../state/actions";
|
||||||
import { Button } from "../ui";
|
import { Button } from "../ui";
|
||||||
|
|
||||||
/** The text "Copy debug info" puts on the clipboard (CR-011). */
|
/** The text "Copy debug info" puts on the clipboard (CR-011; save details since CR-014). */
|
||||||
export function debugInfo(playerId: string): string {
|
export function debugInfo(saveId: string, storage: string): string {
|
||||||
return [
|
return [
|
||||||
"Deadhead: Space Freight Tycoon",
|
"Deadhead: Space Freight Tycoon",
|
||||||
`Version: ${APP_VERSION}`,
|
`Version: ${APP_VERSION}`,
|
||||||
`Build: ${APP_BUILD}`,
|
`Build: ${APP_BUILD}`,
|
||||||
`Player: ${playerId}`,
|
`Save: ${saveId}`,
|
||||||
`Server time: ${new Date(serverNow()).toISOString()}`,
|
`Save version: ${CURRENT_SAVE_VERSION}`,
|
||||||
|
`Storage: ${storage}`,
|
||||||
|
`Game time: ${new Date(serverNow()).toISOString()}`,
|
||||||
`Clock offset: ${Math.round(useClock.getState().offsetMs)} ms`,
|
`Clock offset: ${Math.round(useClock.getState().offsetMs)} ms`,
|
||||||
`Screen: ${lastScreenPath()}`,
|
`Screen: ${lastScreenPath()}`,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
@ -21,6 +25,7 @@ export function debugInfo(playerId: string): string {
|
||||||
/** Settings → About: version and build, and debug info for bug reports. */
|
/** Settings → About: version and build, and debug info for bug reports. */
|
||||||
export function About() {
|
export function About() {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
|
const storage = useGame()?.storage.kind ?? "unknown";
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
|
|
@ -35,7 +40,7 @@ export function About() {
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void navigator.clipboard
|
void navigator.clipboard
|
||||||
?.writeText(debugInfo(state.playerId))
|
?.writeText(debugInfo(state.playerId, storage))
|
||||||
.then(() => setCopied(true))
|
.then(() => setCopied(true))
|
||||||
.catch(() => setCopied(false));
|
.catch(() => setCopied(false));
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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 = (
|
|
||||||
<>
|
|
||||||
<p
|
|
||||||
aria-label="Backup code"
|
|
||||||
className="mt-4 rounded-xl bg-space-950/80 p-4 text-center font-mono text-2xl font-bold tracking-widest text-amber-200 ring-1 ring-amber-400/40 select-all"
|
|
||||||
>
|
|
||||||
{code}
|
|
||||||
</p>
|
|
||||||
<p className="mt-3 text-sm text-ink-200">
|
|
||||||
Keep this somewhere safe. Anyone with this code can open your company.
|
|
||||||
</p>
|
|
||||||
<Button block className="mt-3" onClick={() => copy(code)}>
|
|
||||||
{copied ? "Copied" : "Copy"}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
} else if (status.isPending) {
|
|
||||||
body = <p className="mt-4 text-sm text-ink-400">Checking…</p>;
|
|
||||||
} else if (status.data?.hasCode) {
|
|
||||||
body = (
|
|
||||||
<>
|
|
||||||
<p className="mt-3 text-sm text-ink-200">
|
|
||||||
You already have a backup code. It is only shown when it is made, so if you lost it, make
|
|
||||||
a new one.
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-sm text-amber-200">
|
|
||||||
Your old code will stop working as soon as you generate a new one.
|
|
||||||
</p>
|
|
||||||
<Button block className="mt-3" disabled={create.isPending} onClick={() => create.mutate()}>
|
|
||||||
Generate a new code
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
body = (
|
|
||||||
<>
|
|
||||||
<p className="mt-3 text-sm text-ink-200">
|
|
||||||
A backup code lets you open this company on any device, or get it back if this one is
|
|
||||||
lost. No email or password.
|
|
||||||
</p>
|
|
||||||
<Button block className="mt-3" disabled={create.isPending} onClick={() => create.mutate()}>
|
|
||||||
Create a backup code
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
|
|
||||||
Back up company
|
|
||||||
</Button>
|
|
||||||
{open && (
|
|
||||||
<Sheet titleId="backup-title" title="Back up your company" eyebrow="Backup code">
|
|
||||||
{body}
|
|
||||||
{(create.isError || status.isError) && (
|
|
||||||
<p role="alert" className="mt-2 text-sm text-rose-300">
|
|
||||||
{errorMessage(create.error ?? status.error)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<Button variant="secondary" block className="mt-3" onClick={close}>
|
|
||||||
Done
|
|
||||||
</Button>
|
|
||||||
</Sheet>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "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 (
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
|
|
||||||
Restore a company
|
|
||||||
</Button>
|
|
||||||
{open && (
|
|
||||||
<Sheet titleId="restore-title" title="Restore a company" eyebrow="Backup code" layer="z-30">
|
|
||||||
{pastTutorial && (
|
|
||||||
<p
|
|
||||||
role="note"
|
|
||||||
className="mt-3 rounded-lg bg-amber-950/50 p-2 text-sm text-amber-200 ring-1 ring-amber-400/40"
|
|
||||||
>
|
|
||||||
This device's current company will be left behind. Back it up first if you want
|
|
||||||
to keep it.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<form
|
|
||||||
className="mt-3 space-y-3"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (code.trim()) restore.mutate();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label className="block text-sm">
|
|
||||||
<span className="text-ink-300">Backup code</span>
|
|
||||||
<input
|
|
||||||
value={code}
|
|
||||||
onChange={(e) => setCode(e.target.value)}
|
|
||||||
autoCapitalize="characters"
|
|
||||||
autoComplete="off"
|
|
||||||
spellCheck={false}
|
|
||||||
placeholder="XXXXX-XXXXX-XXXXX-XXXXX"
|
|
||||||
className="mt-1 w-full rounded-lg border border-space-600 bg-space-950/70 px-3 py-2 font-mono tracking-widest text-ink-50 focus:border-amber-400 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{restore.isError && (
|
|
||||||
<p role="alert" className="text-sm text-rose-300">
|
|
||||||
{errorMessage(restore.error)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button type="submit" block disabled={!code.trim() || restore.isPending}>
|
|
||||||
Restore
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" block onClick={() => setOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Sheet>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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<void> {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
|
||||||
|
Export save
|
||||||
|
</Button>
|
||||||
|
{open && (
|
||||||
|
<Sheet titleId="export-title" title="Export save" eyebrow="Your company">
|
||||||
|
<p className="mt-3 text-sm text-ink-200">
|
||||||
|
This code is your whole company. Keep it somewhere safe, or import it on another device.
|
||||||
|
</p>
|
||||||
|
{code ? (
|
||||||
|
<>
|
||||||
|
<textarea readOnly aria-label="Save code" value={code} className={codeBox} />
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard
|
||||||
|
?.writeText(code)
|
||||||
|
.then(() => setCopied(true))
|
||||||
|
.catch(() => setCopied(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
block
|
||||||
|
onClick={() => void shareFile(saveFile(code)).catch(() => undefined)}
|
||||||
|
>
|
||||||
|
Share as file
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : exported.isError ? (
|
||||||
|
<p role="alert" className="mt-3 text-sm text-rose-300">
|
||||||
|
{errorMessage(exported.error)}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 text-sm text-ink-400">Packing up your company…</p>
|
||||||
|
)}
|
||||||
|
<Button variant="secondary" block className="mt-3" onClick={close}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</Sheet>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Import save": paste a code or open a file, then confirm. `hasCompany`
|
||||||
|
* warns that the company on this device is replaced.
|
||||||
|
*/
|
||||||
|
export function ImportSave({ hasCompany }: { hasCompany: boolean }) {
|
||||||
|
const api = useApi();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const importSave = useMutation({
|
||||||
|
mutationFn: () => api.call("importSave", { code }),
|
||||||
|
onSuccess: async ({ state }) => {
|
||||||
|
queryClient.setQueryData(PLAYER_STATE_KEY, state);
|
||||||
|
await queryClient.invalidateQueries();
|
||||||
|
close();
|
||||||
|
void navigate("/");
|
||||||
|
},
|
||||||
|
onError: () => setConfirming(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setOpen(false);
|
||||||
|
setCode("");
|
||||||
|
setConfirming(false);
|
||||||
|
importSave.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
|
||||||
|
Import save
|
||||||
|
</Button>
|
||||||
|
{open && (
|
||||||
|
<Sheet titleId="import-title" title="Import save" eyebrow="Save code" layer="z-30">
|
||||||
|
{hasCompany && (
|
||||||
|
<p
|
||||||
|
role="note"
|
||||||
|
className="mt-3 rounded-lg bg-amber-950/50 p-2 text-sm text-amber-200 ring-1 ring-amber-400/40"
|
||||||
|
>
|
||||||
|
This replaces the company on this device. Export it first if you want to keep it.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<form
|
||||||
|
className="mt-3 space-y-3"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!code.trim()) return;
|
||||||
|
if (confirming) importSave.mutate();
|
||||||
|
else setConfirming(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink-300">Paste a save code</span>
|
||||||
|
<textarea
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCode(e.target.value);
|
||||||
|
setConfirming(false);
|
||||||
|
}}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className={codeBox}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink-300">…or open a save file</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".txt,text/plain"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
void file.text().then((text) => {
|
||||||
|
setCode(text.trim());
|
||||||
|
setConfirming(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="mt-1 block w-full text-xs text-ink-300 file:mr-3 file:rounded-lg file:border-0 file:bg-space-700 file:px-3 file:py-1.5 file:text-ink-100"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{importSave.isError && (
|
||||||
|
<p role="alert" className="text-sm text-rose-300">
|
||||||
|
{errorMessage(importSave.error)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{confirming && (
|
||||||
|
<p className="text-sm text-amber-200">
|
||||||
|
{hasCompany ? "Replace your company with this save?" : "Open this company?"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="submit" block disabled={!code.trim() || importSave.isPending}>
|
||||||
|
{confirming ? (hasCompany ? "Replace company" : "Open company") : "Import"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" block onClick={close}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Sheet>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { resetMapView } from "../map/camera";
|
import { resetMapView } from "../map/camera";
|
||||||
import { About } from "./AboutSettings";
|
import { About } from "./AboutSettings";
|
||||||
import { BackupCompany, RestoreCompany } from "./BackupSettings";
|
|
||||||
import { CompanyName, NewCompany } from "./CompanySettings";
|
import { CompanyName, NewCompany } from "./CompanySettings";
|
||||||
import { DevTools } from "./DevTools";
|
import { DevTools } from "./DevTools";
|
||||||
|
import { ExportSave, ImportSave } from "./SaveSettings";
|
||||||
import { useLoadedPlayer } from "../state/actions";
|
import { useLoadedPlayer } from "../state/actions";
|
||||||
import { usePreferences, type ClockFormat, type CountdownFormat } from "../state/preferences";
|
import { usePreferences, type ClockFormat, type CountdownFormat } from "../state/preferences";
|
||||||
import { Button, Panel, Screen, ScreenHeader } from "../ui";
|
import { Button, Panel, Screen, ScreenHeader } from "../ui";
|
||||||
|
|
@ -25,8 +25,8 @@ export function Settings() {
|
||||||
<Section title="Company">
|
<Section title="Company">
|
||||||
<CompanyName />
|
<CompanyName />
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<BackupCompany />
|
<ExportSave />
|
||||||
<RestoreCompany pastTutorial={state.tutorial === null} />
|
<ImportSave hasCompany />
|
||||||
<NewCompany />
|
<NewCompany />
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useNavigate } from "react-router";
|
||||||
import { useApi } from "../api/context";
|
import { useApi } from "../api/context";
|
||||||
import { PLAYER_STATE_KEY } from "../state/player";
|
import { PLAYER_STATE_KEY } from "../state/player";
|
||||||
import { Art, Button, Icon } from "../ui";
|
import { Art, Button, Icon } from "../ui";
|
||||||
import { RestoreCompany } from "./BackupSettings";
|
import { ImportSave } from "./SaveSettings";
|
||||||
|
|
||||||
/** First launch: create a guest account (decision: guests first, accounts later). */
|
/** First launch: create a guest account (decision: guests first, accounts later). */
|
||||||
export function StartScreen() {
|
export function StartScreen() {
|
||||||
|
|
@ -60,11 +60,11 @@ export function StartScreen() {
|
||||||
{start.isPending ? "Starting…" : "Start your company"}
|
{start.isPending ? "Starting…" : "Start your company"}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<RestoreCompany pastTutorial={false} />
|
<ImportSave hasCompany={false} />
|
||||||
</div>
|
</div>
|
||||||
{start.isError && (
|
{start.isError && (
|
||||||
<p role="alert" className="relative text-sm text-rose-300">
|
<p role="alert" className="relative text-sm text-rose-300">
|
||||||
Could not start a company. Check your connection and try again.
|
Could not start a company. Try again.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { useClock } from "../src/clock/clock";
|
import { useClock } from "../src/clock/clock";
|
||||||
|
import { debugInfo } from "../src/screens/AboutSettings";
|
||||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||||
|
|
||||||
describe("about and debug info (Step 10K.10)", () => {
|
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];
|
const text = (writeText.mock.calls[0] as unknown as [string])[0];
|
||||||
expect(text).toContain("Version: 0.0.0-test");
|
expect(text).toContain("Version: 0.0.0-test");
|
||||||
expect(text).toContain("Build: test");
|
expect(text).toContain("Build: test");
|
||||||
expect(text).toContain("Player: player-1");
|
// CR-014: the save, its format version and where it is stored; no player id.
|
||||||
expect(text).toMatch(/Server time: \d{4}-\d{2}-\d{2}T/);
|
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.
|
// 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(`Clock offset: ${Math.round(useClock.getState().offsetMs)} ms`);
|
||||||
expect(text).toContain("Screen: /fleet");
|
expect(text).toContain("Screen: /fleet");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("names the device's save storage", () => {
|
||||||
|
expect(debugInfo("save-1", "indexeddb")).toContain("Storage: indexeddb");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 |
|
| `getBackupCode` | `GET /api/backup-code` | — | `{ hasCode, createdAt }` | yes |
|
||||||
| `createBackupCode` | `POST /api/backup-code` | — | `{ code, createdAt }` (shown once; replaces any old code) | 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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
||||||
|
|
|
||||||
|
|
@ -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.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.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).
|
- [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.11 — Retire the server
|
||||||
- [ ] Step 14.12 — Plan amendments and regression
|
- [ ] Step 14.12 — Plan amendments and regression
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,13 @@ import {
|
||||||
type RouteRequest,
|
type RouteRequest,
|
||||||
type RouteResponse,
|
type RouteResponse,
|
||||||
type SaveDocument,
|
type SaveDocument,
|
||||||
|
type SaveParse,
|
||||||
} from "@deadhead/schema";
|
} from "@deadhead/schema";
|
||||||
import { realToGame, type RealTime } from "@deadhead/sim";
|
import { realToGame, type RealTime } from "@deadhead/sim";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { EngineCore, type CoreDeps, type RandomSource } from "../core";
|
import { EngineCore, type CoreDeps, type RandomSource } from "../core";
|
||||||
import type { NotificationSync } from "../notifications";
|
import type { NotificationSync } from "../notifications";
|
||||||
|
import { decodeSave, encodeSave } from "../save-code";
|
||||||
import { commandTable } from "./commands";
|
import { commandTable } from "./commands";
|
||||||
import { startingSave } from "./company";
|
import { startingSave } from "./company";
|
||||||
import { CommandError, fail } from "./errors";
|
import { CommandError, fail } from "./errors";
|
||||||
|
|
@ -40,7 +42,8 @@ export type CommandHandler<N extends CommandName> = (
|
||||||
) => RouteResponse<N>;
|
) => RouteResponse<N>;
|
||||||
|
|
||||||
/** Commands that run before a company exists, or touch the clock rather than the save. */
|
/** Commands that run before a company exists, or touch the clock rather than the save. */
|
||||||
type SpecialCommand = "getServerTime" | "createGuest" | "newCompany" | "devAdvanceClock";
|
type SpecialCommand =
|
||||||
|
"getServerTime" | "createGuest" | "newCompany" | "devAdvanceClock" | "exportSave" | "importSave";
|
||||||
export type CommandTable = { [N in Exclude<CommandName, SpecialCommand>]: CommandHandler<N> };
|
export type CommandTable = { [N in Exclude<CommandName, SpecialCommand>]: CommandHandler<N> };
|
||||||
|
|
||||||
export interface GameEngineOptions extends CoreDeps {
|
export interface GameEngineOptions extends CoreDeps {
|
||||||
|
|
@ -53,6 +56,8 @@ export interface GameEngineOptions extends CoreDeps {
|
||||||
overrides?: Partial<CommandTable>;
|
overrides?: Partial<CommandTable>;
|
||||||
/** Local notifications (decision 0.5.91); none if absent. */
|
/** Local notifications (decision 0.5.91); none if absent. */
|
||||||
notifications?: NotificationSync;
|
notifications?: NotificationSync;
|
||||||
|
/** Migrates and validates an imported save (tests replace it). Default: the schema's `migrateSave`. */
|
||||||
|
migrate?: (raw: unknown) => SaveParse;
|
||||||
}
|
}
|
||||||
|
|
||||||
function describeIssues(error: z.ZodError): string {
|
function describeIssues(error: z.ZodError): string {
|
||||||
|
|
@ -177,6 +182,22 @@ export class GameEngine {
|
||||||
});
|
});
|
||||||
return { save: next, result: playerState(next, now) };
|
return { save: next, result: playerState(next, now) };
|
||||||
});
|
});
|
||||||
|
case "exportSave":
|
||||||
|
return this.core.read(async (save) => {
|
||||||
|
if (!save) throw noCompany();
|
||||||
|
return { code: await encodeSave(save) };
|
||||||
|
});
|
||||||
|
case "importSave": {
|
||||||
|
// Decoded before the queue, so a bad code never waits behind or blocks a write.
|
||||||
|
const parsed = await decodeSave((input as { code: string }).code, this.options.migrate);
|
||||||
|
if (!parsed.ok) throw fail("invalid-save", parsed.error);
|
||||||
|
return this.core.write((_current, now) => {
|
||||||
|
const next = parsed.save;
|
||||||
|
resolveArrivals(next, now);
|
||||||
|
pruneSave(next, now);
|
||||||
|
return { save: next, result: { playerId: next.saveId, state: playerState(next, now) } };
|
||||||
|
});
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
const handler = this.#table[name] as (ctx: CommandContext, input: unknown) => unknown;
|
const handler = this.#table[name] as (ctx: CommandContext, input: unknown) => unknown;
|
||||||
return this.core.write((save, now) => {
|
return this.core.write((save, now) => {
|
||||||
|
|
|
||||||
|
|
@ -42,3 +42,4 @@ export {
|
||||||
type NotificationScheduler,
|
type NotificationScheduler,
|
||||||
type ScheduledNotification,
|
type ScheduledNotification,
|
||||||
} from "./notifications";
|
} from "./notifications";
|
||||||
|
export { decodeSave, encodeSave, SAVE_CODE_PREFIX } from "./save-code";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { migrateSave, type SaveDocument, type SaveParse } from "@deadhead/schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save export/import (decision 0.5.92): one text code holding the whole save,
|
||||||
|
* gzip-compressed and base64url-encoded behind a short prefix, so it survives
|
||||||
|
* being pasted into a chat, a note or a file.
|
||||||
|
*/
|
||||||
|
export const SAVE_CODE_PREFIX = "DEADHEAD1.";
|
||||||
|
|
||||||
|
async function pipe(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
stream: CompressionStream | DecompressionStream,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const piped = new Response(bytes as BodyInit).body!.pipeThrough(stream);
|
||||||
|
return new Uint8Array(await new Response(piped).arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBase64Url(bytes: Uint8Array): string {
|
||||||
|
let binary = "";
|
||||||
|
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||||
|
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
||||||
|
}
|
||||||
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromBase64Url(text: string): Uint8Array {
|
||||||
|
const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
|
||||||
|
return Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The save as an export code. */
|
||||||
|
export async function encodeSave(save: SaveDocument): Promise<string> {
|
||||||
|
const json = new TextEncoder().encode(JSON.stringify(save));
|
||||||
|
return SAVE_CODE_PREFIX + toBase64Url(await pipe(json, new CompressionStream("gzip")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads an export code: decodes, migrates an older `saveVersion` and validates.
|
||||||
|
* Never throws; anything that isn't a good save gives a readable error.
|
||||||
|
*/
|
||||||
|
export async function decodeSave(
|
||||||
|
code: string,
|
||||||
|
migrate: (raw: unknown) => SaveParse = migrateSave,
|
||||||
|
): Promise<SaveParse> {
|
||||||
|
// Pasting often adds spaces or line breaks.
|
||||||
|
const text = code.replace(/\s+/g, "");
|
||||||
|
if (!text.startsWith(SAVE_CODE_PREFIX)) {
|
||||||
|
return { ok: false, error: "That isn't a Deadhead save code." };
|
||||||
|
}
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
const bytes = await pipe(
|
||||||
|
fromBase64Url(text.slice(SAVE_CODE_PREFIX.length)),
|
||||||
|
new DecompressionStream("gzip"),
|
||||||
|
);
|
||||||
|
raw = JSON.parse(new TextDecoder().decode(bytes));
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "The save code is incomplete or damaged." };
|
||||||
|
}
|
||||||
|
return migrate(raw);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { migrateSave } from "@deadhead/schema";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { fullSave } from "../../schema/test/save-fixture";
|
||||||
|
import {
|
||||||
|
decodeSave,
|
||||||
|
encodeSave,
|
||||||
|
GameEngine,
|
||||||
|
MemoryBackend,
|
||||||
|
SAVE_CODE_PREFIX,
|
||||||
|
SaveStorage,
|
||||||
|
type GameEngineOptions,
|
||||||
|
} from "../src/index";
|
||||||
|
import {
|
||||||
|
getState,
|
||||||
|
readSave,
|
||||||
|
signUpOwner,
|
||||||
|
setCredits,
|
||||||
|
TestClock,
|
||||||
|
testContract,
|
||||||
|
putBoard,
|
||||||
|
} from "./support";
|
||||||
|
|
||||||
|
function freshDevice(migrate?: GameEngineOptions["migrate"]) {
|
||||||
|
const clock = new TestClock();
|
||||||
|
const app = new GameEngine({
|
||||||
|
storage: new SaveStorage(new MemoryBackend()),
|
||||||
|
clock: { now: () => clock.now },
|
||||||
|
migrate,
|
||||||
|
});
|
||||||
|
return { app, clock };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("save export and import (Step 14.10, decision 0.5.92)", () => {
|
||||||
|
it("round-trips a save through the code", async () => {
|
||||||
|
const save = fullSave();
|
||||||
|
const code = await encodeSave(save);
|
||||||
|
expect(code.startsWith(SAVE_CODE_PREFIX)).toBe(true);
|
||||||
|
expect(code).toMatch(/^[\w.-]+$/);
|
||||||
|
expect(await decodeSave(code)).toEqual({ ok: true, save });
|
||||||
|
// Line breaks and spaces from pasting don't matter.
|
||||||
|
const wrapped = code.replace(/(.{40})/g, "$1\n ");
|
||||||
|
expect(await decodeSave(wrapped)).toEqual({ ok: true, save });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reproduces the same state on a fresh device", async () => {
|
||||||
|
const home = freshDevice();
|
||||||
|
const { shipId, avatarId } = await signUpOwner(home.app);
|
||||||
|
await setCredits(home.app, 12_345);
|
||||||
|
const contract = testContract();
|
||||||
|
await putBoard(home.app, [contract]);
|
||||||
|
await home.app.call("createDispatch", {
|
||||||
|
kind: "contract",
|
||||||
|
contractId: contract.id,
|
||||||
|
shipId,
|
||||||
|
pilotId: avatarId,
|
||||||
|
});
|
||||||
|
const before = await getState(home.app);
|
||||||
|
const { code } = await home.app.call("exportSave", {});
|
||||||
|
|
||||||
|
const other = freshDevice();
|
||||||
|
other.clock.now = home.clock.now;
|
||||||
|
const imported = await other.app.call("importSave", { code });
|
||||||
|
expect(imported.state).toEqual(before);
|
||||||
|
expect(await getState(other.app)).toEqual(before);
|
||||||
|
expect(await readSave(other.app)).toEqual(await readSave(home.app));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces the company already on the device", async () => {
|
||||||
|
const home = freshDevice();
|
||||||
|
await signUpOwner(home.app);
|
||||||
|
const { code } = await home.app.call("exportSave", {});
|
||||||
|
const other = freshDevice();
|
||||||
|
await signUpOwner(other.app);
|
||||||
|
const { playerId } = await other.app.call("importSave", { code });
|
||||||
|
expect(playerId).toBe((await readSave(home.app)).saveId);
|
||||||
|
expect((await getState(other.app)).company).toEqual((await getState(home.app)).company);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("migrates an older save on import", async () => {
|
||||||
|
// Only save version 1 exists so far, so an older format is simulated: this
|
||||||
|
// build's migration fills in what the "older" export lacked.
|
||||||
|
const old = fullSave() as Record<string, unknown>;
|
||||||
|
delete old.stats;
|
||||||
|
const code = await encodeSave(old as never);
|
||||||
|
const migrations: unknown[] = [];
|
||||||
|
const { app } = freshDevice((raw) => {
|
||||||
|
migrations.push(raw);
|
||||||
|
return migrateSave({
|
||||||
|
...(raw as object),
|
||||||
|
stats: { contractDeliveries: 0, priorityDeliveries: 0 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const { state } = await app.call("importSave", { code });
|
||||||
|
expect(migrations).toHaveLength(1);
|
||||||
|
expect(state.company.name).toBe(fullSave().company.name);
|
||||||
|
expect((await readSave(app)).stats).toEqual({ contractDeliveries: 0, priorityDeliveries: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["not a code", "hello there", "That isn't a Deadhead save code."],
|
||||||
|
["damaged", `${SAVE_CODE_PREFIX}AAAAbroken`, "The save code is incomplete or damaged."],
|
||||||
|
])("refuses %s and changes nothing", async (_label, code, message) => {
|
||||||
|
const home = freshDevice();
|
||||||
|
await signUpOwner(home.app);
|
||||||
|
const before = await readSave(home.app);
|
||||||
|
await expect(home.app.call("importSave", { code })).rejects.toMatchObject({
|
||||||
|
code: "invalid-save",
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
expect(await readSave(home.app)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a code that decodes to something that isn't a save", async () => {
|
||||||
|
const { app } = freshDevice();
|
||||||
|
const code = await encodeSave({ hello: "world" } as never);
|
||||||
|
await expect(app.call("importSave", { code })).rejects.toMatchObject({ code: "invalid-save" });
|
||||||
|
await expect(app.call("getPlayerState", {})).rejects.toMatchObject({ code: "unauthorized" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has nothing to export before a company exists", async () => {
|
||||||
|
const { app } = freshDevice();
|
||||||
|
await expect(app.call("exportSave", {})).rejects.toMatchObject({ code: "unauthorized" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -36,6 +36,7 @@ export const SERVER_ERROR_CODES = [
|
||||||
"goal-not-ready",
|
"goal-not-ready",
|
||||||
"goal-already-claimed",
|
"goal-already-claimed",
|
||||||
"restore-failed",
|
"restore-failed",
|
||||||
|
"invalid-save",
|
||||||
"rate-limited",
|
"rate-limited",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,11 @@ export const backupCodeResponseSchema = z.object({
|
||||||
/** A backup code typed by the player; spaces, dashes and case don't matter. */
|
/** A backup code typed by the player; spaces, dashes and case don't matter. */
|
||||||
export const restoreRequestSchema = z.object({ code: z.string().trim().min(1).max(64) });
|
export const restoreRequestSchema = z.object({ code: z.string().trim().min(1).max(64) });
|
||||||
|
|
||||||
|
/** Export save (decision 0.5.92): the whole save as one text code. */
|
||||||
|
export const exportSaveResponseSchema = z.object({ code: z.string().min(1) });
|
||||||
|
/** Import save: a pasted or opened export code (generous: big companies make long codes). */
|
||||||
|
export const importSaveRequestSchema = z.object({ code: z.string().trim().min(1).max(10_000_000) });
|
||||||
|
|
||||||
/** Developer tools, non-production only (CR-011). */
|
/** Developer tools, non-production only (CR-011). */
|
||||||
export const devAdvanceClockRequestSchema = z.object({
|
export const devAdvanceClockRequestSchema = z.object({
|
||||||
advanceMs: z
|
advanceMs: z
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ import {
|
||||||
backupCodeStatusSchema,
|
backupCodeStatusSchema,
|
||||||
renameCompanyRequestSchema,
|
renameCompanyRequestSchema,
|
||||||
restoreRequestSchema,
|
restoreRequestSchema,
|
||||||
|
exportSaveResponseSchema,
|
||||||
|
importSaveRequestSchema,
|
||||||
repayLoanRequestSchema,
|
repayLoanRequestSchema,
|
||||||
shipResponseSchema,
|
shipResponseSchema,
|
||||||
spendSkillPointRequestSchema,
|
spendSkillPointRequestSchema,
|
||||||
|
|
@ -181,6 +183,22 @@ export const routes = {
|
||||||
response: createGuestResponseSchema,
|
response: createGuestResponseSchema,
|
||||||
auth: false,
|
auth: false,
|
||||||
},
|
},
|
||||||
|
/** On the device only (CR-014): the save as an export code. */
|
||||||
|
exportSave: {
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/save/export",
|
||||||
|
request: emptyRequestSchema,
|
||||||
|
response: exportSaveResponseSchema,
|
||||||
|
auth: true,
|
||||||
|
},
|
||||||
|
/** On the device only (CR-014): replaces this device's company with an exported one. */
|
||||||
|
importSave: {
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/save/import",
|
||||||
|
request: importSaveRequestSchema,
|
||||||
|
response: createGuestResponseSchema,
|
||||||
|
auth: false,
|
||||||
|
},
|
||||||
/** Non-production only (CR-011): not registered when NODE_ENV is production. */
|
/** Non-production only (CR-011): not registered when NODE_ENV is production. */
|
||||||
devAdvanceClock: {
|
devAdvanceClock: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ describe("route catalog", () => {
|
||||||
"getBackupCode",
|
"getBackupCode",
|
||||||
"createBackupCode",
|
"createBackupCode",
|
||||||
"restoreCompany",
|
"restoreCompany",
|
||||||
|
"exportSave",
|
||||||
|
"importSave",
|
||||||
"devAdvanceClock",
|
"devAdvanceClock",
|
||||||
"devAddCredits",
|
"devAddCredits",
|
||||||
"devPilot",
|
"devPilot",
|
||||||
|
|
@ -66,9 +68,9 @@ describe("route catalog", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requires a session everywhere except server time, guest creation and restore", () => {
|
it("requires a session everywhere except server time, guest creation, restore and import", () => {
|
||||||
const open = entries.filter(([, r]) => !r.auth).map(([name]) => name);
|
const open = entries.filter(([, r]) => !r.auth).map(([name]) => name);
|
||||||
// Restoring signs a device in, so it runs without a session (CR-011).
|
// Restoring signs a device in (CR-011); importing works on a device with no company yet (CR-014).
|
||||||
expect(open.sort()).toEqual(["createGuest", "getServerTime", "restoreCompany"]);
|
expect(open.sort()).toEqual(["createGuest", "getServerTime", "importSave", "restoreCompany"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue