Step 10A.13: add the deal dialog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c53910b6e3
commit
53b637521c
|
|
@ -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.
|
- **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`.
|
- **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).
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Route, Routes } from "react-router";
|
||||||
import { useApi } from "./api/context";
|
import { useApi } from "./api/context";
|
||||||
import { startClockTicker, syncClock } from "./clock/clock";
|
import { startClockTicker, syncClock } from "./clock/clock";
|
||||||
import { AwaySummary } from "./components/AwaySummary";
|
import { AwaySummary } from "./components/AwaySummary";
|
||||||
|
import { DealModal } from "./components/DealModal";
|
||||||
import { formatCredits } from "./format";
|
import { formatCredits } from "./format";
|
||||||
import { Layout } from "./layout/Layout";
|
import { Layout } from "./layout/Layout";
|
||||||
import { SCREENS } from "./routes";
|
import { SCREENS } from "./routes";
|
||||||
|
|
@ -101,6 +102,7 @@ export function App() {
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
<AwaySummary />
|
<AwaySummary />
|
||||||
|
<DealModal />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="fixed inset-0 z-30 flex items-end justify-center bg-black/70 p-4 sm:items-center">
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="deal-title"
|
||||||
|
className="w-full max-w-md rounded-xl border border-amber-500/50 bg-slate-900 p-4"
|
||||||
|
>
|
||||||
|
<h2 id="deal-title" className="text-lg font-semibold text-amber-300">
|
||||||
|
An offer from Halvorsen
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-slate-300">
|
||||||
|
Old Halvorsen is retiring. He'll sell you his Courier and his Earth bay. Pay him back
|
||||||
|
out of every job.
|
||||||
|
</p>
|
||||||
|
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-sm">
|
||||||
|
<dt className="text-slate-400">You get</dt>
|
||||||
|
<dd>A {courier.name} and a bay at Earth</dd>
|
||||||
|
<dt className="text-slate-400">Loan</dt>
|
||||||
|
<dd aria-label="Loan amount" className="tabular-nums">
|
||||||
|
{formatCredits(dealLoanAmount)}
|
||||||
|
</dd>
|
||||||
|
<dt className="text-slate-400">Repayment</dt>
|
||||||
|
<dd aria-label="Repayment share">
|
||||||
|
{Math.round(loanRepaymentShare * 100)}% of every payout until it's paid off
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
{accept.isError && (
|
||||||
|
<p role="alert" className="mt-3 text-sm text-red-400">
|
||||||
|
{errorMessage(accept.error)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => accept.mutate()}
|
||||||
|
disabled={accept.isPending}
|
||||||
|
className="mt-4 w-full rounded-lg bg-amber-500 py-2 font-semibold text-slate-950 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Shake on it
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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.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.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.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.14 — Client: loan on HQ
|
||||||
- [ ] Step 10A.15 — Client: goals on HQ
|
- [ ] Step 10A.15 — Client: goals on HQ
|
||||||
- [ ] Step 10A.16 — Client: whole map from the start
|
- [ ] Step 10A.16 — Client: whole map from the start
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue