diff --git a/DECISIONS.md b/DECISIONS.md index e1ad2b4..bc71a4e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. diff --git a/apps/server/src/game/player-state.ts b/apps/server/src/game/player-state.ts index fe3a2b9..48bd00b 100644 --- a/apps/server/src/game/player-state.ts +++ b/apps/server/src/game/player-state.ts @@ -67,6 +67,7 @@ export async function loadPlayerState( ); return { + playerId, company: { id: company.id, name: company.name, diff --git a/apps/web/package.json b/apps/web/package.json index 3c0aac2..dc6b7b3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@deadhead/web", - "version": "0.0.0", + "version": "0.10.0", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/build-info.ts b/apps/web/src/build-info.ts new file mode 100644 index 0000000..d8c84de --- /dev/null +++ b/apps/web/src/build-info.ts @@ -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__; diff --git a/apps/web/src/layout/Layout.tsx b/apps/web/src/layout/Layout.tsx index fa9260f..4cd2362 100644 --- a/apps/web/src/layout/Layout.tsx +++ b/apps/web/src/layout/Layout.tsx @@ -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 (
diff --git a/apps/web/src/layout/lastScreen.ts b/apps/web/src/layout/lastScreen.ts new file mode 100644 index 0000000..8d2c4f1 --- /dev/null +++ b/apps/web/src/layout/lastScreen.ts @@ -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; +} diff --git a/apps/web/src/screens/AboutSettings.tsx b/apps/web/src/screens/AboutSettings.tsx new file mode 100644 index 0000000..ac17a4b --- /dev/null +++ b/apps/web/src/screens/AboutSettings.tsx @@ -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 ( +
+
+ Deadhead: Space Freight Tycoon + + {APP_VERSION} · build {APP_BUILD} + +
+ +
+ ); +} diff --git a/apps/web/src/screens/Settings.tsx b/apps/web/src/screens/Settings.tsx index 8536796..be9ece9 100644 --- a/apps/web/src/screens/Settings.tsx +++ b/apps/web/src/screens/Settings.tsx @@ -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() {
- Deadhead: Space Freight Tycoon +
{isDevBuild() && ( @@ -98,15 +99,6 @@ function Section({ title, children }: { title: string; children: ReactNode }) { ); } -function Row({ label, children }: { label: string; children: ReactNode }) { - return ( -
- {label} - {children} -
- ); -} - /** A labelled radio group of a few options, styled as a segmented control. */ function Choice(props: { label: string; diff --git a/apps/web/test/about.test.tsx b/apps/web/test/about.test.tsx new file mode 100644 index 0000000..eb7ba8c --- /dev/null +++ b/apps/web/test/about.test.tsx @@ -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"); + }); +}); diff --git a/apps/web/test/render.tsx b/apps/web/test/render.tsx index 02793b1..092e992 100644 --- a/apps/web/test/render.tsx +++ b/apps/web/test/render.tsx @@ -61,6 +61,7 @@ export function shipExtras( export function samplePlayerState(overrides: Partial = {}): PlayerState { return { + playerId: "player-1", company: { id: "co1", name: "Deadhead Freight", credits: 0, loanBalance: 0 }, ships: [], pilots: [ diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 19e9ecd..8b0b240 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -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 diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 43adfe9..a232fc1 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -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"], diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 05971f8..6ac1138 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -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 diff --git a/memory-bank/progress.md b/memory-bank/progress.md index e8b14b2..6711416 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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 diff --git a/packages/schema/src/entities.ts b/packages/schema/src/entities.ts index 1d224ea..a376485 100644 --- a/packages/schema/src/entities.ts +++ b/packages/schema/src/entities.ts @@ -201,6 +201,8 @@ export const goalStateSchema = z.object({ export type GoalState = z.infer; export const playerStateSchema = z.object({ + /** This player (debug info, CR-011). */ + playerId: idSchema, company: companySchema, ships: z.array(shipSchema), pilots: z.array(pilotSchema), diff --git a/packages/schema/test/schemas.test.ts b/packages/schema/test/schemas.test.ts index e6efb1a..7dc6813 100644 --- a/packages/schema/test/schemas.test.ts +++ b/packages/schema/test/schemas.test.ts @@ -152,6 +152,7 @@ const tutorial = { }; const goal = { id: "first-hire", status: "ready" }; const playerState = { + playerId: "player-1", company, ships: [ship], pilots: [pilot],