215 lines
8.0 KiB
TypeScript
215 lines
8.0 KiB
TypeScript
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" };
|
||
}
|