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

376 lines
15 KiB
TypeScript
Raw Permalink 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 {
applyEventOption,
MS_PER_HOUR,
payoutAfterEvent,
pilotShare,
resolveDispatch,
rollTripEvent,
routeFacts,
type AppliedEvent,
type Contract,
type DispatchRecord,
type EventOutcome,
type EventRollInput,
type TripEvent,
} 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.015);
// Two levels above: a quarter of the weight, so far rarer among events.
expect(twoAbove).toBeLessThan(atTier * 0.4);
});
});
describe("applying an event's outcome (Step 26.3, decisions 0.5.143, 0.5.146, 0.5.147)", () => {
const HOUR_REAL = MS_PER_HOUR / timeCompression;
const none: EventOutcome = {
delayMs: 0,
hullLoss: 0,
payLoss: 0,
fee: 0,
bonus: 0,
xpBonusPct: 0,
extraFuelFu: 0,
};
const event = (
trip: DispatchRecord,
outcome: Partial<EventOutcome>,
available = true,
): TripEvent => ({
eventId: "debris-field",
kind: "radio",
good: false,
fireAt: trip.departureAt + Math.round((trip.arrivalAt - trip.departureAt) * 0.4),
windowEndsAt: trip.arrivalAt,
options: [
{ id: "a", available, outcome: { ...none, ...outcome } },
{ id: "b", available: true, outcome: none },
],
defaultOptionId: "b",
});
const apply = (trip: DispatchRecord, e: TripEvent, optionId = "a") =>
applyEventOption({ record: trip, event: e, optionId, answered: true }, content);
it("moves arrival by exactly a 3-hour detour", () => {
const trip = record(1, "earth", "jupiter-cloud-platform", 200);
const applied = apply(trip, event(trip, { delayMs: 3 * HOUR_REAL }));
expect(applied.arrivalAt - trip.arrivalAt).toBe(3 * HOUR_REAL);
});
it("never lets a tailwind shorten a trip below half of it, or before the event", () => {
const trip = record(1, "earth", "mars", 10);
const applied = apply(trip, event(trip, { delayMs: -100 * HOUR_REAL }));
const half = trip.departureAt + (trip.arrivalAt - trip.departureAt) / 2;
expect(applied.arrivalAt).toBe(Math.ceil(half));
expect(applied.arrivalAt).toBeGreaterThanOrEqual(event(trip, {}).fireAt);
});
it("never takes pay below zero, and pays bonuses on top", () => {
const trip = record(1, "earth", "mars", 10, { payout: 1_000 });
const lost = apply(trip, event(trip, { payLoss: 900, fee: 500 }));
expect(payoutAfterEvent(1_000, lost)).toBe(0);
const tipped = apply(trip, event(trip, { payLoss: 900, fee: 500, bonus: 200 }));
expect(payoutAfterEvent(1_000, tipped)).toBe(200);
});
it("costs only the Priority bonus when a delay misses the window", () => {
const trip = record(1, "earth", "mars", 10, {
payout: 1_500,
priority: true,
deliveryWindowGameHours: 12,
});
const inTime = apply(trip, event(trip, { delayMs: 1 * HOUR_REAL }));
expect(inTime.priorityLost).toBe(0);
const late = apply(trip, event(trip, { delayMs: 3 * HOUR_REAL }));
// 1,500 is 1,000 × the 1.5 Priority bonus: the base 1,000 is still paid.
expect(late.priorityLost).toBe(500);
expect(payoutAfterEvent(1_500, late)).toBe(1_000);
});
it("refuses an option the trip can't take: salvage and spot freight need 10% of the hold free", () => {
const full = (s: number) =>
input(record(s, "earth", "mars", 40, { massTonnes: 95 }), {
company: { firstEventDone: false, tripsSinceDeal: 3 },
});
const pod = rollTripEvent(full(7), content)!;
expect(pod.eventId).toBe("derelict-pod");
expect(pod.options.find((o) => o.id === "salvage")!.available).toBe(false);
expect(() => apply(full(7).record, pod, "salvage")).toThrow(/isn't available/);
const roomy = rollTripEvent({ ...full(7), record: record(7, "earth", "mars", 40) }, content)!;
expect(roomy.options.find((o) => o.id === "salvage")!.available).toBe(true);
});
it("gives an option its pre-rolled outcome whenever it's chosen", () => {
const trip = record(3, "earth", "mars", 40);
const pod = rollTripEvent(
input(trip, { company: { firstEventDone: false, tripsSinceDeal: 3 } }),
content,
)!;
const early = apply(trip, pod, "salvage");
const late = applyEventOption(
{ record: trip, event: pod, optionId: "salvage", answered: true },
content,
);
expect(late).toEqual(early);
const salvage = pod.options.find((o) => o.id === "salvage")!.outcome;
expect(early.bonus).toBe(salvage.bonus);
expect(salvage.bonus).toBeGreaterThanOrEqual(10_000 * 0.15);
expect(salvage.bonus).toBeLessThanOrEqual(10_000 * 0.4);
});
});
describe("resolving a trip with its event (Step 26.3)", () => {
it("pays the event's bonus and extra XP, and takes the pilot's share of the new payout", () => {
const trip = record(5, "earth", "mars", 10, { payout: 4_000 });
const resolve = (event: AppliedEvent | null, pilotIsAvatar: boolean) =>
resolveDispatch(
{
record: trip,
pilotIsAvatar,
discoveredIds: new Set(["earth", "mars"]),
loanBalance: 0,
pilotDutyHours: 0,
pilotSkills: {},
now: trip.arrivalAt,
event,
},
content,
);
const applied: AppliedEvent = {
eventId: "distress-beacon",
optionId: "help",
answered: true,
arrivalAt: trip.arrivalAt,
hullLoss: 0,
payLoss: 0,
fee: 0,
bonus: 1_000,
priorityLost: 0,
xpBonusPct: 50,
extraFuelFu: 0,
};
const plain = resolve(null, true);
const helped = resolve(applied, true);
if (plain.status !== "resolved" || helped.status !== "resolved") throw new Error("in flight");
expect(helped.resolution.credits).toBe(plain.resolution.credits + 1_000);
expect(helped.resolution.pilotXp).toBe(Math.round(plain.resolution.pilotXp * 1.5));
const hired = resolve(applied, false);
if (hired.status !== "resolved") throw new Error("in flight");
expect(hired.resolution.pilotPay).toBe(pilotShare(5_000, trip.fuelCost, content.config));
});
});