deadhead-space-freight-tycoon/packages/sim/test/board-goods.test.ts

162 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, 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);
});
});