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

193 lines
7.6 KiB
TypeScript
Raw Permalink 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,
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<string, number> = { 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<string, number> = {};
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);
});
});