Step 10J.5: the veteran in the deal modal and on the Pilots screen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 22:14:16 -04:00
parent 9eae5ae64d
commit 8cafa50027
5 changed files with 80 additions and 3 deletions

View File

@ -1018,3 +1018,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **The veteran is a pilots row with `is_veteran`**, otherwise identical to a hire (pay, duty, rest, assignment rules). The flag only identifies them for goal 4 and the bio line. - **The veteran is a pilots row with `is_veteran`**, otherwise identical to a hire (pay, duty, rest, assignment rules). The flag only identifies them for goal 4 and the bio line.
- **Level 2 is stored as XP**: the veteran starts with exactly level 2's threshold (250 XP), so levels and skill points come from the normal rules; one point is recorded as spent on Heavy Cargo 1. - **Level 2 is stored as XP**: the veteran starts with exactly level 2's threshold (250 XP), so levels and skill points come from the normal rules; one point is recorded as spent on Heavy Cargo 1.
- **Created inside `acceptDeal`**, after the deal check, in the player-locked transaction: a refused or repeated accept creates nothing (tested). - **Created inside `acceptDeal`**, after the deal check, in the player-locked transaction: a refused or repeated accept creates nothing (tested).
## Step 10J.5
- **Name and bio come from `content.veteranPilot`** in both places; the pilot card shows the bio when the API marks the pilot `isVeteran`.
- **The modal adds a "Pilot" row** to its "You get / Loan / Repayment" list, in the CR's "…and [name]" style, and its opening line mentions the pilot.

View File

