diff --git a/DECISIONS.md b/DECISIONS.md
index 4d27bea..c4f5228 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -563,3 +563,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **XP shown on a tutorial contract is sim `deliveryXp` at the current distance**, the same formula resolution uses, so the light-versus-heavy tradeoff the player sees is the real one.
- **Scripted contracts show their content name** ("Unmarked crates") in a distinct colour with a short "double pay, no questions asked" line; the summary looks up the flavor message by the summary's `scriptedId`.
+
+## Step 10A.13
+
+- **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).
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 89dc96d..231f497 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -3,6 +3,7 @@ import { Route, Routes } from "react-router";
import { useApi } from "./api/context";
import { startClockTicker, syncClock } from "./clock/clock";
import { AwaySummary } from "./components/AwaySummary";
+import { DealModal } from "./components/DealModal";
import { formatCredits } from "./format";
import { Layout } from "./layout/Layout";
import { SCREENS } from "./routes";
@@ -101,6 +102,7 @@ export function App() {
+
>
);
}
diff --git a/apps/web/src/components/DealModal.tsx b/apps/web/src/components/DealModal.tsx
new file mode 100644
index 0000000..921d370
--- /dev/null
+++ b/apps/web/src/components/DealModal.tsx
@@ -0,0 +1,75 @@
+import { content } from "@deadhead/content";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useNavigate } from "react-router";
+import { useApi } from "../api/context";
+import { formatCredits } from "../format";
+import { errorMessage, useLoadedPlayer } from "../state/actions";
+import { PLAYER_STATE_KEY } from "../state/player";
+
+/**
+ * The one-time deal (decision 0.5.30): shown when the server says it is
+ * available, after any delivery summary has been dismissed. There is no
+ * decline option.
+ */
+export function DealModal() {
+ const state = useLoadedPlayer();
+ const api = useApi();
+ const queryClient = useQueryClient();
+ const navigate = useNavigate();
+ const accept = useMutation({
+ mutationFn: () => api.call("acceptDeal", {}),
+ onSuccess: (newState) => {
+ queryClient.setQueryData(PLAYER_STATE_KEY, newState);
+ void navigate("/");
+ },
+ });
+
+ if (!state.tutorial?.dealAvailable || state.summaries.length > 0) return null;
+
+ const { dealLoanAmount, loanRepaymentShare } = content.config;
+ const courier = content.shipModel("courier")!;
+
+ return (
+
+
+
+ An offer from Halvorsen
+
+
+ Old Halvorsen is retiring. He'll sell you his Courier and his Earth bay. Pay him back
+ out of every job.
+
+
+
You get
+
A {courier.name} and a bay at Earth
+
Loan
+
+ {formatCredits(dealLoanAmount)}
+
+
Repayment
+
+ {Math.round(loanRepaymentShare * 100)}% of every payout until it's paid off
+
+
+ {accept.isError && (
+
+ {errorMessage(accept.error)}
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/test/deal-modal.test.tsx b/apps/web/test/deal-modal.test.tsx
new file mode 100644
index 0000000..ee0279a
--- /dev/null
+++ b/apps/web/test/deal-modal.test.tsx
@@ -0,0 +1,120 @@
+import { content } from "@deadhead/content";
+import { screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard } from "./render";
+
+const offered = () =>
+ samplePlayerState({
+ tutorial: {
+ loanerLocationId: "earth",
+ runInFlight: false,
+ runsCompleted: 4,
+ dealAvailable: true,
+ },
+ });
+
+/** State the server returns after the deal: tutorial over, a Courier, a bay, the loan. */
+const afterDeal = () =>
+ samplePlayerState({
+ tutorial: null,
+ company: {
+ id: "co1",
+ name: "Deadhead Freight",
+ credits: 800,
+ loanBalance: content.config.dealLoanAmount,
+ },
+ ships: [
+ {
+ id: "s1",
+ modelId: "courier",
+ name: "Dusty Mule",
+ status: "idle",
+ locationId: "earth",
+ dispatchId: null,
+ pilotId: "avatar",
+ },
+ ],
+ bays: [{ id: "b1", locationId: "earth" }],
+ });
+
+describe("the deal", () => {
+ it("does not appear when the deal is not available", async () => {
+ renderApp(
+ fakeApi({
+ getPlayerState: () => samplePlayerState(),
+ getTutorialBoard: () => sampleTutorialBoard(),
+ }),
+ "/contracts",
+ );
+ await screen.findByRole("heading", { level: 1, name: "Contracts" });
+ expect(
+ screen.queryByRole("dialog", { name: "An offer from Halvorsen" }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("appears when available, showing the configured loan and repayment share", async () => {
+ renderApp(
+ fakeApi({ getPlayerState: offered, getTutorialBoard: () => sampleTutorialBoard() }),
+ "/contracts",
+ );
+ const dialog = await screen.findByRole("dialog", { name: "An offer from Halvorsen" });
+ expect(dialog).toHaveTextContent("Courier and a bay at Earth");
+ expect(screen.getByLabelText("Loan amount")).toHaveTextContent(
+ `${content.config.dealLoanAmount.toLocaleString("en-US")} cr`,
+ );
+ expect(screen.getByLabelText("Repayment share")).toHaveTextContent(
+ `${Math.round(content.config.loanRepaymentShare * 100)}% of every payout`,
+ );
+ expect(screen.getAllByRole("button").filter((b) => dialog.contains(b))).toHaveLength(1);
+ });
+
+ it("waits until the delivery summary is dismissed", async () => {
+ const withSummary = () => ({
+ ...offered(),
+ summaries: [
+ {
+ dispatchId: "d4",
+ shipId: "loaner",
+ pilotId: "avatar",
+ originId: "moon",
+ destinationId: "earth",
+ tutorial: true,
+ credits: 96,
+ loanRepayment: 0,
+ pilotXp: 10,
+ discoveredLocationId: null,
+ scriptedId: null,
+ resolvedAt: 1,
+ },
+ ],
+ });
+ renderApp(
+ fakeApi({ getPlayerState: withSummary, getTutorialBoard: () => sampleTutorialBoard() }),
+ "/contracts",
+ );
+ await screen.findByRole("dialog", { name: "While you were away" });
+ expect(
+ screen.queryByRole("dialog", { name: "An offer from Halvorsen" }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("Shake on it accepts the deal and goes to HQ", async () => {
+ let accepted = false;
+ const api = fakeApi({
+ getPlayerState: () => (accepted ? afterDeal() : offered()),
+ getTutorialBoard: () => sampleTutorialBoard(),
+ acceptDeal: () => {
+ accepted = true;
+ return afterDeal();
+ },
+ });
+ renderApp(api, "/contracts");
+ await userEvent.click(await screen.findByRole("button", { name: "Shake on it" }));
+ await waitFor(() => expect(api.call).toHaveBeenCalledWith("acceptDeal", {}));
+ expect(await screen.findByRole("heading", { level: 1, name: "HQ" })).toBeInTheDocument();
+ expect(
+ screen.queryByRole("dialog", { name: "An offer from Halvorsen" }),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/memory-bank/progress.md b/memory-bank/progress.md
index 458241f..3b3526a 100644
--- a/memory-bank/progress.md
+++ b/memory-bank/progress.md
@@ -118,7 +118,7 @@ Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded a
- [x] **Step 10A.10 — Server: goals.** `src/game/goals.ts`: `goalProgress` (own-ship deliveries, skill points spent, ships, hires, extra discoveries, deal, loan balance) → `goalStates` (empty during the tutorial) in player state; `POST /api/goals/:goalId/claim` adds the reward once (`goal-not-ready`, `goal-already-claimed`, `404 unknown-goal`).
- [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.
-- [ ] Step 10A.13 — Client: the deal
+- [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
- [ ] Step 10A.15 — Client: goals on HQ
- [ ] Step 10A.16 — Client: whole map from the start