From cbcc981b6dda739dbe506d2efb6949be5470dc89 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Fri, 2 Oct 2026 18:44:02 -0400 Subject: [PATCH] Step 3.8: add contract model and payout Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 6 ++++ memory-bank/progress.md | 2 +- packages/sim/src/contract.ts | 45 ++++++++++++++++++++++++++ packages/sim/src/index.ts | 1 + packages/sim/test/contract.test.ts | 52 ++++++++++++++++++++++++++++++ 5 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 packages/sim/src/contract.ts create mode 100644 packages/sim/test/contract.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index a9bbd33..eb0f3e3 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -108,3 +108,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Landing is checked before range**, so a ship that can neither land nor reach reports `cannot-land`. - **The route check looks only at the destination's landing requirement.** A ship is always already sitting at its origin, so it can take off from there. - **Range tests find each planet's closest and farthest point from Earth by sampling** every 4 days over 12 years. All Step 2.3 range choices hold, including decision 0.5.25 (a Freighter reaches Jupiter at its closest). + +## Step 3.8 + +- **Tier scaling is compounding:** pay is multiplied by `tierPayMultiplier ** tier`. Standard (tier 0) is ×1; a tier-5 Dangerous contract pays 1.3⁵ ≈ 3.7× its base rate. +- **Payouts are rounded to whole credits** when the contract is offered. +- **Contracts refer to content by id** (`originId`, `destinationId`, `cargoTypeId`), so they can be stored and sent over the API as plain data. `requiredSkill` is `{ skillId, level }`, or `null` for Standard. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 21fad6d..cf3e16f 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -26,7 +26,7 @@ - [x] **Step 3.5 — Travel time.** `src/travel.ts`: `travelTimeGameHours(ship, distance)` = cruise time + overhead. - [x] **Step 3.6 — Fuel cost.** `src/fuel.ts`: `fuelCost(ship, origin, distance)` = distance × cost per million km × origin launch multiplier. - [x] **Step 3.7 — Range and landing eligibility.** `src/eligibility.ts`: `checkRoute` returns `ok` or `cannot-land` / `out-of-range`; `canLandAt` ranks vacuum < atmospheric < hardened. Step 2.3 ranges confirmed against sampled real distances. -- [ ] Step 3.8 — Contract model and payout +- [x] **Step 3.8 — Contract model and payout.** `src/contract.ts`: `Contract` interface and `contractPayout` (base × multiplier^tier × mass × distance × first-contact multiplier, rounded). - [ ] Step 3.9 — Contract board generation - [ ] Step 3.10 — Tutorial contract board - [ ] Step 3.11 — Pilot eligibility diff --git a/packages/sim/src/contract.ts b/packages/sim/src/contract.ts new file mode 100644 index 0000000..c2da6f0 --- /dev/null +++ b/packages/sim/src/contract.ts @@ -0,0 +1,45 @@ +import type { CargoType, GameConfig } from "@deadhead/content"; +import type { RealTime } from "./clock"; + +export interface Contract { + id: string; + originId: string; + destinationId: string; + cargoTypeId: string; + /** 0 for Standard; 1–5 otherwise, and equal to the required cargo-skill level (decision 0.5.6). */ + tier: number; + massTonnes: number; + /** Credits, fixed at offer time (decision 0.5.11). */ + payout: number; + /** Cargo skill and level needed; null for Standard. */ + requiredSkill: { skillId: string; level: number } | null; + requiredLongHaulLevel: number; + offeredAt: RealTime; + /** End of the board period the contract was offered in. */ + expiresAt: RealTime; + firstContact: boolean; +} + +export interface PayoutInput { + cargoType: CargoType; + tier: number; + massTonnes: number; + /** Route distance at offer time. */ + distanceMillionKm: number; + firstContact: boolean; +} + +/** + * Payout in whole credits: base pay per tonne per million km × tierMultiplier^tier + * × mass × distance, × the first-contact multiplier when flagged. + */ +export function contractPayout(input: PayoutInput, config: GameConfig): number { + const { cargoType, tier, massTonnes, distanceMillionKm, firstContact } = input; + const pay = + cargoType.basePayPerTonnePerMillionKm * + cargoType.tierPayMultiplier ** tier * + massTonnes * + distanceMillionKm * + (firstContact ? config.firstContactPayoutMultiplier : 1); + return Math.round(pay); +} diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index aefca18..440839c 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -5,3 +5,4 @@ export * from "./distance"; export * from "./travel"; export * from "./fuel"; export * from "./eligibility"; +export * from "./contract"; diff --git a/packages/sim/test/contract.test.ts b/packages/sim/test/contract.test.ts new file mode 100644 index 0000000..93f61fa --- /dev/null +++ b/packages/sim/test/contract.test.ts @@ -0,0 +1,52 @@ +import { content } from "@deadhead/content"; +import { describe, expect, it } from "vitest"; +import { contractPayout, type PayoutInput } from "../src/index"; + +const config = content.config; +const standard = content.cargoType("standard")!; +const dangerous = content.cargoType("dangerous")!; + +const base: PayoutInput = { + cargoType: standard, + tier: 0, + massTonnes: 10, + distanceMillionKm: 100, + firstContact: false, +}; + +describe("contract payout", () => { + it("matches a hand calculation", () => { + // 100 credits/t/M km × 10 t × 100M km. + expect(contractPayout(base, config)).toBe(100_000); + }); + + it("scales linearly with mass and distance", () => { + const payout = contractPayout(base, config); + expect(contractPayout({ ...base, massTonnes: 30 }, config)).toBe(3 * payout); + expect(contractPayout({ ...base, distanceMillionKm: 250 }, config)).toBe(2.5 * payout); + }); + + it("pays more for Dangerous than Standard on the same mass and route", () => { + expect(contractPayout({ ...base, cargoType: dangerous, tier: 1 }, config)).toBeGreaterThan( + contractPayout(base, config), + ); + }); + + it("pays more at a higher tier", () => { + const tier1 = contractPayout({ ...base, cargoType: dangerous, tier: 1 }, config); + const tier3 = contractPayout({ ...base, cargoType: dangerous, tier: 3 }, config); + expect(tier3).toBeGreaterThan(tier1); + }); + + it("pays more for a first-contact contract", () => { + expect(contractPayout({ ...base, firstContact: true }, config)).toBe( + contractPayout(base, config) * config.firstContactPayoutMultiplier, + ); + }); + + it("rounds to whole credits", () => { + expect(Number.isInteger(contractPayout({ ...base, distanceMillionKm: 0.384 }, config))).toBe( + true, + ); + }); +});