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 { damageAfterRating, damageChance } from "./hull"; 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; } 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 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); 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) { // Hull hits are rarer on a well-rated hull, likelier on a battered one (decision 0.5.148). edge += bucket.effects.hullLoss ? damageChance(bucket.chance, ship.hullRating, ship.hullConditionPct, config) : bucket.chance; if (draw < edge) { add(bucket.effects); break; } } outcome.hullLoss = damageAfterRating(outcome.hullLoss, ship.hullRating, config); 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; } /** What an event changed about a trip once its option applied (decision 0.5.143). */ export interface AppliedEvent { eventId: string; optionId: string; /** The player chose it; false for an automatic event or a default. */ answered: boolean; /** The trip's arrival after the event. */ arrivalAt: RealTime; hullLoss: number; payLoss: number; fee: number; bonus: number; /** The Priority bonus lost by missing the window (decision 0.5.146). */ priorityLost: number; xpBonusPct: number; extraFuelFu: number; } /** * Applies an option's pre-rolled outcome to a trip (decision 0.5.143). Effects * count from when the event fired: a delay moves the arrival by exactly its * amount, a speed-up never cuts the trip below `minTripFraction` of it (nor * before the event), and a late Priority delivery loses only its bonus. * Throws for an option the trip can't take. */ export function applyEventOption( args: { record: DispatchRecord; event: TripEvent; optionId: string; answered: boolean }, content: EventContent, ): AppliedEvent { const { record, event, optionId, answered } = args; const option = event.options.find((o) => o.id === optionId); if (!option) throw new Error(`No option "${optionId}" on ${event.eventId}`); if (!option.available) throw new Error(`Option "${optionId}" isn't available on this trip`); const { outcome } = option; const { config } = content; const span = record.arrivalAt - record.departureAt; const floor = Math.max( event.fireAt, record.departureAt + Math.ceil(span * config.events.minTripFraction), ); const arrivalAt = Math.max(floor, record.arrivalAt + outcome.delayMs); return { eventId: event.eventId, optionId, answered, arrivalAt, hullLoss: outcome.hullLoss, payLoss: outcome.payLoss, fee: outcome.fee, bonus: outcome.bonus, priorityLost: priorityLost(record, arrivalAt, config), xpBonusPct: outcome.xpBonusPct, extraFuelFu: outcome.extraFuelFu, }; } /** The Priority bonus a delivery loses for arriving outside its window (decision 0.5.146). */ function priorityLost(record: DispatchRecord, arrivalAt: RealTime, config: GameConfig): number { const contract = record.contract; if (!contract?.priority || contract.deliveryWindowGameHours === null) return 0; const hours = ((arrivalAt - record.departureAt) * config.timeCompression) / MS_PER_HOUR; if (hours <= contract.deliveryWindowGameHours) return 0; return contract.payout - Math.round(contract.payout / config.priorityPayoutMultiplier); } /** * A contract's payout after its event (decision 0.5.147): losses, the fee and * a missed Priority bonus never take it below zero; bonuses add on top. */ export function payoutAfterEvent(payout: number, applied: AppliedEvent | null | undefined): number { if (!applied) return payout; const losses = applied.payLoss + applied.fee + applied.priorityLost; return Math.max(0, payout - losses) + applied.bonus; }