From cb54165c90af7cf989bcd3cdab816d5d94fc0e1a Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sun, 4 Oct 2026 21:28:56 -0400 Subject: [PATCH] Step 10J.2: profit-share pilot pay in resolution Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 6 ++ apps/web/src/screens/DispatchSheet.tsx | 5 +- apps/web/test/dispatch-sheet.test.tsx | 3 +- memory-bank/progress.md | 2 +- packages/content/src/data/config.ts | 1 - packages/content/src/schemas.ts | 2 - packages/content/test/schemas.test.ts | 1 - packages/sim/src/index.ts | 2 + packages/sim/src/pay.ts | 11 +++ packages/sim/src/resolution.ts | 11 ++- packages/sim/test/loan.test.ts | 3 +- packages/sim/test/profit-share.test.ts | 117 +++++++++++++++++++++++++ packages/sim/test/resolution.test.ts | 5 +- 13 files changed, 159 insertions(+), 10 deletions(-) create mode 100644 packages/sim/src/pay.ts create mode 100644 packages/sim/test/profit-share.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index c21e0c1..0c86a6e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1000,3 +1000,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **The flat fee is removed one step later**, in 10J.2 together with the formula that replaces it, so every commit builds. `pilotProfitShare` is added here. - **The veteran lives with the onboarding content** (`content.veteranPilot`: name, bio, level, skills) since it belongs to the deal. Name and bio are from the CR's example. - **Skill points are checked against the level:** a level-2 pilot has 2 × 2 = 4 points; the veteran spends 1 (Heavy Cargo 1) and has 3 unspent. + +## Step 10J.2 + +- **Rounding:** pilot pay uses `Math.round` to whole credits, the same as payouts and fuel, so the CR's 0.25 × (400 − 150) = 62.5 pays **63**. The loan repayment keeps its own rule (rounded down, decision 0.5.32), computed on what is left after pilot pay (0.5.69). +- **The split is exact:** credits added + pilot pay + loan repayment always equal the payout (fuel was charged at dispatch); tested over 500 random cases. +- **`Resolution.pilotPay`** is stored with each resolution so summaries can show the share (10J.6); older stored resolutions without it read as 0. diff --git a/apps/web/src/screens/DispatchSheet.tsx b/apps/web/src/screens/DispatchSheet.tsx index 1c4daf6..23f2a21 100644 --- a/apps/web/src/screens/DispatchSheet.tsx +++ b/apps/web/src/screens/DispatchSheet.tsx @@ -4,6 +4,7 @@ import { checkRoute, contractTripGameHours, isInShop, + pilotShare, dutyAfterTrip, LONG_HAUL_SKILL_ID, needsRest, @@ -167,7 +168,9 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number) location(contract.destinationId), gameNow, ); - const pilotPay = pilot && !pilot.isAvatar ? content.config.pilotPayPerDelivery : 0; + // Profit-share pay for a hired pilot; the avatar is exempt (CR-010). + const pilotPay = + pilot && !pilot.isAvatar ? pilotShare(contract.payout, fuelCost, content.config) : 0; let problems: string[] = [INELIGIBLE_REASONS["pilot-not-assigned"]]; if (pilot) { diff --git a/apps/web/test/dispatch-sheet.test.tsx b/apps/web/test/dispatch-sheet.test.tsx index 2c8449e..dc5f650 100644 --- a/apps/web/test/dispatch-sheet.test.tsx +++ b/apps/web/test/dispatch-sheet.test.tsx @@ -6,6 +6,7 @@ import { routeDistance, travelTimeGameHours, tripFuelCost, + pilotShare, } from "@deadhead/sim"; import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -125,7 +126,7 @@ describe("dispatch sheet", () => { MS_PER_HOUR) / content.config.timeCompression; const fuel = tripFuelCost(model, earth, mars, gameNow); - const net = marsJob.payout - fuel - content.config.pilotPayPerDelivery; + const net = marsJob.payout - fuel - pilotShare(marsJob.payout, fuel, content.config); expect(within(freighter).getByRole("radio")).toBeEnabled(); expect(freighter).toHaveTextContent(`Trip${formatDuration(tripMs)}`); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 18d13fd..c8fdfc7 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -241,7 +241,7 @@ Spec: `memory-bank/change-requests/CR-009-ship-upgrades.md` (received as "CR-006 Spec: `memory-bank/change-requests/CR-010-veteran-pilot-profit-share.md` (received as "CR-007 … Phase 10G"). Decision 0.5.12 replaced; new decisions recorded as plan §0.5.69–0.5.70. - [x] **Step 10J.1 — Content: config and veteran.** Config `pilotProfitShare` 0.25 (a fraction 0–1); the flat `pilotPayPerDelivery` is removed in 10J.2 when the sim switches to the share. `veteranPilotSchema` and `content.veteranPilot`: Rosa Kell, "Halvorsen's pilot for twelve years.", level 2, Heavy Cargo 1. Test: `content/test/veteran.test.ts`. -- [ ] Step 10J.2 — Sim: profit-share pay +- [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. - [ ] Step 10J.3 — Sim: goal change - [ ] Step 10J.4 — Server: veteran on the deal - [ ] Step 10J.5 — Client: deal modal and pilot card diff --git a/packages/content/src/data/config.ts b/packages/content/src/data/config.ts index eac31d7..a30f69e 100644 --- a/packages/content/src/data/config.ts +++ b/packages/content/src/data/config.ts @@ -64,7 +64,6 @@ export const gameConfig: GameConfig = { // Ten levels × 2 points = enough to max all four skills (4 × 5 levels). skillPointsPerLevel: 2, pilotHireCost: 2000, - pilotPayPerDelivery: 100, // A quarter of each contract's profit (payout − fuel) goes to a hired pilot (CR-010). pilotProfitShare: 0.25, }; diff --git a/packages/content/src/schemas.ts b/packages/content/src/schemas.ts index c91c33a..4dc370d 100644 --- a/packages/content/src/schemas.ts +++ b/packages/content/src/schemas.ts @@ -263,8 +263,6 @@ export const gameConfigSchema = z skillPointsPerLevel: positiveInt, /** Credits. */ pilotHireCost: nonNegativeInt, - /** Credits per delivery; the avatar is exempt (decision 0.5.12). */ - pilotPayPerDelivery: nonNegativeInt, /** Hired pilots' share of profit (payout − fuel) per contract delivery (decision 0.5.12, CR-010). */ pilotProfitShare: fraction, }) diff --git a/packages/content/test/schemas.test.ts b/packages/content/test/schemas.test.ts index dd221d2..661680a 100644 --- a/packages/content/test/schemas.test.ts +++ b/packages/content/test/schemas.test.ts @@ -99,7 +99,6 @@ const validGameConfig = { pilotLevelXpThresholds: [100, 250, 500], skillPointsPerLevel: 1, pilotHireCost: 1000, - pilotPayPerDelivery: 100, pilotProfitShare: 0.25, tutorialLoanerShipModelId: "company-courier", tutorialRunsBeforeDeal: 4, diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index d364bac..508ad09 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -91,3 +91,5 @@ export type { UpgradeLevels, UpgradeProposal, } from "./upgrades"; + +export { pilotShare } from "./pay"; diff --git a/packages/sim/src/pay.ts b/packages/sim/src/pay.ts new file mode 100644 index 0000000..9d164e3 --- /dev/null +++ b/packages/sim/src/pay.ts @@ -0,0 +1,11 @@ +import type { GameConfig } from "@deadhead/content"; + +/** + * A hired pilot's pay for one contract delivery (decision 0.5.12, CR-010): the + * profit share of payout minus fuel, never negative, rounded to whole credits + * like payouts and fuel. The avatar, tutorial runs and deadheads earn nothing; + * callers apply those exemptions. + */ +export function pilotShare(payout: number, fuelCost: number, config: GameConfig): number { + return Math.round(Math.max(0, payout - fuelCost) * config.pilotProfitShare); +} diff --git a/packages/sim/src/resolution.ts b/packages/sim/src/resolution.ts index 43e7eaf..a89c371 100644 --- a/packages/sim/src/resolution.ts +++ b/packages/sim/src/resolution.ts @@ -3,6 +3,7 @@ import type { RealTime } from "./clock"; import type { DispatchRecord } from "./dispatch"; import { dutyAfterTrip, dutyLimit, needsRest, restEndsAt, tripGameHours } from "./duty"; import { splitLoanRepayment } from "./loan"; +import { pilotShare } from "./pay"; import type { SkillLevels } from "./pilot"; import { deliveryXp } from "./xp"; @@ -12,6 +13,8 @@ export interface Resolution { * Fuel was already charged at departure. */ credits: number; + /** A hired pilot's share of this delivery's profit; 0 for the avatar, tutorial runs and deadheads (CR-010). */ + pilotPay: number; /** Credits that went to the lender out of this payout (decision 0.5.32). */ loanRepayment: number; pilotXp: number; @@ -70,6 +73,7 @@ export function resolveDispatch(input: ResolveInput, content: SimContent): Resol resolution: { ...base, credits: 0, + pilotPay: 0, loanRepayment: 0, pilotXp: 0, discoversDestination: false, @@ -77,7 +81,11 @@ export function resolveDispatch(input: ResolveInput, content: SimContent): Resol }; } - const pilotPay = record.tutorial || pilotIsAvatar ? 0 : content.config.pilotPayPerDelivery; + // Profit-share pay (decision 0.5.12, CR-010), taken before the loan repayment (0.5.69). + const pilotPay = + record.tutorial || pilotIsAvatar + ? 0 + : pilotShare(contract.payout, record.fuelCost, content.config); // Tutorial contracts already store exactly the tutorial share as their payout. const { repayment, net } = splitLoanRepayment( contract.payout - pilotPay, @@ -89,6 +97,7 @@ export function resolveDispatch(input: ResolveInput, content: SimContent): Resol resolution: { ...base, credits: net, + pilotPay, loanRepayment: repayment, pilotXp: deliveryXp(record.distanceMillionKm, contract.tier, content.config), discoversDestination: !discoveredIds.has(record.destinationId), diff --git a/packages/sim/test/loan.test.ts b/packages/sim/test/loan.test.ts index 1a26b78..bbe0822 100644 --- a/packages/sim/test/loan.test.ts +++ b/packages/sim/test/loan.test.ts @@ -5,6 +5,7 @@ import { resolveDispatch, splitLoanRepayment, type Contract, + pilotShare, } from "../src/index"; describe("loan repayment split", () => { @@ -91,7 +92,7 @@ describe("resolution with a loan", () => { content, ); if (result.status !== "resolved") throw new Error("expected a resolution"); - const afterPay = 2000 - content.config.pilotPayPerDelivery; + const afterPay = 2000 - pilotShare(2000, record.fuelCost, content.config); const repayment = Math.floor(afterPay * content.config.loanRepaymentShare); expect(result.resolution).toMatchObject({ loanRepayment: repayment, diff --git a/packages/sim/test/profit-share.test.ts b/packages/sim/test/profit-share.test.ts new file mode 100644 index 0000000..545a58a --- /dev/null +++ b/packages/sim/test/profit-share.test.ts @@ -0,0 +1,117 @@ +import { content } from "@deadhead/content"; +import { describe, expect, it } from "vitest"; +import { + createDispatchRecord, + createRepositionRecord, + createRng, + pilotShare, + resolveDispatch, + type Contract, + type DispatchRecord, + type PilotState, + type ResolveInput, + type ShipState, +} from "../src/index"; + +const now = Date.parse("2100-01-01T00:30:00Z"); +const contract: 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: 400, + requiredSkill: null, + requiredLongHaulLevel: 0, + offeredAt: now, + expiresAt: now + 3_600_000, + firstContact: false, +}; +const ship: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false }; +const pilot: PilotState = { + id: "p1", + isAvatar: false, + assignedShipId: "s1", + inFlight: false, + skills: {}, + restEndsAt: null, +}; +const base = createDispatchRecord( + { id: "d1", ship, pilot, contract, tutorial: false, now, worldSeed: 7 }, + content, +); +/** The trip with a chosen payout and fuel cost. */ +const trip = (payout: number, fuelCost: number, extra: Partial = {}) => ({ + ...base, + contract: { ...contract, payout }, + fuelCost, + ...extra, +}); +const resolve = (record: DispatchRecord, more: Partial = {}) => { + const result = resolveDispatch( + { + record, + pilotIsAvatar: false, + discoveredIds: new Set(["earth", "moon"]), + loanBalance: 0, + pilotDutyHours: 0, + pilotSkills: {}, + now: record.arrivalAt, + ...more, + }, + content, + ); + if (result.status !== "resolved") throw new Error("not resolved"); + return result.resolution; +}; + +describe("profit-share pilot pay (Step 10J.2)", () => { + it("pays a hired pilot a quarter of payout minus fuel, rounded like other credits", () => { + // 0.25 × (400 − 150) = 62.5 → 63 (Math.round, as payouts and fuel). + expect(pilotShare(400, 150, content.config)).toBe(63); + expect(resolve(trip(400, 150))).toMatchObject({ pilotPay: 63, credits: 400 - 63 }); + }); + + it("pays nothing when fuel costs as much as the payout or more", () => { + expect(resolve(trip(400, 400)).pilotPay).toBe(0); + expect(resolve(trip(400, 900))).toMatchObject({ pilotPay: 0, credits: 400 }); + }); + + it("pays the avatar, deadheads and tutorial runs nothing", () => { + expect(resolve(trip(400, 150), { pilotIsAvatar: true }).pilotPay).toBe(0); + expect(resolve(trip(400, 150, { tutorial: true })).pilotPay).toBe(0); + const deadhead = createRepositionRecord( + { id: "r1", ship, pilot, destinationId: "moon", now, worldSeed: 7 }, + content, + ); + expect(resolve(deadhead)).toMatchObject({ pilotPay: 0, credits: 0 }); + }); + + it("takes the loan repayment from what is left after pilot pay", () => { + const resolution = resolve(trip(400, 150), { loanBalance: 5000 }); + const afterPay = 400 - 63; + expect(resolution).toMatchObject({ + pilotPay: 63, + loanRepayment: Math.floor(afterPay * content.config.loanRepaymentShare), + }); + }); + + it("always splits the payout exactly into credits, pilot pay and repayment", () => { + const rng = createRng(10); + for (let i = 0; i < 500; i++) { + const payout = rng.int(0, 50_000); + const fuel = rng.int(0, 20_000); + const r = resolve(trip(payout, fuel), { + loanBalance: rng.int(0, 6000), + pilotIsAvatar: rng.next() < 0.3, + }); + expect(r.credits + r.pilotPay + r.loanRepayment).toBe(payout); + expect(r.pilotPay).toBeGreaterThanOrEqual(0); + } + }); +}); diff --git a/packages/sim/test/resolution.test.ts b/packages/sim/test/resolution.test.ts index 5fe8583..98c4108 100644 --- a/packages/sim/test/resolution.test.ts +++ b/packages/sim/test/resolution.test.ts @@ -8,6 +8,7 @@ import { type PilotState, type ResolveInput, type ShipState, + pilotShare, } from "../src/index"; const now = Date.parse("2100-01-01T00:30:00Z"); @@ -74,7 +75,9 @@ describe("dispatch resolution", () => { it("deducts pilot pay for a hired pilot and grants XP", () => { const resolution = resolve(arrived); - expect(resolution.credits).toBe(2000 - content.config.pilotPayPerDelivery); + const pay = pilotShare(2000, record.fuelCost, content.config); + expect(pay).toBeGreaterThan(0); + expect(resolution).toMatchObject({ pilotPay: pay, credits: 2000 - pay }); expect(resolution.pilotXp).toBeGreaterThan(0); });