Step 10A.15: show onboarding goals on HQ

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 11:30:19 -04:00
parent 6b46400f0a
commit afdd41635e
5 changed files with 139 additions and 1 deletions

View File

@ -576,3 +576,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **The loan card shows while a balance remains** and disappears at zero; the "Pay off your loan" goal (Step 10A.15) marks the moment. - **The loan card shows while a balance remains** and disappears at zero; the "Pay off your loan" goal (Step 10A.15) marks the moment.
- **Paid-off progress is measured against `dealLoanAmount`.** - **Paid-off progress is measured against `dealLoanAmount`.**
- **The UI blocks bad pay-down amounts before sending** (not a whole positive number, above the balance, above credits) with a short reason; the server still enforces the same rules. - **The UI blocks bad pay-down amounts before sending** (not a whole positive number, above the balance, above credits) with a short reason; the server still enforces the same rules.
## Step 10A.15
- **HQ shows the next three unclaimed goals in content order,** whether ready or not, so the player always sees what comes next; claimed goals drop out and the following goal takes their place.
- **Goal titles and rewards come from content;** the API only sends each goal's id and status.

View File

@ -0,0 +1,58 @@
import { content } from "@deadhead/content";
import { formatCredits } from "../format";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
/** How many unclaimed goals HQ shows at once (Step 10A.15). */
export const GOALS_SHOWN = 3;
/** The next few onboarding goals, with rewards; ready ones can be claimed. */
export function GoalsCard() {
const state = useLoadedPlayer();
const claim = useGameAction("claimGoal");
const next = state.goals.filter((g) => g.status !== "claimed").slice(0, GOALS_SHOWN);
if (next.length === 0) return null;
return (
<section
id="goals"
aria-labelledby="goals-title"
className="rounded-lg border border-slate-800 p-3"
>
<h2 id="goals-title" className="font-semibold">
Goals
</h2>
<ul className="mt-2 space-y-2">
{next.map((goal) => {
const details = content.onboardingGoal(goal.id);
if (!details) return null;
const ready = goal.status === "ready";
return (
<li key={goal.id} className="flex items-center justify-between gap-2 text-sm">
<span className={ready ? "text-emerald-300" : "text-slate-300"}>
{details.title}
<span className="ml-2 text-xs text-slate-400">
+{formatCredits(details.reward)}
</span>
</span>
{ready && (
<button
type="button"
disabled={claim.isPending}
onClick={() => claim.mutate({ goalId: goal.id })}
className="shrink-0 rounded-md bg-emerald-500 px-3 py-1 font-semibold text-slate-950 disabled:opacity-40"
>
Claim
</button>
)}
</li>
);
})}
</ul>
{claim.isError && (
<p role="alert" className="mt-2 text-xs text-red-400">
{errorMessage(claim.error)}
</p>
)}
</section>
);
}

View File

@ -3,6 +3,7 @@ import { realToGame } from "@deadhead/sim";
import { useClock } from "../clock/clock"; import { useClock } from "../clock/clock";
import { formatGameTime } from "../clock/GameClock"; import { formatGameTime } from "../clock/GameClock";
import { Countdown } from "../components/Countdown"; import { Countdown } from "../components/Countdown";
import { GoalsCard } from "../components/GoalsCard";
import { LoanCard } from "../components/LoanCard"; import { LoanCard } from "../components/LoanCard";
import { formatCredits, locationName } from "../format"; import { formatCredits, locationName } from "../format";
import { useLoadedPlayer } from "../state/actions"; import { useLoadedPlayer } from "../state/actions";
@ -55,6 +56,8 @@ export function HQ() {
</div> </div>
)} )}
{!state.tutorial && <GoalsCard />}
{!state.tutorial && state.company.loanBalance > 0 && <LoanCard />} {!state.tutorial && state.company.loanBalance > 0 && <LoanCard />}
</section> </section>
); );

View File

