193 lines
7.6 KiB
TypeScript
193 lines
7.6 KiB
TypeScript
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);
|
||
});
|
||
});
|