Step 10M.4: deal modal lists the Clipper and the veteran

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-05 11:57:45 -04:00
parent 256fb7c464
commit f78b488baa
5 changed files with 45 additions and 9 deletions

View File

@ -1223,3 +1223,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **The veteran is stored like any assigned pilot** (location null, following the Clipper), so state shows them at Earth through the ship, as it does the avatar. - **The veteran is stored like any assigned pilot** (location null, following the Clipper), so state shows them at Earth through the ship, as it does the avatar.
- **The Clipper is named with the next seeded ship name**, after the Courier, the same way bought ships are. - **The Clipper is named with the next seeded ship name**, after the Courier, the same way bought ships are.
- **The old rest-and-swap test is replaced** by the CR's "both ships can be dispatched immediately" (both in flight at once); the profit-share check moved onto the veteran's Clipper run. - **The old rest-and-swap test is replaced** by the CR's "both ships can be dispatched immediately" (both in flight at once); the profit-share check moved onto the veteran's Clipper run.
## Step 10M.4
- **"Twelve years" lives in the bio, not the sentence**: the CR's example sentence ("who's flown that Clipper for twelve years") is split so the sentence uses only names (veteran, ships, bay) and the bio carries the history, which also shows on the pilot card.
- **The loan shown is `content.dealLoanAmount`**; before the deal the player has no loan in state, so "matching state" is tested as the offer equalling the balance the after-deal state reports.

View File

@ -29,7 +29,10 @@ export function DealModal() {
const { dealLoanAmount } = content; const { dealLoanAmount } = content;
const { loanRepaymentShare } = content.config; const { loanRepaymentShare } = content.config;
const courier = content.shipModel("courier")!; // The deal's ships (decisions 0.5.30, 0.5.82).
const courier = content.shipModel(content.config.deal.avatarShipModelId)!;
const clipper = content.shipModel(content.config.deal.veteranShipModelId)!;
const bay = content.location(content.config.deal.bayLocationId)!;
const veteran = content.veteranPilot; const veteran = content.veteranPilot;
return ( return (
@ -41,17 +44,23 @@ export function DealModal() {
layer="z-30" layer="z-30"
> >
<div className="mt-3 flex items-center gap-3"> <div className="mt-3 flex items-center gap-3">
<Art sprite={courier.sprite} className="size-20 shrink-0 -rotate-12" /> <div className="flex shrink-0 -space-x-6">
<Art sprite={courier.sprite} className="size-16 -rotate-12" />
<Art sprite={clipper.sprite} className="size-16 rotate-12" />
</div>
<p className="text-sm text-ink-200"> <p className="text-sm text-ink-200">
Old Halvorsen is retiring. He&apos;ll sell you his Courier and his Earth bay, and his Old Halvorsen is retiring. He&apos;ll sell you his {courier.name}, his {clipper.name}, and
pilot comes with them. Pay him back out of every job. his {bay.name} bay — and {veteran.name}, who flies that {clipper.name}. Pay him back out
of every job.
</p> </p>
</div> </div>
<dl className="mt-4 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 rounded-xl bg-space-950/60 p-3 text-sm ring-1 ring-amber-400/25"> <dl className="mt-4 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 rounded-xl bg-space-950/60 p-3 text-sm ring-1 ring-amber-400/25">
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase"> <dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
You get You get
</dt> </dt>
<dd>A {courier.name} and a bay at Earth</dd> <dd aria-label="Ships and bay">
A {courier.name} for you, a {clipper.name} for {veteran.name}, and a bay at {bay.name}
</dd>
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">Pilot</dt> <dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">Pilot</dt>
<dd aria-label="Veteran pilot"> <dd aria-label="Veteran pilot">
…and {veteran.name}. {veteran.bio} …and {veteran.name}. {veteran.bio}

View File

@ -14,7 +14,7 @@ const offered = () =>
}, },
}); });
/** State the server returns after the deal: tutorial over, a Courier, a bay, the loan. */ /** State the server returns after the deal: tutorial over, the Courier and Clipper, a bay, the loan. */
const afterDeal = () => const afterDeal = () =>
samplePlayerState({ samplePlayerState({
tutorial: null, tutorial: null,
@ -36,6 +36,17 @@ const afterDeal = () =>
...shipExtras("courier"), ...shipExtras("courier"),
pilotId: "avatar", pilotId: "avatar",
}, },
{
id: "s2",
modelId: "clipper",
name: "Swift Heron",
status: "idle",
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("clipper"),
pilotId: "veteran",
},
], ],
bays: [{ id: "b1", locationId: "earth" }], bays: [{ id: "b1", locationId: "earth" }],
}); });
@ -61,7 +72,14 @@ describe("the deal", () => {
"/contracts", "/contracts",
); );
const dialog = await screen.findByRole("dialog", { name: "An offer from Halvorsen" }); const dialog = await screen.findByRole("dialog", { name: "An offer from Halvorsen" });
expect(dialog).toHaveTextContent("Courier and a bay at Earth"); // Both ships and the veteran (CR-013), names from content.
const veteran = content.veteranPilot;
expect(screen.getByLabelText("Ships and bay")).toHaveTextContent(
`A Courier for you, a Clipper for ${veteran.name}, and a bay at Earth`,
);
expect(dialog).toHaveTextContent("his Courier, his Clipper, and his Earth bay");
expect(screen.getByLabelText("Veteran pilot")).toHaveTextContent(veteran.name);
expect(screen.getByLabelText("Veteran pilot")).toHaveTextContent(veteran.bio);
expect(screen.getByLabelText("Loan amount")).toHaveTextContent( expect(screen.getByLabelText("Loan amount")).toHaveTextContent(
`${content.dealLoanAmount.toLocaleString("en-US")} cr`, `${content.dealLoanAmount.toLocaleString("en-US")} cr`,
); );
@ -119,6 +137,10 @@ describe("the deal", () => {
}, },
}); });
renderApp(api, "/contracts"); renderApp(api, "/contracts");
// The loan offered is the loan the server books (CR-013: Courier + Clipper + bay).
expect(await screen.findByLabelText("Loan amount")).toHaveTextContent(
`${afterDeal().company.loanBalance.toLocaleString("en-US")} cr`,
);
await userEvent.click(await screen.findByRole("button", { name: "Shake on it" })); await userEvent.click(await screen.findByRole("button", { name: "Shake on it" }));
await waitFor(() => expect(api.call).toHaveBeenCalledWith("acceptDeal", {})); await waitFor(() => expect(api.call).toHaveBeenCalledWith("acceptDeal", {}));
expect(await screen.findByRole("heading", { level: 1, name: "HQ" })).toBeInTheDocument(); expect(await screen.findByRole("heading", { level: 1, name: "HQ" })).toBeInTheDocument();