@ -0,0 +1,72 @@
import { content } from "@deadhead/content";
import type { GoalState } 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";
const title = (id: string) => content.onboardingGoal(id)!.title;
function statesWith(ready: string[], claimed: string[] = []): GoalState[] {
return content.onboardingGoals.map((g) => ({
id: g.id,
status: claimed.includes(g.id) ? "claimed" : ready.includes(g.id) ? "ready" : "incomplete",
}));
}
const owner = (goals: GoalState[]) =>
samplePlayerState({
tutorial: null,
company: { id: "c", name: "D", credits: 100, loanBalance: 4000 },
goals,
});
describe("goals on HQ", () => {
it("shows only the next three unclaimed goals, with rewards", async () => {
renderApp(fakeApi({ getPlayerState: () => owner(statesWith([])) }));
const card = await screen.findByRole("region", { name: "Goals" });
const items = within(card).getAllByRole("listitem");
expect(items).toHaveLength(3);
expect(items[0]).toHaveTextContent(title("first-own-delivery"));
expect(items[0]).toHaveTextContent(
`+${content.onboardingGoal("first-own-delivery")!.reward.toLocaleString("en-US")} cr`,
);
expect(items[2]).toHaveTextContent(title("second-ship"));
});
it("shows Claim only on ready goals", async () => {
renderApp(fakeApi({ getPlayerState: () => owner(statesWith(["first-skill-point"])) }));
const card = await screen.findByRole("region", { name: "Goals" });
const buttons = within(card).getAllByRole("button", { name: "Claim" });
expect(buttons).toHaveLength(1);
expect(buttons[0]!.closest("li")).toHaveTextContent(title("first-skill-point"));
});
it("claims through the endpoint, and the next goal moves into view", async () => {
let claimed = false;
const api = fakeApi({
getPlayerState: () =>
owner(
claimed ? statesWith([], ["first-own-delivery"]) : statesWith(["first-own-delivery"]),
),
claimGoal: () => {
claimed = true;
return { company: { id: "c", name: "D", credits: 400, loanBalance: 4000 } };
},
});
renderApp(api);
const card = await screen.findByRole("region", { name: "Goals" });
expect(card).not.toHaveTextContent(title("first-hire"));
await userEvent.click(within(card).getByRole("button", { name: "Claim" }));
expect(api.call).toHaveBeenCalledWith("claimGoal", { goalId: "first-own-delivery" });
await waitFor(() => expect(card).toHaveTextContent(title("first-hire")));
expect(card).not.toHaveTextContent(title("first-own-delivery"));
});
it("is not shown during the tutorial", async () => {
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }));
await screen.findByRole("heading", { level: 1, name: "HQ" });
expect(screen.queryByRole("region", { name: "Goals" })).not.toBeInTheDocument();
});
});

View File

@ -120,7 +120,7 @@ Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded a
- [x] **Step 10A.12 — Client: tutorial board shows the tradeoff.** Each tutorial contract shows Pay, Time and XP side by side (time and XP from sim); scripted contracts get their own name and fuchsia styling; their flavor message appears in the delivery summary. Step 9.3 tests updated. - [x] **Step 10A.12 — Client: tutorial board shows the tradeoff.** Each tutorial contract shows Pay, Time and XP side by side (time and XP from sim); scripted contracts get their own name and fuchsia styling; their flavor message appears in the delivery summary. Step 9.3 tests updated.
- [x] **Step 10A.13 — Client: the deal.** `src/components/DealModal.tsx`: when `tutorial.dealAvailable` (and no delivery summary is open), a dialog with the Halvorsen story beat, the Courier and Earth bay, the loan amount and repayment share, and one "Shake on it" button that calls `acceptDeal`, stores the returned state and goes to HQ. - [x] **Step 10A.13 — Client: the deal.** `src/components/DealModal.tsx`: when `tutorial.dealAvailable` (and no delivery summary is open), a dialog with the Halvorsen story beat, the Courier and Earth bay, the loan amount and repayment share, and one "Shake on it" button that calls `acceptDeal`, stores the returned state and goes to HQ.
- [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.
- [ ] Step 10A.15 — Client: goals on HQ - [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.
- [ ] Step 10A.16 — Client: whole map from the start - [ ] Step 10A.16 — Client: whole map from the start
- [ ] Step 10A.17 — Client: something waiting on return - [ ] Step 10A.17 — Client: something waiting on return
- [ ] Step 10A.18 — Plan amendments and regression - [ ] Step 10A.18 — Plan amendments and regression