Step 22.2: client: welcome pop-up
CI / check (push) Has been cancelled Details

Three welcome cards for every new company: Next, Skip, '1 of 3', swipe,
a focus trap, and a slide that reduced motion turns off; closing records
it and goes to Contracts (decision 0.5.131).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-11 14:19:03 -04:00
parent 7355f7dbfb
commit a75959f00a
25 changed files with 334 additions and 4 deletions

View File

@ -1750,3 +1750,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **"Halvorsen Lunar Freight" is now content** (`tutorialCompanyName`): the CR says the welcome takes it "from the existing content name", but it was hard-coded in three screens. Card 3 says `{company}`, and Step 22.2 points the screens at the content name too. - **"Halvorsen Lunar Freight" is now content** (`tutorialCompanyName`): the CR says the welcome takes it "from the existing content name", but it was hard-coded in three screens. Card 3 says `{company}`, and Step 22.2 points the screens at the content name too.
- **`welcomeSeen` lives on the save's `player`**, alongside the tutorial state, since it belongs to the company, not the device. That's why it isn't a one-time tip in device preferences like the tutorial intro card. - **`welcomeSeen` lives on the save's `player`**, alongside the tutorial state, since it belongs to the company, not the device. That's why it isn't a one-time tip in device preferences like the tutorial intro card.
## Step 22.2
- **The welcome is its own dialog**, not the shared `Sheet`: it needs a focus trap and swipe handling, which the sheet doesn't have.
- **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.

View File

