From 83d0b3900fbddd1748ce0c6ae7d4bb0a97fd0b05 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sun, 4 Oct 2026 17:01:14 -0400 Subject: [PATCH] Step 10G.3: goods, Priority and shippers in board generation Co-Authored-By: Claude Opus 5.5 --- memory-bank/progress.md | 1 + packages/sim/src/board.ts | 129 ++++++++++++++++++--- packages/sim/test/board-fleet.test.ts | 7 +- packages/sim/test/board-goods.test.ts | 161 ++++++++++++++++++++++++++ packages/sim/test/board.test.ts | 8 +- 5 files changed, 289 insertions(+), 17 deletions(-) create mode 100644 packages/sim/test/board-goods.test.ts diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 814bbba..1281b6e 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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 %. - [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.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. diff --git a/packages/sim/src/board.ts b/packages/sim/src/board.ts index adef65b..ef2728d 100644 --- a/packages/sim/src/board.ts +++ b/packages/sim/src/board.ts @@ -13,6 +13,7 @@ import { routeDistance } from "./distance"; import { checkRoute } from "./eligibility"; import { LONG_HAUL_SKILL_ID, skillLevel, type SkillLevels } from "./pilot"; import { createRng, deriveSeed, type Rng, type Seed } from "./rng"; +import { travelTimeGameHours } from "./travel"; /** The static data the sim needs. The `content` bundle satisfies this. */ export interface SimContent { @@ -141,18 +142,116 @@ function fitJob( ); 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. - const good = routeGoods(cargoType, origin, destination, content, (g) => { + const candidates = routeGoods(cargoType, origin, destination, content, (g) => { const skillId = cargoClassOf(g, content).requiredSkillId; 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 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 }; + return { destination, good, tier, massTonnes, model }; } 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 { const cargoType = content.cargoTypes.find((c) => c.id === 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 = kind === "fit" ? fitJob(rng, band, crews, origin, discovered, offeredAt, content) : null; if (fit) { + const { model, ...job } = fit; return buildContract( { id, origin, - ...fit, + ...job, firstContact: false, periodIndex, - shipper: origin.shippers[0]!, - priority: false, - deliveryWindowGameHours: null, + ...rollPriority(rng, { ...job, origin, offeredAt, model }, content), + shipper: rng.pick(origin.shippers), }, content, ); @@ -321,7 +420,12 @@ export function generateContractBoard(input: BoardInput, content: SimContent): C (discovered.length === 0 || rng.next() < config.firstContactChance); const destination = rng.pick(firstContact ? undiscovered : discovered); 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( { id, @@ -329,12 +433,11 @@ export function generateContractBoard(input: BoardInput, content: SimContent): C destination, good, tier: tierFor(rng, good, content), - massTonnes: rng.int(band.min, band.max), + massTonnes, firstContact, periodIndex, - shipper: origin.shippers[0]!, - priority: false, - deliveryWindowGameHours: null, + ...rollPriority(rng, { good, origin, destination, massTonnes, offeredAt }, content), + shipper: rng.pick(origin.shippers), }, content, ); diff --git a/packages/sim/test/board-fleet.test.ts b/packages/sim/test/board-fleet.test.ts index 96c4d0a..215c6c0 100644 --- a/packages/sim/test/board-fleet.test.ts +++ b/packages/sim/test/board-fleet.test.ts @@ -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: {} }; describe("boards that fit the fleet (CR-006)", () => { @@ -81,13 +84,13 @@ describe("boards that fit the fleet (CR-006)", () => { let slot = 0; for (const band of bands) { 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); } } for (const [i, band] of bands.entries()) { 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)); expect(fitting.length).toBeGreaterThanOrEqual(band.count - config.boardOpenSlotsPerBand); } diff --git a/packages/sim/test/board-goods.test.ts b/packages/sim/test/board-goods.test.ts new file mode 100644 index 0000000..f2012e1 --- /dev/null +++ b/packages/sim/test/board-goods.test.ts @@ -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 = { 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 = {}; + 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); + }); +}); diff --git a/packages/sim/test/board.test.ts b/packages/sim/test/board.test.ts index 333174a..efd94cb 100644 --- a/packages/sim/test/board.test.ts +++ b/packages/sim/test/board.test.ts @@ -13,6 +13,10 @@ const discoveredIds = new Set(["earth", "moon"]); const periodIndex = boardPeriodIndex(Date.parse("2100-01-01T00:00:00Z"), content.config); 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", () => { it("produces an identical board from the same inputs", () => { expect(generateContractBoard(input, content)).toEqual(generateContractBoard(input, content)); @@ -55,7 +59,7 @@ describe("contract board generation", () => { expect(c.requiredSkill?.level).toBe(c.tier); } 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))); } } @@ -66,7 +70,7 @@ describe("contract board generation", () => { let slot = 0; for (const band of content.config.contractMassBands) { 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); } }