diff --git a/DECISIONS.md b/DECISIONS.md index bc25f54..65f5ac1 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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`. - **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. diff --git a/apps/server/src/game/player-state.ts b/apps/server/src/game/player-state.ts index 4e4a82d..fe3a2b9 100644 --- a/apps/server/src/game/player-state.ts +++ b/apps/server/src/game/player-state.ts @@ -121,6 +121,7 @@ export async function loadPlayerState( shipper: d.record.contract?.shipper ?? null, credits: d.resolution?.credits ?? 0, loanRepayment: d.resolution?.loanRepayment ?? 0, + pilotPay: d.resolution?.pilotPay ?? 0, pilotXp: d.resolution?.pilotXp ?? 0, discoveredLocationId: d.resolution?.discoversDestination ? d.record.destinationId : null, scriptedId: d.record.contract?.scriptedId ?? null, diff --git a/apps/web/src/components/AwaySummary.tsx b/apps/web/src/components/AwaySummary.tsx index 2e76ade..37f3ef9 100644 --- a/apps/web/src/components/AwaySummary.tsx +++ b/apps/web/src/components/AwaySummary.tsx @@ -75,6 +75,12 @@ export function AwaySummary() { +{s.pilotXp} XP )} + {s.pilotPay > 0 && ( +
+ {formatCredits(s.pilotPay)} pilot share to{" "} + {state.pilots.find((p) => p.id === s.pilotId)?.name ?? "the pilot"} +
+ )} {s.loanRepayment > 0 && (
{formatCredits(s.loanRepayment)} went to the lender diff --git a/apps/web/src/screens/DispatchSheet.tsx b/apps/web/src/screens/DispatchSheet.tsx index 23f2a21..b6dd34a 100644 --- a/apps/web/src/screens/DispatchSheet.tsx +++ b/apps/web/src/screens/DispatchSheet.tsx @@ -131,6 +131,8 @@ export interface PairOption { problems: string[]; tripMs: number; fuelCost: number; + /** A hired pilot's profit share; 0 for the avatar (CR-010). */ + pilotPay: number; netProfit: number; arrivalAt: number; /** 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, tripMs, fuelCost, + pilotPay, netProfit: contract.payout - fuelCost - pilotPay, arrivalAt: now + tripMs, duty: pilot ? dutyOutlook(pilot, now, now + tripMs) : null, @@ -329,6 +332,10 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
+ {option.pilotId !== null && + !state.pilots.find((p) => p.id === option.pilotId)?.isAvatar && ( + + )} { massTonnes: 12, shipper: "Pacific Orbital Logistics", loanRepayment: 0, + pilotPay: 0, pilotXp: 10, discoveredLocationId: null, scriptedId: null, diff --git a/apps/web/test/hq.test.tsx b/apps/web/test/hq.test.tsx index 64d490e..a4e71d3 100644 --- a/apps/web/test/hq.test.tsx +++ b/apps/web/test/hq.test.tsx @@ -153,6 +153,7 @@ describe("loan on HQ", () => { massTonnes: 12, shipper: "Pacific Orbital Logistics", loanRepayment: 230, + pilotPay: 0, pilotXp: 10, discoveredLocationId: null, scriptedId: null, diff --git a/apps/web/test/pilot-share.test.tsx b/apps/web/test/pilot-share.test.tsx new file mode 100644 index 0000000..d071fe6 --- /dev/null +++ b/apps/web/test/pilot-share.test.tsx @@ -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`); + }); +}); diff --git a/apps/web/test/rest-summary-hq.test.tsx b/apps/web/test/rest-summary-hq.test.tsx index a6173f3..d7d42da 100644 --- a/apps/web/test/rest-summary-hq.test.tsx +++ b/apps/web/test/rest-summary-hq.test.tsx @@ -40,6 +40,7 @@ const summary = (extra: Partial): ResolutionSummary => ({ massTonnes: 12, shipper: "Pacific Orbital Logistics", loanRepayment: 0, + pilotPay: 0, pilotXp: 10, discoveredLocationId: null, scriptedId: null, diff --git a/apps/web/test/tutorial-board.test.tsx b/apps/web/test/tutorial-board.test.tsx index 6a78ca8..6ce1849 100644 --- a/apps/web/test/tutorial-board.test.tsx +++ b/apps/web/test/tutorial-board.test.tsx @@ -155,6 +155,7 @@ describe("scripted delivery summary", () => { massTonnes: 12, shipper: "Pacific Orbital Logistics", loanRepayment: 0, + pilotPay: 0, pilotXp: 10, discoveredLocationId: null, scriptedId: "unmarked-crates", diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 00d7df1..8a072e2 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -31,7 +31,7 @@ Living reference for how the code is organised. Update it after any database sch | `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) | | `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` | | `GoalState` | `id`, `status` (`incomplete` / `ready` / `claimed`) | | `PlayerState` | `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries`, `goals` (empty during the tutorial) | diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 87dac3e..7816cdd 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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.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`. -- [ ] 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 diff --git a/packages/schema/src/entities.ts b/packages/schema/src/entities.ts index d27fecc..1d224ea 100644 --- a/packages/schema/src/entities.ts +++ b/packages/schema/src/entities.ts @@ -170,6 +170,8 @@ export const resolutionSummarySchema = z.object({ credits, /** Credits the lender took from this payout (decision 0.5.32). */ 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(), /** Set when the delivery discovered its destination. */ discoveredLocationId: idSchema.nullable(), diff --git a/packages/schema/test/schemas.test.ts b/packages/schema/test/schemas.test.ts index 6ebdf27..242ebe9 100644 --- a/packages/schema/test/schemas.test.ts +++ b/packages/schema/test/schemas.test.ts @@ -136,6 +136,7 @@ const summary = { massTonnes: 12, shipper: "Pacific Orbital Logistics", loanRepayment: 0, + pilotPay: 0, pilotXp: 10, discoveredLocationId: null, scriptedId: "unmarked-crates", @@ -215,6 +216,12 @@ const cases: [string, z.ZodType, unknown, unknown][] = [ ], ["dispatch", dispatchSchema, dispatch, { ...dispatch, status: "lost" }], ["resolution summary", resolutionSummarySchema, summary, { ...summary, pilotXp: -5 }], + [ + "summary with a pilot share", + resolutionSummarySchema, + { ...summary, pilotPay: 63 }, + { ...summary, pilotPay: -1 }, + ], [ "summary with rest started", resolutionSummarySchema,