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, type SimContent, } 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, }; /** The five places CR-007's baseline was measured with (CR-016 adds more destinations). */ const baseFive = new Set(["earth", "moon", "mars", "ceres-station", "jupiter-cloud-platform"]); const fivePlaces: SimContent = { ...content, locations: content.locations.filter((l) => baseFive.has(l.id)), }; /** Pre-change class shares, 1,000 Earth boards, all five discovered, seed 12345 (CR-007 §6). */ const BASELINE: Record = { standard: 76.79, "high-value": 23.21 }; function boards(input: BoardInput, count: number, at: SimContent = content): Contract[][] { return Array.from({ length: count }, (_, p) => generateContractBoard({ ...input, periodIndex: p }, at), ); } 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, discoveredIds: baseFive }, 1000, fivePlaces)) { 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("filters nothing for a full-capability fleet: every board full, in slot order (CR-019)", () => { // Class selection is measured above without a fleet: fit slots (CR-006) pick goods // a crew can carry, not from the class mix. With a fleet that can do anything, // completability turns no candidate away, so boards are as the slots made them. const maxed = Object.fromEntries(content.skills.map((k) => [k.id, k.maxLevel])); const fleet = content.shipModels .filter((m) => m.purchasable) .map((m) => ({ modelId: m.id, skills: maxed })); for (const board of boards( { ...earthInput, discoveredIds: baseFive, fleet }, 300, fivePlaces, )) { expect(board).toHaveLength(config.contractBoardSize); expect(board.map((c) => Number(c.id.split(":").at(-1)))).toEqual(board.map((_, i) => i)); } }); 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, 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); }); });