Step 10A.14: show the loan on HQ

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 11:29:16 -04:00
parent 53b637521c
commit 6b46400f0a
6 changed files with 202 additions and 51 deletions

View File

@ -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.

View File

@ -47,6 +47,11 @@ export function AwaySummary() {
</span>
<span>+{s.pilotXp} XP</span>
</div>
{s.loanRepayment > 0 && (
<div className="mt-1 text-slate-400">
{formatCredits(s.loanRepayment)} went to the lender
</div>
)}
{s.scriptedId && (
<p className="mt-1 italic text-fuchsia-200">
{

View File

@ -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 (
<div className="rounded-lg border border-slate-700 p-3">
<div className="flex items-baseline justify-between gap-2">
<h2 className="font-semibold">Loan from Halvorsen</h2>
<span aria-label="Loan balance" className="tabular-nums">
{formatCredits(loanBalance)} left
</span>
</div>
<p className="mt-1 text-xs text-slate-400">
{Math.round(content.config.loanRepaymentShare * 100)}% of every payout goes to the lender
until it&apos;s paid off.
</p>
<div
role="progressbar"
aria-label="Loan paid off"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={paidOff}
className="mt-2 h-2 overflow-hidden rounded bg-slate-800"
>
<div className="h-full bg-emerald-400" style={{ width: `${paidOff}%` }} />
</div>
<p className="mt-1 text-right text-xs text-slate-400">{paidOff}% paid off</p>
<form
className="mt-2 flex items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
if (input === "" || problem) return;
repay.mutate({ amount }, { onSuccess: () => setInput("") });
}}
>
<input
type="number"
inputMode="numeric"
min={1}
aria-label="Pay down amount"
placeholder="Amount"
value={input}
onChange={(e) => setInput(e.target.value)}
className="min-w-0 flex-1 rounded-md border border-slate-700 bg-slate-900 px-2 py-1"
/>
<button
type="submit"
disabled={input === "" || problem !== null || repay.isPending}
className="rounded-md bg-amber-500 px-3 py-1 font-semibold text-slate-950 disabled:opacity-40"
>
Pay down
</button>
</form>
{problem && <p className="mt-1 text-xs text-red-400">{problem}</p>}
{repay.isError && (
<p role="alert" className="mt-1 text-xs text-red-400">
{errorMessage(repay.error)}
</p>
)}
</div>
);
}

View File

@ -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 (
<section className="space-y-4 p-4">
<h1 className="text-xl font-semibold">HQ</h1>
@ -51,25 +44,18 @@ export function HQ() {
{state.tutorial && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3">
<h2 className="font-semibold text-amber-300">
Save up for your first ship and an Earth bay
</h2>
<p className="mt-1 text-sm text-slate-300">
{formatCredits(state.company.credits)} of {formatCredits(goal)}
<h2 className="font-semibold text-amber-300">Flying for Halvorsen Lunar Freight</h2>
<p aria-label="Company runs" className="mt-1 text-sm text-slate-300">
Company runs: {Math.min(state.tutorial.runsCompleted, runsBeforeDeal)} of{" "}
{runsBeforeDeal}
</p>
<p className="mt-1 text-xs text-slate-400">
Keep flying — the old man has an offer for you.
</p>
<div
role="progressbar"
aria-label="Tutorial goal"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress}
className="mt-2 h-2 overflow-hidden rounded bg-slate-800"
>
<div className="h-full bg-amber-400" style={{ width: `${progress}%` }} />
</div>
<p className="mt-1 text-right text-xs text-slate-400">{progress}%</p>
</div>
)}
{!state.tutorial && state.company.loanBalance > 0 && <LoanCard />}
</section>
);
}

View File

@ -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");
});
});

View File

@ -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