diff --git a/DECISIONS.md b/DECISIONS.md index 2e3e731..c0a8fc1 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. - **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. + +## 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. diff --git a/apps/web/src/components/GoalsCard.tsx b/apps/web/src/components/GoalsCard.tsx new file mode 100644 index 0000000..7e07e6f --- /dev/null +++ b/apps/web/src/components/GoalsCard.tsx @@ -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 ( +
+

+ Goals +

+ + {claim.isError && ( +

+ {errorMessage(claim.error)} +

+ )} +
+ ); +} diff --git a/apps/web/src/screens/HQ.tsx b/apps/web/src/screens/HQ.tsx index a0022a2..b2ca95a 100644 --- a/apps/web/src/screens/HQ.tsx +++ b/apps/web/src/screens/HQ.tsx @@ -3,6 +3,7 @@ import { realToGame } from "@deadhead/sim"; import { useClock } from "../clock/clock"; import { formatGameTime } from "../clock/GameClock"; import { Countdown } from "../components/Countdown"; +import { GoalsCard } from "../components/GoalsCard"; import { LoanCard } from "../components/LoanCard"; import { formatCredits, locationName } from "../format"; import { useLoadedPlayer } from "../state/actions"; @@ -55,6 +56,8 @@ export function HQ() { )} + {!state.tutorial && } + {!state.tutorial && state.company.loanBalance > 0 && } ); diff --git a/apps/web/test/goals.test.tsx b/apps/web/test/goals.test.tsx new file mode 100644 index 0000000..8038235 --- /dev/null +++ b/apps/web/test/goals.test.tsx @@ -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(); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 54ef239..708c602 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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.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. -- [ ] 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.17 — Client: something waiting on return - [ ] Step 10A.18 — Plan amendments and regression