diff --git a/DECISIONS.md b/DECISIONS.md index 379de9d..3287550 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1082,3 +1082,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **The player row survives** (its id, sign-ins and later its backup code); only its tutorial fields reset, with a world seed guaranteed to differ. - **No job cleanup is needed:** arrival jobs look up the player's due dispatches when they fire, and the deleted ones are simply gone. - **The new company also gets a new default name** (from the new seed); a renamed company's name does not carry over, since everything else starts over too. + +## Step 10K.7 + +- **Client and server share one name rule** (`companyNameSchema` from `@deadhead/schema`), so the inline message is the same check the server will apply. +- **The confirmation is case-sensitive**: the typed text must equal the company name exactly. +- **A new company clears dismissed tips** so the tutorial intro card greets the restart, as the CR's "Contracts with the intro card" asks. diff --git a/apps/web/src/screens/CompanySettings.tsx b/apps/web/src/screens/CompanySettings.tsx new file mode 100644 index 0000000..4acc12a --- /dev/null +++ b/apps/web/src/screens/CompanySettings.tsx @@ -0,0 +1,145 @@ +import { companyNameSchema } from "@deadhead/schema"; +import { useState } from "react"; +import { useNavigate } from "react-router"; +import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; +import { usePreferences } from "../state/preferences"; +import { Button, Sheet } from "../ui"; + +const inputClass = + "w-full rounded-lg border border-space-600 bg-space-950/70 px-3 py-2 text-sm text-ink-50 focus:border-amber-400 focus:outline-none"; + +/** The company name, editable in place with the server's own rules (decision 0.5.75). */ +export function CompanyName() { + const state = useLoadedPlayer(); + const rename = useGameAction("renameCompany"); + const [draft, setDraft] = useState(null); + + if (draft === null) { + return ( +
+ Company name + + {state.company.name} + + +
+ ); + } + const check = companyNameSchema.safeParse(draft); + const problem = check.success ? null : check.error.issues[0]?.message; + return ( +
{ + e.preventDefault(); + if (!check.success) return; + rename.mutate({ name: check.data }, { onSuccess: () => setDraft(null) }); + }} + > + + {problem && ( +

+ {problem} +

+ )} + {rename.isError && ( +

+ {errorMessage(rename.error)} +

+ )} +
+ + +
+
+ ); +} + +/** "Start a new company": a confirmation that needs the company's name typed in (decision 0.5.74). */ +export function NewCompany() { + const state = useLoadedPlayer(); + const start = useGameAction("newCompany"); + const navigate = useNavigate(); + const showTipsAgain = usePreferences((p) => p.showTipsAgain); + const [open, setOpen] = useState(false); + const [typed, setTyped] = useState(""); + const name = state.company.name; + + return ( + <> + + {open && ( + +

+ Everything in {name} will be lost: ships, pilots, HQs, credits, the loan and your + progress. You start again from the first company run. This can't be undone. +

+ + {start.isError && ( +

+ {errorMessage(start.error)} +

+ )} +
+ + +
+
+ )} + + ); +} diff --git a/apps/web/src/screens/Settings.tsx b/apps/web/src/screens/Settings.tsx index 0e6d0d3..af46c9f 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 { CompanyName, NewCompany } from "./CompanySettings"; import { useLoadedPlayer } from "../state/actions"; import { usePreferences, type ClockFormat, type CountdownFormat } from "../state/preferences"; import { Button, Panel, Screen, ScreenHeader } from "../ui"; @@ -19,7 +20,10 @@ export function Settings() {
- {state.company.name} + +
+ +
diff --git a/apps/web/test/company-settings.test.tsx b/apps/web/test/company-settings.test.tsx new file mode 100644 index 0000000..c153b8f --- /dev/null +++ b/apps/web/test/company-settings.test.tsx @@ -0,0 +1,67 @@ +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it } from "vitest"; +import { DEFAULT_PREFERENCES, usePreferences } from "../src/state/preferences"; +import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard } from "./render"; + +const owner = () => + samplePlayerState({ + tutorial: null, + company: { id: "c", name: "Kell Haulage", credits: 9000, loanBalance: 0 }, + }); + +describe("company settings (Step 10K.7)", () => { + afterEach(() => usePreferences.getState().set(DEFAULT_PREFERENCES)); + + it("renames in place, checking the name as you type", async () => { + const api = fakeApi({ + getPlayerState: owner, + renameCompany: ({ name }) => ({ company: { ...owner().company, name } }), + }); + renderApp(api, "/settings"); + const company = await screen.findByRole("region", { name: "Company" }); + await userEvent.click(within(company).getByRole("button", { name: "Rename" })); + const input = within(company).getByRole("textbox"); + await userEvent.clear(input); + await userEvent.type(input, "Ab"); + expect(within(company).getByText("At least 3 characters")).toBeInTheDocument(); + expect(within(company).getByRole("button", { name: "Save" })).toBeDisabled(); + + await userEvent.type(input, "le Freight "); + await userEvent.click(within(company).getByRole("button", { name: "Save" })); + await waitFor(() => + expect(api.call).toHaveBeenCalledWith("renameCompany", { name: "Able Freight" }), + ); + }); + + it("starts over only after the exact company name is typed, then opens the tutorial", async () => { + let reset = false; + const api = fakeApi({ + getPlayerState: () => (reset ? samplePlayerState() : owner()), + getTutorialBoard: () => sampleTutorialBoard(), + newCompany: () => { + reset = true; + return samplePlayerState(); + }, + }); + usePreferences.getState().dismissTip("tutorial-intro"); + renderApp(api, "/settings"); + await userEvent.click(await screen.findByRole("button", { name: "Start a new company" })); + const sheet = screen.getByRole("dialog", { name: "Start a new company?" }); + expect(sheet).toHaveTextContent("Everything in Kell Haulage will be lost"); + const confirm = within(sheet).getByRole("button", { name: "Start over" }); + expect(confirm).toBeDisabled(); + + const input = within(sheet).getByRole("textbox"); + await userEvent.type(input, "kell haulage"); + expect(confirm).toBeDisabled(); + await userEvent.clear(input); + await userEvent.type(input, "Kell Haulage"); + expect(confirm).toBeEnabled(); + + await userEvent.click(confirm); + await waitFor(() => expect(api.call).toHaveBeenCalledWith("newCompany", {})); + expect(await screen.findByRole("heading", { level: 1, name: "Contracts" })).toBeInTheDocument(); + expect(await screen.findByLabelText("Intro")).toBeInTheDocument(); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index a1ef33d..338c2d8 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -258,7 +258,7 @@ Spec: `memory-bank/change-requests/CR-011-settings-backup.md` (received as "CR-0 - [x] **Step 10K.4 — Client: reset map view and show tips again.** The map camera starts from the saved view and saves itself (throttled to 300 ms) as the player pans and zooms; `resetMapView()` restores the default and clears the saved view (Settings and the map's "Reset view"). The tutorial intro card is now a one-time tip (`tutorial-intro`) with a dismiss button; "Show tips again" clears dismissed tips. Test: `help-settings.test.tsx`. - [x] **Step 10K.5 — Server: rename company.** `companyNameSchema` (trimmed; 3–32 of letters, digits, spaces and `. , ' & ! ? -`) and route `renameCompany` (`POST /api/company/name`, `{ name }` → `{ company }`), run in `withPlayer`. New companies get a default name from their world seed (`companyName(seed)`, e.g. "Lodestar Haulage") instead of the fixed "Deadhead Freight". Player state already carried the name. Test: `company-name.test.ts`. - [x] **Step 10K.6 — Server: start a new company.** Route `newCompany` (`POST /api/company/new`, `{}` → player state) runs `newCompany` in `withPlayer` (company row lock): deletes claimed goals, accepted contracts, dispatches, boards, discovered locations, bays, pilots, ships (upgrades cascade) and the company, resets the player's tutorial fields with a new world seed, then calls `createStartingState`, now shared with guest creation. Arrival jobs for deleted flights find nothing. Test: `new-company.test.ts` (a mid-game owner ends up identical to a fresh guest, new seed, same player id; the old flight's job is a no-op). The backup-code part of the test is in 10K.8. -- [ ] Step 10K.7 — Client: rename and new company flows +- [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`. - [ ] Step 10K.8 — Server: backup code and restore - [ ] Step 10K.9 — Client: backup and restore flows - [ ] Step 10K.10 — Client: About and debug info