@ -29,6 +29,7 @@ export function DealModal() {
const { dealLoanAmount, loanRepaymentShare } = content.config; const { dealLoanAmount, loanRepaymentShare } = content.config;
const courier = content.shipModel("courier")!; const courier = content.shipModel("courier")!;
const veteran = content.veteranPilot;
return ( return (
<Sheet <Sheet
@ -41,8 +42,8 @@ export function DealModal() {
<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" /> <Art sprite={courier.sprite} className="size-20 shrink-0 -rotate-12" />
<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. Pay him back Old Halvorsen is retiring. He&apos;ll sell you his Courier and his Earth bay, and his
out of every job. pilot comes with them. 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">
@ -50,6 +51,10 @@ export function DealModal() {
You get You get
</dt> </dt>
<dd>A {courier.name} and a bay at Earth</dd> <dd>A {courier.name} and a bay at Earth</dd>
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">Pilot</dt>
<dd aria-label="Veteran pilot">
…and {veteran.name}. {veteran.bio}
</dd>
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">Loan</dt> <dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">Loan</dt>
<dd <dd
aria-label="Loan amount" aria-label="Loan amount"

View File

@ -133,6 +133,11 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
Level {pilot.level} Level {pilot.level}
</span> </span>
</div> </div>
{pilot.isVeteran && (
<p aria-label={`${pilot.name} bio`} className="mt-0.5 text-xs text-amber-200/90 italic">
{content.veteranPilot.bio}
</p>
)}
<div className="mt-1 text-xs text-ink-400"> <div className="mt-1 text-xs text-ink-400">
{progress {progress
? `${progress.xp} / ${progress.needed} XP to next level` ? `${progress.xp} / ${progress.needed} XP to next level`

View File

@ -0,0 +1,62 @@
import { content } from "@deadhead/content";
import type { Pilot } from "@deadhead/schema";
import { screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard } from "./render";
const veteran = content.veteranPilot;
describe("the veteran in the UI (Step 10J.5)", () => {
it("names the veteran with their bio in the deal modal", async () => {
renderApp(
fakeApi({
getPlayerState: () =>
samplePlayerState({
tutorial: {
loanerLocationId: "earth",
runInFlight: false,
runsCompleted: 4,
dealAvailable: true,
},
}),
getTutorialBoard: () => sampleTutorialBoard(),
}),
"/contracts",
);
const modal = await screen.findByRole("dialog", { name: "An offer from Halvorsen" });
expect(within(modal).getByLabelText("Veteran pilot")).toHaveTextContent(
`…and ${veteran.name}. ${veteran.bio}`,
);
});
it("shows the veteran's bio, level and Heavy Cargo 1 on the Pilots screen", async () => {
const rosa: Pilot = {
id: "p-vet",
name: veteran.name,
isAvatar: false,
isVeteran: true,
xp: content.config.pilotLevelXpThresholds[1]!,
level: 2,
skills: { "heavy-cargo": 1 },
skillPointsAvailable: 3,
assignedShipId: null,
status: "idle",
locationId: "earth",
dutyHours: 0,
dutyLimit: 12,
resting: false,
restEndsAt: null,
};
const base = samplePlayerState({ tutorial: null, bays: [{ id: "b", locationId: "earth" }] });
renderApp(
fakeApi({ getPlayerState: () => ({ ...base, pilots: [...base.pilots, rosa] }) }),
"/pilots",
);
const card = (await screen.findByText(veteran.name)).closest("li")!;
expect(within(card).getByLabelText(`${veteran.name} bio`)).toHaveTextContent(veteran.bio);
expect(card).toHaveTextContent("Level 2");
expect(card).toHaveTextContent("Heavy Cargo 1/5");
// The avatar's card has no bio line.
expect(screen.queryByLabelText("Captain bio")).not.toBeInTheDocument();
});
});

View File

@ -244,6 +244,6 @@ Spec: `memory-bank/change-requests/CR-010-veteran-pilot-profit-share.md` (receiv
- [x] **Step 10J.2 — Sim: profit-share pay.** `sim/src/pay.ts` `pilotShare(payout, fuel)` = round(max(0, payout − fuel) × `pilotProfitShare`). Resolution charges it for hired pilots on contract deliveries (avatar, tutorial runs, deadheads: 0), then takes the loan repayment from what remains; `Resolution.pilotPay` records it. The flat `pilotPayPerDelivery` is gone (config, schema, client). The dispatch sheet's net profit uses the share. Test: `profit-share.test.ts` (400/150 → 63; fuel ≥ payout → 0; exemptions; repayment after pay; credits + pay + repayment = payout over 500 random cases); Step 3.17 tests updated. - [x] **Step 10J.2 — Sim: profit-share pay.** `sim/src/pay.ts` `pilotShare(payout, fuel)` = round(max(0, payout − fuel) × `pilotProfitShare`). Resolution charges it for hired pilots on contract deliveries (avatar, tutorial runs, deadheads: 0), then takes the loan repayment from what remains; `Resolution.pilotPay` records it. The flat `pilotPayPerDelivery` is gone (config, schema, client). The dispatch sheet's net profit uses the share. Test: `profit-share.test.ts` (400/150 → 63; fuel ≥ payout → 0; exemptions; repayment after pay; credits + pay + repayment = payout over 500 random cases); Step 3.17 tests updated.
- [x] **Step 10J.3 — Sim: goal change.** Goal 4 is now `veteran-contract`, "Fly a contract with Rosa Kell" (title from `content.veteranPilot.name`), condition `veteran-contracts` ≥ 1; `GoalProgress.veteranContracts`. The server reports 0 until the veteran is stored (10J.4). Tests: `goals.test.ts` (avatar deliveries alone don't complete it; one veteran contract does) and the goal-id updates in server and web goal tests. - [x] **Step 10J.3 — Sim: goal change.** Goal 4 is now `veteran-contract`, "Fly a contract with Rosa Kell" (title from `content.veteranPilot.name`), condition `veteran-contracts` ≥ 1; `GoalProgress.veteranContracts`. The server reports 0 until the veteran is stored (10J.4). Tests: `goals.test.ts` (avatar deliveries alone don't complete it; one veteran contract does) and the goal-id updates in server and web goal tests.
- [x] **Step 10J.4 — Server: veteran on the deal.** Migration `0007_cr010_veteran_pilot`: `pilots.is_veteran`. `acceptDeal` inserts the veteran in the same transaction: content name, XP at level 2's threshold, Heavy Cargo 1 bought (1 point spent, 3 unspent), at Earth, unassigned. API `Pilot.isVeteran`. Goal progress counts resolved contract deliveries (not deadheads or tutorial runs) by the veteran. Test: `veteran.test.ts` (state after the deal; no veteran on a refused or repeated accept; avatar rests, is swapped out, the veteran flies at once and is paid a profit share, goal 4 ready; avatar deliveries and a veteran deadhead don't count). - [x] **Step 10J.4 — Server: veteran on the deal.** Migration `0007_cr010_veteran_pilot`: `pilots.is_veteran`. `acceptDeal` inserts the veteran in the same transaction: content name, XP at level 2's threshold, Heavy Cargo 1 bought (1 point spent, 3 unspent), at Earth, unassigned. API `Pilot.isVeteran`. Goal progress counts resolved contract deliveries (not deadheads or tutorial runs) by the veteran. Test: `veteran.test.ts` (state after the deal; no veteran on a refused or repeated accept; avatar rests, is swapped out, the veteran flies at once and is paid a profit share, goal 4 ready; avatar deliveries and a veteran deadhead don't count).
- [ ] Step 10J.5 — Client: deal modal and pilot card - [x] **Step 10J.5 — Client: deal modal and pilot card.** The deal modal says Halvorsen's pilot comes with the business and lists "Pilot: …and Rosa Kell. Halvorsen's pilot for twelve years." (from content). The veteran's Pilots card shows the bio line under the name. Test: `veteran-ui.test.tsx`.
- [ ] Step 10J.6 — Client: pilot share in dispatch and summaries - [ ] Step 10J.6 — Client: pilot share in dispatch and summaries
- [ ] Step 10J.7 — Plan amendments and regression - [ ] Step 10J.7 — Plan amendments and regression