376 lines
14 KiB
TypeScript
376 lines
14 KiB
TypeScript
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;
|
||
}
|