Step 22.3: client: new company and How to play
CI / check (push) Has been cancelled Details

A company started from Settings gets its own welcome; Settings -> Help
reopens the cards, ending in Close (decisions 0.5.131, 0.5.132).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-11 14:22:01 -04:00
parent a75959f00a
commit 49998fb4ce
5 changed files with 86 additions and 4 deletions

View File

@ -1757,3 +1757,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Escape skips**, like Skip.
- **"Cross-fade instantly" means no animation**: with reduced motion the card changes at once. The global reduced-motion CSS would stop the slide anyway; the component also drops the class, so the rule doesn't rely on CSS alone.
- **The gate hides itself as soon as the player closes it**, before the command returns, so the cards never flash back while the state refreshes.
## Step 22.3
- **The welcome gate remembers a close per company id**: "Start a new company" keeps the save's id but makes a new company id, and the new company must get its own welcome.
- **How to play doesn't leave Settings**: "Close" just closes it; only a new company's welcome goes to Contracts.

View File

@ -134,17 +134,18 @@ export function WelcomeGate() {
const api = useApi();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [closed, setClosed] = useState(false);
// Closed for this company: a new company (Settings) gets its own welcome.
const [closedFor, setClosedFor] = useState<string | null>(null);
const seen = useMutation({
mutationFn: () => api.call("markWelcomeSeen", {}),
onSuccess: (next) => queryClient.setQueryData(PLAYER_STATE_KEY, next),
});
if (state.welcomeSeen || closed) return null;
if (state.welcomeSeen || closedFor === state.company.id) return null;
return (
<WelcomeCards
final="start"
onClose={() => {
setClosed(true);
setClosedFor(state.company.id);
seen.mutate();
void navigate("/contracts");
}}

View File

@ -1,4 +1,6 @@
import { useState } from "react";
import type { ReactNode } from "react";
import { WelcomeCards } from "../components/Welcome";
import { SpendingFloor } from "../components/IndependentPilots";
import { resetMapView } from "../map/camera";
import { About } from "./AboutSettings";
@ -18,6 +20,7 @@ export function isDevBuild(): boolean {
export function Settings() {
const state = useLoadedPlayer();
const prefs = usePreferences();
const [howToPlay, setHowToPlay] = useState(false);
return (
<Screen className="max-w-2xl">
@ -87,7 +90,12 @@ export function Settings() {
<Button variant="secondary" size="sm" onClick={() => prefs.showTipsAgain()}>
Show tips again
</Button>
<Button variant="secondary" size="sm" onClick={() => setHowToPlay(true)}>
How to play
</Button>
</div>
{/* The welcome cards again, ending in Close (decision 0.5.132). */}
{howToPlay && <WelcomeCards final="close" onClose={() => setHowToPlay(false)} />}
</Section>
<Section title="About">

View File

@ -0,0 +1,68 @@
import type { PlayerState } from "@deadhead/schema";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard } from "./render";
const NAME = "Deadhead Freight";
/** A company whose welcome is `seen`; "Start a new company" makes another with its welcome unseen. */
function company(seen: boolean) {
let state: PlayerState = samplePlayerState({ welcomeSeen: seen });
let companies = 1;
const api = fakeApi({
getPlayerState: () => state,
getTutorialBoard: () => sampleTutorialBoard(),
markWelcomeSeen: () => {
state = { ...state, welcomeSeen: true };
return state;
},
newCompany: () => {
companies++;
state = samplePlayerState({
welcomeSeen: false,
company: { id: `co${companies}`, name: NAME, credits: 0, loanBalance: 0 },
});
return state;
},
});
return api;
}
async function startNewCompany() {
await userEvent.click(await screen.findByRole("button", { name: "Start a new company" }));
const confirm = screen.getByRole("dialog", { name: "Start a new company?" });
await userEvent.type(within(confirm).getByRole("textbox"), NAME);
await userEvent.click(within(confirm).getByRole("button", { name: "Start over" }));
}
describe("welcome for a new company and How to play (Step 22.3, decisions 0.5.131, 0.5.132)", () => {
it("shows the welcome for a company started from Settings, even after closing one earlier", async () => {
const api = company(false);
renderApp(api, "/settings");
// This company's welcome first.
const first = await screen.findByRole("dialog", { name: "Welcome to Deadhead" });
await userEvent.click(within(first).getByRole("button", { name: "Skip" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
await userEvent.click(await screen.findByRole("link", { name: "Settings" }));
await startNewCompany();
const again = await screen.findByRole("dialog", { name: "Welcome to Deadhead" });
expect(again).toHaveTextContent("1 of 3");
});
it("opens How to play from Settings any time, ending in Close, without touching the save", async () => {
const api = company(true);
renderApp(api, "/settings");
await userEvent.click(await screen.findByRole("button", { name: "How to play" }));
const dialog = screen.getByRole("dialog", { name: "Welcome to Deadhead" });
await userEvent.click(within(dialog).getByRole("button", { name: "Next" }));
await userEvent.click(within(dialog).getByRole("button", { name: "Next" }));
expect(within(dialog).queryByRole("button", { name: "Let's fly" })).not.toBeInTheDocument();
await userEvent.click(within(dialog).getByRole("button", { name: "Close" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(api.call).not.toHaveBeenCalledWith("markWelcomeSeen", expect.anything());
// Still on Settings.
expect(screen.getByRole("heading", { level: 1, name: "Settings" })).toBeInTheDocument();
});
});

View File

@ -496,5 +496,5 @@ Spec: `memory-bank/change-requests/CR-022-welcome-screens.md` (received as "CR-0
- **Other changes.** The three screens that wrote "Halvorsen Lunar Freight" now use `content.tutorialCompanyName`. The e2e `startCompany` (and the production-build test) expect the welcome and skip it, waiting for the save to record it before any reload. Screenshots: `docs/screenshots/22-2-welcome-1-phone.png`, `22-2-welcome-3-phone.png`, `22-2-after-welcome-phone.png`, and the same three for desktop.
Tests (`apps/web/test/welcome.test.tsx`): through all three cards to "Let's fly", marked seen, on Contracts with the intro card; Skip on each card does the same; no welcome after a reload; the slide class only without reduced motion; focus stays inside. `packages/engine/test/start.test.ts`: `markWelcomeSeen` records it, and it survives a reload.
- [ ] Step 22.3 — Client: new company and "How to play"
- [x] **Step 22.3 — Client: new company and "How to play".** "Start a new company" makes a save whose welcome is unseen, so the gate shows the cards for it. The gate's "closed" flag is now kept per company id, so a welcome closed earlier in the session doesn't hide the new company's. Settings → Help has "How to play", which opens `WelcomeCards` ending in "Close" and leaves the save alone. Tests (`apps/web/test/welcome-settings.test.tsx`): after skipping one company's welcome, a company started from Settings shows card 1 again; How to play opens anywhere, ends in Close (not "Let's fly"), stays on Settings and never calls `markWelcomeSeen`.
- [ ] Step 22.4 — Regression