View File

@ -290,5 +290,5 @@ Spec: `memory-bank/change-requests/CR-013-veterans-clipper.md` (received as "CR-
- [x] **Step 10M.1 — Content: veteran skill and loan.** The veteran starts with **Expensive Cargo 1**. Config `dealLoanAmount` (5,000) is replaced by `deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" }`, and the loan is computed by `dealLoanAmount({ config, shipModels, locations })` (`packages/content/src/deal.ts`), exposed as `content.dealLoanAmount`: 4,000 + 61,600 + 1,000 = **66,600 cr**. All users (server deal, loan card, deal modal, tests) read `content.dealLoanAmount`. Tests: config (deal ids; loan = Courier + Clipper + Earth lease; it moves by exactly the change when either price or the lease changes), veteran skills (content and server). - [x] **Step 10M.1 — Content: veteran skill and loan.** The veteran starts with **Expensive Cargo 1**. Config `dealLoanAmount` (5,000) is replaced by `deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" }`, and the loan is computed by `dealLoanAmount({ config, shipModels, locations })` (`packages/content/src/deal.ts`), exposed as `content.dealLoanAmount`: 4,000 + 61,600 + 1,000 = **66,600 cr**. All users (server deal, loan card, deal modal, tests) read `content.dealLoanAmount`. Tests: config (deal ids; loan = Courier + Clipper + Earth lease; it moves by exactly the change when either price or the lease changes), veteran skills (content and server).
- [x] **Step 10M.2 — Sim: goal changes.** Goal 3 is now `market-ship` "Buy a ship from the Market" (condition `market-ships-bought` 1) and goal 4 `priority-contract` "Complete a Priority contract" (`priority-deliveries` 1, any pilot); the `veteran-contracts` condition is gone. `GoalProgress` gains `marketShipsBought` and `priorityDeliveries` (drops `veteranContracts`). Server: `ships.from_deal` (migration 0009, backfilling each player's first ship), set on the deal's ship; goal progress counts non-deal ships and resolved non-tutorial dispatches whose contract is Priority. Tests: sim (the deal's two ships don't complete goal 3, a bought Lighter does; Standard deliveries don't complete goal 4, a Priority one does), server goals (buying a Lighter; a Standard then a Priority delivery), web goal list ids. - [x] **Step 10M.2 — Sim: goal changes.** Goal 3 is now `market-ship` "Buy a ship from the Market" (condition `market-ships-bought` 1) and goal 4 `priority-contract` "Complete a Priority contract" (`priority-deliveries` 1, any pilot); the `veteran-contracts` condition is gone. `GoalProgress` gains `marketShipsBought` and `priorityDeliveries` (drops `veteranContracts`). Server: `ships.from_deal` (migration 0009, backfilling each player's first ship), set on the deal's ship; goal progress counts non-deal ships and resolved non-tutorial dispatches whose contract is Priority. Tests: sim (the deal's two ships don't complete goal 3, a bought Lighter does; Standard deliveries don't complete goal 4, a Priority one does), server goals (buying a Lighter; a Standard then a Priority delivery), web goal list ids.
- [x] **Step 10M.3 — Server: deal creates the Clipper.** `acceptDeal` reads `config.deal` and inserts both deal ships at Earth (Courier, then Clipper, consecutive seeded names, `from_deal`), assigns the avatar to the Courier and the veteran to the Clipper (location follows the ship), sets the 66,600 cr loan. Tests: `deal.test.ts` (two crewed ships at Earth, one bay, the loan; two simultaneous accepts still give two ships, not four; refused and repeated accepts change nothing), `veteran.test.ts` (veteran crewing the Clipper, level 2, Expensive Cargo 1; the Courier and the Clipper dispatched at the same time, the veteran's run paying their profit share). - [x] **Step 10M.3 — Server: deal creates the Clipper.** `acceptDeal` reads `config.deal` and inserts both deal ships at Earth (Courier, then Clipper, consecutive seeded names, `from_deal`), assigns the avatar to the Courier and the veteran to the Clipper (location follows the ship), sets the 66,600 cr loan. Tests: `deal.test.ts` (two crewed ships at Earth, one bay, the loan; two simultaneous accepts still give two ships, not four; refused and repeated accepts change nothing), `veteran.test.ts` (veteran crewing the Clipper, level 2, Expensive Cargo 1; the Courier and the Clipper dispatched at the same time, the veteran's run paying their profit share).
- [ ] Step 10M.4 — Client: deal modal - [x] **Step 10M.4 — Client: deal modal.** The modal shows both ship sprites and reads "Old Halvorsen is retiring. He'll sell you his Courier, his Clipper, and his Earth bay — and Rosa Kell, who flies that Clipper. Pay him back out of every job."; "You get" lists "A Courier for you, a Clipper for Rosa Kell, and a bay at Earth"; the Pilot row keeps name and bio. Ship, bay and pilot names come from content (`config.deal`, `veteranPilot`); the veteran's bio is now "Has flown Halvorsen's Clipper for twelve years." Tests (`deal-modal.test.tsx`): both ships, the veteran's name and bio, and the loan offered equal to the loan in the after-deal state.
- [ ] Step 10M.5 — Plan amendments and regression - [ ] Step 10M.5 — Plan amendments and regression

View File

@ -21,7 +21,7 @@ export const scriptedTutorialContracts: ScriptedTutorialContract[] = [
*/ */
export const veteranPilot: VeteranPilot = { export const veteranPilot: VeteranPilot = {
name: "Rosa Kell", name: "Rosa Kell",
bio: "Halvorsen's pilot for twelve years.", bio: "Has flown Halvorsen's Clipper for twelve years.",
level: 2, level: 2,
skills: { "expensive-cargo": 1 }, skills: { "expensive-cargo": 1 },
}; };