Step 10K.7: rename and start-over flows in Settings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 23:36:35 -04:00
parent 531bb77fea
commit 29653e2fdc
5 changed files with 224 additions and 2 deletions

View File

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

View File

@ -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<string | null>(null);
if (draft === null) {
return (
<div className="flex items-center justify-between gap-3 text-sm">
<span className="text-ink-400">Company name</span>
<span className="flex items-center gap-2">
<span className="font-display font-semibold text-ink-50">{state.company.name}</span>
<Button size="sm" variant="ghost" onClick={() => setDraft(state.company.name)}>
Rename
</Button>
</span>
</div>
);
}
const check = companyNameSchema.safeParse(draft);
const problem = check.success ? null : check.error.issues[0]?.message;
return (
<form
className="space-y-2 text-sm"
onSubmit={(e) => {
e.preventDefault();
if (!check.success) return;
rename.mutate({ name: check.data }, { onSuccess: () => setDraft(null) });
}}
>
<label className="block">
<span className="text-ink-400">Company name</span>
<input
aria-invalid={problem ? true : undefined}
aria-describedby="company-name-problem"
value={draft}
maxLength={40}
onChange={(e) => setDraft(e.target.value)}
className={`${inputClass} mt-1`}
/>
</label>
{problem && (
<p id="company-name-problem" className="text-rose-300">
{problem}
</p>
)}
{rename.isError && (
<p role="alert" className="text-rose-300">
{errorMessage(rename.error)}
</p>
)}
<div className="flex gap-2">
<Button size="sm" type="submit" disabled={!check.success || rename.isPending}>
Save
</Button>
<Button size="sm" variant="secondary" onClick={() => setDraft(null)}>
Cancel
</Button>
</div>
</form>
);
}
/** "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 (
<>
<Button variant="danger" size="sm" onClick={() => setOpen(true)}>
Start a new company
</Button>
{open && (
<Sheet titleId="new-company-title" title="Start a new company?" tone="rose" layer="z-30">
<p className="mt-2 text-sm text-ink-200">
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&apos;t be undone.
</p>
<label className="mt-3 block text-sm">
<span className="text-ink-300">
Type <strong className="text-ink-50">{name}</strong> to confirm
</span>
<input
value={typed}
onChange={(e) => setTyped(e.target.value)}
className={`${inputClass} mt-1`}
autoComplete="off"
/>
</label>
{start.isError && (
<p role="alert" className="mt-2 text-sm text-rose-300">
{errorMessage(start.error)}
</p>
)}
<div className="mt-4 flex gap-2">
<Button
variant="danger"
block
disabled={typed !== name || start.isPending}
onClick={() =>
start.mutate(
{},
{
onSuccess: () => {
showTipsAgain();
void navigate("/contracts");
},
},
)
}
>
Start over
</Button>
<Button
variant="secondary"
block
onClick={() => {
setOpen(false);
setTyped("");
}}
>
Cancel
</Button>
</div>
</Sheet>
)}
</>
);
}

View File

@ -1,5 +1,6 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { resetMapView } from "../map/camera"; import { resetMapView } from "../map/camera";
import { CompanyName, NewCompany } from "./CompanySettings";
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";
@ -19,7 +20,10 @@ export function Settings() {
<ScreenHeader title="Settings" eyebrow={state.company.name} icon="gear" /> <ScreenHeader title="Settings" eyebrow={state.company.name} icon="gear" />
<Section title="Company"> <Section title="Company">
<Row label="Company name">{state.company.name}</Row> <CompanyName />
<div className="flex flex-wrap gap-2">
<NewCompany />
</div>
</Section> </Section>
<Section title="Display"> <Section title="Display">

View File

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

View File

@ -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.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.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. - [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.8 — Server: backup code and restore
- [ ] Step 10K.9 — Client: backup and restore flows - [ ] Step 10K.9 — Client: backup and restore flows
- [ ] Step 10K.10 — Client: About and debug info - [ ] Step 10K.10 — Client: About and debug info