From 6b46400f0a2b748b159849e140cb3102f0835847 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 11:29:16 -0400 Subject: [PATCH] Step 10A.14: show the loan on HQ Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 7 ++ apps/web/src/components/AwaySummary.tsx | 5 + apps/web/src/components/LoanCard.tsx | 83 ++++++++++++++++ apps/web/src/screens/HQ.tsx | 36 +++---- apps/web/test/hq.test.tsx | 120 +++++++++++++++++++----- memory-bank/progress.md | 2 +- 6 files changed, 202 insertions(+), 51 deletions(-) create mode 100644 apps/web/src/components/LoanCard.tsx 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

+ +
{ + e.preventDefault(); + if (input === "" || problem) return; + repay.mutate({ amount }, { onSuccess: () => setInput("") }); + }} + > + setInput(e.target.value)} + className="min-w-0 flex-1 rounded-md border border-slate-700 bg-slate-900 px-2 py-1" + /> + +
+ {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