Compare commits

..

8 Commits

Author SHA1 Message Date
fireshaper fcdefb911f Step 10J.7: CR-010 plan amendments and regression
CI / check (push) Successful in 4m28s Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:27:38 -04:00
fireshaper f042555502 Step 10J.6: pilot share in the dispatch sheet and delivery summaries
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:23:17 -04:00
fireshaper 8cafa50027 Step 10J.5: the veteran in the deal modal and on the Pilots screen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:14:16 -04:00
fireshaper 9eae5ae64d Step 10J.4: the veteran pilot comes with the deal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:42:43 -04:00
fireshaper 8acc81bc10 Step 10J.3: goal 4 becomes flying a contract with the veteran
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:35:20 -04:00
fireshaper cb54165c90 Step 10J.2: profit-share pilot pay in resolution
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:28:56 -04:00
fireshaper e0742fff76 Step 10J.1: pilot profit share config and the veteran pilot
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:22:21 -04:00
fireshaper 727b0c02da CR-010: record veteran pilot and profit-share pay change request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:18:20 -04:00
58 changed files with 2286 additions and 31 deletions

View File

@ -988,3 +988,48 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **GDD §8 is left to the designer**, as the CR says; it doesn't mention upgrades yet, so the "confirm it matches" check is flagged for the user rather than written here.
- **The manual check was scripted** (`cr009-smoke.mjs`). It found that with 10-minute shop times and 10-minute boards, the heavy job you spot before upgrading always expires before the ship leaves the shop; the script finds a fresh 21–23 t job afterwards. Whether to keep that tension is a Phase 12 call.
## CR-010 — Veteran Pilot and Profit-Share Pay (Phase 10J)
- **Renumbered on arrival:** titled "CR-007 … Phase 10G"; CR-007 to CR-009 and Phases 10G–10I already exist, so it is **CR-010**, done as **Phase 10J** (its 10G.n are 10J.n).
- **Decisions:** 0.5.12 Pilot pay is replaced in place (profit share). The CR's 0.5.51 and 0.5.52 are **0.5.69 Deduction order** and **0.5.70 Veteran pilot**. Its "CR-001 decision 0.5.24" (the loan) is this plan's §0.5.32.
- **Goal and timing target changes** to CR-001's goal 4 and the "hire first pilot" target are applied in their steps (10J.3, 10J.7).
## Step 10J.1
- **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.
## Step 10J.3
- **New goal id `veteran-contract`** (was `first-hire`), with a new condition kind `veteran-contracts`. A new id keeps any old "first hire" claim from counting as this different goal; `pilots-hired` stays available as a condition kind but no goal uses it now.
- **The title is built from content** (`Fly a contract with ${veteranPilot.name}`), so renaming the veteran renames the goal.
- **Counting is the server's job**: the sim only compares `veteranContracts` to the goal; which deliveries count (contracts, not deadheads or tutorial runs, flown by the veteran) is decided where they are stored (10J.4).
## Step 10J.4
- **The veteran is a pilots row with `is_veteran`**, otherwise identical to a hire (pay, duty, rest, assignment rules). The flag only identifies them for goal 4 and the bio line.
- **Level 2 is stored as XP**: the veteran starts with exactly level 2's threshold (250 XP), so levels and skill points come from the normal rules; one point is recorded as spent on Heavy Cargo 1.
- **Created inside `acceptDeal`**, after the deal check, in the player-locked transaction: a refused or repeated accept creates nothing (tested).
## Step 10J.5
- **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.
## Step 10J.7
- **CR-001's text is amended in place** where the CR asks (goal 4, the deal, the modal, and the "hire first pilot" target now "buy a second ship"), with a note of what changed, so its history stays readable.
- **The manual check was scripted** (`cr010-smoke.mjs`); its numbers confirm the deduction order: (1118 − 230) × 0.25 = 222 to the pilot, then 30 % of the remaining 896 (268, rounded down) to the lender, 628 credited.

View File

@ -0,0 +1 @@
ALTER TABLE "pilots" ADD COLUMN "is_veteran" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@ -50,6 +50,13 @@
"when": 1791155978006,
"tag": "0006_cr009_ship_upgrades",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1791164145228,
"tag": "0007_cr010_veteran_pilot",
"breakpoints": true
}
]
}

View File

@ -100,6 +100,8 @@ export const pilots = pgTable(
.references(() => players.id, { onDelete: "cascade" }),
name: text("name").notNull(),
isAvatar: boolean("is_avatar").notNull().default(false),
/** The veteran who came with the deal (decision 0.5.70); otherwise a normal hired pilot. */
isVeteran: boolean("is_veteran").notNull().default(false),
xp: integer("xp").notNull().default(0),
skills: jsonb("skills").$type<Record<string, number>>().notNull().default({}),
skillPointsSpent: integer("skill_points_spent").notNull().default(0),

View File

@ -71,6 +71,22 @@ export async function acceptDeal(tx: Queryable, playerId: string, now: RealTime)
.update(pilots)
.set({ assignedShipId: shipId, locationId: null, status: "idle" })
.where(and(eq(pilots.playerId, playerId), eq(pilots.isAvatar, true)));
// Halvorsen's veteran comes with the business (decision 0.5.70): at Earth,
// unassigned, at their level with their skills bought and the rest unspent.
const veteran = content.veteranPilot;
const xpForLevel =
veteran.level === 0 ? 0 : content.config.pilotLevelXpThresholds[veteran.level - 1]!;
await tx.insert(pilots).values({
id: randomUUID(),
playerId,
name: veteran.name,
isVeteran: true,
xp: xpForLevel,
skills: veteran.skills,
skillPointsSpent: Object.values(veteran.skills).reduce((a, b) => a + b, 0),
locationId: DEAL_BAY_LOCATION_ID,
createdAt: now,
});
await tx
.update(companies)
.set({ loanBalance: content.config.dealLoanAmount })

View File

@ -32,6 +32,20 @@ export async function goalProgress(tx: Queryable, playerId: string): Promise<Goa
sql`${dispatches.record} -> 'contract' <> 'null'::jsonb`,
),
);
// Contracts (not deadheads or tutorial runs) the veteran has delivered (CR-010).
const [veteranContracts] = await tx
.select({ n: count() })
.from(dispatches)
.innerJoin(pilots, eq(pilots.id, dispatches.pilotId))
.where(
and(
eq(dispatches.playerId, playerId),
eq(dispatches.status, "resolved"),
eq(dispatches.tutorial, false),
eq(pilots.isVeteran, true),
sql`${dispatches.record} -> 'contract' <> 'null'::jsonb`,
),
);
const [spent] = await tx
.select({ n: sum(pilots.skillPointsSpent) })
.from(pilots)
@ -55,6 +69,7 @@ export async function goalProgress(tx: Queryable, playerId: string): Promise<Goa
skillPointsSpent: Number(spent?.n ?? 0),
shipsOwned: owned!.n,
pilotsHired: hired!.n,
veteranContracts: veteranContracts!.n,
locationsDiscovered: discovered!.n,
dealAccepted: player.dealAccepted,
loanBalance: company.loanBalance,

View File

