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:
fireshaper 2026-10-05 00:00:37 -04:00
parent 106d44d384
commit 5030341eb7
16 changed files with 140 additions and 13 deletions

View File

@ -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.

View File

@ -67,6 +67,7 @@ export async function loadPlayerState(
);
return {
playerId,
company: {
id: company.id,
name: company.name,

View File

@ -1,6 +1,6 @@
{
"name": "@deadhead/web",
"version": "0.0.0",
"version": "0.10.0",
"private": true,
"type": "module",
"scripts": {

View File

@ -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__;

View File

@ -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">

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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");
});
});

View File

@ -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: [

View File

@ -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

View File

@ -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"],

View File

@ -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

View File

@ -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

View File

@ -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),

View File

@ -152,6 +152,7 @@ const tutorial = {
};
const goal = { id: "first-hire", status: "ready" };
const playerState = {
playerId: "player-1",
company,
ships: [ship],
pilots: [pilot],