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