Step 10K.10: About section with version, build and debug info
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
106d44d384
commit
5030341eb7
|
|
@ -1104,3 +1104,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
|||
- **Restore from the start screen never warns** (there's no company yet); in Settings it warns only when the current company is past the tutorial, as the CR asks.
|
||||
- **Success replaces the cached player state with the restored one** and refreshes boards, then goes to HQ; the server has already switched the device's session cookie.
|
||||
- **The device id is a random UUID in local storage**, sent only with restore requests for rate limiting; it isn't tied to the player.
|
||||
|
||||
## Step 10K.10
|
||||
|
||||
- **Version is the web package's version, bumped to 0.10.0** (it was 0.0.0) to mark the Phase 10 build; **build is the git short hash** at build time ("dev" when built outside git), injected with Vite `define`. Tests use fixed stand-ins.
|
||||
- **"Current screen" in debug info is the last screen before Settings**: the button lives in Settings, so the useful answer is where the player was when they opened it. The layout records it.
|
||||
- **Player state now includes `playerId`** for debug info; it was only returned at sign-up.
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export async function loadPlayerState(
|
|||
);
|
||||
|
||||
return {
|
||||
playerId,
|
||||
company: {
|
||||
id: company.id,
|
||||
name: company.name,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@deadhead/web",
|
||||
"version": "0.0.0",
|
||||
"version": "0.10.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
declare const __APP_VERSION__: string;
|
||||
declare const __APP_BUILD__: string;
|
||||
|
||||
/** The game's version and build (git commit), baked in by Vite (CR-011). */
|
||||
export const APP_VERSION: string = __APP_VERSION__;
|
||||
export const APP_BUILD: string = __APP_BUILD__;
|
||||
|
|
@ -3,6 +3,7 @@ import { Link, NavLink, Outlet } from "react-router";
|
|||
import { GameClock } from "../clock/GameClock";
|
||||
import { SCREENS, type ScreenPath } from "../routes";
|
||||
import { Icon } from "../ui";
|
||||
import { useTrackScreen } from "./lastScreen";
|
||||
|
||||
export interface LayoutProps {
|
||||
/** Shown at the top right. Credits arrive with the player state (Step 8.5). */
|
||||
|
|
@ -25,6 +26,7 @@ export function Layout({
|
|||
banner,
|
||||
attention = {},
|
||||
}: LayoutProps) {
|
||||
useTrackScreen();
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col text-ink-50 [--header-h:3.5rem] [--nav-h:calc(4.25rem+env(safe-area-inset-bottom))] md:pl-24 md:[--nav-h:0px]">
|
||||
<header className="sticky top-0 z-10 flex h-(--header-h) items-center justify-between gap-2 border-b border-space-700/80 bg-space-950/80 px-3 backdrop-blur-xl sm:px-4">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
|
||||
let lastScreen = "/";
|
||||
|
||||
/** Records the screen the player is on, ignoring Settings itself (for debug info, CR-011). */
|
||||
export function useTrackScreen(): void {
|
||||
const { pathname } = useLocation();
|
||||
useEffect(() => {
|
||||
if (pathname !== "/settings") lastScreen = pathname;
|
||||
}, [pathname]);
|
||||
}
|
||||
|
||||
/** The last screen before Settings, e.g. "/fleet". */
|
||||
export function lastScreenPath(): string {
|
||||
return lastScreen;
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { useState } from "react";
|
||||
import { APP_BUILD, APP_VERSION } from "../build-info";
|
||||
import { serverNow, useClock } from "../clock/clock";
|
||||
import { lastScreenPath } from "../layout/lastScreen";
|
||||
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 {
|
||||
return [
|
||||
"Deadhead: Space Freight Tycoon",
|
||||
`Version: ${APP_VERSION}`,
|
||||
`Build: ${APP_BUILD}`,
|
||||
`Player: ${playerId}`,
|
||||
`Server time: ${new Date(serverNow()).toISOString()}`,
|
||||
`Clock offset: ${Math.round(useClock.getState().offsetMs)} ms`,
|
||||
`Screen: ${lastScreenPath()}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Settings → About: version and build, and debug info for bug reports. */
|
||||
export function About() {
|
||||
const state = useLoadedPlayer();
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-ink-400">Deadhead: Space Freight Tycoon</span>
|
||||
<span aria-label="Version" className="font-display text-ink-100 tabular-nums">
|
||||
{APP_VERSION} · build {APP_BUILD}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void navigator.clipboard
|
||||
?.writeText(debugInfo(state.playerId))
|
||||
.then(() => setCopied(true))
|
||||
.catch(() => setCopied(false));
|
||||
}}
|
||||
>
|
||||
{copied ? "Debug info copied" : "Copy debug info"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { resetMapView } from "../map/camera";
|
||||
import { About } from "./AboutSettings";
|
||||
import { BackupCompany, RestoreCompany } from "./BackupSettings";
|
||||
import { CompanyName, NewCompany } from "./CompanySettings";
|
||||
import { useLoadedPlayer } from "../state/actions";
|
||||
|
|
@ -74,7 +75,7 @@ export function Settings() {
|
|||
</Section>
|
||||
|
||||
<Section title="About">
|
||||
<Row label="Game">Deadhead: Space Freight Tycoon</Row>
|
||||
<About />
|
||||
</Section>
|
||||
|
||||
{isDevBuild() && (
|
||||
|
|
@ -98,15 +99,6 @@ function Section({ title, children }: { title: string; children: ReactNode }) {
|
|||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3 text-sm">
|
||||
<span className="text-ink-400">{label}</span>
|
||||
<span className="text-right text-ink-100">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A labelled radio group of a few options, styled as a segmented control. */
|
||||
function Choice<T extends string>(props: {
|
||||
label: string;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
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 { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||
|
||||
describe("about and debug info (Step 10K.10)", () => {
|
||||
it("shows the version and copies debug info with every field", async () => {
|
||||
const writeText = vi.fn(() => Promise.resolve());
|
||||
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
|
||||
renderApp(fakeApi({ getPlayerState: () => samplePlayerState({ tutorial: null }) }), "/fleet");
|
||||
|
||||
await userEvent.click(await screen.findByRole("link", { name: "Settings" }));
|
||||
const about = await screen.findByRole("region", { name: "About" });
|
||||
// vitest.config.ts stands in for the build-time version and commit.
|
||||
expect(within(about).getByLabelText("Version")).toHaveTextContent("0.0.0-test · build test");
|
||||
|
||||
await userEvent.click(within(about).getByRole("button", { name: "Copy debug info" }));
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
|
||||
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/);
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -61,6 +61,7 @@ export function shipExtras(
|
|||
|
||||
export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerState {
|
||||
return {
|
||||
playerId: "player-1",
|
||||
company: { id: "co1", name: "Deadhead Freight", credits: 0, loanBalance: 0 },
|
||||
ships: [],
|
||||
pilots: [
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { execSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
|
|
@ -23,7 +25,26 @@ function omitSourceArt(): Plugin {
|
|||
};
|
||||
}
|
||||
|
||||
/** Version from package.json and the git commit, shown in Settings → About (CR-011). */
|
||||
function buildInfo() {
|
||||
const { version } = JSON.parse(
|
||||
readFileSync(new URL("./package.json", import.meta.url), "utf8"),
|
||||
) as {
|
||||
version: string;
|
||||
};
|
||||
let build = "dev";
|
||||
try {
|
||||
build = execSync("git rev-parse --short HEAD", { stdio: ["ignore", "pipe", "ignore"] })
|
||||
.toString()
|
||||
.trim();
|
||||
} catch {
|
||||
// Not a git checkout (e.g. a tarball build): keep "dev".
|
||||
}
|
||||
return { __APP_VERSION__: JSON.stringify(version), __APP_BUILD__: JSON.stringify(build) };
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
define: buildInfo(),
|
||||
plugins: [react(), tailwindcss(), omitSourceArt()],
|
||||
server: {
|
||||
// Same-domain API in development too (decision 0.5.20). The server listens on
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { defineProject } from "vitest/config";
|
|||
|
||||
export default defineProject({
|
||||
plugins: [react()],
|
||||
// Stand-ins for the build-time version and commit (see vite.config.ts).
|
||||
define: { __APP_VERSION__: JSON.stringify("0.0.0-test"), __APP_BUILD__: JSON.stringify("test") },
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./test/setup.ts"],
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ Living reference for how the code is organised. Update it after any database sch
|
|||
| `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `deadhead`, `fuelCost`, `goodId` / `massTonnes` / `shipper` (null for a deadhead, CR-007), `credits` (net), `loanRepayment`, `pilotPay` (hired pilot's profit share, CR-010), `pilotXp`, `discoveredLocationId`, `scriptedId`, `restEndsAt` (set when the trip ended in mandatory rest), `resolvedAt` |
|
||||
| `TutorialState` | `loanerLocationId` (`earth` / `moon`), `runInFlight`, `runsCompleted`, `dealAvailable` |
|
||||
| `GoalState` | `id`, `status` (`incomplete` / `ready` / `claimed`) |
|
||||
| `PlayerState` | `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries`, `goals` (empty during the tutorial) |
|
||||
| `PlayerState` | `playerId` (CR-011), `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries`, `goals` (empty during the tutorial) |
|
||||
|
||||
### Errors
|
||||
|
||||
|
|
|
|||
|
|
@ -261,6 +261,6 @@ Spec: `memory-bank/change-requests/CR-011-settings-backup.md` (received as "CR-0
|
|||
- [x] **Step 10K.7 — Client: rename and new company flows.** `screens/CompanySettings.tsx`: the company name has "Rename", an in-place field checked as you type with the server's `companyNameSchema` (message under the field, Save disabled until valid; saves the trimmed name). "Start a new company" opens a confirmation explaining what is lost; "Start over" stays disabled until the exact company name is typed (case-sensitive), then calls `newCompany`, clears dismissed tips and goes to Contracts, where the intro card shows. Test: `company-settings.test.tsx`.
|
||||
- [x] **Step 10K.8 — Server: backup code and restore.** Migration `0008_cr011_backup_codes`: `players.backup_code_hash` (unique) and `backup_code_created_at`; table `player_logins` (extra sign-ins per player). `game/backup.ts`: 20-character codes from `23456789ABCDEFGHJKMNPQRSTUVWXYZ` in four groups of five (≈ 99 bits), SHA-256 of the normalized code stored, `RestoreLimiter` (5 failures per 15 min per IP and per device id). Routes: `getBackupCode` (GET, whether one exists), `createBackupCode` (POST, new code shown once, old one invalidated), `restoreCompany` (POST `/api/restore`, no session: a fresh guest sign-in for the device linked to the code's player; `409 restore-failed` for a bad code, `429 rate-limited`). Session lookup falls back to `player_logins`. Test: `backup.test.ts` (format, restore on a second device with shared state, regenerate invalidates, wrong code and rate limit, no plain code in the database, still works after a new company).
|
||||
- [x] **Step 10K.9 — Client: backup and restore flows.** `screens/BackupSettings.tsx`: "Back up company" (Settings) creates a code and shows it once, large and grouped, with Copy and "Keep this somewhere safe. Anyone with this code can open your company."; with a code already made it offers "Generate a new code" and warns the old one stops working. "Restore a company" (Settings, and under "Start your company" on the start screen) takes a code; past the tutorial it warns the device's company will be left behind; success loads the restored state and goes to HQ; failures show the server's message in place. The API client sends `x-device-id` (`api/device.ts`) on restore. Test: `backup-ui.test.tsx`.
|
||||
- [ ] Step 10K.10 — Client: About and debug info
|
||||
- [x] **Step 10K.10 — Client: About and debug info.** Vite bakes in `__APP_VERSION__` (web package version, now 0.10.0) and `__APP_BUILD__` (git short hash, "dev" outside git) — `src/build-info.ts`. Settings → About shows "0.10.0 · build abc1234" and "Copy debug info", which copies version, build, player id, server time, clock offset and the last screen before Settings. Player state gains `playerId`. Test: `about.test.tsx`.
|
||||
- [ ] Step 10K.11 — Client and server: developer tools (non-production only)
|
||||
- [ ] Step 10K.12 — Plan amendments and regression
|
||||
|
|
|
|||
|
|
@ -201,6 +201,8 @@ export const goalStateSchema = z.object({
|
|||
export type GoalState = z.infer<typeof goalStateSchema>;
|
||||
|
||||
export const playerStateSchema = z.object({
|
||||
/** This player (debug info, CR-011). */
|
||||
playerId: idSchema,
|
||||
company: companySchema,
|
||||
ships: z.array(shipSchema),
|
||||
pilots: z.array(pilotSchema),
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ const tutorial = {
|
|||
};
|
||||
const goal = { id: "first-hire", status: "ready" };
|
||||
const playerState = {
|
||||
playerId: "player-1",
|
||||
company,
|
||||
ships: [ship],
|
||||
pilots: [pilot],
|
||||
|
|
|
|||
Loading…
Reference in New Issue