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:
parent
531bb77fea
commit
29653e2fdc
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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'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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue