Step 10A.17: list ready goals in the away summary

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 11:35:27 -04:00
parent 28cb919c05
commit f2f719415d
5 changed files with 125 additions and 2 deletions

View File

@ -587,3 +587,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **The map was already reachable during the tutorial** (its tab exists from the first screen); this step adds the lock and reachability. - **The map was already reachable during the tutorial** (its tab exists from the first screen); this step adds the lock and reachability.
- **Locked locations are labelled "🔒 Name"** on the canvas (they were dimmed already), replacing the earlier "Name?". - **Locked locations are labelled "🔒 Name"** on the canvas (they were dimmed already), replacing the earlier "Name?".
- **Reachability uses sim `checkRoute` (range and landing) from Earth at the current server time,** over `content.purchasableShipModels`, so it changes as planets move. When nothing can reach it the panel says so. - **Reachability uses sim `checkRoute` (range and landing) from Earth at the current server time,** over `content.purchasableShipModels`, so it changes as planets move. When nothing can reach it the panel says so.
## Step 10A.17
- **The summary lists every goal currently ready to claim,** not only ones that became ready since the last visit. The server does not track when a goal became ready, and an unclaimed ready goal is worth pointing out on any return.
- **"Claim on HQ" acknowledges the deliveries and navigates to `/#goals`.**
- **Web tests wait up to 3 s for elements** (`asyncUtilTimeout`); the 1 s default occasionally ran out under a fully parallel run.

View File

@ -1,5 +1,6 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router";
import { formatCredits, locationName } from "../format"; import { formatCredits, locationName } from "../format";
import { useGameAction, useLoadedPlayer } from "../state/actions"; import { useGameAction, useLoadedPlayer } from "../state/actions";
@ -10,7 +11,13 @@ export function AwaySummary() {
// Hide as soon as the player dismisses; the refetch clears the list for real. // Hide as soon as the player dismisses; the refetch clears the list for real.
const [dismissed, setDismissed] = useState<ReadonlySet<string>>(new Set()); const [dismissed, setDismissed] = useState<ReadonlySet<string>>(new Set());
const navigate = useNavigate();
const summaries = state.summaries.filter((s) => !dismissed.has(s.dispatchId)); const summaries = state.summaries.filter((s) => !dismissed.has(s.dispatchId));
// Goals waiting to be claimed (Step 10A.17).
const readyGoals = state.goals
.filter((g) => g.status === "ready")
.map((g) => content.onboardingGoal(g.id))
.filter((g) => g !== undefined);
if (summaries.length === 0) return null; if (summaries.length === 0) return null;
const shipName = (shipId: string, tutorial: boolean) => const shipName = (shipId: string, tutorial: boolean) =>
@ -68,6 +75,28 @@ export function AwaySummary() {
</li> </li>
))} ))}
</ul> </ul>
{readyGoals.length > 0 && (
<div className="mt-3 rounded-lg border border-emerald-500/40 bg-emerald-500/10 p-3 text-sm">
<h3 className="font-semibold text-emerald-300">Goals ready to claim</h3>
<ul aria-label="Ready goals" className="mt-1 space-y-0.5">
{readyGoals.map((goal) => (
<li key={goal.id}>
{goal.title} <span className="text-slate-400">+{formatCredits(goal.reward)}</span>
</li>
))}
</ul>
<button
type="button"
onClick={() => {
dismiss();
void navigate({ pathname: "/", hash: "#goals" });
}}
className="mt-2 font-medium text-emerald-300 underline"
>
Claim on HQ
</button>
</div>
)}
<button <button
type="button" type="button"
onClick={dismiss} onClick={dismiss}

View File

