Step 17.6: client: skill point pop-up
CI / check (push) Has been cancelled
Details
CI / check (push) Has been cancelled
Details
A one-time tip after the deal and the notification permission prompt, listing each pilot's unspent skill points, with Go to Pilots and Later (decision 0.5.109). The engine client exposes the pending permission prompt as ApiClient.notificationPrompt(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b0502d4a7a
commit
e6d273cfce
|
|
@ -1570,3 +1570,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
|||
## Step 17.5
|
||||
|
||||
- **The badge counts points, not pilots**: one pilot with three points to spend shows 3, which matches what the Step 17.6 pop-up lists.
|
||||
|
||||
## Step 17.6
|
||||
|
||||
- **The pop-up's trigger is a state, not an event**: tutorial over, permission prompt answered, some pilot with points, tip not dismissed. That makes "Show tips again" bring it back as CR-011 tips do, and survives a reload between the deal and the pop-up. An existing owner who still has unspent points sees it once after updating; that is the nudge working.
|
||||
- **Waiting for the permission prompt** goes through an optional `ApiClient.notificationPrompt()`: the engine client already made the request after `acceptDeal` and now keeps its promise. A client without it (the tests' fake) has no prompt.
|
||||
- **Names**: the avatar is "You" (with "have"), others by first name ("Rosa has"); two pilots "both have", more "all have".
|
||||
- **Go to Pilots also dismisses the tip**: the player has gone where it pointed.
|
||||
- **The pop-up waits for delivery summaries**, like the deal sheet.
|
||||
|
|
|
|||
|
|
@ -95,6 +95,12 @@ export async function playToDeal(page: Page) {
|
|||
await dismissLogbook(page);
|
||||
await page.getByRole("button", { name: "Shake on it" }).click();
|
||||
await expect(page.getByRole("heading", { level: 1, name: "HQ" })).toBeVisible();
|
||||
// Both pilots have skill points at the deal (CR-017); the pop-up says so.
|
||||
const skills = page.getByRole("dialog", { name: "Your pilots are ready to train" });
|
||||
await expect(skills.getByLabel("Unspent skill points")).toContainText(/You: \d+ points?/);
|
||||
await expect(skills.getByLabel("Unspent skill points")).toContainText(/Rosa: \d+ points?/);
|
||||
await skills.getByRole("button", { name: "Later" }).click();
|
||||
await expect(skills).toBeHidden();
|
||||
return state(page);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useApi } from "./api/context";
|
|||
import { startClockTicker, syncClock } from "./clock/clock";
|
||||
import { AwaySummary } from "./components/AwaySummary";
|
||||
import { DealModal } from "./components/DealModal";
|
||||
import { SkillPointPrompt } from "./components/SkillPointPrompt";
|
||||
import { formatCredits } from "./format";
|
||||
import { tabAttention } from "./layout/attention";
|
||||
import { Layout } from "./layout/Layout";
|
||||
|
|
@ -113,6 +114,7 @@ export function App() {
|
|||
</Routes>
|
||||
<AwaySummary />
|
||||
<DealModal />
|
||||
<SkillPointPrompt />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,4 +15,10 @@ export class ApiError extends Error {
|
|||
/** Every catalog command, typed (the engine on this device, CR-014). */
|
||||
export interface ApiClient {
|
||||
call<N extends RouteName>(name: N, input: RouteRequest<N>): Promise<RouteResponse<N>>;
|
||||
/**
|
||||
* Settles once the notification permission prompt asked for after the deal
|
||||
* has been answered (decision 0.5.91); settled at once when none is pending.
|
||||
* Missing means there is no prompt.
|
||||
*/
|
||||
notificationPrompt?(): Promise<void>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,12 +14,13 @@ function statusFor(code: CommandError["code"]): number {
|
|||
* same catalog names, inputs and outputs, and refusals as the same `ApiError`s.
|
||||
*/
|
||||
export function createEngineClient(engine: GameEngine): ApiClient {
|
||||
let prompt: Promise<void> = Promise.resolve();
|
||||
return {
|
||||
async call(name, input) {
|
||||
try {
|
||||
const result = await engine.call(name as RouteName as CommandName, input as never);
|
||||
// Notifications are asked for once the player owns the business (decision 0.5.91).
|
||||
if (name === "acceptDeal") void engine.requestNotificationPermission().catch(() => {});
|
||||
if (name === "acceptDeal") prompt = engine.requestNotificationPermission().catch(() => {});
|
||||
return result as never;
|
||||
} catch (error) {
|
||||
if (error instanceof CommandError) {
|
||||
|
|
@ -28,5 +29,6 @@ export function createEngineClient(engine: GameEngine): ApiClient {
|
|||
throw error;
|
||||
}
|
||||
},
|
||||
notificationPrompt: () => prompt,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
import type { Pilot } from "@deadhead/schema";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useApi } from "../api/context";
|
||||
import { useLoadedPlayer } from "../state/actions";
|
||||
import { usePreferences } from "../state/preferences";
|
||||
import { Button, Sheet } from "../ui";
|
||||
|
||||
/** The id of the skill point pop-up in dismissed tips (CR-011 one-time tips). */
|
||||
export const SKILL_POINTS_TIP = "skill-points";
|
||||
|
||||
/** "You" for the player's own pilot, else the pilot's first name. */
|
||||
function callName(pilot: Pilot): string {
|
||||
return pilot.isAvatar ? "You" : (pilot.name.split(" ")[0] ?? pilot.name);
|
||||
}
|
||||
|
||||
/** "You", "You and Rosa", "You, Rosa and Jin". */
|
||||
function listNames(names: string[]): string {
|
||||
return names.length < 2 ? names.join("") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The skill point nudge (decision 0.5.109): once the business is the player's
|
||||
* and the notification permission prompt has been answered, a one-time tip
|
||||
* listing every pilot with unspent skill points. "Show tips again" brings it
|
||||
* back.
|
||||
*/
|
||||
export function SkillPointPrompt() {
|
||||
const state = useLoadedPlayer();
|
||||
const api = useApi();
|
||||
const navigate = useNavigate();
|
||||
const dismissed = usePreferences((p) => p.dismissedTips.includes(SKILL_POINTS_TIP));
|
||||
const dismissTip = usePreferences((p) => p.dismissTip);
|
||||
const owner = state.tutorial === null;
|
||||
// After the deal, wait for the player to answer the permission prompt (decision 0.5.91).
|
||||
const [promptAnswered, setPromptAnswered] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!owner) return;
|
||||
let live = true;
|
||||
void (api.notificationPrompt?.() ?? Promise.resolve()).then(() => {
|
||||
if (live) setPromptAnswered(true);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [api, owner]);
|
||||
|
||||
const ready = state.pilots.filter((p) => p.skillPointsAvailable > 0);
|
||||
if (!owner || !promptAnswered || dismissed || ready.length === 0) return null;
|
||||
// Let delivery summaries go first.
|
||||
if (state.summaries.length > 0) return null;
|
||||
|
||||
const names = ready.map(callName);
|
||||
const subject =
|
||||
ready.length === 1
|
||||
? `${names[0]} ${ready[0]!.isAvatar ? "have" : "has"}`
|
||||
: `${listNames(names)} ${ready.length === 2 ? "both" : "all"} have`;
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
titleId="skill-points-title"
|
||||
eyebrow="Pilots"
|
||||
title="Your pilots are ready to train"
|
||||
tone="cyan"
|
||||
layer="z-30"
|
||||
>
|
||||
<p className="mt-3 text-sm text-ink-200">
|
||||
{subject} skill points to spend. Skills unlock better-paying contracts: heavier loads,
|
||||
valuable cargo, longer routes.
|
||||
</p>
|
||||
<p aria-label="Unspent skill points" className="mt-3 text-sm text-ink-100">
|
||||
{ready.map((pilot, i) => (
|
||||
<span key={pilot.id}>
|
||||
{i > 0 && " · "}
|
||||
<strong className="font-semibold text-cyan-200">{callName(pilot)}:</strong>{" "}
|
||||
{pilot.skillPointsAvailable} {pilot.skillPointsAvailable === 1 ? "point" : "points"}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
<div className="mt-5 flex gap-3">
|
||||
<Button
|
||||
size="lg"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
dismissTip(SKILL_POINTS_TIP);
|
||||
void navigate("/pilots");
|
||||
}}
|
||||
>
|
||||
Go to Pilots
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => dismissTip(SKILL_POINTS_TIP)}
|
||||
>
|
||||
Later
|
||||
</Button>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
|
@ -62,4 +62,24 @@ describe("local notifications on the device (Step 14.9)", () => {
|
|||
await api.call("acceptDeal", {});
|
||||
await vi.waitFor(() => expect(scheduler.permissionAsked).toBe(1));
|
||||
});
|
||||
|
||||
it("tells the skill point pop-up when the permission prompt is answered (Step 17.6)", async () => {
|
||||
let answer!: () => void;
|
||||
const scheduler = new FakeScheduler();
|
||||
scheduler.requestPermission = () => new Promise<void>((resolve) => (answer = resolve));
|
||||
const game = createGame(scheduler);
|
||||
await game.lock.acquire();
|
||||
const api = createEngineClient(game.engine);
|
||||
// Before the deal there is nothing to wait for.
|
||||
await expect(api.notificationPrompt!()).resolves.toBeUndefined();
|
||||
|
||||
vi.spyOn(game.engine, "call").mockResolvedValueOnce({} as never);
|
||||
await api.call("acceptDeal", {});
|
||||
let answered = false;
|
||||
void api.notificationPrompt!().then(() => (answered = true));
|
||||
await Promise.resolve();
|
||||
expect(answered).toBe(false);
|
||||
answer();
|
||||
await vi.waitFor(() => expect(answered).toBe(true));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, configure } from "@testing-library/react";
|
||||
import { afterEach, vi } from "vitest";
|
||||
import { usePreferences } from "../src/state/preferences";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
// The board remembers what was seen per location (CR-008); start each test fresh.
|
||||
if (typeof localStorage !== "undefined") localStorage.clear();
|
||||
// One-time tips (the skill point pop-up among them) are shown again in each test.
|
||||
usePreferences.setState({ dismissedTips: [] });
|
||||
});
|
||||
|
||||
// findBy*/waitFor default to 1 s, which a busy parallel run can exceed; wait up to 3 s.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import type { PlayerState } from "@deadhead/schema";
|
||||
import { act, screen, waitFor } 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 } from "./render";
|
||||
|
||||
const POP_UP = "Your pilots are ready to train";
|
||||
const veteran = content.veteranPilot;
|
||||
const base = samplePlayerState();
|
||||
const avatar = base.pilots[0]!;
|
||||
|
||||
/** The tutorial with the deal on offer; the avatar already has a point from five runs. */
|
||||
const offered = (): PlayerState =>
|
||||
samplePlayerState({
|
||||
tutorial: {
|
||||
loanerLocationId: "moon",
|
||||
runInFlight: false,
|
||||
runsCompleted: 5,
|
||||
dealAvailable: true,
|
||||
},
|
||||
pilots: [{ ...avatar, skillPointsAvailable: 1 }],
|
||||
});
|
||||
|
||||
/** After the deal: You with `you` points, Rosa with `rosa`. */
|
||||
const afterDeal = (you = 1, rosa = 2): PlayerState =>
|
||||
samplePlayerState({
|
||||
tutorial: null,
|
||||
bays: [{ id: "b1", locationId: "earth" }],
|
||||
pilots: [
|
||||
{ ...avatar, skillPointsAvailable: you },
|
||||
{
|
||||
...avatar,
|
||||
id: "veteran",
|
||||
name: veteran.name,
|
||||
isAvatar: false,
|
||||
isVeteran: true,
|
||||
skillPointsAvailable: rosa,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/** A permission prompt the test answers. */
|
||||
function pendingPrompt() {
|
||||
let answer!: () => void;
|
||||
const answered = new Promise<void>((resolve) => (answer = resolve));
|
||||
return { answered, answer };
|
||||
}
|
||||
|
||||
describe("skill point pop-up (Step 17.6, decision 0.5.109)", () => {
|
||||
it("appears only after both the deal and the permission prompt, listing each pilot's points", async () => {
|
||||
let accepted = false;
|
||||
const prompt = pendingPrompt();
|
||||
const api = Object.assign(
|
||||
fakeApi({
|
||||
getPlayerState: () => (accepted ? afterDeal() : offered()),
|
||||
getTutorialBoard: () => sampleTutorialBoard(),
|
||||
acceptDeal: () => {
|
||||
accepted = true;
|
||||
return afterDeal();
|
||||
},
|
||||
}),
|
||||
{ notificationPrompt: () => (accepted ? prompt.answered : Promise.resolve()) },
|
||||
);
|
||||
renderApp(api, "/contracts");
|
||||
|
||||
// During the tutorial the avatar's point doesn't bring it up.
|
||||
await screen.findByRole("dialog", { name: "An offer from Halvorsen" });
|
||||
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Shake on it" }));
|
||||
await screen.findByRole("heading", { level: 1, name: "HQ" });
|
||||
// The deal is done, but the permission prompt is still open.
|
||||
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();
|
||||
|
||||
act(() => prompt.answer());
|
||||
const dialog = await screen.findByRole("dialog", { name: POP_UP });
|
||||
expect(dialog).toHaveTextContent("You and Rosa both have skill points to spend.");
|
||||
expect(screen.getByLabelText("Unspent skill points")).toHaveTextContent(
|
||||
"You: 1 point · Rosa: 2 points",
|
||||
);
|
||||
});
|
||||
|
||||
it("Go to Pilots opens the Pilots tab and the pop-up doesn't come back", async () => {
|
||||
renderApp(fakeApi({ getPlayerState: () => afterDeal() }), "/");
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Go to Pilots" }));
|
||||
await screen.findByRole("heading", { level: 1, name: "Pilots" });
|
||||
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Later dismisses it until tips are shown again", async () => {
|
||||
renderApp(fakeApi({ getPlayerState: () => afterDeal(0, 2) }), "/");
|
||||
const dialog = await screen.findByRole("dialog", { name: POP_UP });
|
||||
// Only the pilots with points are listed.
|
||||
expect(dialog).toHaveTextContent("Rosa has skill points to spend.");
|
||||
expect(screen.getByLabelText("Unspent skill points")).toHaveTextContent(/^Rosa: 2 points$/);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Later" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument(),
|
||||
);
|
||||
await screen.findByRole("heading", { level: 1, name: "HQ" });
|
||||
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();
|
||||
|
||||
act(() => usePreferences.getState().showTipsAgain());
|
||||
expect(await screen.findByRole("dialog", { name: POP_UP })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("doesn't appear with no unspent points", async () => {
|
||||
renderApp(fakeApi({ getPlayerState: () => afterDeal(0, 0) }), "/");
|
||||
await screen.findByRole("heading", { level: 1, name: "HQ" });
|
||||
// Give the prompt check a turn to settle.
|
||||
await act(async () => {});
|
||||
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 241 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
|
|
@ -107,7 +107,7 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). The game runs
|
|||
|
||||
| Module | Role |
|
||||
|---|---|
|
||||
| `src/api/client.ts` | The `ApiClient` interface (typed by the route catalog) and `ApiError`; `src/api/engine-client.ts` implements it over the engine |
|
||||
| `src/api/client.ts` | The `ApiClient` interface (typed by the route catalog) and `ApiError`; `src/api/engine-client.ts` implements it over the engine. Its optional `notificationPrompt()` settles once the permission prompt asked for after the deal is answered (CR-017) |
|
||||
| `src/api/context.tsx` | `ApiProvider` / `useApi` (tests inject a fake) |
|
||||
| `src/clock/clock.ts` | Zustand `useClock`: offset to the engine clock (which includes the developer offset), `serverNow()`, 1 s ticker |
|
||||
| `src/state/player.ts` | `usePlayerState` (TanStack Query, key `player-state`): `null` means no company on this device; refetch on focus and on arrival |
|
||||
|
|
@ -133,6 +133,6 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). The game runs
|
|||
| `src/map/*` | System map: `projection` (square-root radial scale), `camera` (Zustand pan/zoom), `scene` (locations, flights, idle badges), `hitTest`, `MapCanvas` (PixiJS v8, lazy-loaded with the Map screen) |
|
||||
| `src/game.ts`, `src/format.ts` | Sim-based predictions (distance, trip time) and display formatting |
|
||||
|
||||
Since CR-014 the client runs the game itself: `src/platform/game.ts` (storage, device clock, tab lock, engine), `src/api/engine-client.ts` (the `ApiClient` over engine commands), `src/GameRoot.tsx` (tab lock screen, resolution loop, the `window.deadhead` test hook in developer builds). `src/platform/notifications.ts` picks the notification scheduler; `src/platform/context.tsx` gives screens the `Game` (debug info). `src/screens/SaveSettings.tsx`: Export save and Import save.
|
||||
Since CR-014 the client runs the game itself: `src/platform/game.ts` (storage, device clock, tab lock, engine), `src/api/engine-client.ts` (the `ApiClient` over engine commands), `src/GameRoot.tsx` (tab lock screen, resolution loop, the `window.deadhead` test hook in developer builds). `src/platform/notifications.ts` picks the notification scheduler; `src/platform/context.tsx` gives screens the `Game` (debug info). `src/screens/SaveSettings.tsx`: Export save and Import save. `src/components/SkillPointPrompt.tsx` (CR-017): the one-time skill point pop-up (tip id `skill-points`), shown once the tutorial is over, the permission prompt is answered and some pilot has unspent points.
|
||||
|
||||
On the device the client keeps the save (IndexedDB, or files on native) and, in local storage, device preferences, seen board ids and, in developer builds, the developer clock offset. The game works the same with or without a network.
|
||||
|
|
|
|||
|
|
@ -419,5 +419,5 @@ Spec: `memory-bank/change-requests/CR-017-tutorial-xp-skill-prompt.md` (received
|
|||
- [x] **Step 17.3 — Sim: tutorial timing.** The CR-001 Step 10A.3 timing test now covers five runs (Earth → Moon → Earth → Moon → Earth → Moon), each taking the slowest job on its board, and requires the total under 7 real minutes (it was four runs under 6), keeping "own a ship in under 10 minutes". Test: `packages/sim/test/tutorial.test.ts`.
|
||||
- [x] **Step 17.4 — Engine: deal availability and placement.** (The rule itself changed in 17.1.) No save migration and no save-version change: the save keeps the completed-run count and the deal is computed from it, so a save with four runs done now simply needs a fifth. Tests (`packages/engine/test/deal-five-runs.test.ts`): after four runs the deal isn't on offer (and accepting is refused), after the fifth, ending at the Moon, it is; accepting puts the avatar on the Courier and Rosa on the Clipper, both at Earth, with no loaner left; a save from before CR-017 with four runs done, opened on this build, still has four runs and no deal until one more run.
|
||||
- [x] **Step 17.5 — Client: Pilots tab badge.** The Pilots tab badge now shows the total unspent skill points across all pilots (was the number of pilots with points), hidden at zero (`tabAttention` in `apps/web/src/layout/attention.ts`, decision 0.5.109). Tests (`apps/web/test/attention.test.tsx`): pilots with 2, 0 and 1 points count 3; on the Pilots screen, the badge reads 3 with You on 2 and Rosa on 1, drops to 2 after one raise, and disappears once all three are spent.
|
||||
- [ ] Step 17.6 — Client: skill point pop-up
|
||||
- [x] **Step 17.6 — Client: skill point pop-up.** `SkillPointPrompt` (`apps/web/src/components/SkillPointPrompt.tsx`, decision 0.5.109): once the tutorial is over and the notification permission prompt is answered, a one-time tip (id `skill-points`) titled "Your pilots are ready to train" lists each pilot with unspent points ("**You:** 2 points · **Rosa:** 3 points"), with Go to Pilots (opens the Pilots tab) and Later; either dismisses it, and Show tips again brings it back. It waits for delivery summaries. The engine client keeps the permission request it makes after `acceptDeal` and exposes it as `ApiClient.notificationPrompt()` (settled at once on the web, where there is no prompt). The e2e `playToDeal` now expects the pop-up, with points for You and Rosa, and dismisses it. Screenshots: `docs/screenshots/17-6-skill-points-desktop.png`, `17-6-skill-points-phone.png`, `17-6-pilots-badge-phone.png` (badge 5). Tests: `apps/web/test/skill-point-prompt.test.tsx` (not during the tutorial; after the deal it waits for the permission answer, then lists You 1 and Rosa 2; Go to Pilots navigates and it stays gone; Later dismisses it until Show tips again, and only pilots with points are listed; no pop-up with no points); `apps/web/test/notifications.test.ts` (`notificationPrompt` settles only when the prompt after the deal is answered); e2e suite (9 pass).
|
||||
- [ ] Step 17.7 — Plan amendments and regression
|
||||
|
|
|
|||
Loading…
Reference in New Issue