From bf19f498b886215a35bea976ea58ea7b946d7489 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sun, 4 Oct 2026 18:02:31 -0400 Subject: [PATCH] Step 10H.2: each board contract pays its own market rate Co-Authored-By: Claude Opus 5.5 --- memory-bank/progress.md | 1 + packages/sim/src/board.ts | 10 ++++ packages/sim/src/contract.ts | 7 ++- packages/sim/test/pay-rate.test.ts | 82 ++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 packages/sim/test/pay-rate.test.ts diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 70fb1d1..4209bcd 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -218,3 +218,4 @@ Spec: `memory-bank/change-requests/CR-007-cargo-goods.md` (supplied as "CR-005 / Spec: `memory-bank/change-requests/CR-008-fresh-boards.md`. New decisions recorded as plan §0.5.60–0.5.62. - [x] **Step 10H.1 — Content: pay variance and new goods.** Config `contractPayVariance` 0.15. Five Standard goods (decision 0.5.61): Water Recyclers, Spare Parts (Priority-eligible) and Lunar Base Supplies Earth → Moon; Crew Personal Effects and Recycled Alloys Moon → Earth (locations' goods lists update from the catalog). Earth → Moon now has 6 Standard goods, Moon → Earth 4. All existing tests still pass (tutorial masses unchanged: goods on both routes still hold 5/12/20 t). Test: `goods.test.ts` (route variety, variance is a fraction). +- [x] **Step 10H.2 — Sim: per-contract pay rate.** `PayoutInput.marketRate` (default 1) multiplies the payout; `payRate(rng, content)` draws 1 ± `contractPayVariance` uniformly as the last draw of every board contract (fit and normal); `buildContract` takes `payRate` (tutorial boards pass none, so tutorial payouts are unchanged). Tests: `pay-rate.test.ts` (payout scales with the rate; 1,000 Earth boards: rates within ±15 %, mean within 1 ± 0.01; the same good, mass and route now pays different amounts); the tutorial and CR-007 class-mix tests still pass. diff --git a/packages/sim/src/board.ts b/packages/sim/src/board.ts index ef2728d..3e96c6b 100644 --- a/packages/sim/src/board.ts +++ b/packages/sim/src/board.ts @@ -252,6 +252,11 @@ function rollPriority( return { priority: true, deliveryWindowGameHours: window }; } +/** A board contract's market rate: uniform within ± `contractPayVariance` of 1 (decision 0.5.60). */ +export function payRate(rng: Rng, content: SimContent): number { + return 1 + content.config.contractPayVariance * (2 * rng.next() - 1); +} + export function cargoClassOf(good: Good, content: SimContent): CargoType { const cargoType = content.cargoTypes.find((c) => c.id === good.cargoClassId); if (!cargoType) throw new Error(`Unknown cargo class ${good.cargoClassId}`); @@ -284,6 +289,8 @@ export function buildContract( shipper: string; priority: boolean; deliveryWindowGameHours: number | null; + /** Market rate (decision 0.5.60); 1 when absent, as on the tutorial board. */ + payRate?: number; }, content: SimContent, ): Contract { @@ -311,6 +318,7 @@ export function buildContract( distanceMillionKm: distance, firstContact: args.firstContact, priority: args.priority, + marketRate: args.payRate ?? 1, }, config, ), @@ -408,6 +416,7 @@ export function generateContractBoard(input: BoardInput, content: SimContent): C periodIndex, ...rollPriority(rng, { ...job, origin, offeredAt, model }, content), shipper: rng.pick(origin.shippers), + payRate: payRate(rng, content), }, content, ); @@ -438,6 +447,7 @@ export function generateContractBoard(input: BoardInput, content: SimContent): C periodIndex, ...rollPriority(rng, { good, origin, destination, massTonnes, offeredAt }, content), shipper: rng.pick(origin.shippers), + payRate: payRate(rng, content), }, content, ); diff --git a/packages/sim/src/contract.ts b/packages/sim/src/contract.ts index 03e946c..3532507 100644 --- a/packages/sim/src/contract.ts +++ b/packages/sim/src/contract.ts @@ -44,12 +44,14 @@ export interface PayoutInput { distanceMillionKm: number; firstContact: boolean; priority: boolean; + /** This contract's market rate around 1 (decision 0.5.60); 1 when absent. */ + marketRate?: number; } /** * Payout in whole credits: class base pay per tonne per million km × the good's * pay multiplier × tierMultiplier^tier × mass × distance, × the first-contact - * multiplier and the Priority bonus when flagged (CR-007). + * multiplier and the Priority bonus when flagged (CR-007), × its market rate (CR-008). */ export function contractPayout(input: PayoutInput, config: GameConfig): number { const { cargoType, payMultiplier, tier, massTonnes, distanceMillionKm, firstContact } = input; @@ -60,6 +62,7 @@ export function contractPayout(input: PayoutInput, config: GameConfig): number { massTonnes * distanceMillionKm * (firstContact ? config.firstContactPayoutMultiplier : 1) * - (input.priority ? config.priorityPayoutMultiplier : 1); + (input.priority ? config.priorityPayoutMultiplier : 1) * + (input.marketRate ?? 1); return Math.round(pay); } diff --git a/packages/sim/test/pay-rate.test.ts b/packages/sim/test/pay-rate.test.ts new file mode 100644 index 0000000..fa54474 --- /dev/null +++ b/packages/sim/test/pay-rate.test.ts @@ -0,0 +1,82 @@ +import { content } from "@deadhead/content"; +import { describe, expect, it } from "vitest"; +import { + contractPayout, + generateContractBoard, + realToGame, + routeDistance, + type Contract, +} from "../src/index"; + +const { config } = content; +const v = config.contractPayVariance; +const all = new Set(content.locations.map((l) => l.id)); + +/** The contract's payout without its market rate, from the same inputs. */ +function basePayout(c: Contract): number { + const good = content.good(c.goodId)!; + return contractPayout( + { + cargoType: content.cargoType(c.cargoTypeId)!, + payMultiplier: good.payMultiplier, + tier: c.tier, + massTonnes: c.massTonnes, + distanceMillionKm: routeDistance( + content.location(c.originId)!, + content.location(c.destinationId)!, + realToGame(c.offeredAt, config), + ), + firstContact: c.firstContact, + priority: c.priority, + }, + config, + ); +} + +const contracts = Array.from({ length: 1000 }, (_, p) => + generateContractBoard( + { location: content.location("earth")!, discoveredIds: all, periodIndex: p, worldSeed: 99 }, + content, + ), +).flat(); + +describe("market rate (CR-008)", () => { + it("scales a payout by its rate", () => { + const input = { + cargoType: content.cargoType("standard")!, + payMultiplier: 1, + tier: 0, + massTonnes: 10, + distanceMillionKm: 100, + firstContact: false, + priority: false, + }; + expect(contractPayout({ ...input, marketRate: 1.1 }, config)).toBe( + Math.round(contractPayout(input, config) * 1.1), + ); + }); + + it("keeps every board contract's rate within ±variance, averaging 1", () => { + // Ignore tiny payouts, where rounding to whole credits blurs the rate. + const rates = contracts + .filter((c) => basePayout(c) >= 200) + .map((c) => c.payout / basePayout(c)); + expect(rates.length).toBeGreaterThan(1000); + for (const rate of rates) { + expect(rate).toBeGreaterThanOrEqual(1 - v - 0.005); + expect(rate).toBeLessThanOrEqual(1 + v + 0.005); + } + const mean = rates.reduce((a, b) => a + b, 0) / rates.length; + expect(Math.abs(mean - 1)).toBeLessThanOrEqual(0.01); + }); + + it("no longer pays the identical amount for the same good, mass and route", () => { + const seen = new Map>(); + for (const c of contracts) { + const key = `${c.goodId}|${c.massTonnes}|${c.destinationId}|${c.priority}|${c.firstContact}`; + seen.set(key, (seen.get(key) ?? new Set()).add(c.payout)); + } + const repeated = [...seen.values()].filter((payouts) => payouts.size > 1); + expect(repeated.length).toBeGreaterThan(50); + }); +});