@ -0,0 +1,85 @@
import { content } from "@deadhead/content";
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 } from "./render";
function returningPlayer(acknowledged: boolean): PlayerState {
return samplePlayerState({
tutorial: null,
company: { id: "c", name: "D", credits: 900, loanBalance: 4000 },
ships: [
{
id: "s1",
modelId: "courier",
name: "Dusty Mule",
status: "idle",
locationId: "moon",
dispatchId: null,
pilotId: "avatar",
},
],
summaries: acknowledged
? []
: [
{
dispatchId: "d1",
shipId: "s1",
pilotId: "avatar",
originId: "earth",
destinationId: "moon",
tutorial: false,
credits: 538,
loanRepayment: 230,
pilotXp: 10,
discoveredLocationId: null,
scriptedId: null,
resolvedAt: 1,
},
],
goals: content.onboardingGoals.map((g) => ({
id: g.id,
status: g.id === "first-own-delivery" ? "ready" : "incomplete",
})),
});
}
describe("something waiting on return", () => {
it("lists ready goals alongside the delivery, and the link opens HQ's goals", async () => {
let acknowledged = false;
const api = fakeApi({
getPlayerState: () => returningPlayer(acknowledged),
acknowledgeSummaries: () => {
acknowledged = true;
return { acknowledged: 1 };
},
});
renderApp(api, "/fleet");
const dialog = await screen.findByRole("dialog", { name: "While you were away" });
expect(dialog).toHaveTextContent("Dusty Mule");
const ready = within(dialog).getByRole("list", { name: "Ready goals" });
expect(ready).toHaveTextContent(content.onboardingGoal("first-own-delivery")!.title);
await userEvent.click(within(dialog).getByRole("button", { name: "Claim on HQ" }));
expect(screen.queryByRole("dialog", { name: "While you were away" })).not.toBeInTheDocument();
expect(await screen.findByRole("heading", { level: 1, name: "HQ" })).toBeInTheDocument();
const goals = screen.getByRole("region", { name: "Goals" });
expect(within(goals).getByRole("button", { name: "Claim" })).toBeInTheDocument();
await waitFor(() =>
expect(api.call).toHaveBeenCalledWith("acknowledgeSummaries", { dispatchIds: ["d1"] }),
);
});
it("does not list goals when none are ready", async () => {
const state = returningPlayer(false);
const quiet = {
...state,
goals: state.goals.map((g) => ({ ...g, status: "incomplete" as const })),
};
renderApp(fakeApi({ getPlayerState: () => quiet }));
const dialog = await screen.findByRole("dialog", { name: "While you were away" });
expect(within(dialog).queryByRole("list", { name: "Ready goals" })).not.toBeInTheDocument();
});
});

View File

@ -1,9 +1,12 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react"; import { cleanup, configure } from "@testing-library/react";
import { afterEach, vi } from "vitest"; import { afterEach, vi } from "vitest";
afterEach(() => cleanup()); afterEach(() => cleanup());
// findBy*/waitFor default to 1 s, which a busy parallel run can exceed; wait up to 3 s.
configure({ asyncUtilTimeout: 3000 });
// PixiJS needs WebGL, which jsdom lacks. Component tests use a stand-in canvas; // PixiJS needs WebGL, which jsdom lacks. Component tests use a stand-in canvas;
// the map's logic (projection, camera, scene, hit-testing) is unit-tested directly. // the map's logic (projection, camera, scene, hit-testing) is unit-tested directly.
vi.mock("../src/map/MapCanvas", () => import("./map-canvas-double")); vi.mock("../src/map/MapCanvas", () => import("./map-canvas-double"));

View File

@ -122,5 +122,5 @@ Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded a
- [x] **Step 10A.14 — Client: loan on HQ.** HQ's savings goal is replaced: during the tutorial a "Company runs: n of 4" card; afterwards `LoanCard` (balance, paid-off bar, "Pay down" with amounts above credits or balance blocked in the UI). Delivery summaries show "X cr went to the lender". Step 9.1 tutorial-goal tests replaced. - [x] **Step 10A.14 — Client: loan on HQ.** HQ's savings goal is replaced: during the tutorial a "Company runs: n of 4" card; afterwards `LoanCard` (balance, paid-off bar, "Pay down" with amounts above credits or balance blocked in the UI). Delivery summaries show "X cr went to the lender". Step 9.1 tutorial-goal tests replaced.
- [x] **Step 10A.15 — Client: goals on HQ.** `src/components/GoalsCard.tsx` (`#goals`): the next three unclaimed goals with rewards; ready goals have "Claim", which calls `claimGoal`; the refreshed state brings the next goal into view. Hidden during the tutorial. - [x] **Step 10A.15 — Client: goals on HQ.** `src/components/GoalsCard.tsx` (`#goals`): the next three unclaimed goals with rewards; ready goals have "Claim", which calls `claimGoal`; the refreshed state brings the next goal into view. Hidden during the tutorial.
- [x] **Step 10A.16 — Client: whole map from the start.** The Map tab works from the first screen (tutorial included); undiscovered locations are labelled with a lock; tapping one shows "Undiscovered — deliver here to unlock" and "Reachable with: …" from the sim route check over purchasable models from Earth (`src/map/reach.ts`). - [x] **Step 10A.16 — Client: whole map from the start.** The Map tab works from the first screen (tutorial included); undiscovered locations are labelled with a lock; tapping one shows "Undiscovered — deliver here to unlock" and "Reachable with: …" from the sim route check over purchasable models from Earth (`src/map/reach.ts`).
- [ ] Step 10A.17 — Client: something waiting on return - [x] **Step 10A.17 — Client: something waiting on return.** The "while you were away" dialog lists goals ready to claim, with a "Claim on HQ" link that dismisses it and opens HQ's goals.
- [ ] Step 10A.18 — Plan amendments and regression - [ ] Step 10A.18 — Plan amendments and regression