deadhead-space-freight-tycoon/packages/sim/src/events.ts

376 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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