@ -12,6 +12,11 @@ test.describe("the production build (CR-014 Step 14.11)", () => {
// Start a company and fly a tutorial run, then reload: everything a first session does. // Start a company and fly a tutorial run, then reload: everything a first session does.
await page.goto(SITE); await page.goto(SITE);
await page.getByRole("button", { name: "Start your company" }).click(); await page.getByRole("button", { name: "Start your company" }).click();
// The welcome cards (CR-022) come first.
await page
.getByRole("dialog", { name: "Welcome to Deadhead" })
.getByRole("button", { name: "Skip" })
.click();
await page.getByRole("button", { name: "Fly it" }).first().click(); await page.getByRole("button", { name: "Fly it" }).first().click();
await expect(page.getByText(/in flight/i).first()).toBeVisible(); await expect(page.getByText(/in flight/i).first()).toBeVisible();
await page.reload(); await page.reload();

View File

@ -71,6 +71,13 @@ export async function dismissLogbook(page: Page) {
export async function startCompany(page: Page) { export async function startCompany(page: Page) {
await page.goto("/"); await page.goto("/");
await page.getByRole("button", { name: "Start your company" }).click(); await page.getByRole("button", { name: "Start your company" }).click();
// Every new company opens with the welcome cards (CR-022): skip them.
const welcome = page.getByRole("dialog", { name: "Welcome to Deadhead" });
await expect(welcome).toBeVisible();
await welcome.getByRole("button", { name: "Skip" }).click();
await expect(welcome).toBeHidden();
// Closing records it in the save; wait for that before any reload.
await expect.poll(async () => (await state(page)).welcomeSeen).toBe(true);
await expect(page.getByRole("button", { name: "Fly it" }).first()).toBeVisible(); await expect(page.getByRole("button", { name: "Fly it" }).first()).toBeVisible();
} }

View File

@ -4,6 +4,7 @@ import { useApi } from "./api/context";
import { startClockTicker, syncClock } from "./clock/clock"; import { startClockTicker, syncClock } from "./clock/clock";
import { AwaySummary } from "./components/AwaySummary"; import { AwaySummary } from "./components/AwaySummary";
import { DealModal } from "./components/DealModal"; import { DealModal } from "./components/DealModal";
import { WelcomeGate } from "./components/Welcome";
import { SkillPointPrompt } from "./components/SkillPointPrompt"; import { SkillPointPrompt } from "./components/SkillPointPrompt";
import { formatCredits } from "./format"; import { formatCredits } from "./format";
import { tabAttention } from "./layout/attention"; import { tabAttention } from "./layout/attention";
@ -116,6 +117,7 @@ export function App() {
</Routes> </Routes>
<AwaySummary /> <AwaySummary />
<DealModal /> <DealModal />
<WelcomeGate />
<SkillPointPrompt /> <SkillPointPrompt />
</> </>
); );

View File

@ -0,0 +1,153 @@
import { content } from "@deadhead/content";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import { createPortal } from "react-dom";
import { useNavigate } from "react-router";
import { useApi } from "../api/context";
import { useLoadedPlayer } from "../state/actions";
import { PLAYER_STATE_KEY } from "../state/player";
import { reducedMotionOn, usePreferences } from "../state/preferences";
import { Button } from "../ui";
const FOCUSABLE = 'button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])';
/** A card's body with the tutorial employer's name filled in. */
export function welcomeBody(body: string): string {
return body.split("{company}").join(content.tutorialCompanyName);
}
/**
* The three welcome cards (decisions 0.5.131, 0.5.132): Next through them,
* Skip on every card, and a last button that starts the game ("Let's fly") or,
* reopened from Settings → Help, just closes ("Close"). Cards slide in, or
* change at once with reduced motion; focus stays inside until it closes.
*/
export function WelcomeCards({
final,
onClose,
}: {
final: "start" | "close";
onClose: () => void;
}) {
const { cards, next, skip, start, close } = content.welcome;
const [index, setIndex] = useState(0);
const reduced = usePreferences(reducedMotionOn);
const dialog = useRef<HTMLDivElement>(null);
const touchX = useRef<number | null>(null);
const card = cards[index]!;
const last = index === cards.length - 1;
// Focus moves into the pop-up, onto the main button of each card.
useEffect(() => {
dialog.current?.querySelector<HTMLElement>("[data-primary]")?.focus();
}, [index]);
const advance = () => (last ? onClose() : setIndex(index + 1));
// Tab and Shift+Tab stay inside; Escape is Skip.
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
return;
}
if (e.key !== "Tab" || !dialog.current) return;
const focusable = [...dialog.current.querySelectorAll<HTMLElement>(FOCUSABLE)];
if (focusable.length === 0) return;
const first = focusable[0]!;
const end = focusable.at(-1)!;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
end.focus();
} else if (!e.shiftKey && document.activeElement === end) {
e.preventDefault();
first.focus();
}
};
return createPortal(
<div className="fixed inset-0 z-40 flex items-end justify-center bg-space-950/80 backdrop-blur-sm sm:items-center sm:p-4">
<div
ref={dialog}
role="dialog"
aria-modal="true"
aria-labelledby="welcome-title"
onKeyDown={onKeyDown}
onTouchStart={(e) => {
touchX.current = e.touches[0]?.clientX ?? null;
}}
onTouchEnd={(e) => {
const from = touchX.current;
const to = e.changedTouches[0]?.clientX;
touchX.current = null;
if (from === null || to === undefined) return;
// Swipe left for the next card, right for the previous one.
if (from - to > 50) advance();
else if (to - from > 50 && index > 0) setIndex(index - 1);
}}
className="w-full max-w-md overflow-hidden rounded-t-2xl border border-amber-400/50 bg-space-900/95 p-6 pb-[max(1.5rem,env(safe-area-inset-bottom))] shadow-panel sm:rounded-2xl"
>
<p
aria-live="polite"
className="font-display text-[11px] font-semibold tracking-[0.2em] text-cyan-300/80 uppercase"
>
{index + 1} of {cards.length}
</p>
<div key={index} data-testid="welcome-card" className={reduced ? "" : "animate-slide"}>
<h2
id="welcome-title"
className="mt-2 font-display text-2xl font-bold tracking-wide text-amber-300"
>
{card.title}
</h2>
<p className="mt-3 text-ink-200">{welcomeBody(card.body)}</p>
</div>
<div aria-hidden="true" className="mt-5 flex justify-center gap-1.5">
{cards.map((_, i) => (
<span
key={i}
className={`h-1.5 rounded-full ${i === index ? "w-5 bg-amber-300" : "w-1.5 bg-space-600"}`}
/>
))}
</div>
<div className="mt-5 flex gap-3">
<Button variant="secondary" onClick={onClose} className="flex-1">
{skip}
</Button>
<Button data-primary onClick={advance} className="flex-1">
{last ? (final === "start" ? start : close) : next}
</Button>
</div>
</div>
</div>,
document.body,
);
}
/**
* A new company's welcome (decision 0.5.131): shown until this company has
* seen it; closing it records that and goes to Contracts, where the tutorial
* intro card follows.
*/
export function WelcomeGate() {
const state = useLoadedPlayer();
const api = useApi();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [closed, setClosed] = useState(false);
const seen = useMutation({
mutationFn: () => api.call("markWelcomeSeen", {}),
onSuccess: (next) => queryClient.setQueryData(PLAYER_STATE_KEY, next),
});
if (state.welcomeSeen || closed) return null;
return (
<WelcomeCards
final="start"
onClose={() => {
setClosed(true);
seen.mutate();
void navigate("/contracts");
}}
/>
);
}

