Step 10G.3: goods, Priority and shippers in board generation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
13f5b04ffb
commit
83d0b3900f
|
|
@ -204,3 +204,4 @@ Spec: `memory-bank/change-requests/CR-006-boards-that-fit.md`. New decisions rec
|
||||||
Spec: `memory-bank/change-requests/CR-007-cargo-goods.md` (supplied as "CR-005 / Phase 10E"; renumbered, see its implementation notes). New decisions recorded as plan §0.5.54–0.5.59. Baseline class mix before the change (1,000 Earth boards, all discovered): Standard 76.79 %, High-Value 23.21 %.
|
Spec: `memory-bank/change-requests/CR-007-cargo-goods.md` (supplied as "CR-005 / Phase 10E"; renumbered, see its implementation notes). New decisions recorded as plan §0.5.54–0.5.59. Baseline class mix before the change (1,000 Earth boards, all discovered): Standard 76.79 %, High-Value 23.21 %.
|
||||||
- [x] **Step 10G.1 — Content: goods catalog.** `goodSchema` (id, name, `cargoClassId`, `massTonnes {min,max}`, `payMultiplier`, optional `tier` 1–5, `priorityEligible`, optional `priorityName`, `producedAt`, `demandedAt`; min ≤ max refinement) and `data/goods.ts` with all 29 goods from CR-007 §3, plus `goodsProducedAt`/`goodsDemandedAt`. Locations: `produces`/`demands` now derive from the catalog (good ids); the old class lists are kept as `cargoClassMix` (decision 0.5.57) and the sim's class selection and the UI's produces/demands now read them, so nothing visible changes yet; `shippers` added (≥ 1 each). Config: `priorityChance` 0.15, `priorityPayoutMultiplier` 1.5, `priorityReferenceShipModelId` "freighter", `priorityWindowFactor` 1. Bundle: `goods`, `good(id)`. Tests: `goods.test.ts` (schema, references, tiers by class, Standard good everywhere, ≥ 2 Standard goods each way Earth↔Moon, location lists in sync, Priority config), schema tests for Good and the new fields.
|
- [x] **Step 10G.1 — Content: goods catalog.** `goodSchema` (id, name, `cargoClassId`, `massTonnes {min,max}`, `payMultiplier`, optional `tier` 1–5, `priorityEligible`, optional `priorityName`, `producedAt`, `demandedAt`; min ≤ max refinement) and `data/goods.ts` with all 29 goods from CR-007 §3, plus `goodsProducedAt`/`goodsDemandedAt`. Locations: `produces`/`demands` now derive from the catalog (good ids); the old class lists are kept as `cargoClassMix` (decision 0.5.57) and the sim's class selection and the UI's produces/demands now read them, so nothing visible changes yet; `shippers` added (≥ 1 each). Config: `priorityChance` 0.15, `priorityPayoutMultiplier` 1.5, `priorityReferenceShipModelId` "freighter", `priorityWindowFactor` 1. Bundle: `goods`, `good(id)`. Tests: `goods.test.ts` (schema, references, tiers by class, Standard good everywhere, ≥ 2 Standard goods each way Earth↔Moon, location lists in sync, Priority config), schema tests for Good and the new fields.
|
||||||
- [x] **Step 10G.2 — Sim: contracts carry goods.** `Contract` gains `goodId`, `shipper`, `priority`, `deliveryWindowGameHours` (null unless Priority); the API `contractSchema` mirrors them now (the schema package's type-parity test requires it), so test fixtures across packages gained a good of their class. `contractPayout` = class base × good `payMultiplier` × tierMultiplier^tier × mass × distance × first-contact × Priority bonus. `buildContract` takes a good (the class comes from it) plus shipper and Priority fields; `tierFor` (0.5.55: Standard 0, High-Value/Dangerous the good's tier, Heavy the random roll or up to a fit pilot's level); `routeGoods` (0.5.54 fallback chain). Interim choice until 10G.3: the first route good in catalog order, the origin's first shipper, never Priority; fit jobs only use goods whose tier the pilot meets. `SimContent` gains `goods`. Tests: `contract.test.ts` (tiers from goods, Heavy 1–5, ×2 pay multiplier doubles payout, Priority = bonus × normal).
|
- [x] **Step 10G.2 — Sim: contracts carry goods.** `Contract` gains `goodId`, `shipper`, `priority`, `deliveryWindowGameHours` (null unless Priority); the API `contractSchema` mirrors them now (the schema package's type-parity test requires it), so test fixtures across packages gained a good of their class. `contractPayout` = class base × good `payMultiplier` × tierMultiplier^tier × mass × distance × first-contact × Priority bonus. `buildContract` takes a good (the class comes from it) plus shipper and Priority fields; `tierFor` (0.5.55: Standard 0, High-Value/Dangerous the good's tier, Heavy the random roll or up to a fit pilot's level); `routeGoods` (0.5.54 fallback chain). Interim choice until 10G.3: the first route good in catalog order, the origin's first shipper, never Priority; fit jobs only use goods whose tier the pilot meets. `SimContent` gains `goods`. Tests: `contract.test.ts` (tiers from goods, Heavy 1–5, ×2 pay multiplier doubles payout, Priority = bonus × normal).
|
||||||
|
- [x] **Step 10G.3 — Sim: goods, Priority and shippers in board generation.** Class selection is unchanged (by `cargoClassMix`). Then `chooseGood` (band wins): a random good of the class on the route whose mass range overlaps the slot's band (the lightest band has no floor) with mass in both and within a fit ship's capacity; else the largest-range good as a bulk lot from the band. Masses under 10 t to 0.1 t, else whole tonnes. `rollPriority`: eligible goods become Priority with `priorityChance`; window = `priorityWindow` (reference model's trip time at offer × factor); a fit job is Priority only if its own ship makes the window. Shipper: random from the origin's list. Measured over 1,000 Earth boards (all discovered, seed 12345): Standard 76.04 % (baseline 76.79), High-Value 23.96 % (23.21); Priority on 13.6 % of eligible contracts. Bulk lots make Packaged Food and Electronics the most common goods in the big bands. Tests: `board-goods.test.ts` (determinism with and without a fleet; goods made at the origin and wanted at the destination except when no Standard good goes that way; mass rules; class mix ±3 points; Priority 10–20 % of eligible, only eligible goods, window = reference trip; shippers from the origin's list; `chooseGood` overlap, bulk and capacity cases). `board.test.ts` / `board-fleet.test.ts`: the lightest band's floor is 0 for goods.
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { routeDistance } from "./distance";
|
||||||
import { checkRoute } from "./eligibility";
|
import { checkRoute } from "./eligibility";
|
||||||
import { LONG_HAUL_SKILL_ID, skillLevel, type SkillLevels } from "./pilot";
|
import { LONG_HAUL_SKILL_ID, skillLevel, type SkillLevels } from "./pilot";
|
||||||
import { createRng, deriveSeed, type Rng, type Seed } from "./rng";
|
import { createRng, deriveSeed, type Rng, type Seed } from "./rng";
|
||||||
|
import { travelTimeGameHours } from "./travel";
|
||||||
|
|
||||||
/** The static data the sim needs. The `content` bundle satisfies this. */
|
/** The static data the sim needs. The `content` bundle satisfies this. */
|
||||||
export interface SimContent {
|
export interface SimContent {
|
||||||
|
|
@ -141,18 +142,116 @@ function fitJob(
|
||||||
);
|
);
|
||||||
const cargoType = skilled.length > 0 ? rng.pick(skilled) : standardCargo(content);
|
const cargoType = skilled.length > 0 ? rng.pick(skilled) : standardCargo(content);
|
||||||
// Only goods this pilot may carry: a good's own tier must not exceed their skill.
|
// Only goods this pilot may carry: a good's own tier must not exceed their skill.
|
||||||
const good = routeGoods(cargoType, origin, destination, content, (g) => {
|
const candidates = routeGoods(cargoType, origin, destination, content, (g) => {
|
||||||
const skillId = cargoClassOf(g, content).requiredSkillId;
|
const skillId = cargoClassOf(g, content).requiredSkillId;
|
||||||
return skillId === null || skillLevel(skills, skillId) >= (g.tier ?? 1);
|
return skillId === null || skillLevel(skills, skillId) >= (g.tier ?? 1);
|
||||||
})[0]!;
|
});
|
||||||
|
const { good, massTonnes } = chooseGood(
|
||||||
|
rng,
|
||||||
|
candidates,
|
||||||
|
band,
|
||||||
|
content,
|
||||||
|
model.cargoCapacityTonnes,
|
||||||
|
);
|
||||||
const skillId = cargoClassOf(good, content).requiredSkillId;
|
const skillId = cargoClassOf(good, content).requiredSkillId;
|
||||||
const tier = tierFor(rng, good, content, skillId ? skillLevel(skills, skillId) : undefined);
|
const tier = tierFor(rng, good, content, skillId ? skillLevel(skills, skillId) : undefined);
|
||||||
const massTonnes = rng.int(band.min, Math.min(band.max, model.cargoCapacityTonnes));
|
return { destination, good, tier, massTonnes, model };
|
||||||
return { destination, good, tier, massTonnes };
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A mass in [lo, hi]: to 0.1 t under 10 t, whole tonnes from there (CR-007). */
|
||||||
|
function drawMass(rng: Rng, lo: number, hi: number): number {
|
||||||
|
const raw = lo + rng.next() * (hi - lo);
|
||||||
|
const rounded = raw < 10 ? Math.round(raw * 10) / 10 : Math.round(raw);
|
||||||
|
return Math.min(hi, Math.max(lo, rounded));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A good and its mass for a slot (CR-007, "the band wins"): a good whose mass
|
||||||
|
* range overlaps the slot's band (the lightest band has no floor), with mass in
|
||||||
|
* both and within `capacity`; with no overlap, the candidate with the largest
|
||||||
|
* range and a mass from the band, a bulk lot.
|
||||||
|
*/
|
||||||
|
export function chooseGood(
|
||||||
|
rng: Rng,
|
||||||
|
candidates: readonly Good[],
|
||||||
|
band: MassBand,
|
||||||
|
content: SimContent,
|
||||||
|
capacity = Infinity,
|
||||||
|
): { good: Good; massTonnes: number } {
|
||||||
|
const lightest = Math.min(...content.config.contractMassBands.map((b) => b.min));
|
||||||
|
const floor = band.min === lightest ? 0 : band.min;
|
||||||
|
const ceiling = Math.min(band.max, capacity);
|
||||||
|
const range = (g: Good) => ({
|
||||||
|
lo: Math.max(floor, g.massTonnes.min),
|
||||||
|
hi: Math.min(ceiling, g.massTonnes.max),
|
||||||
|
});
|
||||||
|
const overlapping = candidates.filter((g) => range(g).lo <= range(g).hi);
|
||||||
|
if (overlapping.length > 0) {
|
||||||
|
const good = rng.pick(overlapping);
|
||||||
|
const { lo, hi } = range(good);
|
||||||
|
return { good, massTonnes: drawMass(rng, lo, hi) };
|
||||||
|
}
|
||||||
|
const good = candidates.reduce((a, b) => (b.massTonnes.max > a.massTonnes.max ? b : a));
|
||||||
|
return { good, massTonnes: drawMass(rng, band.min, ceiling) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Priority contract's delivery window (decision 0.5.56): the reference
|
||||||
|
* ship's trip time for this mass and route at offer time × the window factor.
|
||||||
|
*/
|
||||||
|
export function priorityWindow(
|
||||||
|
origin: Location,
|
||||||
|
destination: Location,
|
||||||
|
massTonnes: number,
|
||||||
|
offeredAt: RealTime,
|
||||||
|
content: SimContent,
|
||||||
|
): number {
|
||||||
|
const { config } = content;
|
||||||
|
const reference = content.shipModels.find((s) => s.id === config.priorityReferenceShipModelId);
|
||||||
|
if (!reference) throw new Error(`Unknown ship model ${config.priorityReferenceShipModelId}`);
|
||||||
|
const distance = routeDistance(origin, destination, realToGame(offeredAt, config));
|
||||||
|
return travelTimeGameHours(reference, distance, massTonnes, config) * config.priorityWindowFactor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rolls Priority for an eligible good (decision 0.5.56). A fit job is Priority
|
||||||
|
* only when its own ship (`model`) makes the window.
|
||||||
|
*/
|
||||||
|
function rollPriority(
|
||||||
|
rng: Rng,
|
||||||
|
args: {
|
||||||
|
good: Good;
|
||||||
|
origin: Location;
|
||||||
|
destination: Location;
|
||||||
|
massTonnes: number;
|
||||||
|
offeredAt: RealTime;
|
||||||
|
model?: ShipModel;
|
||||||
|
},
|
||||||
|
content: SimContent,
|
||||||
|
): { priority: boolean; deliveryWindowGameHours: number | null } {
|
||||||
|
const none = { priority: false, deliveryWindowGameHours: null };
|
||||||
|
if (!args.good.priorityEligible || rng.next() >= content.config.priorityChance) return none;
|
||||||
|
const window = priorityWindow(
|
||||||
|
args.origin,
|
||||||
|
args.destination,
|
||||||
|
args.massTonnes,
|
||||||
|
args.offeredAt,
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
if (args.model) {
|
||||||
|
const distance = routeDistance(
|
||||||
|
args.origin,
|
||||||
|
args.destination,
|
||||||
|
realToGame(args.offeredAt, content.config),
|
||||||
|
);
|
||||||
|
const trip = travelTimeGameHours(args.model, distance, args.massTonnes, content.config);
|
||||||
|
if (trip > window) return none;
|
||||||
|
}
|
||||||
|
return { priority: true, deliveryWindowGameHours: window };
|
||||||
|
}
|
||||||
|
|
||||||
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}`);
|
||||||
|
|
@ -299,16 +398,16 @@ export function generateContractBoard(input: BoardInput, content: SimContent): C
|
||||||
const fit =
|
const fit =
|
||||||
kind === "fit" ? fitJob(rng, band, crews, origin, discovered, offeredAt, content) : null;
|
kind === "fit" ? fitJob(rng, band, crews, origin, discovered, offeredAt, content) : null;
|
||||||
if (fit) {
|
if (fit) {
|
||||||
|
const { model, ...job } = fit;
|
||||||
return buildContract(
|
return buildContract(
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
origin,
|
origin,
|
||||||
...fit,
|
...job,
|
||||||
firstContact: false,
|
firstContact: false,
|
||||||
periodIndex,
|
periodIndex,
|
||||||
shipper: origin.shippers[0]!,
|
...rollPriority(rng, { ...job, origin, offeredAt, model }, content),
|
||||||
priority: false,
|
shipper: rng.pick(origin.shippers),
|
||||||
deliveryWindowGameHours: null,
|
|
||||||
},
|
},
|
||||||
content,
|
content,
|
||||||
);
|
);
|
||||||
|
|
@ -321,7 +420,12 @@ export function generateContractBoard(input: BoardInput, content: SimContent): C
|
||||||
(discovered.length === 0 || rng.next() < config.firstContactChance);
|
(discovered.length === 0 || rng.next() < config.firstContactChance);
|
||||||
const destination = rng.pick(firstContact ? undiscovered : discovered);
|
const destination = rng.pick(firstContact ? undiscovered : discovered);
|
||||||
const cargoType = plain ? standardCargo(content) : pickCargo(rng, origin, destination, content);
|
const cargoType = plain ? standardCargo(content) : pickCargo(rng, origin, destination, content);
|
||||||
const good = routeGoods(cargoType, origin, destination, content)[0]!;
|
const { good, massTonnes } = chooseGood(
|
||||||
|
rng,
|
||||||
|
routeGoods(cargoType, origin, destination, content),
|
||||||
|
band,
|
||||||
|
content,
|
||||||
|
);
|
||||||
return buildContract(
|
return buildContract(
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
|
|
@ -329,12 +433,11 @@ export function generateContractBoard(input: BoardInput, content: SimContent): C
|
||||||
destination,
|
destination,
|
||||||
good,
|
good,
|
||||||
tier: tierFor(rng, good, content),
|
tier: tierFor(rng, good, content),
|
||||||
massTonnes: rng.int(band.min, band.max),
|
massTonnes,
|
||||||
firstContact,
|
firstContact,
|
||||||
periodIndex,
|
periodIndex,
|
||||||
shipper: origin.shippers[0]!,
|
...rollPriority(rng, { good, origin, destination, massTonnes, offeredAt }, content),
|
||||||
priority: false,
|
shipper: rng.pick(origin.shippers),
|
||||||
deliveryWindowGameHours: null,
|
|
||||||
},
|
},
|
||||||
content,
|
content,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ function fits(contract: Contract, member: FleetMember): boolean {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const floorOf = (band: { min: number }) =>
|
||||||
|
band.min === Math.min(...bands.map((b) => b.min)) ? 0 : band.min;
|
||||||
|
|
||||||
const courier: FleetMember = { modelId: "courier", skills: {} };
|
const courier: FleetMember = { modelId: "courier", skills: {} };
|
||||||
|
|
||||||
describe("boards that fit the fleet (CR-006)", () => {
|
describe("boards that fit the fleet (CR-006)", () => {
|
||||||
|
|
@ -81,13 +84,13 @@ describe("boards that fit the fleet (CR-006)", () => {
|
||||||
let slot = 0;
|
let slot = 0;
|
||||||
for (const band of bands) {
|
for (const band of bands) {
|
||||||
for (let k = 0; k < band.count; k++, slot++) {
|
for (let k = 0; k < band.count; k++, slot++) {
|
||||||
expect(board[slot]!.massTonnes).toBeGreaterThanOrEqual(band.min);
|
expect(board[slot]!.massTonnes).toBeGreaterThanOrEqual(floorOf(band));
|
||||||
expect(board[slot]!.massTonnes).toBeLessThanOrEqual(band.max);
|
expect(board[slot]!.massTonnes).toBeLessThanOrEqual(band.max);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const [i, band] of bands.entries()) {
|
for (const [i, band] of bands.entries()) {
|
||||||
const member = fleet[i]!;
|
const member = fleet[i]!;
|
||||||
const inBand = board.filter((c) => c.massTonnes >= band.min && c.massTonnes <= band.max);
|
const inBand = board.filter((c) => c.massTonnes >= floorOf(band) && c.massTonnes <= band.max);
|
||||||
const fitting = inBand.filter((c) => fits(c, member));
|
const fitting = inBand.filter((c) => fits(c, member));
|
||||||
expect(fitting.length).toBeGreaterThanOrEqual(band.count - config.boardOpenSlotsPerBand);
|
expect(fitting.length).toBeGreaterThanOrEqual(band.count - config.boardOpenSlotsPerBand);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
import { content } from "@deadhead/content";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { chooseGood, priorityWindow, routeGoods } from "../src/board";
|
||||||
|
import { createRng, generateContractBoard, type BoardInput, type Contract } from "../src/index";
|
||||||
|
|
||||||
|
const { config } = content;
|
||||||
|
const bands = config.contractMassBands;
|
||||||
|
const lightest = Math.min(...bands.map((b) => b.min));
|
||||||
|
const all = new Set(content.locations.map((l) => l.id));
|
||||||
|
const loc = (id: string) => content.location(id)!;
|
||||||
|
const earthInput: BoardInput = {
|
||||||
|
location: loc("earth"),
|
||||||
|
discoveredIds: all,
|
||||||
|
periodIndex: 0,
|
||||||
|
worldSeed: 12345,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Pre-change class shares, 1,000 Earth boards, all discovered, seed 12345 (CR-007 §6). */
|
||||||
|
const BASELINE: Record<string, number> = { standard: 76.79, "high-value": 23.21 };
|
||||||
|
|
||||||
|
function boards(input: BoardInput, count: number): Contract[][] {
|
||||||
|
return Array.from({ length: count }, (_, p) =>
|
||||||
|
generateContractBoard({ ...input, periodIndex: p }, content),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("goods in board generation (CR-007)", () => {
|
||||||
|
it("gives identical boards for identical inputs, fleet or not", () => {
|
||||||
|
expect(generateContractBoard(earthInput, content)).toEqual(
|
||||||
|
generateContractBoard(earthInput, content),
|
||||||
|
);
|
||||||
|
const fleet = [{ modelId: "courier", skills: { "expensive-cargo": 2 } }];
|
||||||
|
expect(generateContractBoard({ ...earthInput, fleet }, content)).toEqual(
|
||||||
|
generateContractBoard({ ...earthInput, fleet }, content),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ships goods made at the origin to where they're wanted, unless no good fits the route", () => {
|
||||||
|
for (const origin of ["earth", "moon", "mars", "ceres-station", "jupiter-cloud-platform"]) {
|
||||||
|
for (const board of boards({ ...earthInput, location: loc(origin) }, 100)) {
|
||||||
|
for (const c of board) {
|
||||||
|
const good = content.good(c.goodId)!;
|
||||||
|
expect(good.producedAt).toContain(origin);
|
||||||
|
expect(good.cargoClassId).toBe(c.cargoTypeId);
|
||||||
|
if (!good.demandedAt.includes(c.destinationId)) {
|
||||||
|
// Fallback: no Standard good goes this way.
|
||||||
|
const onRoute = routeGoods(
|
||||||
|
content.cargoType("standard")!,
|
||||||
|
loc(origin),
|
||||||
|
loc(c.destinationId),
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
expect(onRoute.some((g) => g.demandedAt.includes(c.destinationId))).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws mass inside the good's range and the slot's band, or makes a bulk lot from the band", () => {
|
||||||
|
for (const board of boards(earthInput, 300)) {
|
||||||
|
let slot = 0;
|
||||||
|
for (const band of bands) {
|
||||||
|
for (let k = 0; k < band.count; k++, slot++) {
|
||||||
|
const c = board[slot]!;
|
||||||
|
const { min, max } = content.good(c.goodId)!.massTonnes;
|
||||||
|
const floor = band.min === lightest ? 0 : band.min;
|
||||||
|
expect(c.massTonnes).toBeLessThanOrEqual(band.max);
|
||||||
|
const overlaps = Math.max(floor, min) <= Math.min(band.max, max);
|
||||||
|
if (overlaps) {
|
||||||
|
expect(c.massTonnes).toBeGreaterThanOrEqual(Math.max(floor, min));
|
||||||
|
expect(c.massTonnes).toBeLessThanOrEqual(max);
|
||||||
|
} else {
|
||||||
|
expect(c.massTonnes).toBeGreaterThanOrEqual(band.min);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps each cargo class within ±3 points of the pre-change mix from Earth", () => {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
let total = 0;
|
||||||
|
for (const board of boards(earthInput, 1000)) {
|
||||||
|
for (const c of board) {
|
||||||
|
counts[c.cargoTypeId] = (counts[c.cargoTypeId] ?? 0) + 1;
|
||||||
|
total++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const cls of content.cargoTypes.map((c) => c.id)) {
|
||||||
|
const share = ((counts[cls] ?? 0) / total) * 100;
|
||||||
|
expect(Math.abs(share - (BASELINE[cls] ?? 0))).toBeLessThanOrEqual(3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes 10–20% of eligible contracts Priority, only on eligible goods, with the reference window", () => {
|
||||||
|
let eligible = 0;
|
||||||
|
let priority = 0;
|
||||||
|
for (const board of boards(earthInput, 1000)) {
|
||||||
|
for (const c of board) {
|
||||||
|
const good = content.good(c.goodId)!;
|
||||||
|
if (good.priorityEligible) eligible++;
|
||||||
|
if (!c.priority) {
|
||||||
|
expect(c.deliveryWindowGameHours).toBeNull();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
priority++;
|
||||||
|
expect(good.priorityEligible).toBe(true);
|
||||||
|
expect(c.deliveryWindowGameHours).toBeCloseTo(
|
||||||
|
priorityWindow(loc(c.originId), loc(c.destinationId), c.massTonnes, c.offeredAt, content),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(eligible).toBeGreaterThan(100);
|
||||||
|
expect(priority / eligible).toBeGreaterThanOrEqual(0.1);
|
||||||
|
expect(priority / eligible).toBeLessThanOrEqual(0.2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names a shipper from the origin's list on every contract", () => {
|
||||||
|
for (const origin of ["earth", "moon", "mars"]) {
|
||||||
|
const shippers = loc(origin).shippers;
|
||||||
|
for (const board of boards({ ...earthInput, location: loc(origin) }, 50)) {
|
||||||
|
for (const c of board) expect(shippers).toContain(c.shipper);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("choosing a good for a slot (the band wins)", () => {
|
||||||
|
const rng = createRng(7);
|
||||||
|
const standard = content.cargoType("standard")!;
|
||||||
|
const earthToMoon = routeGoods(standard, loc("earth"), loc("moon"), content);
|
||||||
|
|
||||||
|
it("picks an overlapping good and a mass inside both ranges", () => {
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
const { good, massTonnes } = chooseGood(rng, earthToMoon, bands[1]!, content);
|
||||||
|
expect(massTonnes).toBeGreaterThanOrEqual(Math.max(bands[1]!.min, good.massTonnes.min));
|
||||||
|
expect(massTonnes).toBeLessThanOrEqual(Math.min(bands[1]!.max, good.massTonnes.max));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes a bulk lot of the largest-range good when none overlaps the band", () => {
|
||||||
|
const heavyBand = bands[bands.length - 1]!;
|
||||||
|
const { good, massTonnes } = chooseGood(rng, earthToMoon, heavyBand, content);
|
||||||
|
const largest = Math.max(...earthToMoon.map((g) => g.massTonnes.max));
|
||||||
|
expect(good.massTonnes.max).toBe(largest);
|
||||||
|
expect(massTonnes).toBeGreaterThanOrEqual(heavyBand.min);
|
||||||
|
expect(massTonnes).toBeLessThanOrEqual(heavyBand.max);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects a ship's capacity and lets light goods into the lightest band", () => {
|
||||||
|
const light = bands.find((b) => b.min === lightest)!;
|
||||||
|
let below = 0;
|
||||||
|
for (let i = 0; i < 300; i++) {
|
||||||
|
const { massTonnes } = chooseGood(rng, earthToMoon, light, content, 12);
|
||||||
|
expect(massTonnes).toBeLessThanOrEqual(12);
|
||||||
|
if (massTonnes < light.min) below++;
|
||||||
|
}
|
||||||
|
expect(below).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -13,6 +13,10 @@ const discoveredIds = new Set(["earth", "moon"]);
|
||||||
const periodIndex = boardPeriodIndex(Date.parse("2100-01-01T00:00:00Z"), content.config);
|
const periodIndex = boardPeriodIndex(Date.parse("2100-01-01T00:00:00Z"), content.config);
|
||||||
const input: BoardInput = { location: earth, discoveredIds, periodIndex, worldSeed: 12345 };
|
const input: BoardInput = { location: earth, discoveredIds, periodIndex, worldSeed: 12345 };
|
||||||
|
|
||||||
|
/** A band's lower bound for goods: the lightest band has none (CR-007, light goods). */
|
||||||
|
const floorOf = (band: { min: number }) =>
|
||||||
|
band.min === Math.min(...content.config.contractMassBands.map((b) => b.min)) ? 0 : band.min;
|
||||||
|
|
||||||
describe("contract board generation", () => {
|
describe("contract board generation", () => {
|
||||||
it("produces an identical board from the same inputs", () => {
|
it("produces an identical board from the same inputs", () => {
|
||||||
expect(generateContractBoard(input, content)).toEqual(generateContractBoard(input, content));
|
expect(generateContractBoard(input, content)).toEqual(generateContractBoard(input, content));
|
||||||
|
|
@ -55,7 +59,7 @@ describe("contract board generation", () => {
|
||||||
expect(c.requiredSkill?.level).toBe(c.tier);
|
expect(c.requiredSkill?.level).toBe(c.tier);
|
||||||
}
|
}
|
||||||
const bands = content.config.contractMassBands;
|
const bands = content.config.contractMassBands;
|
||||||
expect(c.massTonnes).toBeGreaterThanOrEqual(Math.min(...bands.map((b) => b.min)));
|
expect(c.massTonnes).toBeGreaterThan(0);
|
||||||
expect(c.massTonnes).toBeLessThanOrEqual(Math.max(...bands.map((b) => b.max)));
|
expect(c.massTonnes).toBeLessThanOrEqual(Math.max(...bands.map((b) => b.max)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -66,7 +70,7 @@ describe("contract board generation", () => {
|
||||||
let slot = 0;
|
let slot = 0;
|
||||||
for (const band of content.config.contractMassBands) {
|
for (const band of content.config.contractMassBands) {
|
||||||
for (let k = 0; k < band.count; k++, slot++) {
|
for (let k = 0; k < band.count; k++, slot++) {
|
||||||
expect(board[slot]!.massTonnes).toBeGreaterThanOrEqual(band.min);
|
expect(board[slot]!.massTonnes).toBeGreaterThanOrEqual(floorOf(band));
|
||||||
expect(board[slot]!.massTonnes).toBeLessThanOrEqual(band.max);
|
expect(board[slot]!.massTonnes).toBeLessThanOrEqual(band.max);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue