138 lines
5.3 KiB
TypeScript
138 lines
5.3 KiB
TypeScript
import { content } from "@deadhead/content";
|
||
import { describe, expect, it } from "vitest";
|
||
import {
|
||
boardPeriodIndex,
|
||
boardPeriodMs,
|
||
generateContractBoard,
|
||
requiredLongHaulLevel,
|
||
type BoardInput,
|
||
} from "../src/index";
|
||
|
||
const earth = content.location("earth")!;
|
||
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));
|
||
});
|
||
|
||
it("produces a different board for a different period", () => {
|
||
const next = generateContractBoard({ ...input, periodIndex: periodIndex + 1 }, content);
|
||
expect(next).not.toEqual(generateContractBoard(input, content));
|
||
});
|
||
|
||
it("has the configured number of contracts, none to its own origin", () => {
|
||
const board = generateContractBoard(input, content);
|
||
expect(board).toHaveLength(content.config.contractBoardSize);
|
||
for (const contract of board) {
|
||
expect(contract.originId).toBe("earth");
|
||
expect(contract.destinationId).not.toBe("earth");
|
||
}
|
||
});
|
||
|
||
it("expires every contract at the end of its board period", () => {
|
||
const periodEnd = (periodIndex + 1) * boardPeriodMs(content.config);
|
||
for (const contract of generateContractBoard(input, content)) {
|
||
expect(contract.expiresAt).toBe(periodEnd);
|
||
}
|
||
});
|
||
|
||
it("ties tier to skill: Standard is tier 0 with no skill, others 1–5 matching the skill level", () => {
|
||
const moon = content.location("moon")!;
|
||
for (let p = 0; p < 50; p++) {
|
||
for (const c of generateContractBoard(
|
||
{ ...input, location: moon, periodIndex: p },
|
||
content,
|
||
)) {
|
||
if (c.cargoTypeId === "standard") {
|
||
expect(c.tier).toBe(0);
|
||
expect(c.requiredSkill).toBeNull();
|
||
} else {
|
||
expect(c.tier).toBeGreaterThanOrEqual(1);
|
||
expect(c.tier).toBeLessThanOrEqual(5);
|
||
expect(c.requiredSkill?.level).toBe(c.tier);
|
||
}
|
||
const bands = content.config.contractMassBands;
|
||
expect(c.massTonnes).toBeGreaterThan(0);
|
||
expect(c.massTonnes).toBeLessThanOrEqual(Math.max(...bands.map((b) => b.max)));
|
||
}
|
||
}
|
||
});
|
||
|
||
it("fills each mass band with its configured number of contracts", () => {
|
||
const board = generateContractBoard(input, content);
|
||
let slot = 0;
|
||
for (const band of content.config.contractMassBands) {
|
||
for (let k = 0; k < band.count; k++, slot++) {
|
||
expect(board[slot]!.massTonnes).toBeGreaterThanOrEqual(floorOf(band));
|
||
expect(board[slot]!.massTonnes).toBeLessThanOrEqual(band.max);
|
||
}
|
||
}
|
||
expect(slot).toBe(board.length);
|
||
});
|
||
|
||
it("always offers each ship size a Standard job to a discovered place (no skill needed)", () => {
|
||
for (let p = 0; p < 500; p++) {
|
||
const board = generateContractBoard({ ...input, periodIndex: p }, content);
|
||
for (const ship of content.shipModels.filter((s) => s.purchasable)) {
|
||
const flyable = board.some(
|
||
(c) =>
|
||
c.massTonnes <= ship.cargoCapacityTonnes &&
|
||
c.requiredSkill === null &&
|
||
!c.firstContact &&
|
||
discoveredIds.has(c.destinationId),
|
||
);
|
||
expect(flyable).toBe(true);
|
||
}
|
||
}
|
||
});
|
||
|
||
it("makes 5–15% of contracts first contact over 1,000 boards, all to undiscovered places", () => {
|
||
let total = 0;
|
||
let firstContact = 0;
|
||
for (let p = 0; p < 1000; p++) {
|
||
for (const contract of generateContractBoard({ ...input, periodIndex: p }, content)) {
|
||
total++;
|
||
if (contract.firstContact) {
|
||
firstContact++;
|
||
expect(discoveredIds.has(contract.destinationId)).toBe(false);
|
||
} else {
|
||
expect(discoveredIds.has(contract.destinationId)).toBe(true);
|
||
}
|
||
}
|
||
}
|
||
const share = firstContact / total;
|
||
expect(share).toBeGreaterThanOrEqual(0.05);
|
||
expect(share).toBeLessThanOrEqual(0.15);
|
||
});
|
||
});
|
||
|
||
describe("location boards (CR-002)", () => {
|
||
it("generates a deterministic Moon board from the location alone, with no bay data", () => {
|
||
const moon = content.location("moon")!;
|
||
const moonInput: BoardInput = { location: moon, discoveredIds, periodIndex, worldSeed: 12345 };
|
||
const board = generateContractBoard(moonInput, content);
|
||
expect(board).toHaveLength(content.config.contractBoardSize);
|
||
for (const c of board) {
|
||
expect(c.originId).toBe("moon");
|
||
expect(c.destinationId).not.toBe("moon");
|
||
}
|
||
expect(generateContractBoard(moonInput, content)).toEqual(board);
|
||
});
|
||
});
|
||
|
||
describe("required Long Haul level", () => {
|
||
it("counts the cut-offs a distance exceeds", () => {
|
||
expect(requiredLongHaulLevel(0.4, content.config)).toBe(0);
|
||
expect(requiredLongHaulLevel(100, content.config)).toBe(0);
|
||
expect(requiredLongHaulLevel(101, content.config)).toBe(1);
|
||
expect(requiredLongHaulLevel(900, content.config)).toBe(5);
|
||
});
|
||
});
|