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 => ({ 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 | 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 => ({ 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][] = [ ["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(); 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); }); });