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

215 lines
8.0 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 { boardPeriodIndex, boardPeriodMs } from "./board";
import { MS_PER_MINUTE, type RealTime } from "./clock";
import type { Contract } from "./contract";
import {
contractTripGameHours,
findLocation,
shipStats,
tripFuel,
validateDispatch,
validateReposition,
type PilotState,
type ShipState,
} from "./dispatch";
import { fuelPriceAt, refuel, type Tank } from "./fuel";
import { pilotShare } from "./pay";
/** What the player tells an Independent pilot (decision 0.5.124). */
export interface PilotOrders {
/** Destinations allowed; null means every discovered location. */
destinationIds: readonly string[] | null;
/** Cargo classes allowed (the pilot's skills limit them anyway). */
cargoClassIds: readonly string[];
takePriority: boolean;
/** Credits: the least profit (payout − fuel − pilot share) a job must make. */
minProfit: number;
/** Where the pilot heads after empty boards away (decision 0.5.128). */
homeLocationId: string;
}
/** The orders a pilot starts with (CR-021 §2): anything goes, home where hired. */
export function defaultOrders(homeLocationId: string, content: SimContent): PilotOrders {
return {
destinationIds: null,
cargoClassIds: content.cargoTypes.map((c) => c.id),
takePriority: true,
minProfit: 0,
homeLocationId,
};
}
export interface IndependentInput {
/** The pilot's ship, idle at the board's location, with its tank. */
ship: ShipState & { tank: Tank };
pilot: PilotState;
orders: PilotOrders;
/** The board where the ship is: completable for the fleet, minus jobs already taken. */
board: readonly Contract[];
now: RealTime;
credits: number;
/** Credits pilots never spend the company below (decision 0.5.127). */
spendingFloor: number;
discoveredIds: ReadonlySet<string>;
/** Board periods in a row this pilot has found nothing to take (decision 0.5.128). */
emptyPeriods: number;
/** The contract the player has open, which pilots leave alone (decision 0.5.126). */
viewedContractId?: string | null;
}
/** Fuel to buy before a leg: what it burns beyond what's aboard, rounded up to 0.001 fu. */
function fuelToBuy(burnFu: number, tank: Tank): number {
const short = burnFu - tank.fuelFu;
return short > 0 ? Math.ceil(short * 1000) / 1000 : 0;
}
/**
* The fuel a pilot may buy for a leg (decision 0.5.127): none if the tank holds
* enough; otherwise exactly what the leg needs, if the location sells it and
* credits stay at or above the spending floor. Null when they can't.
*/
export function legRefuel(
input: Pick<IndependentInput, "ship" | "credits" | "spendingFloor">,
burnFu: number,
content: SimContent,
): { buyFu: number; cost: number; tank: Tank } | null {
const { ship } = input;
const buyFu = fuelToBuy(burnFu, ship.tank);
if (buyFu === 0) return { buyFu: 0, cost: 0, tank: ship.tank };
const location = findLocation(ship.locationId, content);
if (!location.sellsFuel) return null;
const capacity = shipStats(ship, content).tankCapacityFu;
if (ship.tank.fuelFu + buyFu > capacity + 1e-9) return null;
const bought = refuel(ship.tank, capacity, buyFu, fuelPriceAt(location, content.config));
if (input.credits - bought.cost < input.spendingFloor) return null;
return { buyFu, cost: bought.cost, tank: bought.tank };
}
/** A job an Independent pilot could take, with its fuel and profit (decision 0.5.125). */
export interface JobOption {
contract: Contract;
/** Fuel bought first (0 if the tank holds enough). */
buyFu: number;
/** Fuel aboard at its average price, plus fuel bought at the local price. */
fuelCost: number;
pilotShare: number;
profit: number;
tripGameHours: number;
profitPerHour: number;
}
/** Whether the first-look window of the current board period is over (decision 0.5.126). */
export function pastFirstLook(now: RealTime, content: SimContent): boolean {
const periodStart = boardPeriodIndex(now, content.config) * boardPeriodMs(content.config);
return now - periodStart >= content.config.independentPilots.firstLookRealMinutes * MS_PER_MINUTE;
}
/**
* The jobs on the board this pilot may take now (decisions 0.5.124–0.5.127):
* past the first-look window, not first contacts, not the one the player has
* open, within the orders, flyable by this pair once the leg's fuel is bought
* (every dispatch check), and making at least the minimum profit.
*/
export function independentOptions(input: IndependentInput, content: SimContent): JobOption[] {
const { ship, pilot, orders, now } = input;
if (!pastFirstLook(now, content)) return [];
const model = shipStats(ship, content);
const origin = findLocation(ship.locationId, content);
const options: JobOption[] = [];
for (const contract of input.board) {
if (contract.firstContact || contract.id === input.viewedContractId) continue;
if (orders.destinationIds && !orders.destinationIds.includes(contract.destinationId)) continue;
if (!orders.cargoClassIds.includes(contract.cargoTypeId)) continue;
if (contract.priority && !orders.takePriority) continue;
const destination = findLocation(contract.destinationId, content);
const burnFu = tripFuel(model, origin, destination, content.config);
const fuel = legRefuel(input, burnFu, content);
if (!fuel) continue;
const check = validateDispatch(
{
ship: { ...ship, tank: fuel.tank },
pilot,
contract,
tutorial: false,
now,
discoveredIds: input.discoveredIds,
},
content,
);
if (!check.ok) continue;
const fromTank = Math.min(burnFu, ship.tank.fuelFu);
const fuelCost = Math.round(fromTank * ship.tank.avgPrice + fuel.cost);
const share = pilot.isAvatar ? 0 : pilotShare(contract.payout, fuelCost, content.config);
const profit = contract.payout - fuelCost - share;
if (profit < orders.minProfit) continue;
const tripGameHours = contractTripGameHours(model, contract, content);
options.push({
contract,
buyFu: fuel.buyFu,
fuelCost,
pilotShare: share,
profit,
tripGameHours,
profitPerHour: profit / tripGameHours,
});
}
return options;
}
/** Best first: highest profit per hour, then the shorter trip, then the lower contract id. */
export function compareOptions(a: JobOption, b: JobOption): number {
if (a.profitPerHour !== b.profitPerHour) return b.profitPerHour - a.profitPerHour;
if (a.tripGameHours !== b.tripGameHours) return a.tripGameHours - b.tripGameHours;
return a.contract.id < b.contract.id ? -1 : a.contract.id > b.contract.id ? 1 : 0;
}
/** What an Independent pilot does when they can act (CR-021 §2). */
export type PilotAction =
| { kind: "take"; contractId: string; buyFu: number }
| { kind: "deadhead-home"; destinationId: string; buyFu: number }
| { kind: "wait" };
/**
* An Independent pilot's decision (decisions 0.5.125, 0.5.128): the best job
* on the board; with none, after enough empty periods away from home, a
* deadhead home if the ship can make it under the fuel rules; else wait.
*/
export function chooseAction(input: IndependentInput, content: SimContent): PilotAction {
const [best] = independentOptions(input, content).sort(compareOptions);
if (best) return { kind: "take", contractId: best.contract.id, buyFu: best.buyFu };
const { ship, orders } = input;
const home = orders.homeLocationId;
if (
ship.locationId !== home &&
input.emptyPeriods >= content.config.independentPilots.emptyPeriodsBeforeHome
) {
const model = shipStats(ship, content);
const burnFu = tripFuel(
model,
findLocation(ship.locationId, content),
findLocation(home, content),
content.config,
);
const fuel = legRefuel(input, burnFu, content);
if (
fuel &&
validateReposition(
{
ship: { ...ship, tank: fuel.tank },
pilot: input.pilot,
destinationId: home,
discoveredIds: input.discoveredIds,
now: input.now,
},
content,
).ok
) {
return { kind: "deadhead-home", destinationId: home, buyFu: fuel.buyFu };
}
}
return { kind: "wait" };
}