Step 26.2: sim: event roll at dispatch
CI / check (push) Has been cancelled Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-16 23:53:52 -04:00
parent f7a39b5921
commit f042c9c6ca
5 changed files with 555 additions and 1 deletions

View File

@ -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. - **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. - **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. - **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").

View File

@ -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. 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). - [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.3 — Sim: applying outcomes
- [ ] Step 26.4 — Sim: hull condition and repairs - [ ] Step 26.4 — Sim: hull condition and repairs
- [ ] Step 26.5 — Engine: events in play and catch-up - [ ] Step 26.5 — Engine: events in play and catch-up

308
packages/sim/src/events.ts Normal file
View File

@ -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;
}

View File

@ -149,3 +149,12 @@ export type {
AwaySummary, AwaySummary,
LogbookEntry, LogbookEntry,
} from "./away"; } from "./away";
export {
eventChance,
eventWeight,
hullRatingReduction,
rollTripEvent,
routeFacts,
tripGameHoursOf,
} from "./events";
export type { EventContent, EventOption, EventOutcome, EventRollInput, TripEvent } from "./events";

View File

@ -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> = {}): 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);
});
});