237 lines
9.9 KiB
TypeScript
237 lines
9.9 KiB
TypeScript
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||
import { simulate, type Milestones, type SimulationResult } from "../balance/simulate";
|
||
import { content } from "@deadhead/content";
|
||
import { fuelPriceAt, MS_PER_HOUR, tripFuel } from "../src/index";
|
||
|
||
// Long catch-ups and simulations: slow under coverage on CI.
|
||
vi.setConfig({ testTimeout: 60_000 });
|
||
|
||
/**
|
||
* 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 30–95 minutes (CR-013, widened for CR-016 and CR-018)", () => {
|
||
const minutes = median(lighterFirst, "firstMarketPurchase");
|
||
// Cheaper fuel and full deal tanks let the player buy sooner (user decision, CR-018).
|
||
expect(minutes).toBeGreaterThanOrEqual(30);
|
||
expect(minutes).toBeLessThanOrEqual(95);
|
||
});
|
||
|
||
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("never needs emergency fuel with positive credits (CR-015, Step 15.9; Manual since CR-018)", () => {
|
||
for (const result of [...lighterFirst, ...freighterFirst]) {
|
||
const stuck = result.violations.filter((v) => v.rule === "needs-emergency-fuel");
|
||
expect(stuck, `${result.policy}`).toEqual([]);
|
||
}
|
||
});
|
||
|
||
it("never shows the player a job its fleet can't complete, in any world (CR-019)", () => {
|
||
for (const result of [...lighterFirst, ...freighterFirst]) {
|
||
expect(result.jobsShown).toBeGreaterThan(0);
|
||
expect(result.uncompletableShown).toBe(0);
|
||
}
|
||
});
|
||
|
||
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. Since
|
||
// CR-015 the deal Courier's tank counts (full since CR-018), with credits to buy more at Earth.
|
||
const courier = content.shipModel("courier")!;
|
||
const [earth, moon] = [content.location("earth")!, content.location("moon")!];
|
||
const runFu = tripFuel(courier, earth, moon, content.config);
|
||
const inTank = courier.tankCapacityFu;
|
||
const earthPrice = fuelPriceAt(earth, 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(inTank + result.creditsAtDeal! / earthPrice).toBeGreaterThanOrEqual(3 * runFu);
|
||
}
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|
||
|
||
describe("the away scenario (CR-021 Step 21.8)", () => {
|
||
// Three hours of play (four ships), then eight hours either way.
|
||
const scenario = (worldSeed: number, mode: "active" | "away") =>
|
||
simulate({
|
||
worldSeed,
|
||
policy: "lighter-first",
|
||
durationMs: 12 * MS_PER_HOUR,
|
||
scenario: { atMinute: 180, minutes: 8 * 60, mode },
|
||
});
|
||
let ratios: number[];
|
||
let awayRuns: SimulationResult[];
|
||
beforeAll(() => {
|
||
awayRuns = SEEDS.map((seed) => scenario(seed, "away"));
|
||
ratios = SEEDS.map(
|
||
(seed, i) => awayRuns[i]!.scenarioEarnings! / scenario(seed, "active").scenarioEarnings!,
|
||
);
|
||
});
|
||
|
||
it("earns 40–80% of active play with the same fleet over 8 hours away", () => {
|
||
const sorted = [...ratios].sort((a, b) => a - b);
|
||
const median = sorted[Math.floor(sorted.length / 2)]!;
|
||
expect(median).toBeGreaterThanOrEqual(0.4);
|
||
expect(median).toBeLessThanOrEqual(0.8);
|
||
});
|
||
|
||
it("never lets a pilot buy fuel below the spending floor or get stranded", () => {
|
||
for (const run of awayRuns) expect(run.violations).toEqual([]);
|
||
});
|
||
|
||
it("keeps Independent hulls serviced with events on and auto-repair at 40% (CR-027 Step 27.7)", () => {
|
||
for (const run of awayRuns) {
|
||
// No ship flies more than one trip below 25% while its repair is affordable.
|
||
expect(run.service.maxLowHullTrips).toBeLessThanOrEqual(1);
|
||
expect(run.violations.filter((v) => v.rule === "hull-neglected")).toEqual([]);
|
||
}
|
||
// Auto-repair books repairs, and they get done.
|
||
const bookings = awayRuns.reduce((n, r) => n + r.service.bookings, 0);
|
||
const started = awayRuns.reduce((n, r) => n + r.service.started, 0);
|
||
expect(bookings).toBeGreaterThan(0);
|
||
expect(started).toBeGreaterThanOrEqual(bookings - awayRuns.length);
|
||
});
|
||
});
|
||
|
||
describe("a week away (CR-024 Step 24.7, plan 12.1)", () => {
|
||
const away = (worldSeed: number, hours: number) =>
|
||
simulate({
|
||
worldSeed,
|
||
policy: "lighter-first",
|
||
durationMs: (3 + hours) * 60 * 60_000,
|
||
scenario: { atMinute: 180, minutes: hours * 60, mode: "away" },
|
||
});
|
||
|
||
it("earns no more in 7 days away than in 24 hours plus the trips in flight when the fleet went quiet", () => {
|
||
for (const seed of SEEDS) {
|
||
const day = away(seed, 24);
|
||
const week = away(seed, 7 * 24);
|
||
expect(week.payoutInFlightAtSleep, `seed ${seed}`).not.toBeNull();
|
||
expect(week.scenarioEarnings!, `seed ${seed}`).toBeLessThanOrEqual(
|
||
day.scenarioEarnings! + week.payoutInFlightAtSleep!,
|
||
);
|
||
// The pilots did work before going quiet.
|
||
expect(day.scenarioEarnings!, `seed ${seed}`).toBeGreaterThan(0);
|
||
expect(week.violations).toEqual([]);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("events in a long-haul mid-game fleet (CR-026 Step 26.9)", () => {
|
||
// Most of the usual fleet's trips are Earth↔Moon shuttles, where events stay at 3% or
|
||
// less (test/events.test.ts); the rate and repair targets are for a fleet on long
|
||
// hauls (decision 0.5.152's note, the user's choice).
|
||
const EVENT_SEEDS = [
|
||
12345, 777, 4242, 99, 2026, 31337, 8, 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 14,
|
||
];
|
||
const median = (xs: number[]) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)]!;
|
||
let rows: { share: number; good: number; defaults: number; best: number; repairs: number }[];
|
||
|
||
beforeAll(() => {
|
||
rows = EVENT_SEEDS.map((worldSeed) => {
|
||
const run = simulate({
|
||
worldSeed,
|
||
policy: "lighter-first",
|
||
durationMs: 54 * MS_PER_HOUR,
|
||
scenario: { atMinute: 300, minutes: 48 * 60, mode: "active" },
|
||
events: "defaults",
|
||
eventsFromMinute: 300,
|
||
longHaulMinGameHours: 12,
|
||
firstEventDone: true,
|
||
});
|
||
const e = run.events;
|
||
const income = run.scenarioEarnings!;
|
||
return {
|
||
share: e.windowWithEvent / e.windowTrips,
|
||
good: e.good / e.withEvent,
|
||
// What the window's events were worth, every call left to its default or answered well.
|
||
defaults: 1 + e.windowDefaultValue / income,
|
||
best: 1 + e.windowBestValue / income,
|
||
repairs: e.windowRepairSpend / income,
|
||
};
|
||
});
|
||
}, 120_000);
|
||
|
||
it("gives 8–15% of trips an event, about a third of them good", () => {
|
||
expect(median(rows.map((r) => r.share))).toBeGreaterThanOrEqual(0.08);
|
||
expect(median(rows.map((r) => r.share))).toBeLessThanOrEqual(0.15);
|
||
expect(median(rows.map((r) => r.good))).toBeGreaterThanOrEqual(0.28);
|
||
expect(median(rows.map((r) => r.good))).toBeLessThanOrEqual(0.38);
|
||
});
|
||
|
||
it("keeps defaults at 93%+ of no events, and good answers at no less", () => {
|
||
expect(median(rows.map((r) => r.defaults))).toBeGreaterThanOrEqual(0.93);
|
||
expect(median(rows.map((r) => r.best))).toBeGreaterThanOrEqual(1);
|
||
});
|
||
|
||
it("spends 5–10% of income on hull repairs", () => {
|
||
expect(median(rows.map((r) => r.repairs))).toBeGreaterThanOrEqual(0.05);
|
||
expect(median(rows.map((r) => r.repairs))).toBeLessThanOrEqual(0.1);
|
||
});
|
||
});
|