Step 10J.6: pilot share in the dispatch sheet and delivery summaries

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 22:23:17 -04:00
parent 8cafa50027
commit f042555502
15 changed files with 168 additions and 2 deletions

View File

@ -1023,3 +1023,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Name and bio come from `content.veteranPilot`** in both places; the pilot card shows the bio when the API marks the pilot `isVeteran`. - **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. - **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.
## Step 10J.6
- **The share uses the sim's `pilotShare`** with the trip's predicted fuel, so the sheet's net profit equals what the server will credit (before any loan repayment).
- **`ResolutionSummary.pilotPay`** comes from the stored resolution; older resolutions without it show 0, so no share line.

View File

@ -121,6 +121,7 @@ export async function loadPlayerState(
shipper: d.record.contract?.shipper ?? null, shipper: d.record.contract?.shipper ?? null,
credits: d.resolution?.credits ?? 0, credits: d.resolution?.credits ?? 0,
loanRepayment: d.resolution?.loanRepayment ?? 0, loanRepayment: d.resolution?.loanRepayment ?? 0,
pilotPay: d.resolution?.pilotPay ?? 0,
pilotXp: d.resolution?.pilotXp ?? 0, pilotXp: d.resolution?.pilotXp ?? 0,
discoveredLocationId: d.resolution?.discoversDestination ? d.record.destinationId : null, discoveredLocationId: d.resolution?.discoversDestination ? d.record.destinationId : null,
scriptedId: d.record.contract?.scriptedId ?? null, scriptedId: d.record.contract?.scriptedId ?? null,

View File

@ -75,6 +75,12 @@ export function AwaySummary() {
<span className="text-violet-300">+{s.pilotXp} XP</span> <span className="text-violet-300">+{s.pilotXp} XP</span>
</div> </div>
)} )}
{s.pilotPay > 0 && (
<div className="mt-1 text-ink-400">
{formatCredits(s.pilotPay)} pilot share to{" "}
{state.pilots.find((p) => p.id === s.pilotId)?.name ?? "the pilot"}
</div>
)}
{s.loanRepayment > 0 && ( {s.loanRepayment > 0 && (
<div className="mt-1 text-ink-400"> <div className="mt-1 text-ink-400">
{formatCredits(s.loanRepayment)} went to the lender {formatCredits(s.loanRepayment)} went to the lender

View File

@ -131,6 +131,8 @@ export interface PairOption {
problems: string[]; problems: string[];
tripMs: number; tripMs: number;
fuelCost: number; fuelCost: number;
/** A hired pilot's profit share; 0 for the avatar (CR-010). */
pilotPay: number;
netProfit: number; netProfit: number;
arrivalAt: number; arrivalAt: number;
/** The pilot's duty after this trip and their limit (CR-004); null without a pilot. */ /** The pilot's duty after this trip and their limit (CR-004); null without a pilot. */
@ -213,6 +215,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
problems, problems,
tripMs, tripMs,
fuelCost, fuelCost,
pilotPay,
netProfit: contract.payout - fuelCost - pilotPay, netProfit: contract.payout - fuelCost - pilotPay,
arrivalAt: now + tripMs, arrivalAt: now + tripMs,
duty: pilot ? dutyOutlook(pilot, now, now + tripMs) : null, duty: pilot ? dutyOutlook(pilot, now, now + tripMs) : null,
@ -329,6 +332,10 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
<dl className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3"> <dl className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3">
<Cell label="Trip" value={formatDuration(option.tripMs)} /> <Cell label="Trip" value={formatDuration(option.tripMs)} />
<Cell label="Fuel" value={formatCredits(option.fuelCost)} /> <Cell label="Fuel" value={formatCredits(option.fuelCost)} />
{option.pilotId !== null &&
!state.pilots.find((p) => p.id === option.pilotId)?.isAvatar && (
<Cell label="Pilot share" value={formatCredits(option.pilotPay)} />
)}
<Cell label="Net profit" value={formatCredits(option.netProfit)} good /> <Cell label="Net profit" value={formatCredits(option.netProfit)} good />
<Cell <Cell
label="ETA (game)" label="ETA (game)"

View File

@ -39,6 +39,7 @@ function returningPlayer(acknowledged: boolean): PlayerState {
massTonnes: 12, massTonnes: 12,
shipper: "Pacific Orbital Logistics", shipper: "Pacific Orbital Logistics",
loanRepayment: 230, loanRepayment: 230,
pilotPay: 0,
pilotXp: 10, pilotXp: 10,
discoveredLocationId: null, discoveredLocationId: null,
scriptedId: null, scriptedId: null,

View File

@ -31,6 +31,7 @@ const summaries: ResolutionSummary[] = [
massTonnes: 12, massTonnes: 12,
shipper: "Pacific Orbital Logistics", shipper: "Pacific Orbital Logistics",
loanRepayment: 0, loanRepayment: 0,
pilotPay: 0,
pilotXp: 32, pilotXp: 32,
discoveredLocationId: "mars", discoveredLocationId: "mars",
scriptedId: null, scriptedId: null,
@ -51,6 +52,7 @@ const summaries: ResolutionSummary[] = [
massTonnes: 12, massTonnes: 12,
shipper: "Pacific Orbital Logistics", shipper: "Pacific Orbital Logistics",
loanRepayment: 0, loanRepayment: 0,
pilotPay: 0,
pilotXp: 10, pilotXp: 10,
discoveredLocationId: null, discoveredLocationId: null,
scriptedId: null, scriptedId: null,

View File

@ -89,6 +89,7 @@ describe("the deal", () => {
massTonnes: 12, massTonnes: 12,
shipper: "Pacific Orbital Logistics", shipper: "Pacific Orbital Logistics",
loanRepayment: 0, loanRepayment: 0,
pilotPay: 0,
pilotXp: 10, pilotXp: 10,
discoveredLocationId: null, discoveredLocationId: null,
scriptedId: null, scriptedId: null,

View File

@ -153,6 +153,7 @@ describe("loan on HQ", () => {
massTonnes: 12, massTonnes: 12,
shipper: "Pacific Orbital Logistics", shipper: "Pacific Orbital Logistics",
loanRepayment: 230, loanRepayment: 230,
pilotPay: 0,
pilotXp: 10, pilotXp: 10,
discoveredLocationId: null, discoveredLocationId: null,
scriptedId: null, scriptedId: null,

View File

@ -0,0 +1,131 @@
import { content } from "@deadhead/content";
import type { Contract, Pilot, ResolutionSummary, Ship } from "@deadhead/schema";
import { pilotShare, realToGame, tripFuelCost } from "@deadhead/sim";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { formatCredits } from "../src/format";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
const ship = (id: string, name: string, pilotId: string): Ship => ({
id,
name,
modelId: "courier",
status: "idle",
locationId: "earth",
dispatchId: null,
pilotId,
lastArrivalAt: 0,
...shipExtras("courier"),
});
const pilot = (id: string, name: string, shipId: string, isAvatar: boolean): Pilot => ({
id,
name,
isAvatar,
isVeteran: !isAvatar,
xp: 0,
level: 0,
skills: {},
skillPointsAvailable: 0,
assignedShipId: shipId,
status: "idle",
locationId: "earth",
dutyHours: 0,
dutyLimit: 12,
resting: false,
restEndsAt: null,
});
const job: Contract = {
id: "c1",
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 10,
payout: 900,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: NOW,
expiresAt: NOW + 30 * 60_000,
firstContact: false,
};
const summary: ResolutionSummary = {
dispatchId: "d1",
shipId: "s-vet",
pilotId: "p-vet",
originId: "earth",
destinationId: "moon",
tutorial: false,
deadhead: false,
fuelCost: 150,
goodId: "consumer-goods",
massTonnes: 10,
shipper: "Pacific Orbital Logistics",
credits: 600,
loanRepayment: 0,
pilotPay: 63,
pilotXp: 10,
discoveredLocationId: null,
scriptedId: null,
restEndsAt: null,
resolvedAt: NOW,
};
const state = (summaries: ResolutionSummary[] = []) =>
samplePlayerState({
tutorial: null,
company: { id: "c", name: "D", credits: 100_000, loanBalance: 0 },
bays: [{ id: "b", locationId: "earth" }],
ships: [ship("s-me", "My Lark", "avatar"), ship("s-vet", "Rosa's Lark", "p-vet")],
pilots: [pilot("avatar", "Captain", "s-me", true), pilot("p-vet", "Rosa Kell", "s-vet", false)],
summaries,
});
describe("pilot share in the client (Step 10J.6)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
it("shows the hired pilot's share and the net profit after it, and no share for the avatar", async () => {
renderApp(
fakeApi({
getPlayerState: () => state(),
getContractBoard: () => ({ contracts: [job], periodEndsAt: NOW + 600_000 }),
}),
"/contracts?location=earth",
);
await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
const dialog = screen.getByRole("dialog");
const fuel = tripFuelCost(
content.shipModel("courier")!,
content.location("earth")!,
content.location("moon")!,
realToGame(NOW, content.config),
);
const share = pilotShare(job.payout, fuel, content.config);
const hired = within(dialog).getByText("Rosa's Lark").closest("li")!;
expect(hired).toHaveTextContent(`Pilot share${formatCredits(share)}`);
expect(hired).toHaveTextContent(`Net profit${formatCredits(job.payout - fuel - share)}`);
const mine = within(dialog).getByText("My Lark").closest("li")!;
expect(mine).not.toHaveTextContent("Pilot share");
expect(mine).toHaveTextContent(`Net profit${formatCredits(job.payout - fuel)}`);
});
it("lists the pilot's share in a delivery summary", async () => {
renderApp(fakeApi({ getPlayerState: () => state([summary]) }), "/");
const dialog = await screen.findByRole("dialog", { name: "While you were away" });
expect(dialog).toHaveTextContent(`${formatCredits(63)} pilot share to Rosa Kell`);
});
});

View File

@ -40,6 +40,7 @@ const summary = (extra: Partial<ResolutionSummary>): ResolutionSummary => ({
massTonnes: 12, massTonnes: 12,
shipper: "Pacific Orbital Logistics", shipper: "Pacific Orbital Logistics",
loanRepayment: 0, loanRepayment: 0,
pilotPay: 0,
pilotXp: 10, pilotXp: 10,
discoveredLocationId: null, discoveredLocationId: null,
scriptedId: null, scriptedId: null,

View File

@ -155,6 +155,7 @@ describe("scripted delivery summary", () => {
massTonnes: 12, massTonnes: 12,
shipper: "Pacific Orbital Logistics", shipper: "Pacific Orbital Logistics",
loanRepayment: 0, loanRepayment: 0,
pilotPay: 0,
pilotXp: 10, pilotXp: 10,
discoveredLocationId: null, discoveredLocationId: null,
scriptedId: "unmarked-crates", scriptedId: "unmarked-crates",

View File

@ -31,7 +31,7 @@ Living reference for how the code is organised. Update it after any database sch
| `LocationState` | `id`, `discovered` | | `LocationState` | `id`, `discovered` |
| `Contract` | mirrors sim `Contract`: route, `cargoTypeId` (class), `goodId`, `shipper`, `priority`, `deliveryWindowGameHours` (Priority only, else null), `tier`, `massTonnes` (to 0.1 t under 10 t), `payout`, requirements, `offeredAt`/`expiresAt`, `firstContact`, optional `scriptedId` on a scripted tutorial contract (CR-007) | | `Contract` | mirrors sim `Contract`: route, `cargoTypeId` (class), `goodId`, `shipper`, `priority`, `deliveryWindowGameHours` (Priority only, else null), `tier`, `massTonnes` (to 0.1 t under 10 t), `payout`, requirements, `offeredAt`/`expiresAt`, `firstContact`, optional `scriptedId` on a scripted tutorial contract (CR-007) |
| `Dispatch` | mirrors sim `DispatchRecord` + `status` (`in-flight` / `resolved`), `resolvedAt` | | `Dispatch` | mirrors sim `DispatchRecord` + `status` (`in-flight` / `resolved`), `resolvedAt` |
| `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `deadhead`, `fuelCost`, `goodId` / `massTonnes` / `shipper` (null for a deadhead, CR-007), `credits` (net), `loanRepayment`, `pilotXp`, `discoveredLocationId`, `scriptedId`, `restEndsAt` (set when the trip ended in mandatory rest), `resolvedAt` | | `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `deadhead`, `fuelCost`, `goodId` / `massTonnes` / `shipper` (null for a deadhead, CR-007), `credits` (net), `loanRepayment`, `pilotPay` (hired pilot's profit share, CR-010), `pilotXp`, `discoveredLocationId`, `scriptedId`, `restEndsAt` (set when the trip ended in mandatory rest), `resolvedAt` |
| `TutorialState` | `loanerLocationId` (`earth` / `moon`), `runInFlight`, `runsCompleted`, `dealAvailable` | | `TutorialState` | `loanerLocationId` (`earth` / `moon`), `runInFlight`, `runsCompleted`, `dealAvailable` |
| `GoalState` | `id`, `status` (`incomplete` / `ready` / `claimed`) | | `GoalState` | `id`, `status` (`incomplete` / `ready` / `claimed`) |
| `PlayerState` | `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries`, `goals` (empty during the tutorial) | | `PlayerState` | `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries`, `goals` (empty during the tutorial) |

View File

@ -245,5 +245,5 @@ Spec: `memory-bank/change-requests/CR-010-veteran-pilot-profit-share.md` (receiv
- [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).
- [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`. - [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 - [x] **Step 10J.6 — Client: pilot share in dispatch and summaries.** The dispatch sheet shows "Pilot share" between Fuel and Net profit for hired pilots (sim `pilotShare`), none for the avatar; net profit is payout − fuel − share. API `ResolutionSummary.pilotPay` (from the stored resolution); the away summary shows "[share] pilot share to [pilot]" when above zero. Test: `pilot-share.test.tsx`.
- [ ] Step 10J.7 — Plan amendments and regression - [ ] Step 10J.7 — Plan amendments and regression

View File

@ -170,6 +170,8 @@ export const resolutionSummarySchema = z.object({
credits, credits,
/** Credits the lender took from this payout (decision 0.5.32). */ /** Credits the lender took from this payout (decision 0.5.32). */
loanRepayment: credits.nonnegative(), loanRepayment: credits.nonnegative(),
/** A hired pilot's profit share of this delivery; 0 for the avatar, tutorial runs and deadheads (CR-010). */
pilotPay: credits.nonnegative(),
pilotXp: z.number().int().nonnegative(), pilotXp: z.number().int().nonnegative(),
/** Set when the delivery discovered its destination. */ /** Set when the delivery discovered its destination. */
discoveredLocationId: idSchema.nullable(), discoveredLocationId: idSchema.nullable(),

View File

@ -136,6 +136,7 @@ const summary = {
massTonnes: 12, massTonnes: 12,
shipper: "Pacific Orbital Logistics", shipper: "Pacific Orbital Logistics",
loanRepayment: 0, loanRepayment: 0,
pilotPay: 0,
pilotXp: 10, pilotXp: 10,
discoveredLocationId: null, discoveredLocationId: null,
scriptedId: "unmarked-crates", scriptedId: "unmarked-crates",
@ -215,6 +216,12 @@ const cases: [string, z.ZodType, unknown, unknown][] = [
], ],
["dispatch", dispatchSchema, dispatch, { ...dispatch, status: "lost" }], ["dispatch", dispatchSchema, dispatch, { ...dispatch, status: "lost" }],
["resolution summary", resolutionSummarySchema, summary, { ...summary, pilotXp: -5 }], ["resolution summary", resolutionSummarySchema, summary, { ...summary, pilotXp: -5 }],
[
"summary with a pilot share",
resolutionSummarySchema,
{ ...summary, pilotPay: 63 },
{ ...summary, pilotPay: -1 },
],
[ [
"summary with rest started", "summary with rest started",
resolutionSummarySchema, resolutionSummarySchema,