Step 10H.2: each board contract pays its own market rate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 18:02:31 -04:00
parent 6e427e52f7
commit bf19f498b8
4 changed files with 98 additions and 2 deletions

View File

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

View File

@ -252,6 +252,11 @@ function rollPriority(
return { priority: true, deliveryWindowGameHours: window }; 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 { export function cargoClassOf(good: Good, content: SimContent): CargoType {
const cargoType = content.cargoTypes.find((c) => c.id === good.cargoClassId); const cargoType = content.cargoTypes.find((c) => c.id === good.cargoClassId);
if (!cargoType) throw new Error(`Unknown cargo class ${good.cargoClassId}`); if (!cargoType) throw new Error(`Unknown cargo class ${good.cargoClassId}`);
@ -284,6 +289,8 @@ export function buildContract(
shipper: string; shipper: string;
priority: boolean; priority: boolean;
deliveryWindowGameHours: number | null; deliveryWindowGameHours: number | null;
/** Market rate (decision 0.5.60); 1 when absent, as on the tutorial board. */
payRate?: number;
}, },
content: SimContent, content: SimContent,
): Contract { ): Contract {
@ -311,6 +318,7 @@ export function buildContract(
distanceMillionKm: distance, distanceMillionKm: distance,
firstContact: args.firstContact, firstContact: args.firstContact,
priority: args.priority, priority: args.priority,
marketRate: args.payRate ?? 1,
}, },
config, config,
), ),
@ -408,6 +416,7 @@ export function generateContractBoard(input: BoardInput, content: SimContent): C
periodIndex, periodIndex,
...rollPriority(rng, { ...job, origin, offeredAt, model }, content), ...rollPriority(rng, { ...job, origin, offeredAt, model }, content),
shipper: rng.pick(origin.shippers), shipper: rng.pick(origin.shippers),
payRate: payRate(rng, content),
}, },
content, content,
); );
@ -438,6 +447,7 @@ export function generateContractBoard(input: BoardInput, content: SimContent): C
periodIndex, periodIndex,
...rollPriority(rng, { good, origin, destination, massTonnes, offeredAt }, content), ...rollPriority(rng, { good, origin, destination, massTonnes, offeredAt }, content),
shipper: rng.pick(origin.shippers), shipper: rng.pick(origin.shippers),
payRate: payRate(rng, content),
}, },
content, content,
); );

View File

@ -44,12 +44,14 @@ export interface PayoutInput {
distanceMillionKm: number; distanceMillionKm: number;
firstContact: boolean; firstContact: boolean;
priority: 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 * Payout in whole credits: class base pay per tonne per million km × the good's
* pay multiplier × tierMultiplier^tier × mass × distance, × the first-contact * 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 { export function contractPayout(input: PayoutInput, config: GameConfig): number {
const { cargoType, payMultiplier, tier, massTonnes, distanceMillionKm, firstContact } = input; const { cargoType, payMultiplier, tier, massTonnes, distanceMillionKm, firstContact } = input;
@ -60,6 +62,7 @@ export function contractPayout(input: PayoutInput, config: GameConfig): number {
massTonnes * massTonnes *
distanceMillionKm * distanceMillionKm *
(firstContact ? config.firstContactPayoutMultiplier : 1) * (firstContact ? config.firstContactPayoutMultiplier : 1) *
(input.priority ? config.priorityPayoutMultiplier : 1); (input.priority ? config.priorityPayoutMultiplier : 1) *
(input.marketRate ?? 1);
return Math.round(pay); return Math.round(pay);
} }

View File

@ -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<string, Set<number>>();
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);
});
});