deadhead-space-freight-tycoon/packages/sim/test/balance.test.ts

97 lines
4.1 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 { beforeAll, describe, expect, it } from "vitest";
import { simulate, type Milestones, type SimulationResult } from "../balance/simulate";
import { content } from "@deadhead/content";
import { MS_PER_HOUR, realToGame, tripFuelCost } from "../src/index";
/**
* Balance targets (plan Step 12.1 as amended by CR-001 to CR-013), checked on
* the median of several worlds: one world's job luck moves a milestone by tens
* of minutes. The invariants must hold in every world.
*/
const SEEDS = [12345, 777, 4242, 99, 2026, 31337, 8];
const RUN = { durationMs: 8 * MS_PER_HOUR };
let lighterFirst: SimulationResult[];
let freighterFirst: SimulationResult[];
beforeAll(() => {
lighterFirst = SEEDS.map((worldSeed) => simulate({ ...RUN, worldSeed, policy: "lighter-first" }));
freighterFirst = SEEDS.map((worldSeed) =>
simulate({ ...RUN, worldSeed, policy: "freighter-first" }),
);
});
/** Median real minutes to a milestone; never reached counts as infinitely late. */
function median(results: SimulationResult[], milestone: keyof Milestones): number {
const sorted = results
.map((r) => r.milestones[milestone] ?? Number.POSITIVE_INFINITY)
.sort((a, b) => a - b);
return sorted[Math.floor(sorted.length / 2)]!;
}
describe("balance simulation (Step 12.1)", () => {
it("gives identical output for the same seed", () => {
const a = simulate({ ...RUN, worldSeed: 4242, policy: "lighter-first" });
const b = simulate({ ...RUN, worldSeed: 4242, policy: "lighter-first" });
expect(b).toEqual(a);
});
it("accepts the deal in under 10 real minutes", () => {
expect(median(lighterFirst, "dealAccepted")).toBeLessThan(10);
});
it("buys the first Market ship in 45–75 minutes (CR-013)", () => {
const minutes = median(lighterFirst, "firstMarketPurchase");
expect(minutes).toBeGreaterThanOrEqual(45);
expect(minutes).toBeLessThanOrEqual(75);
});
it("discovers a new location in under 90 minutes", () => {
expect(median(lighterFirst, "firstDiscovery")).toBeLessThan(90);
});
it("pays off the loan in under 4 hours of active play (CR-013)", () => {
expect(median(lighterFirst, "loanPaidOff")).toBeLessThan(240);
});
it("reaches four ships Lighter-first and Freighter-first within 25 % of each other", () => {
const a = median(lighterFirst, "ownFourShips");
const b = median(freighterFirst, "ownFourShips");
expect(Number.isFinite(a) && Number.isFinite(b)).toBe(true);
expect(Math.max(a, b)).toBeLessThanOrEqual(1.25 * Math.min(a, b));
});
it("never soft-locks, strands an idle ship or flies a resting pilot, in any world", () => {
for (const result of [...lighterFirst, ...freighterFirst]) {
expect(result.violations, `${result.policy}`).toEqual([]);
}
});
it("has the veteran fly at least one contract in the first session (CR-010)", () => {
for (const result of lighterFirst)
expect(result.veteranContractsFirstSession).toBeGreaterThan(0);
});
it("leaves fuel for at least three Courier runs from Earth after the deal (Step 2.5, measured)", () => {
// Step 12.2: the tutorial share and starting credits as actually earned.
const courier = content.shipModel("courier")!;
const [earth, moon] = [content.location("earth")!, content.location("moon")!];
const start = realToGame(Date.parse(content.config.epoch.realStart), content.config);
const fuel = tripFuelCost(courier, earth, moon, start, content.config);
for (const result of lighterFirst) {
expect(result.tutorialRuns).toBe(content.config.tutorialRunsBeforeDeal);
expect(result.tutorialShareEarned).toBeGreaterThan(0);
expect(result.creditsAtDeal).toBe(
content.config.startingCredits + result.tutorialShareEarned,
);
expect(result.creditsAtDeal!).toBeGreaterThanOrEqual(3 * fuel);
}
});
it("reports credits and pilot levels over time", () => {
const [run] = lighterFirst;
expect(run!.samples.length).toBeGreaterThan(5);
expect(run!.samples[0]).toMatchObject({ minute: 0, credits: expect.any(Number) });
expect(run!.samples.at(-1)!.pilotLevels.length).toBeGreaterThanOrEqual(2);
});
});