@ -41,6 +41,7 @@ export function toApiPilot(
id: row.id,
name: row.name,
isAvatar: row.isAvatar,
isVeteran: row.isVeteran,
xp: row.xp,
level,
skills: row.skills,

View File

@ -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,

View File

@ -48,7 +48,7 @@ describe("onboarding goals", () => {
expect(goals.map((g) => g.id)).toEqual(content.onboardingGoals.map((g) => g.id));
// Paid-off loan: the test owner has no balance.
expect(goals.find((g) => g.id === "loan-paid-off")?.status).toBe("ready");
expect(goals.find((g) => g.id === "first-hire")?.status).toBe("incomplete");
expect(goals.find((g) => g.id === "veteran-contract")?.status).toBe("incomplete");
});
it("becomes ready when its condition is met: a delivery with the player's own ship", async () => {
@ -93,7 +93,7 @@ describe("onboarding goals", () => {
it("refuses an incomplete goal and an unknown one", async () => {
const owner = await dealOwner();
await setCredits(database().db, owner.playerId, 100);
const response = await claim(owner, "first-hire");
const response = await claim(owner, "veteran-contract");
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("goal-not-ready");
expect((await claim(owner, "win-the-game")).statusCode).toBe(404);

View File

@ -0,0 +1,147 @@
import { content } from "@deadhead/content";
import {
dispatchResponseSchema,
errorResponseSchema,
playerStateSchema,
type PlayerState,
} from "@deadhead/schema";
import { pilotShare } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import { setCredits, useTestDatabase } from "./db";
import {
call,
getState,
insertDispatch,
putBoard,
signUp,
testContract,
useTestApp,
type Guest,
} from "./game";
import { flyTutorialRun } from "./tutorial-flow";
const database = useTestDatabase();
const testApp = useTestApp(database);
const errorCode = (response: { json(): unknown }) =>
errorResponseSchema.parse(response.json()).error.code;
const veteranOf = (state: PlayerState) => state.pilots.find((p) => p.isVeteran)!;
const avatarOf = (state: PlayerState) => state.pilots.find((p) => p.isAvatar)!;
const FAR_FUTURE = Date.parse("2200-01-01T00:00:00Z");
/** A guest who flies the tutorial runs and accepts the deal. */
async function dealt(): Promise<{ guest: Guest; state: PlayerState }> {
const guest = await signUp(testApp().app);
for (let run = 0; run < content.config.tutorialRunsBeforeDeal; run++) {
await flyTutorialRun(testApp(), guest);
}
const response = await call(testApp().app, guest, "POST", "/api/deal/accept", {});
expect(response.statusCode).toBe(200);
return { guest, state: playerStateSchema.parse(response.json()) };
}
describe("the veteran comes with the deal (Step 10J.4)", () => {
it("adds the veteran at Earth, unassigned, level 2 with Heavy Cargo 1", async () => {
const { state } = await dealt();
expect(state.pilots).toHaveLength(2);
const courier = state.ships[0]!;
expect(avatarOf(state)).toMatchObject({ assignedShipId: courier.id, locationId: "earth" });
const spentPoints = 1;
expect(veteranOf(state)).toMatchObject({
name: content.veteranPilot.name,
isAvatar: false,
isVeteran: true,
level: 2,
skills: { "heavy-cargo": 1 },
skillPointsAvailable: 2 * content.config.skillPointsPerLevel - spentPoints,
assignedShipId: null,
locationId: "earth",
status: "idle",
});
});
it("creates no veteran on a refused or repeated accept", async () => {
const guest = await signUp(testApp().app);
const early = await call(testApp().app, guest, "POST", "/api/deal/accept", {});
expect(errorCode(early)).toBe("deal-not-available");
expect((await getState(testApp().app, guest)).pilots).toHaveLength(1);
const { guest: owner } = await dealt();
const again = await call(testApp().app, owner, "POST", "/api/deal/accept", {});
expect(again.statusCode).toBe(409);
const pilots = (await getState(testApp().app, owner)).pilots;
expect(pilots.filter((p) => p.isVeteran)).toHaveLength(1);
});
it("swaps in the veteran while the avatar rests, and pays the veteran a profit share", async () => {
const { app, clock } = testApp();
const { guest, state } = await dealt();
const courier = state.ships[0]!;
const veteran = veteranOf(state);
await setCredits(database().db, guest.playerId, 10_000);
expect(
(await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/rest`, {})).statusCode,
).toBe(200);
expect(
(await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/unassign`, {})).statusCode,
).toBe(200);
expect(
(await call(app, guest, "POST", `/api/pilots/${veteran.id}/assign`, { shipId: courier.id }))
.statusCode,
).toBe(200);
const job = testContract({ payout: 2000, expiresAt: FAR_FUTURE });
await putBoard(database().db, guest.playerId, [job], clock.now);
const response = await call(app, guest, "POST", "/api/dispatches", {
kind: "contract",
contractId: job.id,
shipId: courier.id,
pilotId: veteran.id,
});
expect(response.statusCode).toBe(200);
const { dispatch } = dispatchResponseSchema.parse(response.json());
clock.now = dispatch.arrivalAt;
const after = await getState(app, guest);
const summary = after.summaries.find((s) => s.dispatchId === dispatch.id)!;
const pay = pilotShare(job.payout, dispatch.fuelCost, content.config);
expect(pay).toBeGreaterThan(0);
expect(summary.credits + summary.loanRepayment).toBe(job.payout - pay);
expect(after.goals.find((g) => g.id === "veteran-contract")?.status).toBe("ready");
});
it("counts only contracts the veteran flew toward goal 4", async () => {
const { app, clock } = testApp();
const { guest, state } = await dealt();
const courier = state.ships[0]!;
const veteran = veteranOf(state);
const goal = async () =>
(await getState(app, guest)).goals.find((g) => g.id === "veteran-contract")?.status;
// The avatar's delivery doesn't count.
const byAvatar = await insertDispatch(database().db, {
playerId: guest.playerId,
shipId: courier.id,
pilotId: guest.avatarId,
departAt: clock.now,
contract: testContract({ expiresAt: FAR_FUTURE }),
});
clock.now = byAvatar.arrivalAt;
expect(await goal()).toBe("incomplete");
// A deadhead by the veteran doesn't count either.
await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/unassign`, {});
await call(app, guest, "POST", `/api/pilots/${veteran.id}/assign`, { shipId: courier.id });
const deadhead = await insertDispatch(database().db, {
playerId: guest.playerId,
shipId: courier.id,
pilotId: veteran.id,
departAt: clock.now,
contract: null,
destinationId: "earth",
});
clock.now = deadhead.arrivalAt;
expect(await goal()).toBe("incomplete");
});
});

View File

@ -75,6 +75,12 @@ export function AwaySummary() {
<span className="text-violet-300">+{s.pilotXp} XP</span>
</div>
)}
{s.pilotPay > 0 && (
<div className="mt-1 text-ink-400">
{formatCredits(s.pilotPay)} pilot share to{" "}
{state.pilots.find((p) => p.id === s.pilotId)?.name ?? "the pilot"}
</div>
)}
{s.loanRepayment > 0 && (
<div className="mt-1 text-ink-400">
{formatCredits(s.loanRepayment)} went to the lender

View File

@ -29,6 +29,7 @@ export function DealModal() {
const { dealLoanAmount, loanRepaymentShare } = content.config;
const courier = content.shipModel("courier")!;
const veteran = content.veteranPilot;
return (
<Sheet
@ -41,8 +42,8 @@ export function DealModal() {
<div className="mt-3 flex items-center gap-3">
<Art sprite={courier.sprite} className="size-20 shrink-0 -rotate-12" />
<p className="text-sm text-ink-200">
Old Halvorsen is retiring. He&apos;ll sell you his Courier and his Earth bay. Pay him back
out of every job.
Old Halvorsen is retiring. He&apos;ll sell you his Courier and his Earth bay, and his
pilot comes with them. Pay him back out of every job.
</p>
</div>
<dl className="mt-4 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 rounded-xl bg-space-950/60 p-3 text-sm ring-1 ring-amber-400/25">
@ -50,6 +51,10 @@ export function DealModal() {
You get
</dt>
<dd>A {courier.name} and a bay at Earth</dd>
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">Pilot</dt>
<dd aria-label="Veteran pilot">
…and {veteran.name}. {veteran.bio}
</dd>
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">Loan</dt>
<dd
aria-label="Loan amount"

View File

@ -4,6 +4,7 @@ import {
checkRoute,
contractTripGameHours,
isInShop,
pilotShare,
dutyAfterTrip,
LONG_HAUL_SKILL_ID,
needsRest,
@ -130,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. */
@ -167,7 +170,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) {
@ -210,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,
@ -326,6 +332,10 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
<dl className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3">
<Cell label="Trip" value={formatDuration(option.tripMs)} />
<Cell label="Fuel" value={formatCredits(option.fuelCost)} />
{option.pilotId !== null &&
!state.pilots.find((p) => p.id === option.pilotId)?.isAvatar && (
<Cell label="Pilot share" value={formatCredits(option.pilotPay)} />
)}
<Cell label="Net profit" value={formatCredits(option.netProfit)} good />
<Cell
label="ETA (game)"

View File

@ -133,6 +133,11 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
Level {pilot.level}
</span>
</div>
{pilot.isVeteran && (
<p aria-label={`${pilot.name} bio`} className="mt-0.5 text-xs text-amber-200/90 italic">
{content.veteranPilot.bio}
</p>
)}
<div className="mt-1 text-xs text-ink-400">
{progress
? `${progress.xp} / ${progress.needed} XP to next level`

View File

@ -31,6 +31,7 @@ describe("typed API client", () => {
id: "p1",
name: "Rae Okafor",
isAvatar: false,
isVeteran: false,
xp: 0,
level: 0,
skills: {},

View File

@ -43,6 +43,7 @@ describe("navigation attention badges (Step 10E.3)", () => {
...avatar,
id: "p2",
isAvatar: false,
isVeteran: false,
assignedShipId: "s2",
resting: true,
restEndsAt: 1,

View File

@ -39,6 +39,7 @@ function returningPlayer(acknowledged: boolean): PlayerState {
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
loanRepayment: 230,
pilotPay: 0,
pilotXp: 10,
discoveredLocationId: null,
scriptedId: null,

View File

@ -31,6 +31,7 @@ const summaries: ResolutionSummary[] = [
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
loanRepayment: 0,
pilotPay: 0,
pilotXp: 32,
discoveredLocationId: "mars",
scriptedId: null,
@ -51,6 +52,7 @@ const summaries: ResolutionSummary[] = [
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
loanRepayment: 0,
pilotPay: 0,
pilotXp: 10,
discoveredLocationId: null,
scriptedId: null,

View File

@ -28,6 +28,7 @@ const rae: Pilot = {
id: "p1",
name: "Rae Okafor",
isAvatar: false,
isVeteran: false,
xp: 0,
level: 0,
skills: {},

View File

@ -89,6 +89,7 @@ describe("the deal", () => {
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
loanRepayment: 0,
pilotPay: 0,
pilotXp: 10,
discoveredLocationId: null,
scriptedId: null,

View File

@ -26,6 +26,7 @@ const pilot = (id: string, name: string, shipId: string, extra: Partial<Pilot>):
id,
name,
isAvatar: false,
isVeteran: false,
xp: 0,
level: 0,
skills: {},

View File

@ -28,6 +28,7 @@ const pilot = (id: string, name: string, shipId: string): Pilot => ({
id,
name,
isAvatar: false,
isVeteran: false,
xp: 0,
level: 0,
skills: {},

View File

@ -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";
@ -35,6 +36,7 @@ const pilot = (id: string, name: string, shipId: string, isAvatar: boolean): Pil
id,
name,
isAvatar,
isVeteran: false,
xp: 0,
level: 0,
skills: {},
@ -125,7 +127,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)}`);

View File

@ -26,6 +26,7 @@ const pilot: Pilot = {
id: "p1",
name: "Rae Okafor",
isAvatar: false,
isVeteran: false,
xp: 0,
level: 0,
skills: {},

View File

@ -68,6 +68,7 @@ function fleetState(overrides: Partial<PlayerState> = {}): PlayerState {
id: "p2",
name: "Rae Okafor",
isAvatar: false,
isVeteran: false,
assignedShipId: "s-fly",
status: "in-flight",
locationId: null,

View File

@ -56,11 +56,11 @@ describe("goals on HQ", () => {
});
renderApp(api);
const card = await screen.findByRole("region", { name: "Goals" });
expect(card).not.toHaveTextContent(title("first-hire"));
expect(card).not.toHaveTextContent(title("veteran-contract"));
await userEvent.click(within(card).getByRole("button", { name: "Claim" }));
expect(api.call).toHaveBeenCalledWith("claimGoal", { goalId: "first-own-delivery" });
await waitFor(() => expect(card).toHaveTextContent(title("first-hire")));
await waitFor(() => expect(card).toHaveTextContent(title("veteran-contract")));
expect(card).not.toHaveTextContent(title("first-own-delivery"));
});

View File

@ -153,6 +153,7 @@ describe("loan on HQ", () => {
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
loanRepayment: 230,
pilotPay: 0,
pilotXp: 10,
discoveredLocationId: null,
scriptedId: null,

View File

@ -53,6 +53,7 @@ function mapState(): PlayerState {
id: "p-rae",
name: "Rae Okafor",
isAvatar: false,
isVeteran: false,
assignedShipId: "s1",
status: "in-flight",
locationId: null,

View File

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

View File

@ -11,6 +11,7 @@ const pilot = (overrides: Partial<Pilot>): Pilot => ({
id: "p",
name: "Pilot",
isAvatar: false,
isVeteran: false,
xp: 0,
level: 0,
skills: {},

View File

@ -23,6 +23,7 @@ const rae: Pilot = {
id: "p-rae",
name: "Rae Okafor",
isAvatar: false,
isVeteran: false,
xp: 260,
level: 2,
skills: { "heavy-cargo": 1 },

View File

@ -68,6 +68,7 @@ export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerS
id: "avatar",
name: "Captain",
isAvatar: true,
isVeteran: false,
xp: 0,
level: 0,
skills: {},

View File

@ -11,6 +11,7 @@ const pilot = (id: string, name: string, extra: Partial<Pilot> = {}): Pilot => (
id,
name,
isAvatar: false,
isVeteran: false,
xp: 0,
level: 0,
skills: {},
@ -39,6 +40,7 @@ const summary = (extra: Partial<ResolutionSummary>): ResolutionSummary => ({
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
loanRepayment: 0,
pilotPay: 0,
pilotXp: 10,
discoveredLocationId: null,
scriptedId: null,

View File

@ -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",

View File

@ -0,0 +1,62 @@
import { content } from "@deadhead/content";
import type { Pilot } from "@deadhead/schema";
import { screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard } from "./render";
const veteran = content.veteranPilot;
describe("the veteran in the UI (Step 10J.5)", () => {
it("names the veteran with their bio in the deal modal", async () => {
renderApp(
fakeApi({
getPlayerState: () =>
samplePlayerState({
tutorial: {
loanerLocationId: "earth",
runInFlight: false,
runsCompleted: 4,
dealAvailable: true,
},
}),
getTutorialBoard: () => sampleTutorialBoard(),
}),
"/contracts",
);
const modal = await screen.findByRole("dialog", { name: "An offer from Halvorsen" });
expect(within(modal).getByLabelText("Veteran pilot")).toHaveTextContent(
`…and ${veteran.name}. ${veteran.bio}`,
);
});
it("shows the veteran's bio, level and Heavy Cargo 1 on the Pilots screen", async () => {
const rosa: Pilot = {
id: "p-vet",
name: veteran.name,
isAvatar: false,
isVeteran: true,
xp: content.config.pilotLevelXpThresholds[1]!,
level: 2,
skills: { "heavy-cargo": 1 },
skillPointsAvailable: 3,
assignedShipId: null,
status: "idle",
locationId: "earth",
dutyHours: 0,
dutyLimit: 12,
resting: false,
restEndsAt: null,
};
const base = samplePlayerState({ tutorial: null, bays: [{ id: "b", locationId: "earth" }] });
renderApp(
fakeApi({ getPlayerState: () => ({ ...base, pilots: [...base.pilots, rosa] }) }),
"/pilots",
);
const card = (await screen.findByText(veteran.name)).closest("li")!;
expect(within(card).getByLabelText(`${veteran.name} bio`)).toHaveTextContent(veteran.bio);
expect(card).toHaveTextContent("Level 2");
expect(card).toHaveTextContent("Heavy Cargo 1/5");
// The avatar's card has no bio line.
expect(screen.queryByLabelText("Captain bio")).not.toBeInTheDocument();
});
});

View File

@ -26,12 +26,12 @@ Living reference for how the code is organised. Update it after any database sch
| `ServerTime` | `realTime`, `gameTime` |
| `Company` | `id`, `name`, `credits`, `loanBalance` |
| `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId`, `lastArrivalAt` (latest resolved arrival, else delivery time; picks the default Contracts location), `upgrades` (`{ upgradeId: level }`), `stats` (effective: speed, capacity, range, overhead, handling per tonne), `inShopUntil` (null unless in the shop now), `shop` (`{ upgradeId, level, until }` of the latest job until acknowledged; CR-009) |
| `Pilot` | `id`, `name`, `isAvatar`, `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight), `dutyHours`, `dutyLimit`, `resting`, `restEndsAt` (null unless resting; CR-004) |
| `Pilot` | `id`, `name`, `isAvatar`, `isVeteran` (the deal's veteran, CR-010), `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight), `dutyHours`, `dutyLimit`, `resting`, `restEndsAt` (null unless resting; CR-004) |
| `Bay` | `id`, `locationId` |
| `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) |
@ -75,7 +75,7 @@ Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/dri
| `companies` | Name, credits and loan balance (the row locked for money changes) | unique `player_id` |
| `ships` | Owned ships: model, name, status, location (null in flight), in-shop-until and the latest shop job (upgrade id, level) until acknowledged (CR-009) | — |
| `ship_upgrades` | Installed upgrade level per ship and upgrade (CR-009) | primary key (ship, upgrade) |
| `pilots` | XP, skills (jsonb), points spent, assignment, location (unassigned only), status, duty hours, rest-ends-at (CR-004) | unique `assigned_ship_id` (one pilot per ship) |
| `pilots` | XP, skills (jsonb), points spent, assignment, location (unassigned only), status, duty hours, rest-ends-at (CR-004), `is_veteran` (CR-010) | unique `assigned_ship_id` (one pilot per ship) |
| `bays` | Leased bays | unique (`player_id`, `location_id`) |
| `discovered_locations` | Discovered location ids | pk (`player_id`, `location_id`) |
| `contract_boards` | Stored location boards (one per discovered location, CR-002) and tutorial boards per period (tutorial boards also per run); `kind` is `location` or `tutorial` (jsonb contracts) | unique (`player_id`, `kind`, `location_id`, `period_index`, `tutorial_run`) |

View File

@ -22,7 +22,7 @@ This change keeps the employee opening but cuts it to about four quick runs, giv
| First dispatch | Under 30 seconds from "Start your company" |
| First real choice between contracts | Under 2 minutes |
| Own a ship (deal accepted) | Under 10 minutes |
| Hire first pilot | 30–45 minutes |
| Buy a second ship *(changed by CR-010; was: Hire first pilot)* | 30–45 minutes |
| First discovery | Within 90 minutes |
### New opening, in order
@ -99,6 +99,7 @@ Record these in `DECISIONS.md` and in `implementation-plan.md` §0.5.
5. Discover a new location.
6. Pay off your loan.
- **Test:** For each goal, a state summary just short of the condition reports it incomplete, and a summary that meets it reports it complete. Results are identical for identical inputs.
- **Amended by CR-010:** Goal 4 "Hire your first pilot" is now "Fly a contract with [veteran's name]" (`veteran-contract`, Step 10J.3).
### Step 10A.6 — Schema and database
- Add to the database: company loan balance; tutorial runs completed; a deal-accepted flag; claimed goals per player; the repayment amount on each dispatch record; the `purchasable` flag reflected wherever ship models are listed.
@ -121,6 +122,7 @@ Record these in `DECISIONS.md` and in `implementation-plan.md` §0.5.
- In one transaction: remove the loaner; create an owned Courier at Earth; create an Earth bay; assign the avatar to the Courier; set the loan balance to the deal loan amount; turn the tutorial off; set deal accepted.
- This replaces Step 6.7. Remove the old automatic tutorial-end rule and its tests.
- **Test:** Accepting when available produces exactly that state: one owned Courier at Earth crewed by the avatar, one Earth bay, the configured loan balance, tutorial off, no loaner. Accepting when not available fails with no changes. Accepting twice fails the second time. Two simultaneous accepts produce exactly one deal.
- **Amended by CR-010:** Accepting the deal also creates the veteran pilot at Earth, unassigned (decision 0.5.70, Step 10J.4).
### Step 10A.9 — Server: loan repayment
- Resolution applies the repayment from Step 10A.4: reduce the loan balance, add only the net to credits, and store the repayment on the dispatch.
@ -147,6 +149,7 @@ Record these in `DECISIONS.md` and in `implementation-plan.md` §0.5.
- When the deal is available, show a modal with a short story beat (for example: "Old Halvorsen is retiring. He'll sell you his Courier and his Earth bay. Pay him back out of every job."), the ship and bay included, the loan amount, the repayment share, and a single "Shake on it" button.
- After accepting, go to HQ.
- **Test:** Component test: the modal appears only when state says the deal is available; it shows the configured loan amount and share; the button calls accept and navigates to HQ.
- **Amended by CR-010:** The deal modal lists the veteran by name with their bio line (Step 10J.5).
### Step 10A.14 — Client: loan on HQ
- Replace the old "save up for your first ship" goal on HQ (Step 9.1) with the loan: balance, a paid-off progress bar, and a "Pay down" action with an amount input.
@ -185,7 +188,7 @@ Record these in `DECISIONS.md` and in `implementation-plan.md` §0.5.
| 8.4 | Lands on Contracts, not HQ (10A.11). |
| 9.1 | Tutorial savings goal replaced by loan and goals (10A.14, 10A.15). |
| 9.3 | Shows pay, time, and XP (10A.12). |
| 12.1 | Not built yet. Change the thresholds to: deal accepted under 10 real minutes; first pilot hired between 30 and 45 minutes; first discovery under 90 minutes; and a new check that after the deal the player always either has a ship in flight or can afford fuel for at least one contract. The simulated player accepts the deal as soon as it is available, takes the highest credits-per-minute contract, and pays down the loan only through automatic repayment. Adjust content values, not thresholds. |
| 12.1 | Not built yet. Change the thresholds to: deal accepted under 10 real minutes; second ship bought (changed by CR-010; was: first pilot hired) between 30 and 45 minutes; first discovery under 90 minutes; and a new check that after the deal the player always either has a ship in flight or can afford fuel for at least one contract. The simulated player accepts the deal as soon as it is available, takes the highest credits-per-minute contract, and pays down the loan only through automatic repayment. Adjust content values, not thresholds. |
| 12.2 | Replaced: use the balance script's measured numbers in the new Step 2.5 test. |
| 12.3 | Rewritten: guest → four tutorial runs → accept deal → state shows one owned Courier at Earth crewed by the avatar, one Earth bay, a loan balance, and tutorial off → one contract dispatched and resolved with a repayment applied. |

View File

@ -0,0 +1,113 @@
# Change Request CR-010 — Inherited Veteran Pilot and Profit-Share Pay
> **Numbering note (added when implemented):** this request arrived titled "CR-007 — Inherited Veteran Pilot and Profit-Share Pay" for "Phase 10G", applying to "CR-001 through CR-006". CR-007 to CR-009 and Phases 10G–10I already existed, so it is stored as **CR-010** and done as **Phase 10J**: every "10G.n" below means **10J.n**. Decision 0.5.12 is replaced in place; the new 0.5.51 and 0.5.52 are recorded as **0.5.69 Deduction order** and **0.5.70 Veteran pilot** (0.5.51–0.5.68 were in use). "CR-001 decision 0.5.24" (the loan) is plan §0.5.32 in this repo. "CR-001 through CR-006" means the build as it stands (CR-001 to CR-009).
**Project:** Deadhead: Space Freight Tycoon
**Applies to:** a build with Phases 1–10 and CR-001 through CR-006 complete
**Do this:** as **Phase 10G**, after Phase 10F and before Phase 11
**Related docs:** `implementation-plan.md`, `change-request-faster-start.md` (CR-001), `change-request-pilot-rest.md` (CR-004), `game-design-document.md`, `DECISIONS.md`
---
## 1. Why
Two related changes to pilots:
1. **The deal comes with a veteran pilot.** The player takes over the retiring owner's business, so his last hire comes with it. Today, when the avatar must rest, the player's only ship sits idle. With a second pilot at the Earth HQ, the player can swap crews and keep flying, which teaches the rest-and-swap system in the first session. The veteran has a skill the avatar lacks (Heavy Cargo 1), so choosing who flies is a real decision.
2. **Hired pilots earn a share of profit instead of a flat fee.** A flat per-delivery fee can eat most of a cheap Moon run, or more once fuel is counted. A share of profit (payout minus fuel) guarantees the business always keeps the larger share of a profitable job and never pays a pilot on a losing one.
## 2. How it works
### The veteran
| Rule | Detail |
|---|---|
| When | Created when the player accepts the CR-001 deal. |
| Where | At Earth, unassigned. The avatar crews the Courier as before. |
| Who | A fixed name and short bio from content (for example "Rosa Kell, Halvorsen's pilot for twelve years"). |
| Level | Level 2, with one skill point already spent on Heavy Cargo (Heavy Cargo 1) and the remaining earned points unspent. |
| Rules | Same as any hired pilot: profit-share pay, duty and rest, assign and unassign only at a bay. |
### Profit-share pay
`pilot pay = max(0, payout − fuel cost) × pilot profit share`
| Case | Pilot pay |
|---|---|
| Hired pilot, profitable contract | Profit share of (payout − fuel) |
| Hired pilot, fuel ≥ payout | 0 |
| Avatar | 0 (exempt) |
| Deadhead or tutorial run | 0 |
Example (default share 0.25): payout 400 cr, fuel 150 cr → pilot gets 62.5 cr, business keeps 187.5 cr before loan repayment.
Order of deductions on resolution: payout − pilot pay − loan repayment (CR-001 decision 0.5.24 applies to what remains after pilot pay). Fuel was already charged at dispatch.
## 3. Rules for this change
- All rules in `implementation-plan.md` §0.1 apply: steps in order, one commit per step, each step's test must pass, `packages/sim` stays pure, all numbers and names live in `packages/content`.
- Credits round the same way they already do elsewhere; use that rounding for pilot pay and say so in `DECISIONS.md`.
- Resetting the dev and test databases is fine. Schema migrations are still required if any are needed.
- Update existing tests whose expectations change. Update `architecture.md` for schema or API changes and `progress.md` after every step.
- Stop for the user's review at the end of Phase 10G.
## 4. Decisions
Record these in `DECISIONS.md` and `implementation-plan.md` §0.5.
- **0.5.12 (replaced) Pilot pay.** Hired pilots earn `max(0, payout − fuel cost) × pilot profit share` per contract delivery. The avatar is exempt. Deadheads and tutorial runs pay nothing.
- **0.5.51 Deduction order.** On resolution: pilot pay is taken from the payout first; loan repayment (0.5.24) is calculated on what remains.
- **0.5.52 Veteran pilot.** Accepting the deal also creates the veteran per §2. The veteran is a normal hired pilot in every respect except how they were acquired.
- **CR-001 goal change.** Goal 4 "Hire your first pilot" becomes "Fly a contract with [veteran's name]".
- **CR-001 timing target change.** "Hire first pilot: 30–45 minutes" becomes "Buy a second ship: 30–45 minutes".
## 5. Steps — Phase 10G
### Step 10G.1 — Content: config and veteran
- Replace the GameConfig field "pilot pay per delivery (flat fee)" with "pilot profit share" (default 0.25, must be between 0 and 1).
- Add a veteran pilot entry: name, short bio line, starting level (2), and starting skill allocation (Heavy Cargo 1).
- **Test:** Config validates; the profit share is between 0 and 1. The veteran's starting skill allocation does not exceed the skill points a level-2 pilot has. The veteran's skill references an existing skill.
### Step 10G.2 — Sim: profit-share pay
- Replace the flat fee in dispatch resolution with the profit-share formula (decision 0.5.12) and apply the deduction order (decision 0.5.51).
- **Test:** A hired pilot on a 400 cr payout with 150 cr fuel earns 62.5 cr (with the project's rounding). Fuel greater than or equal to payout gives zero pilot pay. The avatar earns zero. A deadhead and a tutorial run earn zero. With a loan balance, repayment is calculated on payout minus pilot pay. Credits added + pilot pay + loan repayment = payout, for a range of inputs. Update the Step 3.17 tests that assumed a flat fee.
### Step 10G.3 — Sim: goal change
- Change goal 4 in the onboarding goals (CR-001 Step 10A.5) to "Fly a contract with the veteran": complete when any contract delivered by the veteran has resolved.
- **Test:** The goal is incomplete when only the avatar has delivered and complete after one resolved contract flown by the veteran. A deadhead by the veteran does not complete it.
### Step 10G.4 — Server: veteran on the deal
- Accepting the deal (CR-001 Step 10A.8) also creates the veteran per §2, in the same transaction.
- **Test:** After accepting, state has exactly two pilots at Earth: the avatar crewing the Courier, and the veteran unassigned at level 2 with Heavy Cargo 1 and the correct number of unspent skill points. The veteran is not exempt from pilot pay. A failed or repeated accept creates no veteran. When the avatar must rest at Earth, the avatar can be unassigned and the veteran assigned to the Courier and dispatched immediately.
### Step 10G.5 — Client: deal modal and pilot card
- The deal modal (CR-001 Step 10A.13) lists the veteran by name with their bio line, for example "…and Rosa Kell, who's flown for me for twelve years."
- The veteran's card on the Pilots screen shows the bio line.
- **Test:** Component test: the modal shows the veteran's name and bio from content; the Pilots screen shows the veteran's bio, level, and Heavy Cargo 1.
### Step 10G.6 — Client: pilot share in dispatch and summaries
- The dispatch sheet shows a "Pilot share" line between fuel and net profit for hired pilots, and no line for the avatar. Net profit accounts for it.
- Delivery summaries show the pilot's share as its own line when it's above zero.
- **Test:** Component test: for a hired pilot the sheet shows the share and a net profit matching the sim; for the avatar there's no share line. A summary for a hired pilot's delivery shows the share line.
### Step 10G.7 — Plan amendments and regression
- Apply the amendments in §6 to `implementation-plan.md` and CR-001's text.
- Run the full suite.
- **Test:** CI is green. Manual check: on a fresh account, accept the deal and confirm the veteran is at Earth; fly the avatar until mandatory rest at Earth; swap in the veteran, take a Heavy contract the avatar couldn't, and confirm the summary shows the pilot share and goal 4 becomes claimable.
## 6. Amendments
| Where | Change |
|---|---|
| Plan 0.5.12 | Replaced by profit-share pay (10G.2). |
| Plan 0.5 | Add 0.5.51 and 0.5.52. |
| Plan 2.1 | Config field change (10G.1). |
| Plan 3.17 | Profit-share formula and deduction order (10G.2). |
| Plan 9.5 | Pilot share line in the dispatch sheet (10G.6). |
| Plan 12.1 | Replace "first pilot hired between 30 and 45 minutes" with "second ship bought between 30 and 45 minutes". Add a check that the veteran flies at least one contract in the first session. Re-check all targets with profit-share pay; adjust content values, not thresholds. |
| CR-001 10A.5 | Goal 4 changed (10G.3). |
| CR-001 10A.8 | Deal also creates the veteran (10G.4). |
| CR-001 10A.13 | Deal modal lists the veteran (10G.5). |
| CR-001 §2 targets | "Hire first pilot" target replaced as above. |
## 7. Out of scope
Pay share that scales with pilot level, salaries, pilot morale or loyalty, pilots leaving or being poached, additional inherited assets, and anything else in `implementation-plan.md` §0.3.

View File

@ -67,7 +67,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
9. **Game epoch.** Two fields: a real start timestamp and the in-game ephemeris date it maps to. The clock is global for all players.
10. **World seed** is per player and is also used for pilot and ship names.
11. **Payout** is fixed when a contract is offered, using the distance at that moment. Travel time and fuel use the distance at departure.
12. **Pilot pay** applies to every hired pilot. The avatar is exempt.
12. **Pilot pay.** *(Replaced by CR-010.)* Hired pilots earn `max(0, payout − fuel cost) × pilot profit share` per contract delivery. The avatar is exempt. Deadheads and tutorial runs pay nothing.
13. **Hull rating** is stored but has no effect in the base game.
14. **Ship names** are generated from the world seed. No renaming.
15. **Bays** have no ship limit.
@ -124,6 +124,8 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
66. **Shop time** (CR-009). Starting an upgrade deducts the cost, raises the level immediately, and sets the ship's in-shop-until time. The ship can't be dispatched until then (`ship-in-shop`). Because dispatch is blocked, there is nothing to complete later: no background job is needed. The assigned pilot stays assigned and can rest meanwhile.
67. **Models stay distinct** (CR-009). Content caps guarantee: a maxed Courier's capacity is below a base Freighter's; a maxed Bulk Hauler's speed is below a base Freighter's; a maxed Freighter's range is below a base Bulk Hauler's (so only Bulk Haulers reach Jupiter at its farthest).
68. **Courier to Mars** (CR-009). A Courier with maxed fuel tanks can reach Mars at and around closest approach, but not at Mars's farthest distance. Base and lower-level Couriers still can't reach Mars at all.
69. **Deduction order** (CR-010). On resolution: pilot pay is taken from the payout first; loan repayment (0.5.32) is calculated on what remains.
70. **Veteran pilot** (CR-010). Accepting the deal also creates the veteran (CR-010 §2): at Earth, unassigned, level 2 with Heavy Cargo 1. The veteran is a normal hired pilot in every respect except how they were acquired.
---
@ -169,6 +171,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
- **Test:** Unit tests confirm each schema accepts a valid example and rejects an object missing a required field.
- **Amended by CR-004:** GameConfig gains `dutyLimitGameHours`, `longHaulDutyBonusGameHours` and `restGameHours` (Step 10D.1).
- **Amended by CR-009:** Content gains the Upgrade schema (`upgradeSchema`, `SHIP_STATS`, per-model overrides), the five upgrades, and `upgradeLevels` cost/shop-time config (Step 10I.1).
- **Amended by CR-010:** GameConfig's flat `pilotPayPerDelivery` is replaced by `pilotProfitShare` (0.25); content gains the veteran pilot (Step 10J.1).
### Step 2.2 — Add the five locations
- Add data for Earth, Moon, Mars, Ceres Station, and Jupiter Cloud Platform following the destinations table in the GDD.
@ -290,6 +293,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
- Add a function that, given a dispatch and current real time, returns either "still in flight" or a Resolution: credits to add (payout minus pilot pay, with the avatar exempt from pilot pay; for tutorial runs, exactly the tutorial share), XP to add to the pilot, ship's new location, and whether the destination becomes discovered.
- **Test:** Before arrival → in flight. At or after arrival → Resolution. A delivery to an undiscovered location marks it discovered. A delivery flown by the avatar deducts no pilot pay; one flown by a hired pilot does. A reposition gives no credits or XP. Resolving the same dispatch twice with the same inputs returns identical results.
- **Amended by CR-004:** Resolution returns the pilot's new duty hours and, on mandatory rest, rest-ends-at from the arrival time (Step 10D.3).
- **Amended by CR-010:** Hired pilots earn round(max(0, payout − fuel) × profit share); the loan repayment is taken from what remains (decisions 0.5.12, 0.5.69; Step 10J.2).
### Step 3.18 — Sim package public API and coverage
- Export only the public functions from a single entry point. Configure coverage reporting for `packages/sim`.
@ -490,6 +494,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
- **Amended by CR-004:** The dispatch sheet disables resting pilots with a countdown and shows duty after the trip, warning "Will need rest on arrival" (Step 10D.7).
- **Amended by CR-007:** For a Priority contract each pair shows its trip against the window; too-slow pairs are disabled with the numbers (Step 10G.9).
- **Amended by CR-009:** The dispatch sheet uses effective stats and lists "In the shop — mm:ss" for ships in the shop (Step 10I.7).
- **Amended by CR-010:** The dispatch sheet shows a "Pilot share" line for hired pilots, and net profit accounts for it (Step 10J.6).
### Step 9.6 — Fleet screen
- List all ships: name, model, location or route, assigned pilot, and for in-flight ships a progress bar and countdown. Idle ships at a bay location have a "Reposition" action to another bay location.
@ -593,6 +598,12 @@ Steps 10I.1–10I.9 are specified in `memory-bank/change-requests/CR-009-ship-up
---
## Phase 10J — Veteran Pilot and Profit-Share Pay (CR-010)
Steps 10J.1–10J.7 are specified in `memory-bank/change-requests/CR-010-veteran-pilot-profit-share.md` §5 (written there as 10G.1–10G.7), with its §6 amendments. Done after Phase 10I and before Phase 11; stop for review at the end.
---
## Phase 11 — PWA
### Step 11.1 — Installable PWA
@ -613,11 +624,12 @@ Steps 10I.1–10I.9 are specified in `memory-bank/change-requests/CR-009-ship-up
- Add a script in `packages/sim` that simulates a player making reasonable choices (always take the highest-paying eligible contract, buy the cheapest ship when affordable, lease Earth bay, then hire pilots) over a configurable span of real time, using only sim functions.
- Output: real time to finish the tutorial, real time to own 3 ships, credits over time, and pilot levels over time.
- **Test:** Running the script with a fixed seed produces identical output twice. With default content, tutorial completion falls between 45 and 120 real minutes of active play. (Adjust content values, not the test thresholds, if it fails; record changes in `DECISIONS.md`.)
- **Amended by CR-001:** Change the thresholds to: deal accepted under 10 real minutes; first pilot hired between 30 and 45 minutes; first discovery under 90 minutes; and a new check that after the deal the player always either has a ship in flight or can afford fuel for at least one contract. The simulated player accepts the deal as soon as it is available, takes the highest credits-per-minute contract, and pays down the loan only through automatic repayment. Adjust content values, not thresholds.
- **Amended by CR-001:** Change the thresholds to: deal accepted under 10 real minutes; second ship bought between 30 and 45 minutes *(changed by CR-010; was: first pilot hired)*; first discovery under 90 minutes; and a new check that after the deal the player always either has a ship in flight or can afford fuel for at least one contract. The simulated player accepts the deal as soon as it is available, takes the highest credits-per-minute contract, and pays down the loan only through automatic repayment. Adjust content values, not thresholds.
- **Amended by CR-002:** The simulated player picks up contracts at whatever location its ships are at, and only deadheads when no eligible contract exists there. Add a check: across the whole run, no ship is ever idle with no eligible contract **and** no reachable deadhead destination.
- **Amended by CR-004:** The simulated player follows rest rules: it rests a pilot when no eligible contract exists at its location and deadheading isn't better. Re-check all CR-001 timing targets with rest in place; adjust content values, not thresholds. Add a check that no pilot is ever dispatched while resting.
- **Amended by CR-007:** The simulated player considers Priority contracts and skips ones its ships are too slow for. Re-check all timing targets; adjust content values, not thresholds.
- **Amended by CR-009:** The simulated player may buy upgrades: when it can't yet afford its next planned ship, it buys the cheapest available upgrade on its most-used ship, preferring cargo bay and engines. Re-check all timing targets; adjust content values, not thresholds.
- **Amended by CR-010:** Replace "first pilot hired between 30 and 45 minutes" with "second ship bought between 30 and 45 minutes". Add a check that the veteran flies at least one contract in the first session. Re-check all targets with profit-share pay; adjust content values, not thresholds.
### Step 12.2 — Revisit the tutorial economy test
- Replace the placeholder average in the Step 2.5 test with the average tutorial payout measured by the balance script.

View File

@ -235,3 +235,15 @@ Spec: `memory-bank/change-requests/CR-009-ship-upgrades.md` (received as "CR-006
- [x] **Step 10I.7 — Client: effective stats everywhere.** `game.ts` `shipStatsOf(ship)` (sim `effectiveStats` from model + upgrades) and `simShip(ship)` (sim `ShipState` with upgrades and in-shop-until). Used by: the board's Too heavy badge (`carryLimit`), Too slow (`tooSlowForAll`), the dispatch sheet (problems, trip time, Priority window, validation; an in-shop ship lists "In the shop — mm:ss" first), and the deadhead picker. The Market lists your upgraded ships of each model with their effective speed, capacity and range; the map's "Reachable with" adds "your [ship] (upgraded)" when only upgrades get it there. Test: `effective-stats-ui.test.tsx` (Cargo Bay 1 clears Too heavy on a 22 t job; Fuel Tanks 3 enables Mars in the deadhead picker near closest approach).
- [x] **Step 10I.8 — Client: shop in summaries and on HQ.** The "While you were away" modal also opens for finished, unseen upgrades and lists "[Ship] is out of the shop: Engine Tuning 2"; "Got it" sends their `shipIds` with `acknowledgeSummaries`, and the server (`acknowledgeFinishedUpgrades`) clears only jobs whose shop time has passed. HQ has an "In the shop" card (when any) listing each ship, its upgrade and level, and a countdown, soonest first. Tests: `shop-summary-hq.test.tsx`, and the acknowledge case in `upgrades.test.ts`.
- [x] **Step 10I.9 — Plan amendments and regression.** CR-009 §6 amendments added to plan steps 2.1, 3.5, 3.7, 3.12, 5.3, 9.5, 9.6, 9.8 and 12.1 (§0.5.63–68 were added with the CR). Typecheck, lint, full suite with coverage and the web build are green. **GDD §8 not yet updated:** the CR says the designer updates it; it still describes ship stats with no upgrades, so there is nothing to confirm yet. Scripted manual check against a live server (scratchpad `cr009-smoke.mjs`): after the deal (899 cr), Cargo Bay Expansion 1 on the Courier costs 400 cr, capacity 20 → 23 t, 10 minutes in the shop; dispatch in the shop is refused with `ship-in-shop`; afterwards the Courier takes a 22 t job. **Gameplay note:** shop time (10 min) equals the board period, so a job seen before an upgrade has always expired when the ship comes out. **Phase 10I complete — stopped for review.**
## Phase 10J — Veteran Pilot and Profit-Share Pay (CR-010)
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.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.
- [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`.
- [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`.
- [x] **Step 10J.7 — Plan amendments and regression.** CR-010 §6 amendments: plan steps 2.1, 3.17, 9.5 and 12.1 (the 30–45 minute target is now the second ship), and CR-001 Steps 10A.5, 10A.8, 10A.13 and its §2 targets (decisions 0.5.12, 0.5.69–70 were set with the CR). Typecheck, lint, full suite with coverage (828 tests) and the web build are green. Scripted manual check against a live server (scratchpad `cr010-smoke.mjs`): after the deal Rosa Kell is at Earth, unassigned, level 2, Heavy Cargo 1; the avatar rests at Earth after six trips; swapped crews; Rosa flies a 20 t Machine Parts job needing Heavy Cargo 1 (the avatar has 0): payout 1118, fuel 230, pilot share 222, loan repayment 268, credits 628; goal 4 ready. **Phase 10J complete — stopped for review.**

View File

@ -2,7 +2,7 @@ import { cargoTypes, skills } from "./data/cargo";
import { gameConfig } from "./data/config";
import { goods } from "./data/goods";
import { locations } from "./data/locations";
import { onboardingGoals, scriptedTutorialContracts } from "./data/onboarding";
import { onboardingGoals, scriptedTutorialContracts, veteranPilot } from "./data/onboarding";
import { shipModels } from "./data/ships";
import { upgrades } from "./data/upgrades";
@ -24,6 +24,7 @@ export const content = {
config: gameConfig,
scriptedTutorialContracts,
onboardingGoals,
veteranPilot,
upgrades,
location: byId(locations),
shipModel: byId(shipModels),

View File

@ -64,5 +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,
};

View File

@ -1,4 +1,4 @@
import type { OnboardingGoal, ScriptedTutorialContract } from "../schemas";
import type { OnboardingGoal, ScriptedTutorialContract, VeteranPilot } from "../schemas";
/** Special tutorial contracts by run (decision 0.5.33). */
export const scriptedTutorialContracts: ScriptedTutorialContract[] = [
@ -14,6 +14,17 @@ export const scriptedTutorialContracts: ScriptedTutorialContract[] = [
];
/** First-session goals, in the order they are shown (decision 0.5.34). Rewards are first guesses. */
/**
* Halvorsen's last hire, who comes with the deal (decision 0.5.70): level 2,
* one point already in Heavy Cargo, the rest unspent.
*/
export const veteranPilot: VeteranPilot = {
name: "Rosa Kell",
bio: "Halvorsen's pilot for twelve years.",
level: 2,
skills: { "heavy-cargo": 1 },
};
export const onboardingGoals: OnboardingGoal[] = [
{
id: "first-own-delivery",
@ -34,9 +45,10 @@ export const onboardingGoals: OnboardingGoal[] = [
reward: 1000,
},
{
id: "first-hire",
title: "Hire your first pilot",
condition: { kind: "pilots-hired", count: 1 },
// Replaces "Hire your first pilot" (CR-010): the veteran comes with the deal.
id: "veteran-contract",
title: `Fly a contract with ${veteranPilot.name}`,
condition: { kind: "veteran-contracts", count: 1 },
reward: 1000,
},
{

View File

@ -263,8 +263,8 @@ 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,
})
.refine(
(config) =>
@ -299,6 +299,8 @@ export const goalConditionSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("skill-points-spent"), count: positiveInt }),
z.object({ kind: z.literal("ships-owned"), count: positiveInt }),
z.object({ kind: z.literal("pilots-hired"), count: positiveInt }),
/** Contracts (not deadheads) delivered by the veteran pilot (CR-010). */
z.object({ kind: z.literal("veteran-contracts"), count: positiveInt }),
/** Locations discovered beyond the ones that start discovered. */
z.object({ kind: z.literal("locations-discovered"), count: positiveInt }),
/** The deal's loan is fully repaid. */
@ -315,3 +317,14 @@ export const onboardingGoalSchema = z.object({
reward: positiveInt,
});
export type OnboardingGoal = z.infer<typeof onboardingGoalSchema>;
/** The veteran pilot who comes with the deal (decision 0.5.70, CR-010). */
export const veteranPilotSchema = z.object({
name: z.string().min(1),
/** One line shown on the deal and the pilot card. */
bio: z.string().min(1),
level: positiveInt,
/** Skill levels already bought, by skill id. */
skills: z.record(idSchema, positiveInt),
});
export type VeteranPilot = z.infer<typeof veteranPilotSchema>;

View File

@ -54,7 +54,7 @@ describe("onboarding goals", () => {
"own-ship-deliveries",
"skill-points-spent",
"ships-owned",
"pilots-hired",
"veteran-contracts",
"locations-discovered",
"loan-paid-off",
]);

View File

@ -99,7 +99,7 @@ const validGameConfig = {
pilotLevelXpThresholds: [100, 250, 500],
skillPointsPerLevel: 1,
pilotHireCost: 1000,
pilotPayPerDelivery: 100,
pilotProfitShare: 0.25,
tutorialLoanerShipModelId: "company-courier",
tutorialRunsBeforeDeal: 4,
tutorialMassTonnes: { light: 5, medium: 12, full: 20 },

View File

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import {
content,
gameConfig,
gameConfigSchema,
veteranPilot,
veteranPilotSchema,
} from "../src/index";
describe("veteran pilot and profit share (CR-010)", () => {
it("has a valid profit share between 0 and 1", () => {
expect(gameConfigSchema.safeParse(gameConfig).success).toBe(true);
expect(gameConfig.pilotProfitShare).toBeGreaterThan(0);
expect(gameConfig.pilotProfitShare).toBeLessThan(1);
expect(gameConfigSchema.safeParse({ ...gameConfig, pilotProfitShare: 1.2 }).success).toBe(
false,
);
});
it("gives the veteran a valid level and existing skills within their skill points", () => {
expect(veteranPilotSchema.safeParse(veteranPilot).success).toBe(true);
expect(veteranPilot.level).toBe(2);
expect(veteranPilot.level).toBeLessThanOrEqual(gameConfig.pilotLevelXpThresholds.length);
const spent = Object.values(veteranPilot.skills).reduce((a, b) => a + b, 0);
expect(spent).toBeLessThanOrEqual(veteranPilot.level * gameConfig.skillPointsPerLevel);
for (const [skillId, level] of Object.entries(veteranPilot.skills)) {
const skill = content.skill(skillId);
expect(skill, skillId).toBeDefined();
expect(level).toBeLessThanOrEqual(skill!.maxLevel);
}
expect(veteranPilot.skills).toEqual({ "heavy-cargo": 1 });
});
});

View File

@ -72,6 +72,8 @@ export const pilotSchema = z.object({
id: idSchema,
name: z.string().min(1),
isAvatar: z.boolean(),
/** Halvorsen's veteran, who came with the deal (CR-010); paid like any hired pilot. */
isVeteran: z.boolean(),
xp: z.number().int().nonnegative(),
level: z.number().int().nonnegative(),
/** Skill levels by skill id; a missing skill is level 0. */
@ -168,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(),

View File

@ -72,6 +72,7 @@ const pilot = {
id: "p1",
name: "Avery Kade",
isAvatar: true,
isVeteran: false,
xp: 120,
level: 1,
skills: { "long-haul": 1 },
@ -135,6 +136,7 @@ const summary = {
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
loanRepayment: 0,
pilotPay: 0,
pilotXp: 10,
discoveredLocationId: null,
scriptedId: "unmarked-crates",
@ -214,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,

View File

@ -9,6 +9,8 @@ export interface GoalProgress {
shipsOwned: number;
/** Pilots hired (the avatar excluded). */
pilotsHired: number;
/** Resolved contract deliveries (not deadheads) flown by the veteran (CR-010). */
veteranContracts: number;
/** Locations discovered beyond the ones that start discovered. */
locationsDiscovered: number;
dealAccepted: boolean;
@ -25,6 +27,8 @@ export function isGoalConditionMet(condition: GoalCondition, progress: GoalProgr
return progress.shipsOwned >= condition.count;
case "pilots-hired":
return progress.pilotsHired >= condition.count;
case "veteran-contracts":
return progress.veteranContracts >= condition.count;
case "locations-discovered":
return progress.locationsDiscovered >= condition.count;
case "loan-paid-off":

View File

@ -91,3 +91,5 @@ export type {
UpgradeLevels,
UpgradeProposal,
} 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 { 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),

View File

@ -10,6 +10,7 @@ const fresh: GoalProgress = {
skillPointsSpent: 0,
shipsOwned: 1,
pilotsHired: 0,
veteranContracts: 0,
locationsDiscovered: 0,
dealAccepted: true,
loanBalance: content.config.dealLoanAmount,
@ -23,7 +24,7 @@ const cases: [string, Partial<GoalProgress>, Partial<GoalProgress>][] = [
["first-own-delivery", { ownShipDeliveries: 0 }, { ownShipDeliveries: 1 }],
["first-skill-point", { skillPointsSpent: 0 }, { skillPointsSpent: 1 }],
["second-ship", { shipsOwned: 1 }, { shipsOwned: 2 }],
["first-hire", { pilotsHired: 0 }, { pilotsHired: 1 }],
["veteran-contract", { veteranContracts: 0 }, { veteranContracts: 1 }],
["first-discovery", { locationsDiscovered: 0 }, { locationsDiscovered: 1 }],
["loan-paid-off", { loanBalance: 1 }, { loanBalance: 0 }],
];
@ -41,6 +42,16 @@ describe("onboarding goal evaluation", () => {
},
);
it("needs a contract flown by the veteran, not just the avatar's deliveries (CR-010)", () => {
expect(
isComplete("veteran-contract", { ...fresh, ownShipDeliveries: 12, pilotsHired: 1 }),
).toBe(false);
expect(isComplete("veteran-contract", { ...fresh, veteranContracts: 1 })).toBe(true);
expect(goals.find((g) => g.id === "veteran-contract")?.title).toBe(
`Fly a contract with ${content.veteranPilot.name}`,
);
});
it("does not count a loan as paid off before the deal", () => {
expect(isComplete("loan-paid-off", { ...fresh, dealAccepted: false, loanBalance: 0 })).toBe(
false,
@ -48,8 +59,8 @@ describe("onboarding goal evaluation", () => {
});
it("returns completed goals in content order, identically for identical inputs", () => {
const progress: GoalProgress = { ...fresh, pilotsHired: 3, ownShipDeliveries: 5 };
expect(completedGoals(goals, progress)).toEqual(["first-own-delivery", "first-hire"]);
const progress: GoalProgress = { ...fresh, veteranContracts: 3, ownShipDeliveries: 5 };
expect(completedGoals(goals, progress)).toEqual(["first-own-delivery", "veteran-contract"]);
expect(completedGoals(goals, progress)).toEqual(completedGoals(goals, { ...progress }));
});
});

View File

@ -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,

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