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

166 lines
6.4 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 { 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),
},
};
}