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 => ({ 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.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, 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)); }); });