From f042c9c6ca7b39d1e9fa89cbfaa021011008b4a2 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Fri, 16 Oct 2026 23:53:52 -0400 Subject: [PATCH] Step 26.2: sim: event roll at dispatch Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 8 + memory-bank/progress.md | 2 +- packages/sim/src/events.ts | 308 +++++++++++++++++++++++++++++++ packages/sim/src/index.ts | 9 + packages/sim/test/events.test.ts | 229 +++++++++++++++++++++++ 5 files changed, 555 insertions(+), 1 deletion(-) create mode 100644 packages/sim/src/events.ts create mode 100644 packages/sim/test/events.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 5d7eeb0..f43a7a3 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1906,3 +1906,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **An option is "always" effects plus exclusive chances**: push through the debris field is 35% hull damage, 10% lost pay, otherwise nothing; seal the hazmat leak is +2 h always and 30% hull damage. - **Weights:** bad 70 (flare 10, micrometeor 8, hiccup 10, debris 10, storm 8, customs 8, hazmat 6, load shift 6, spoilage 4), good 36 (tailwind 10, grateful client 10, pod 6, beacon 4, spot freight 6): 34% good. Step 26.9 tunes them against the balance targets. - **Hull Plating adds a flat +1 a level** (`flatPerLevel`), since a percent of a rating of 1–3 would be fractions; its cost and shop time follow the usual upgrade levels. + +## Step 26.2 + +- **The cap applies after the route multipliers**: `min(35%, …) × multipliers` would let a belt crossing to Jupiter reach 55%, against the CR's "long hauls 35% or less". The chance is capped at 35% again after them. +- **One random stream per trip** (`deriveSeed(seed, "event")`), drawn in a fixed order: whether, which, when, then every option's outcome in order. So an option's outcome is the same whenever, and whether, it's chosen. +- **The hull rating cuts both the chance and the amount** of hull damage (decision 0.5.148): a chance bucket with hull damage is scaled by (1 − reduction) and doubled below 25% condition; the amount by (1 − reduction). Automatic hull damage (micrometeor) always happens once rolled, so only its amount is cut. +- **The first event waits for a long-enough contract**: until the company has had one, trips under 6 game hours, and deadheads, are quiet, so the pod really is the first event. +- **A radio call too close to another is dropped, not swapped** for an automatic event, as the CR says ("the trip is quiet"). diff --git a/memory-bank/progress.md b/memory-bank/progress.md index adbb412..90d7e41 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -548,7 +548,7 @@ Spec: `memory-bank/change-requests/CR-025-short-away-summary.md` (received as "C Spec: `memory-bank/change-requests/CR-026-random-events-hull.md` (received as "CR-024 … Phase 24"). New decisions recorded as plan §0.5.143–0.5.151. - [x] **Step 26.1 — Content.** `content.tripEvents` (`packages/content/src/data/events.ts`, `tripEventSchema`): the 14 events of CR-026 §3, each with kind (automatic or radio), good flag, weight, title, text, when it can happen (trip length, belt crossings, inner/belt legs, arrival regions or places, contracts, cargo class with skill halving, Priority perishables, a hull-below weight factor) and options (label, default, effects, exclusive chances, free hold or fuel needed). Config `events` (pacing, decision 0.5.145) and `hull` (decision 0.5.148). Every location has a `region` (inner, belt, jupiter). Hull Plating (`hull-plating`, +1 hull rating a level, three levels): upgrades can now add a flat amount (`flatPerLevel`), and `hullRating` is an upgradable stat. Tests: `packages/content/test/events.test.ts` (the 14 valid events; one default per radio call, and two refused; chances at most 100%, and over refused; good weight 34%, within 28–38%; ids that exist; the config numbers and regions; Hull Plating); `packages/sim/test/effective-stats.test.ts` (Hull Plating adds 1 a level). -- [ ] Step 26.2 — Sim: event roll at dispatch +- [x] **Step 26.2 — Sim: event roll at dispatch.** `rollTripEvent` (`packages/sim/src/events.ts`, pure, seeded from the trip's seed) returns null or a `TripEvent`: its id, kind, fire time (20–70% of the trip), window end (radio: 30 real minutes or arrival, whichever is first; automatic: the fire time), every option's pre-rolled `EventOutcome` (delay in real ms, hull loss after the hull rating, pay lost, fee, bonus, extra XP %, extra fuel), whether each is available (10% free hold, fuel for the burn) and the default (another available option when the cautious one can't be taken). Quiet in the tutorial, the first 3 trips after the deal and a ship's 2 trips after an event; the first event is the derelict pod on the first contract of 6+ game hours; otherwise `eventChance` (decision 0.5.145, capped at 35% after the route multipliers) and a weighted pick among the events allowed on the trip (`eventWeight`: conditions, skill halving, the hull factor); a radio call within 45 real minutes of another in the fleet is dropped. Tests (`packages/sim/test/events.test.ts`): the same trip gives the same event; none in the tutorial or the grace trips; the first event is the pod on 6+ hours only; the cooldown; fleet pacing at 44 and 46 minutes, the window, the fire point; 100,000 trips (Earth↔Moon ≤ 3%, long hauls ≤ 35%, every event only where allowed); Dangerous Cargo two levels above the tier makes leaks under 40% as common. - [ ] Step 26.3 — Sim: applying outcomes - [ ] Step 26.4 — Sim: hull condition and repairs - [ ] Step 26.5 — Engine: events in play and catch-up diff --git a/packages/sim/src/events.ts b/packages/sim/src/events.ts new file mode 100644 index 0000000..5b983e5 --- /dev/null +++ b/packages/sim/src/events.ts @@ -0,0 +1,308 @@ +import type { EventEffects, GameConfig, Location, TripEventDef } from "@deadhead/content"; +import type { SimContent } from "./board"; +import { MS_PER_HOUR, type RealTime } from "./clock"; +import type { DispatchRecord } from "./dispatch"; +import { findLocation } from "./dispatch"; +import type { SkillLevels } from "./pilot"; +import { createRng, deriveSeed, type Rng } from "./rng"; + +/** + * One option's pre-rolled outcome (decision 0.5.143), in concrete amounts: + * whenever it's chosen, this is what happens. + */ +export interface EventOutcome { + /** Real ms added to the trip from when it fired; negative brings arrival forward. */ + delayMs: number; + /** Hull condition lost, in points, after the hull rating (decision 0.5.148). */ + hullLoss: number; + /** Cargo pay lost, in credits. */ + payLoss: number; + /** A fee, in credits. */ + fee: number; + /** Extra pay on delivery, in credits. */ + bonus: number; + /** Extra XP for the trip, as a percent. */ + xpBonusPct: number; + /** Extra fuel burned for the leg, in fu. */ + extraFuelFu: number; +} + +export interface EventOption { + id: string; + /** Unavailable options can't be chosen (no free hold, no fuel for the burn). */ + available: boolean; + outcome: EventOutcome; +} + +/** An event rolled for a trip (decisions 0.5.143–0.5.145). */ +export interface TripEvent { + eventId: string; + kind: "automatic" | "radio"; + good: boolean; + /** When it happens: between 20% and 70% of the trip. */ + fireAt: RealTime; + /** Radio calls: when the default applies if unanswered; automatic events: `fireAt`. */ + windowEndsAt: RealTime; + options: EventOption[]; + /** The cautious option; another one if it can't be taken. */ + defaultOptionId: string; +} + +/** What the roll needs to know (CR-026 §2). */ +export interface EventRollInput { + record: DispatchRecord; + ship: { + /** Effective hull rating, Hull Plating included. */ + hullRating: number; + hullConditionPct: number; + cargoCapacityTonnes: number; + /** Fuel left in the tank after this leg's burn, in fu. */ + fuelAfterDepartureFu: number; + /** Trips since the ship's last event (cooldown, decision 0.5.145). */ + quietTrips: number; + }; + pilotSkills: SkillLevels; + /** Fire times of the fleet's other radio calls, scheduled or recent. */ + fleetRadioCalls: readonly RealTime[]; + company: { + /** The company has had its first event (the derelict pod). */ + firstEventDone: boolean; + /** Trips sent since the deal, before this one (the grace period). */ + tripsSinceDeal: number; + }; +} + +export interface EventContent extends SimContent { + tripEvents: readonly TripEventDef[]; +} + +/** A trip's length in game hours. */ +export function tripGameHoursOf(record: DispatchRecord, config: GameConfig): number { + return ((record.arrivalAt - record.departureAt) * config.timeCompression) / MS_PER_HOUR; +} + +/** Route facts for event conditions and chances (CR-026 §2–3). */ +export function routeFacts(origin: Location, destination: Location) { + const ends = [origin.id, destination.id].sort().join("|"); + return { + localLoop: + ends === "earth|moon" || (origin.region === "jupiter" && destination.region === "jupiter"), + crossesBelt: origin.region !== destination.region, + innerOrBelt: [origin.region, destination.region].some((r) => r === "inner" || r === "belt"), + stormArrival: destination.region === "jupiter" || destination.id === "venus-cloud-city", + }; +} + +/** The chance a trip has an event (decision 0.5.145), before the cooldowns. */ +export function eventChance(record: DispatchRecord, content: EventContent): number { + const { events } = content.config; + const hours = tripGameHoursOf(record, content.config); + const route = routeFacts( + findLocation(record.originId, content), + findLocation(record.destinationId, content), + ); + let chance = Math.min(events.maxChance, events.baseChance + events.chancePerGameHour * hours); + if (route.localLoop) chance *= events.localLoopMultiplier; + if (route.crossesBelt) chance *= events.beltCrossingMultiplier; + if (route.stormArrival) chance *= events.stormArrivalMultiplier; + // Multipliers can't lift a long haul past the cap. + return Math.min(events.maxChance, chance); +} + +/** Whether an event can happen on this trip, and its weight here (0: it can't). */ +export function eventWeight( + def: TripEventDef, + input: EventRollInput, + content: EventContent, +): number { + const { record } = input; + const { when } = def; + const contract = record.contract; + const origin = findLocation(record.originId, content); + const destination = findLocation(record.destinationId, content); + const route = routeFacts(origin, destination); + const hours = tripGameHoursOf(record, content.config); + if (when.minTripGameHours !== undefined && hours < when.minTripGameHours) return 0; + if (when.crossesBelt && !route.crossesBelt) return 0; + if (when.innerOrBelt && !route.innerOrBelt) return 0; + if (when.arrivalRegions || when.arrivalIds) { + const inRegion = when.arrivalRegions?.includes(destination.region) ?? false; + const atPlace = when.arrivalIds?.includes(destination.id) ?? false; + if (!inRegion && !atPlace) return 0; + } + if ((when.contract || when.cargoClassId || when.priorityGoods) && !contract) return 0; + if (when.cargoClassId && contract?.cargoTypeId !== when.cargoClassId) return 0; + if (when.priorityGoods && !(contract?.priority && when.priorityGoods.includes(contract.goodId))) { + return 0; + } + let weight = def.weight; + if (when.skillHalving && contract) { + const above = (input.pilotSkills[when.skillHalving] ?? 0) - contract.tier; + if (above > 0) weight *= 0.5 ** above; + } + const hullFactor = when.hullBelowWeightFactor; + if (hullFactor && input.ship.hullConditionPct < hullFactor.belowPct) weight *= hullFactor.factor; + return weight; +} + +/** How much the hull rating cuts damage chances and amounts (decision 0.5.148). */ +export function hullRatingReduction(hullRating: number, config: GameConfig): number { + return Math.min(config.hull.maxRatingReduction, hullRating * config.hull.ratingReductionPerPoint); +} + +const between = (rng: Rng, [low, high]: readonly [number, number]) => + low + rng.next() * (high - low); + +/** One option's outcome, rolled now (decision 0.5.143). */ +function rollOutcome( + option: TripEventDef["options"][number], + input: EventRollInput, + rng: Rng, + content: EventContent, +): EventOutcome { + const { config } = content; + const { record, ship } = input; + const payout = record.contract?.payout ?? 0; + const reduction = hullRatingReduction(ship.hullRating, config); + // A badly damaged hull takes damage twice as often (decision 0.5.148). + const damagedFactor = + ship.hullConditionPct < config.hull.needsRepairBelowPct + ? config.hull.damagedChanceMultiplier + : 1; + const outcome: EventOutcome = { + delayMs: 0, + hullLoss: 0, + payLoss: 0, + fee: 0, + bonus: 0, + xpBonusPct: 0, + extraFuelFu: 0, + }; + const add = (effects: EventEffects) => { + if (effects.delayGameHours) { + const hours = between(rng, effects.delayGameHours); + outcome.delayMs += Math.round((hours * MS_PER_HOUR) / config.timeCompression); + } + if (effects.hullLoss) outcome.hullLoss += between(rng, effects.hullLoss) * (1 - reduction); + if (effects.payLossPct) outcome.payLoss += Math.round((payout * effects.payLossPct) / 100); + if (effects.feePct) { + outcome.fee += Math.max(effects.feeMin ?? 0, Math.round((payout * effects.feePct) / 100)); + } + if (effects.bonusPct) + outcome.bonus += Math.round((payout * between(rng, effects.bonusPct)) / 100); + if (effects.xpBonusPct) outcome.xpBonusPct += effects.xpBonusPct; + if (effects.extraFuelPct) + outcome.extraFuelFu += (record.fuelBurnedFu * effects.extraFuelPct) / 100; + }; + add(option.effects); + // One draw picks among the exclusive chances; hull damage chances scale with the hull. + const draw = rng.next(); + let edge = 0; + for (const bucket of option.chances) { + const scale = bucket.effects.hullLoss ? (1 - reduction) * damagedFactor : 1; + edge += Math.min(1, bucket.chance * scale); + if (draw < edge) { + add(bucket.effects); + break; + } + } + outcome.hullLoss = Math.round(outcome.hullLoss * 10) / 10; + return outcome; +} + +/** Whether an option can be taken on this trip (free hold, fuel for the burn). */ +function optionAvailable( + option: TripEventDef["options"][number], + outcome: EventOutcome, + input: EventRollInput, +): boolean { + const { ship, record } = input; + if (option.requiresFreeHoldPct !== undefined) { + const free = ship.cargoCapacityTonnes - (record.contract?.massTonnes ?? 0); + if (free < (ship.cargoCapacityTonnes * option.requiresFreeHoldPct) / 100) return false; + } + if (option.requiresFuel && outcome.extraFuelFu > ship.fuelAfterDepartureFu) return false; + return true; +} + +/** Builds the event for a trip: when it fires, its window, and every option's outcome. */ +function buildEvent( + def: TripEventDef, + input: EventRollInput, + rng: Rng, + content: EventContent, +): TripEvent { + const { record } = input; + const { events } = content.config; + const span = record.arrivalAt - record.departureAt; + const at = events.fireFrom + rng.next() * (events.fireTo - events.fireFrom); + const fireAt = record.departureAt + Math.round(span * at); + const windowEndsAt = + def.kind === "radio" + ? Math.min(fireAt + events.responseWindowRealMinutes * 60_000, record.arrivalAt) + : fireAt; + const options = def.options.map((option) => { + const outcome = rollOutcome(option, input, rng, content); + return { id: option.id, available: optionAvailable(option, outcome, input), outcome }; + }); + const preferred = def.options.find((o) => o.default)!; + const usable = options.find((o) => o.id === preferred.id && o.available); + const fallback = options.find((o) => o.available) ?? options[0]!; + return { + eventId: def.id, + kind: def.kind, + good: def.good, + fireAt, + windowEndsAt, + options, + defaultOptionId: (usable ?? fallback).id, + }; +} + +/** + * The event for a trip, or null for a quiet one (decisions 0.5.143–0.5.145). + * Pure: the trip's seed decides everything, so the same trip always gets the + * same event with the same outcomes. + */ +export function rollTripEvent(input: EventRollInput, content: EventContent): TripEvent | null { + const { record, ship, company } = input; + const { events } = content.config; + if (record.tutorial) return null; + if (company.tripsSinceDeal < events.graceTripsAfterDeal) return null; + if (ship.quietTrips < events.shipCooldownTrips) return null; + const rng = createRng(deriveSeed(record.seed, "event")); + + let def: TripEventDef | undefined; + if (!company.firstEventDone) { + // The first event is the derelict pod, on the first trip long enough for it. + const first = content.tripEvents.find((e) => e.id === events.firstEventId); + if ( + !first || + tripGameHoursOf(record, content.config) < events.firstEventMinTripGameHours || + eventWeight(first, input, content) <= 0 + ) { + return null; + } + def = first; + } else { + if (rng.next() >= eventChance(record, content)) return null; + const weighted = content.tripEvents + .map((e) => ({ e, w: eventWeight(e, input, content) })) + .filter(({ w }) => w > 0); + const total = weighted.reduce((sum, { w }) => sum + w, 0); + if (total <= 0) return null; + let pick = rng.next() * total; + def = weighted.find(({ w }) => (pick -= w) < 0)?.e ?? weighted.at(-1)!.e; + } + + const event = buildEvent(def, input, rng, content); + // At most one radio call per 45 real minutes across the fleet: later ones are dropped. + const gap = events.fleetRadioGapRealMinutes * 60_000; + if ( + event.kind === "radio" && + input.fleetRadioCalls.some((t) => Math.abs(t - event.fireAt) < gap) + ) { + return null; + } + return event; +} diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index e145f22..d207239 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -149,3 +149,12 @@ export type { AwaySummary, LogbookEntry, } from "./away"; +export { + eventChance, + eventWeight, + hullRatingReduction, + rollTripEvent, + routeFacts, + tripGameHoursOf, +} from "./events"; +export type { EventContent, EventOption, EventOutcome, EventRollInput, TripEvent } from "./events"; diff --git a/packages/sim/test/events.test.ts b/packages/sim/test/events.test.ts new file mode 100644 index 0000000..56e8adb --- /dev/null +++ b/packages/sim/test/events.test.ts @@ -0,0 +1,229 @@ +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); + }); +});