Step 10J.2: profit-share pilot pay in resolution
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e0742fff76
commit
cb54165c90
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -99,7 +99,6 @@ const validGameConfig = {
|
|||
pilotLevelXpThresholds: [100, 250, 500],
|
||||
skillPointsPerLevel: 1,
|
||||
pilotHireCost: 1000,
|
||||
pilotPayPerDelivery: 100,
|
||||
pilotProfitShare: 0.25,
|
||||
tutorialLoanerShipModelId: "company-courier",
|
||||
tutorialRunsBeforeDeal: 4,
|
||||
|
|
|
|||
|
|
@ -91,3 +91,5 @@ export type {
|
|||
UpgradeLevels,
|
||||
UpgradeProposal,
|
||||
} from "./upgrades";
|
||||
|
||||
export { pilotShare } from "./pay";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue