Step 10J.2: profit-share pilot pay in resolution

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 21:28:56 -04:00
parent e0742fff76
commit cb54165c90
13 changed files with 159 additions and 10 deletions

View File

@ -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 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. - **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. - **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.

View File

@ -4,6 +4,7 @@ import {
checkRoute, checkRoute,
contractTripGameHours, contractTripGameHours,
isInShop, isInShop,
pilotShare,
dutyAfterTrip, dutyAfterTrip,
LONG_HAUL_SKILL_ID, LONG_HAUL_SKILL_ID,
needsRest, needsRest,
@ -167,7 +168,9 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
location(contract.destinationId), location(contract.destinationId),
gameNow, 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"]]; let problems: string[] = [INELIGIBLE_REASONS["pilot-not-assigned"]];
if (pilot) { if (pilot) {

View File

@ -6,6 +6,7 @@ import {
routeDistance, routeDistance,
travelTimeGameHours, travelTimeGameHours,
tripFuelCost, tripFuelCost,
pilotShare,
} from "@deadhead/sim"; } from "@deadhead/sim";
import { screen, waitFor, within } from "@testing-library/react"; import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
@ -125,7 +126,7 @@ describe("dispatch sheet", () => {
MS_PER_HOUR) / MS_PER_HOUR) /
content.config.timeCompression; content.config.timeCompression;
const fuel = tripFuelCost(model, earth, mars, gameNow); 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(within(freighter).getByRole("radio")).toBeEnabled();
expect(freighter).toHaveTextContent(`Trip${formatDuration(tripMs)}`); expect(freighter).toHaveTextContent(`Trip${formatDuration(tripMs)}`);

View File

@ -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. 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`. - [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.3 — Sim: goal change
- [ ] Step 10J.4 — Server: veteran on the deal - [ ] Step 10J.4 — Server: veteran on the deal
- [ ] Step 10J.5 — Client: deal modal and pilot card - [ ] Step 10J.5 — Client: deal modal and pilot card

View File

@ -64,7 +64,6 @@ export const gameConfig: GameConfig = {
// Ten levels × 2 points = enough to max all four skills (4 × 5 levels). // Ten levels × 2 points = enough to max all four skills (4 × 5 levels).
skillPointsPerLevel: 2, skillPointsPerLevel: 2,
pilotHireCost: 2000, pilotHireCost: 2000,
pilotPayPerDelivery: 100,
// A quarter of each contract's profit (payout − fuel) goes to a hired pilot (CR-010). // A quarter of each contract's profit (payout − fuel) goes to a hired pilot (CR-010).
pilotProfitShare: 0.25, pilotProfitShare: 0.25,
}; };

View File

@ -263,8 +263,6 @@ export const gameConfigSchema = z
skillPointsPerLevel: positiveInt, skillPointsPerLevel: positiveInt,
/** Credits. */ /** Credits. */
pilotHireCost: nonNegativeInt, 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). */ /** Hired pilots' share of profit (payout − fuel) per contract delivery (decision 0.5.12, CR-010). */
pilotProfitShare: fraction, pilotProfitShare: fraction,
}) })

View File

@ -99,7 +99,6 @@ const validGameConfig = {
pilotLevelXpThresholds: [100, 250, 500], pilotLevelXpThresholds: [100, 250, 500],
skillPointsPerLevel: 1, skillPointsPerLevel: 1,
pilotHireCost: 1000, pilotHireCost: 1000,
pilotPayPerDelivery: 100,
pilotProfitShare: 0.25, pilotProfitShare: 0.25,
tutorialLoanerShipModelId: "company-courier", tutorialLoanerShipModelId: "company-courier",
tutorialRunsBeforeDeal: 4, tutorialRunsBeforeDeal: 4,

View File

@ -91,3 +91,5 @@ export type {
UpgradeLevels, UpgradeLevels,
UpgradeProposal, UpgradeProposal,
} from "./upgrades"; } from "./upgrades";
export { pilotShare } from "./pay";

11
packages/sim/src/pay.ts Normal file
View File

@ -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);
}

View File

@ -3,6 +3,7 @@ import type { RealTime } from "./clock";
import type { DispatchRecord } from "./dispatch"; import type { DispatchRecord } from "./dispatch";
import { dutyAfterTrip, dutyLimit, needsRest, restEndsAt, tripGameHours } from "./duty"; import { dutyAfterTrip, dutyLimit, needsRest, restEndsAt, tripGameHours } from "./duty";
import { splitLoanRepayment } from "./loan"; import { splitLoanRepayment } from "./loan";
import { pilotShare } from "./pay";
import type { SkillLevels } from "./pilot"; import type { SkillLevels } from "./pilot";
import { deliveryXp } from "./xp"; import { deliveryXp } from "./xp";
@ -12,6 +13,8 @@ export interface Resolution {
* Fuel was already charged at departure. * Fuel was already charged at departure.
*/ */
credits: number; 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). */ /** Credits that went to the lender out of this payout (decision 0.5.32). */
loanRepayment: number; loanRepayment: number;
pilotXp: number; pilotXp: number;
@ -70,6 +73,7 @@ export function resolveDispatch(input: ResolveInput, content: SimContent): Resol
resolution: { resolution: {
...base, ...base,
credits: 0, credits: 0,
pilotPay: 0,
loanRepayment: 0, loanRepayment: 0,
pilotXp: 0, pilotXp: 0,
discoversDestination: false, 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. // Tutorial contracts already store exactly the tutorial share as their payout.
const { repayment, net } = splitLoanRepayment( const { repayment, net } = splitLoanRepayment(
contract.payout - pilotPay, contract.payout - pilotPay,
@ -89,6 +97,7 @@ export function resolveDispatch(input: ResolveInput, content: SimContent): Resol
resolution: { resolution: {
...base, ...base,
credits: net, credits: net,
pilotPay,
loanRepayment: repayment, loanRepayment: repayment,
pilotXp: deliveryXp(record.distanceMillionKm, contract.tier, content.config), pilotXp: deliveryXp(record.distanceMillionKm, contract.tier, content.config),
discoversDestination: !discoveredIds.has(record.destinationId), discoversDestination: !discoveredIds.has(record.destinationId),

View File

@ -5,6 +5,7 @@ import {
resolveDispatch, resolveDispatch,
splitLoanRepayment, splitLoanRepayment,
type Contract, type Contract,
pilotShare,
} from "../src/index"; } from "../src/index";
describe("loan repayment split", () => { describe("loan repayment split", () => {
@ -91,7 +92,7 @@ describe("resolution with a loan", () => {
content, content,
); );
if (result.status !== "resolved") throw new Error("expected a resolution"); 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); const repayment = Math.floor(afterPay * content.config.loanRepaymentShare);
expect(result.resolution).toMatchObject({ expect(result.resolution).toMatchObject({
loanRepayment: repayment, loanRepayment: repayment,

View File

@ -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<DispatchRecord> = {}) => ({
...base,
contract: { ...contract, payout },
fuelCost,
...extra,
});
const resolve = (record: DispatchRecord, more: Partial<ResolveInput> = {}) => {
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);
}
});
});

View File

@ -8,6 +8,7 @@ import {
type PilotState, type PilotState,
type ResolveInput, type ResolveInput,
type ShipState, type ShipState,
pilotShare,
} from "../src/index"; } from "../src/index";
const now = Date.parse("2100-01-01T00:30:00Z"); 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", () => { it("deducts pilot pay for a hired pilot and grants XP", () => {
const resolution = resolve(arrived); 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); expect(resolution.pilotXp).toBeGreaterThan(0);
}); });