Step 10A.14: show the loan on HQ
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
53b637521c
commit
6b46400f0a
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
expect(screen.queryByText("Loan from Halvorsen")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("caps tutorial progress at 100%", async () => {
|
||||
const state = samplePlayerState({
|
||||
company: { id: "c", name: "D", credits: tutorialGoalCost() * 3, loanBalance: 0 },
|
||||
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",
|
||||
);
|
||||
});
|
||||
renderApp(fakeApi({ getPlayerState: () => state }));
|
||||
expect(await screen.findByText("100%")).toBeInTheDocument();
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue