diff --git a/DECISIONS.md b/DECISIONS.md
index c4f5228..2e3e731 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -569,3 +569,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **The deal dialog waits for the delivery summary:** the fourth run's landing also produces a "while you were away" entry, so the deal appears once that is dismissed, instead of two dialogs stacking.
- **The accept response is the new player state,** written straight into the query cache before navigating to HQ, so HQ shows the ship, bay and loan without another request.
- **The dialog has a single button** ("Shake on it"); there is no decline (CR-001 §7).
+
+## Step 10A.14
+
+- **During the tutorial HQ shows run progress toward the deal** ("Company runs: n of 4") in place of the old savings goal, so HQ still points at the next milestone.
+- **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.
diff --git a/apps/web/src/components/AwaySummary.tsx b/apps/web/src/components/AwaySummary.tsx
index c93b236..79db38b 100644
--- a/apps/web/src/components/AwaySummary.tsx
+++ b/apps/web/src/components/AwaySummary.tsx
@@ -47,6 +47,11 @@ export function AwaySummary() {
+{s.pilotXp} XP
+ {s.loanRepayment > 0 && (
+
+ {formatCredits(s.loanRepayment)} went to the lender
+
+ )}
{s.scriptedId && (
{
diff --git a/apps/web/src/components/LoanCard.tsx b/apps/web/src/components/LoanCard.tsx
new file mode 100644
index 0000000..bc11e5a
--- /dev/null
+++ b/apps/web/src/components/LoanCard.tsx
@@ -0,0 +1,83 @@
+import { content } from "@deadhead/content";
+import { useState } from "react";
+import { formatCredits } from "../format";
+import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
+
+/** Why a pay-down amount cannot be sent, or null if it can. */
+export function payDownProblem(amount: number, credits: number, balance: number): string | null {
+ if (!Number.isInteger(amount) || amount <= 0) return "Enter a whole number of credits.";
+ if (amount > balance) return `You only owe ${formatCredits(balance)}.`;
+ if (amount > credits) return `You only have ${formatCredits(credits)}.`;
+ return null;
+}
+
+/** The deal's loan on HQ: balance, paid-off progress and paying it down (Step 10A.14). */
+export function LoanCard() {
+ const state = useLoadedPlayer();
+ const repay = useGameAction("repayLoan");
+ const [input, setInput] = useState("");
+ const { credits, loanBalance } = state.company;
+ const total = content.config.dealLoanAmount;
+ const paidOff = Math.max(0, Math.min(100, Math.floor(((total - loanBalance) / total) * 100)));
+ const amount = Number(input);
+ const problem = input === "" ? null : payDownProblem(amount, credits, loanBalance);
+
+ return (
+
+
+
Loan from Halvorsen
+
+ {formatCredits(loanBalance)} left
+
+
+
+ {Math.round(content.config.loanRepaymentShare * 100)}% of every payout goes to the lender
+ until it's paid off.
+
+
+
{paidOff}% paid off
+
+
+ {problem &&
{problem}
}
+ {repay.isError && (
+
+ {errorMessage(repay.error)}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/screens/HQ.tsx b/apps/web/src/screens/HQ.tsx
index bb740b0..a0022a2 100644
--- a/apps/web/src/screens/HQ.tsx
+++ b/apps/web/src/screens/HQ.tsx
@@ -3,25 +3,18 @@ import { realToGame } from "@deadhead/sim";
import { useClock } from "../clock/clock";
import { formatGameTime } from "../clock/GameClock";
import { Countdown } from "../components/Countdown";
+import { LoanCard } from "../components/LoanCard";
import { formatCredits, locationName } from "../format";
import { useLoadedPlayer } from "../state/actions";
-/** Cost of the tutorial goal: the cheapest ship plus an Earth bay. */
-export function tutorialGoalCost(): number {
- const cheapest = Math.min(...content.shipModels.map((s) => s.price));
- return cheapest + (content.location("earth")?.bayLeasePrice ?? 0);
-}
-
export function HQ() {
const state = useLoadedPlayer();
const now = useClock((s) => s.now);
const idle = state.ships.filter((s) => s.status === "idle").length;
const flying = state.ships.filter((s) => s.status === "in-flight").length;
+ const runsBeforeDeal = content.config.tutorialRunsBeforeDeal;
const next = [...state.activeDispatches].sort((a, b) => a.arrivalAt - b.arrivalAt)[0];
- const goal = tutorialGoalCost();
- const progress = Math.min(100, Math.floor((state.company.credits / goal) * 100));
-
return (
HQ
@@ -51,25 +44,18 @@ export function HQ() {
{state.tutorial && (
-
- Save up for your first ship and an Earth bay
-
-
- {formatCredits(state.company.credits)} of {formatCredits(goal)}
+
Flying for Halvorsen Lunar Freight
+
+ Company runs: {Math.min(state.tutorial.runsCompleted, runsBeforeDeal)} of{" "}
+ {runsBeforeDeal}
+
+
+ Keep flying — the old man has an offer for you.
-
-
{progress}%
)}
+
+ {!state.tutorial && state.company.loanBalance > 0 && }
);
}
diff --git a/apps/web/test/hq.test.tsx b/apps/web/test/hq.test.tsx
index f2f28de..5a2ed2e 100644
--- a/apps/web/test/hq.test.tsx
+++ b/apps/web/test/hq.test.tsx
@@ -1,8 +1,9 @@
-import type { Dispatch, Ship } from "@deadhead/schema";
-import { act, screen, within } from "@testing-library/react";
+import { content } from "@deadhead/content";
+import type { Dispatch, PlayerState, Ship } from "@deadhead/schema";
+import { act, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
-import { tutorialGoalCost } from "../src/screens/HQ";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
@@ -60,33 +61,102 @@ describe("HQ screen", () => {
expect(within(next).getByText("1m 05s")).toBeInTheDocument();
act(() => vi.advanceTimersByTime(10_000));
expect(within(next).getByText("55s")).toBeInTheDocument();
-
- expect(screen.queryByText(/Save up for your first ship/)).not.toBeInTheDocument();
});
- it("shows tutorial goal progress as a percentage of a ship plus an Earth bay", async () => {
- const goal = tutorialGoalCost();
+ it("shows tutorial run progress toward the deal during the tutorial", async () => {
const state = samplePlayerState({
- company: { id: "c", name: "D", credits: Math.round(goal * 0.4), loanBalance: 0 },
+ tutorial: {
+ loanerLocationId: "moon",
+ runInFlight: false,
+ runsCompleted: 3,
+ dealAvailable: false,
+ },
});
renderApp(fakeApi({ getPlayerState: () => state }));
-
- expect(
- await screen.findByText("Save up for your first ship and an Earth bay"),
- ).toBeInTheDocument();
- expect(screen.getByRole("progressbar", { name: "Tutorial goal" })).toHaveAttribute(
- "aria-valuenow",
- "40",
+ expect(await screen.findByLabelText("Company runs")).toHaveTextContent(
+ `Company runs: 3 of ${content.config.tutorialRunsBeforeDeal}`,
);
- expect(screen.getByText("40%")).toBeInTheDocument();
- expect(screen.getByText("Nothing in flight.")).toBeInTheDocument();
- });
-
- it("caps tutorial progress at 100%", async () => {
- const state = samplePlayerState({
- company: { id: "c", name: "D", credits: tutorialGoalCost() * 3, loanBalance: 0 },
- });
- renderApp(fakeApi({ getPlayerState: () => state }));
- expect(await screen.findByText("100%")).toBeInTheDocument();
+ expect(screen.queryByText("Loan from Halvorsen")).not.toBeInTheDocument();
+ });
+});
+
+describe("loan on HQ", () => {
+ const total = content.config.dealLoanAmount;
+ const owner = (credits: number, loanBalance: number): PlayerState =>
+ samplePlayerState({ tutorial: null, company: { id: "c", name: "D", credits, loanBalance } });
+
+ it("shows the balance and a paid-off progress bar matching state", async () => {
+ renderApp(fakeApi({ getPlayerState: () => owner(2000, total * 0.75) }));
+ expect(await screen.findByLabelText("Loan balance")).toHaveTextContent(
+ `${(total * 0.75).toLocaleString("en-US")} cr left`,
+ );
+ expect(screen.getByRole("progressbar", { name: "Loan paid off" })).toHaveAttribute(
+ "aria-valuenow",
+ "25",
+ );
+ });
+
+ it("pays down with the entered amount", async () => {
+ const api = fakeApi({
+ getPlayerState: () => owner(2000, 4000),
+ repayLoan: ({ amount }) => ({
+ company: { id: "c", name: "D", credits: 2000 - amount, loanBalance: 4000 - amount },
+ }),
+ });
+ renderApp(api);
+ await userEvent.type(await screen.findByLabelText("Pay down amount"), "750");
+ await userEvent.click(screen.getByRole("button", { name: "Pay down" }));
+ await waitFor(() => expect(api.call).toHaveBeenCalledWith("repayLoan", { amount: 750 }));
+ });
+
+ it("blocks an amount above the balance", async () => {
+ const api = fakeApi({ getPlayerState: () => owner(5000, 300) });
+ renderApp(api);
+ await userEvent.type(await screen.findByLabelText("Pay down amount"), "400");
+ expect(screen.getByRole("button", { name: "Pay down" })).toBeDisabled();
+ expect(screen.getByText("You only owe 300 cr.")).toBeInTheDocument();
+ expect(api.call).not.toHaveBeenCalledWith("repayLoan", expect.anything());
+ });
+
+ it("blocks an amount above the player's credits", async () => {
+ const api = fakeApi({ getPlayerState: () => owner(200, 3000) });
+ renderApp(api);
+ await userEvent.type(await screen.findByLabelText("Pay down amount"), "250");
+ expect(screen.getByRole("button", { name: "Pay down" })).toBeDisabled();
+ expect(screen.getByText("You only have 200 cr.")).toBeInTheDocument();
+ expect(api.call).not.toHaveBeenCalledWith("repayLoan", expect.anything());
+ });
+
+ it("is hidden once the loan is paid off", async () => {
+ renderApp(fakeApi({ getPlayerState: () => owner(2000, 0) }));
+ await screen.findByRole("heading", { level: 1, name: "HQ" });
+ expect(screen.queryByText("Loan from Halvorsen")).not.toBeInTheDocument();
+ });
+
+ it("shows the lender's cut in a delivery summary", async () => {
+ const state: PlayerState = {
+ ...owner(2000, 4000),
+ summaries: [
+ {
+ dispatchId: "d1",
+ shipId: "s1",
+ pilotId: "avatar",
+ originId: "earth",
+ destinationId: "moon",
+ tutorial: false,
+ credits: 538,
+ loanRepayment: 230,
+ pilotXp: 10,
+ discoveredLocationId: null,
+ scriptedId: null,
+ resolvedAt: 1,
+ },
+ ],
+ };
+ renderApp(
+ fakeApi({ getPlayerState: () => state, acknowledgeSummaries: () => ({ acknowledged: 1 }) }),
+ );
+ const dialog = await screen.findByRole("dialog", { name: "While you were away" });
+ expect(dialog).toHaveTextContent("230 cr went to the lender");
});
});
diff --git a/memory-bank/progress.md b/memory-bank/progress.md
index 3b3526a..54ef239 100644
--- a/memory-bank/progress.md
+++ b/memory-bank/progress.md
@@ -119,7 +119,7 @@ Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded a
- [x] **Step 10A.11 — Client: first launch goes straight to work.** "Start your company" opens Contracts; on the first run the tutorial board shows the Halvorsen intro card and highlights the light contract as "Suggested". Step 8.4 test 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.
-- [ ] Step 10A.14 — Client: loan on 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
- [ ] Step 10A.16 — Client: whole map from the start
- [ ] Step 10A.17 — Client: something waiting on return