166 lines
6.4 KiB
TypeScript
166 lines
6.4 KiB
TypeScript
import type { SimContent } from "./board";
|
||
import type { RealTime } from "./clock";
|
||
import type { DispatchRecord } from "./dispatch";
|
||
import { findLocation } from "./dispatch";
|
||
import { dutyAfterTrip, dutyLimit, needsRest, restEndsAt, tripGameHours } from "./duty";
|
||
import { affordableRefuel, fuelPriceAt, type Tank } from "./fuel";
|
||
import { splitLoanRepayment } from "./loan";
|
||
import { pilotShare } from "./pay";
|
||
import type { SkillLevels } from "./pilot";
|
||
import { deliveryXp } from "./xp";
|
||
import { payoutAfterEvent, type AppliedEvent } from "./events";
|
||
|
||
/** Auto top-up (default) fills the tank on landing; Manual never does (decision 0.5.97). */
|
||
export type RefuelPolicy = "auto" | "manual";
|
||
|
||
/** Fuel an Auto top-up bought on landing (decision 0.5.97). */
|
||
export interface TopUp {
|
||
locationId: string;
|
||
boughtFu: number;
|
||
/** Credits per fu. */
|
||
price: number;
|
||
/** Whole credits, paid on top of the delivery's `credits`. */
|
||
cost: number;
|
||
/** The tank afterwards. */
|
||
tank: Tank;
|
||
}
|
||
|
||
export interface Resolution {
|
||
/**
|
||
* Credits to add: payout minus pilot pay, minus the loan repayment. Fuel
|
||
* came out of the tank at departure; any top-up is `topUp.cost`.
|
||
*/
|
||
credits: number;
|
||
/** Auto top-up on landing, or null (Manual, nothing bought, or no tank). */
|
||
topUp: TopUp | null;
|
||
/** A hired pilot's share of this delivery's profit; 0 for the avatar, tutorial runs and deadheads (CR-010). */
|
||
pilotPay: number;
|
||
/** Credits that went to the lender out of this payout (decision 0.5.32). */
|
||
loanRepayment: number;
|
||
pilotXp: number;
|
||
/** Where the ship (or tutorial loaner) is now. */
|
||
shipLocationId: string;
|
||
/** True when this delivery discovers the destination. */
|
||
discoversDestination: boolean;
|
||
/** The pilot's duty hours after this trip: 0 if it ended in mandatory rest (CR-004). */
|
||
pilotDutyHours: number;
|
||
/** Mandatory rest from the arrival time, or null (decision 0.5.45). */
|
||
pilotRestEndsAt: RealTime | null;
|
||
}
|
||
|
||
export type ResolveResult =
|
||
{ status: "in-flight" } | { status: "resolved"; resolution: Resolution };
|
||
|
||
export interface ResolveInput {
|
||
record: DispatchRecord;
|
||
/** The avatar is exempt from pilot pay (decision 0.5.12). */
|
||
pilotIsAvatar: boolean;
|
||
discoveredIds: ReadonlySet<string>;
|
||
/** The company's loan balance before this resolution. */
|
||
loanBalance: number;
|
||
/** The pilot's duty hours before this trip, and skills (Long Haul sets the limit). */
|
||
pilotDutyHours: number;
|
||
pilotSkills: SkillLevels;
|
||
now: RealTime;
|
||
/**
|
||
* The ship's tank on landing, its policy, and the company's credits before this
|
||
* resolution (Auto top-up buys what they cover). Absent: no top-up (the
|
||
* tutorial loaner uses no fuel).
|
||
*/
|
||
fuel?: { tank: Tank; capacityFu: number; refuelPolicy: RefuelPolicy; credits: number };
|
||
/** The trip's event once applied (CR-026): it changes the pay and XP. */
|
||
event?: AppliedEvent | null;
|
||
}
|
||
|
||
/** Auto top-up on landing (decision 0.5.97): as much as the credits after this trip cover. */
|
||
function topUpOnLanding(input: ResolveInput, net: number, content: SimContent): TopUp | null {
|
||
const { fuel, record } = input;
|
||
if (!fuel || record.tutorial || fuel.refuelPolicy !== "auto") return null;
|
||
const location = findLocation(record.destinationId, content);
|
||
if (!location.sellsFuel) return null;
|
||
const price = fuelPriceAt(location, content.config);
|
||
const bought = affordableRefuel(fuel.tank, fuel.capacityFu, price, fuel.credits + net);
|
||
if (bought.boughtFu <= 0) return null;
|
||
return {
|
||
locationId: location.id,
|
||
boughtFu: bought.boughtFu,
|
||
price,
|
||
cost: bought.cost,
|
||
tank: bought.tank,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The pilot's duty after a trip (decisions 0.5.42–0.5.45): the trip's game
|
||
* hours are added (tutorial runs add none); at or over the limit, rest starts
|
||
* at the arrival time and duty resets, however late the trip is resolved.
|
||
*/
|
||
function dutyOutcome(input: ResolveInput, content: SimContent) {
|
||
const { record, pilotDutyHours, pilotSkills } = input;
|
||
const hours = tripGameHours(record.departureAt, record.arrivalAt, content.config);
|
||
const duty = dutyAfterTrip(pilotDutyHours, hours, record.tutorial);
|
||
if (!record.tutorial && needsRest(duty, dutyLimit(pilotSkills, content.config))) {
|
||
return { pilotDutyHours: 0, pilotRestEndsAt: restEndsAt(record.arrivalAt, content.config) };
|
||
}
|
||
return { pilotDutyHours: duty, pilotRestEndsAt: null };
|
||
}
|
||
|
||
/**
|
||
* XP for a delivery (Step 6.8); tutorial runs earn it × the tutorial XP
|
||
* multiplier, so the avatar has a skill point by the deal (decision 0.5.107).
|
||
*/
|
||
export function deliveryXpFor(record: DispatchRecord, tier: number, content: SimContent): number {
|
||
const xp = deliveryXp(record.distanceMillionKm, tier, content.config);
|
||
return record.tutorial ? Math.round(xp * content.config.tutorialXpMultiplier) : xp;
|
||
}
|
||
|
||
/** Resolves a dispatch once it has arrived. Same inputs always give the same result. */
|
||
export function resolveDispatch(input: ResolveInput, content: SimContent): ResolveResult {
|
||
const { record, pilotIsAvatar, discoveredIds, loanBalance, now } = input;
|
||
if (now < record.arrivalAt) return { status: "in-flight" };
|
||
|
||
const { contract } = record;
|
||
const base = { shipLocationId: record.destinationId, ...dutyOutcome(input, content) };
|
||
if (contract === null) {
|
||
// Deadheads earn nothing and only fly to discovered locations.
|
||
return {
|
||
status: "resolved",
|
||
resolution: {
|
||
...base,
|
||
credits: 0,
|
||
topUp: topUpOnLanding(input, 0, content),
|
||
pilotPay: 0,
|
||
loanRepayment: 0,
|
||
pilotXp: 0,
|
||
discoversDestination: false,
|
||
},
|
||
};
|
||
}
|
||
|
||
// An event's losses, fees and bonuses change what the shipper pays (CR-026).
|
||
const payout = payoutAfterEvent(contract.payout, input.event);
|
||
// Profit-share pay (decision 0.5.12, CR-010), taken before the loan repayment (0.5.69).
|
||
const pilotPay =
|
||
record.tutorial || pilotIsAvatar ? 0 : pilotShare(payout, record.fuelCost, content.config);
|
||
// Tutorial contracts already store exactly the tutorial share as their payout.
|
||
const { repayment, net } = splitLoanRepayment(
|
||
payout - pilotPay,
|
||
loanBalance,
|
||
content.config.loanRepaymentShare,
|
||
);
|
||
return {
|
||
status: "resolved",
|
||
resolution: {
|
||
...base,
|
||
credits: net,
|
||
topUp: topUpOnLanding(input, net, content),
|
||
pilotPay,
|
||
loanRepayment: repayment,
|
||
pilotXp: Math.round(
|
||
deliveryXpFor(record, contract.tier, content) * (1 + (input.event?.xpBonusPct ?? 0) / 100),
|
||
),
|
||
discoversDestination: !discoveredIds.has(record.destinationId),
|
||
},
|
||
};
|
||
}
|