View File

@ -35,6 +35,7 @@
--animate-rise: rise 0.35s cubic-bezier(0.2, 0.8, 0.2, 1) both; --animate-rise: rise 0.35s cubic-bezier(0.2, 0.8, 0.2, 1) both;
--animate-glow: glow 2.4s ease-in-out infinite; --animate-glow: glow 2.4s ease-in-out infinite;
--animate-sheet: sheet 0.28s cubic-bezier(0.2, 0.8, 0.2, 1) both; --animate-sheet: sheet 0.28s cubic-bezier(0.2, 0.8, 0.2, 1) both;
--animate-slide: slide 0.25s cubic-bezier(0.2, 0.8, 0.2, 1) both;
@keyframes rise { @keyframes rise {
from { from {
@ -55,6 +56,16 @@
opacity: 0.55; opacity: 0.55;
} }
} }
@keyframes slide {
from {
opacity: 0;
transform: translateX(32px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes sheet { @keyframes sheet {
from { from {
opacity: 0; opacity: 0;

View File

@ -1,3 +1,4 @@
import { content } from "@deadhead/content";
import type { Contract } from "@deadhead/schema"; import type { Contract } from "@deadhead/schema";
import { useState } from "react"; import { useState } from "react";
import { useLoadedPlayer } from "../state/actions"; import { useLoadedPlayer } from "../state/actions";
@ -14,7 +15,7 @@ export function Contracts() {
<Screen className="max-w-4xl"> <Screen className="max-w-4xl">
<ScreenHeader <ScreenHeader
title="Contracts" title="Contracts"
eyebrow={state.tutorial ? "Halvorsen Lunar Freight" : "Freight market"} eyebrow={state.tutorial ? content.tutorialCompanyName : "Freight market"}
icon="jobs" icon="jobs"
/> />
{state.tutorial ? <TutorialBoard /> : <LocationBoards onSelect={setSelected} />} {state.tutorial ? <TutorialBoard /> : <LocationBoards onSelect={setSelected} />}

View File

@ -109,7 +109,7 @@ export function Dashboard() {
<Panel feature tone="amber"> <Panel feature tone="amber">
<Eyebrow className="text-amber-300/80">Contract pilot</Eyebrow> <Eyebrow className="text-amber-300/80">Contract pilot</Eyebrow>
<h2 className="mt-0.5 font-display text-lg font-bold text-amber-200"> <h2 className="mt-0.5 font-display text-lg font-bold text-amber-200">
Flying for Halvorsen Lunar Freight Flying for {content.tutorialCompanyName}
</h2> </h2>
<p aria-label="Company runs" className="mt-2 text-sm text-ink-200"> <p aria-label="Company runs" className="mt-2 text-sm text-ink-200">
Company runs: {Math.min(state.tutorial.runsCompleted, runsBeforeDeal)} of{" "} Company runs: {Math.min(state.tutorial.runsCompleted, runsBeforeDeal)} of{" "}

View File

@ -50,7 +50,7 @@ export function TutorialBoard() {
<Art sprite={loaner.sprite} className="size-16 shrink-0 -rotate-12" /> <Art sprite={loaner.sprite} className="size-16 shrink-0 -rotate-12" />
<div> <div>
<p className="font-display text-base font-bold text-amber-200"> <p className="font-display text-base font-bold text-amber-200">
Halvorsen Lunar Freight needs a pilot. {content.tutorialCompanyName} needs a pilot.
</p> </p>
<p className="text-ink-200">Their ship, your hands. Get the crates to the Moon.</p> <p className="text-ink-200">Their ship, your hands. Get the crates to the Moon.</p>
</div> </div>

View File

@ -93,6 +93,7 @@ export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerS
summaries: [], summaries: [],
goals: [], goals: [],
dispatchOffice: { owned: false, spendingFloor: 500 }, dispatchOffice: { owned: false, spendingFloor: 500 },
welcomeSeen: true,
...overrides, ...overrides,
}; };
} }

View File

@ -0,0 +1,111 @@
import type { PlayerState } from "@deadhead/schema";
import { cleanup, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { usePreferences } from "../src/state/preferences";
import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard, unauthorized } from "./render";
/** A fresh guest: "Start your company", then the new company with its welcome unseen. */
function newCompany() {
let started = false;
let state: PlayerState = samplePlayerState({ welcomeSeen: false });
const api = fakeApi({
getPlayerState: () => (started ? state : unauthorized()),
createGuest: () => {
started = true;
return { playerId: "p1", state };
},
getTutorialBoard: () => sampleTutorialBoard(),
markWelcomeSeen: () => {
state = { ...state, welcomeSeen: true };
return state;
},
});
return { api, state: () => state };
}
async function start(api: ReturnType<typeof newCompany>["api"]) {
renderApp(api, "/");
await userEvent.click(await screen.findByRole("button", { name: "Start your company" }));
return screen.findByRole("dialog", { name: "Welcome to Deadhead" });
}
describe("welcome cards (Step 22.2, decision 0.5.131)", () => {
it("walks through three cards and lands on Contracts with the intro card", async () => {
const { api, state } = newCompany();
const dialog = await start(api);
expect(dialog).toHaveTextContent("1 of 3");
expect(dialog).toHaveTextContent("never fly home empty");
await userEvent.click(within(dialog).getByRole("button", { name: "Next" }));
expect(dialog).toHaveTextContent("2 of 3");
expect(within(dialog).getByRole("heading")).toHaveTextContent("Your ships keep flying");
await userEvent.click(within(dialog).getByRole("button", { name: "Next" }));
expect(dialog).toHaveTextContent("3 of 3");
expect(dialog).toHaveTextContent(
"You're flying for Halvorsen Lunar Freight on the Earth–Moon run.",
);
expect(within(dialog).queryByRole("button", { name: "Next" })).not.toBeInTheDocument();
await userEvent.click(within(dialog).getByRole("button", { name: "Let's fly" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(api.call).toHaveBeenCalledWith("markWelcomeSeen", {});
expect(state().welcomeSeen).toBe(true);
expect(await screen.findByRole("heading", { level: 1, name: "Contracts" })).toBeInTheDocument();
expect(await screen.findByLabelText("Intro")).toHaveTextContent("needs a pilot");
});
it("closes from Skip on any card, marking it seen and landing on Contracts", async () => {
for (const nexts of [0, 1, 2]) {
const { api, state } = newCompany();
const dialog = await start(api);
for (let i = 0; i < nexts; i++) {
await userEvent.click(within(dialog).getByRole("button", { name: "Next" }));
}
await userEvent.click(within(dialog).getByRole("button", { name: "Skip" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(state().welcomeSeen).toBe(true);
expect(await screen.findByLabelText("Intro")).toBeInTheDocument();
cleanup();
}
});
it("doesn't come back after a reload once seen", async () => {
const { api } = newCompany();
const dialog = await start(api);
await userEvent.click(within(dialog).getByRole("button", { name: "Skip" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
cleanup();
renderApp(api, "/contracts");
expect(await screen.findByLabelText("Intro")).toBeInTheDocument();
expect(screen.queryByRole("dialog", { name: "Welcome to Deadhead" })).not.toBeInTheDocument();
});
it("slides cards in, but not with reduced motion on", async () => {
const { api } = newCompany();
await start(api);
expect(screen.getByTestId("welcome-card")).toHaveClass("animate-slide");
cleanup();
usePreferences.getState().set({ reducedMotion: true });
const again = newCompany();
await start(again.api);
expect(screen.getByTestId("welcome-card")).not.toHaveClass("animate-slide");
usePreferences.getState().set({ reducedMotion: null });
});
it("keeps focus inside the pop-up until it closes", async () => {
const { api } = newCompany();
const dialog = await start(api);
// Focus starts on the main button.
expect(within(dialog).getByRole("button", { name: "Next" })).toHaveFocus();
for (let i = 0; i < 5; i++) {
await userEvent.tab();
expect(dialog).toContainElement(document.activeElement as HTMLElement);
}
await userEvent.tab({ shift: true });
expect(dialog).toContainElement(document.activeElement as HTMLElement);
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 774 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@ -490,6 +490,11 @@ Spec: `memory-bank/change-requests/CR-021-independent-pilots.md` (received as "C
Spec: `memory-bank/change-requests/CR-022-welcome-screens.md` (received as "CR-020 … Phase 20"). New decisions recorded as plan §0.5.131–0.5.132. Spec: `memory-bank/change-requests/CR-022-welcome-screens.md` (received as "CR-020 … Phase 20"). New decisions recorded as plan §0.5.131–0.5.132.
- [x] **Step 22.1 — Content and save field.** Content: `welcome` (three cards' titles and bodies, and the Next, Skip, "Let's fly" and Close labels; `welcomeSchema`), with `{company}` in the last card filled from the new `tutorialCompanyName` ("Halvorsen Lunar Freight", until now written into three screens). Save version 7: `player.welcomeSeen`, false for a new company; the 6 → 7 migration sets it true for existing ones. Tests: `packages/content/test/onboarding.test.ts` (the cards validate, in order, with `{company}` and the labels; empty text is rejected); `packages/engine/test/start.test.ts` (a new company's welcome is unseen); `packages/schema/test/save.test.ts` (6 → 7 marks it seen and changes nothing else). - [x] **Step 22.1 — Content and save field.** Content: `welcome` (three cards' titles and bodies, and the Next, Skip, "Let's fly" and Close labels; `welcomeSchema`), with `{company}` in the last card filled from the new `tutorialCompanyName` ("Halvorsen Lunar Freight", until now written into three screens). Save version 7: `player.welcomeSeen`, false for a new company; the 6 → 7 migration sets it true for existing ones. Tests: `packages/content/test/onboarding.test.ts` (the cards validate, in order, with `{company}` and the labels; empty text is rejected); `packages/engine/test/start.test.ts` (a new company's welcome is unseen); `packages/schema/test/save.test.ts` (6 → 7 marks it seen and changes nothing else).
- [ ] Step 22.2 — Client: welcome pop-up - [x] **Step 22.2 — Client: welcome pop-up.**
- **The cards.** `WelcomeCards` (`apps/web/src/components/Welcome.tsx`) shows the three cards with "1 of 3" announced politely, Next, Skip on every card, and "Let's fly" (or "Close") on the last. Swipe left and right moves between cards. Focus starts on the main button, Tab and Shift+Tab stay inside, and Escape skips. Cards slide in (`animate-slide`) unless reduced motion is on, when they change at once.
- **The gate.** `WelcomeGate` (in `App`) shows the cards while `PlayerState.welcomeSeen` is false. Closing them calls the new `markWelcomeSeen` command and goes to Contracts, where the tutorial intro card follows.
- **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" - [ ] Step 22.3 — Client: new company and "How to play"
- [ ] Step 22.4 — Regression - [ ] Step 22.4 — Regression

View File

@ -67,6 +67,10 @@ export function commandTable(): CommandTable {
acknowledgeSummaries: ({ save, now }, input) => ({ acknowledgeSummaries: ({ save, now }, input) => ({
acknowledged: acknowledge(save, input, now), acknowledged: acknowledge(save, input, now),
}), }),
markWelcomeSeen: ({ save, now }) => {
save.player.welcomeSeen = true;
return playerState(save, now);
},
acceptDeal: ({ save, random, now }) => { acceptDeal: ({ save, random, now }) => {
acceptDeal(save, random, now); acceptDeal(save, random, now);
return playerState(save, now); return playerState(save, now);

View File

@ -281,6 +281,7 @@ export function playerState(save: SaveDocument, now: RealTime): PlayerState {
resolvedAt: d.resolvedAt ?? d.arrivalAt, resolvedAt: d.resolvedAt ?? d.arrivalAt,
})), })),
goals: goalStates(save), goals: goalStates(save),
welcomeSeen: save.player.welcomeSeen,
dispatchOffice: { dispatchOffice: {
owned: save.company.dispatchOffice, owned: save.company.dispatchOffice,
spendingFloor: save.company.npcSpendingFloor, spendingFloor: save.company.npcSpendingFloor,

View File

@ -72,6 +72,7 @@ describe("company creation on the device (Step 14.8)", () => {
activeDispatches: [], activeDispatches: [],
summaries: [], summaries: [],
goals: [], goals: [],
welcomeSeen: false,
dispatchOffice: { dispatchOffice: {
owned: false, owned: false,
spendingFloor: content.config.independentPilots.defaultSpendingFloor, spendingFloor: content.config.independentPilots.defaultSpendingFloor,
@ -105,4 +106,13 @@ describe("welcome cards in the save (Step 22.1, decision 0.5.131)", () => {
if (save.status !== "loaded") throw new Error(save.status); if (save.status !== "loaded") throw new Error(save.status);
expect(save.save.player.welcomeSeen).toBe(false); expect(save.save.player.welcomeSeen).toBe(false);
}); });
it("records the welcome as seen with markWelcomeSeen (Step 22.2)", async () => {
const backend = new MemoryBackend();
const engine = engineOn(backend);
expect((await engine.call("createGuest", {})).state.welcomeSeen).toBe(false);
expect((await engine.call("markWelcomeSeen", {})).welcomeSeen).toBe(true);
// A reload keeps it.
expect((await engineOn(backend).call("getPlayerState", {})).welcomeSeen).toBe(true);
});
}); });

View File

@ -257,5 +257,7 @@ export const playerStateSchema = z.object({
/** Onboarding goals in content order; empty during the tutorial. */ /** Onboarding goals in content order; empty during the tutorial. */
goals: z.array(goalStateSchema), goals: z.array(goalStateSchema),
dispatchOffice: dispatchOfficeSchema, dispatchOffice: dispatchOfficeSchema,
/** This company's welcome cards have been seen (decision 0.5.131). */
welcomeSeen: z.boolean(),
}); });
export type PlayerState = z.infer<typeof playerStateSchema>; export type PlayerState = z.infer<typeof playerStateSchema>;

View File

@ -211,6 +211,14 @@ export const routes = {
response: acknowledgeSummariesResponseSchema, response: acknowledgeSummariesResponseSchema,
auth: true, auth: true,
}, },
/** CR-022: the welcome cards are closed for this company (decision 0.5.131). */
markWelcomeSeen: {
method: "POST",
path: "/api/welcome/seen",
request: emptyRequestSchema,
response: playerStateSchema,
auth: true,
},
acceptDeal: { acceptDeal: {
method: "POST", method: "POST",
path: "/api/deal/accept", path: "/api/deal/accept",

View File

@ -31,6 +31,7 @@ describe("route catalog", () => {
"setSpendingFloor", "setSpendingFloor",
"acknowledgeSummaries", "acknowledgeSummaries",
"acceptDeal", "acceptDeal",
"markWelcomeSeen",
"exportSave", "exportSave",
"importSave", "importSave",
"devAdvanceClock", "devAdvanceClock",

View File

@ -178,6 +178,7 @@ const playerState = {
summaries: [summary], summaries: [summary],
goals: [goal], goals: [goal],
dispatchOffice: { owned: true, spendingFloor: 500 }, dispatchOffice: { owned: true, spendingFloor: 500 },
welcomeSeen: true,
}; };
const cases: [string, z.ZodType, unknown, unknown][] = [ const cases: [string, z.ZodType, unknown, unknown][] = [