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

230 lines
9.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 { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import {
MS_PER_HOUR,
rollTripEvent,
routeFacts,
type Contract,
type DispatchRecord,
type EventRollInput,
} from "../src/index";
const T0 = Date.parse("2100-01-01T00:00:00Z");
const { timeCompression } = content.config;
const contract = (over: Partial<Contract> = {}): Contract => ({
id: "c",
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 10,
payout: 10_000,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: T0,
expiresAt: T0 + 600_000,
firstContact: false,
...over,
});
/** A trip of `hours` game hours from `origin` to `destination`. */
function record(
seed: number,
origin: string,
destination: string,
hours: number,
job: Partial<Contract> | null = {},
): DispatchRecord {
return {
id: `d${seed}`,
shipId: "s1",
pilotId: "p1",
contract:
job === null ? null : contract({ originId: origin, destinationId: destination, ...job }),
originId: origin,
destinationId: destination,
tutorial: false,
departureAt: T0,
arrivalAt: T0 + Math.round((hours * MS_PER_HOUR) / timeCompression),
distanceMillionKm: 1,
fuelBurnedFu: 100,
fuelCost: 500,
expectedPayout: 10_000,
seed,
};
}
const input = (rec: DispatchRecord, over: Partial<EventRollInput> = {}): EventRollInput => ({
record: rec,
ship: {
hullRating: 1,
hullConditionPct: 100,
cargoCapacityTonnes: 100,
fuelAfterDepartureFu: 500,
quietTrips: 5,
},
pilotSkills: {},
fleetRadioCalls: [],
company: { firstEventDone: true, tripsSinceDeal: 10 },
...over,
});
/** Rolls `n` trips made by `make(seed)` and returns the events. */
function rolls(n: number, make: (seed: number) => EventRollInput) {
return Array.from({ length: n }, (_, seed) => rollTripEvent(make(seed + 1), content));
}
describe("rolling a trip's event (Step 26.2, decisions 0.5.143–0.5.145)", () => {
it("gives the same event, outcomes and all, for the same trip", () => {
const trip = input(record(42, "earth", "jupiter-cloud-platform", 200));
expect(rollTripEvent(trip, content)).toEqual(rollTripEvent(trip, content));
const events = rolls(500, (s) => input(record(s, "earth", "jupiter-cloud-platform", 200)));
expect(new Set(events.map((e) => e?.eventId ?? "quiet")).size).toBeGreaterThan(3);
});
it("has none in the tutorial or on the first 3 trips after the deal", () => {
const long = (s: number) => record(s, "earth", "jupiter-cloud-platform", 200);
const tutorial = rolls(500, (s) => input({ ...long(s), tutorial: true }));
expect(tutorial.every((e) => e === null)).toBe(true);
for (const tripsSinceDeal of [0, 1, 2]) {
const early = rolls(500, (s) =>
input(long(s), { company: { firstEventDone: true, tripsSinceDeal } }),
);
expect(
early.every((e) => e === null),
`trip ${tripsSinceDeal + 1}`,
).toBe(true);
}
const after = rolls(500, (s) =>
input(long(s), { company: { firstEventDone: true, tripsSinceDeal: 3 } }),
);
expect(after.some((e) => e !== null)).toBe(true);
});
it("makes the first event the derelict pod, on the first contract of 6+ game hours", () => {
const first = { company: { firstEventDone: false, tripsSinceDeal: 3 } };
const short = rolls(200, (s) => input(record(s, "earth", "moon", 5), first));
expect(short.every((e) => e === null)).toBe(true);
const long = rolls(200, (s) => input(record(s, "earth", "mars", 6), first));
expect(long.every((e) => e?.eventId === "derelict-pod")).toBe(true);
const deadhead = rolls(50, (s) => input(record(s, "earth", "mars", 40, null), first));
expect(deadhead.every((e) => e === null)).toBe(true);
});
it("gives a ship none on its next 2 trips after an event", () => {
const long = (s: number) => record(s, "earth", "jupiter-cloud-platform", 200);
for (const quietTrips of [0, 1]) {
const cooling = rolls(500, (s) =>
input(long(s), { ship: { ...input(long(s)).ship, quietTrips } }),
);
expect(cooling.every((e) => e === null)).toBe(true);
}
const ready = rolls(500, (s) =>
input(long(s), { ship: { ...input(long(s)).ship, quietTrips: 2 } }),
);
expect(ready.some((e) => e !== null)).toBe(true);
});
it("drops a radio call within 45 real minutes of another one in the fleet", () => {
const long = (s: number) => record(s, "earth", "jupiter-cloud-platform", 200);
const seed = Array.from({ length: 5_000 }, (_, i) => i + 1).find(
(s) => rollTripEvent(input(long(s)), content)?.kind === "radio",
)!;
const call = rollTripEvent(input(long(seed)), content)!;
const near = [call.fireAt + 44 * 60_000];
const far = [call.fireAt - 46 * 60_000];
expect(rollTripEvent(input(long(seed), { fleetRadioCalls: near }), content)).toBeNull();
expect(rollTripEvent(input(long(seed), { fleetRadioCalls: far }), content)).toEqual(call);
// A radio call's window: 30 minutes, or what's left of the trip.
expect(call.windowEndsAt).toBe(Math.min(call.fireAt + 30 * 60_000, long(seed).arrivalAt));
const trip = long(seed);
const span = trip.arrivalAt - trip.departureAt;
expect(call.fireAt).toBeGreaterThanOrEqual(trip.departureAt + span * 0.2 - 1);
expect(call.fireAt).toBeLessThanOrEqual(trip.departureAt + span * 0.7 + 1);
});
it("over 100,000 trips: Earth↔Moon at 3% or less, long hauls at 35% or less, every event where it may happen", () => {
const routes: [string, string, number, Partial<Contract>][] = [
["earth", "moon", 4, { cargoTypeId: "dangerous", goodId: "helium-3", tier: 1 }],
["moon", "earth", 4, { cargoTypeId: "heavy", goodId: "machine-parts", tier: 1 }],
[
"earth",
"mars",
40,
{ priority: true, goodId: "medical-supplies", deliveryWindowGameHours: 60 },
],
[
"earth",
"jupiter-cloud-platform",
220,
{ cargoTypeId: "dangerous", goodId: "helium-3", tier: 1 },
],
["ceres-station", "mars", 30, { cargoTypeId: "heavy", goodId: "machine-parts", tier: 1 }],
["io-sulfur-works", "europa-ice-station", 8, {}],
[
"venus-cloud-city",
"earth",
20,
{ priority: true, goodId: "cloud-botanicals", deliveryWindowGameHours: 30 },
],
];
const tally = new Map<string, { trips: number; events: number }>();
for (let s = 1; s <= 100_000; s++) {
const [origin, destination, hours, job] = routes[s % routes.length]!;
const trip = record(s, origin, destination, hours, s % 11 === 0 ? null : job);
const event = rollTripEvent(input(trip), content);
const key = `${origin}>${destination}`;
const row = tally.get(key) ?? { trips: 0, events: 0 };
row.trips++;
if (event) row.events++;
tally.set(key, row);
if (!event) continue;
const def = content.tripEvent(event.eventId)!;
const route = routeFacts(content.location(origin)!, content.location(destination)!);
const c = trip.contract;
const where = `${event.eventId} on ${key}`;
if (def.when.minTripGameHours)
expect(hours, where).toBeGreaterThanOrEqual(def.when.minTripGameHours);
if (event.eventId === "hazmat-leak") expect(c?.cargoTypeId, where).toBe("dangerous");
if (event.eventId === "load-shift") expect(c?.cargoTypeId, where).toBe("heavy");
if (event.eventId === "debris-field") expect(route.crossesBelt, where).toBe(true);
if (event.eventId === "storm-approach") expect(route.stormArrival, where).toBe(true);
if (event.eventId === "customs-hold") expect(["earth", "mars"], where).toContain(destination);
if (event.eventId === "spoilage-risk") expect(c?.priority, where).toBe(true);
if (event.eventId === "spot-freight") expect(route.innerOrBelt, where).toBe(true);
if (def.when.contract) expect(c, where).not.toBeNull();
}
const rate = (key: string) => tally.get(key)!.events / tally.get(key)!.trips;
expect(rate("earth>moon")).toBeLessThanOrEqual(0.03);
expect(rate("moon>earth")).toBeLessThanOrEqual(0.03);
expect(rate("earth>jupiter-cloud-platform")).toBeLessThanOrEqual(0.35);
expect(rate("earth>jupiter-cloud-platform")).toBeGreaterThan(0.25);
});
it("makes leaks rarer for a pilot whose Dangerous Cargo skill is above the job's tier", () => {
const share = (level: number) => {
const events = rolls(30_000, (s) =>
input(
record(s, "earth", "jupiter-cloud-platform", 220, {
cargoTypeId: "dangerous",
goodId: "helium-3",
tier: 1,
}),
{ pilotSkills: { "dangerous-cargo": level } },
),
).filter((e) => e !== null);
return events.filter((e) => e!.eventId === "hazmat-leak").length / events.length;
};
const atTier = share(1);
const twoAbove = share(3);
expect(atTier).toBeGreaterThan(0.03);
// Two levels above: a quarter of the weight, so far rarer among events.
expect(twoAbove).toBeLessThan(atTier * 0.4);
});
});