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 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.
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)}`);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -91,3 +91,5 @@ export type {
|
||||||
UpgradeLevels,
|
UpgradeLevels,
|
||||||
UpgradeProposal,
|
UpgradeProposal,
|
||||||
} from "./upgrades";
|
} 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 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),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue