Step 10A.15: show onboarding goals on HQ
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6b46400f0a
commit
afdd41635e
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue