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:
parent
6e427e52f7
commit
bf19f498b8
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue