219 lines
7.5 KiB
TypeScript
219 lines
7.5 KiB
TypeScript
import { content } from "@deadhead/content";
|
|
import type { SaveDocument } from "@deadhead/schema";
|
|
import {
|
|
boardPeriodIndex,
|
|
boardPeriodMs,
|
|
chooseAction,
|
|
isInShop,
|
|
isResting,
|
|
MS_PER_MINUTE,
|
|
type RealTime,
|
|
} from "@deadhead/sim";
|
|
import type { RandomSource } from "../core";
|
|
import { locationBoard } from "./boards";
|
|
import { dispatchContract, dispatchReposition } from "./dispatch";
|
|
import { refuelShip } from "./fleet";
|
|
import { scheduleDecision } from "./independent";
|
|
import { resolveArrivals } from "./resolve";
|
|
import { discoveredIds, pilotState, shipState } from "./state";
|
|
|
|
type SavedPilot = SaveDocument["pilots"][number];
|
|
|
|
const DAY_MS = 24 * 60 * MS_PER_MINUTE;
|
|
|
|
/** When the current board period's first-look window ends (decision 0.5.126). */
|
|
function firstLookEnd(t: RealTime): RealTime {
|
|
const period = boardPeriodMs(content.config);
|
|
return (
|
|
boardPeriodIndex(t, content.config) * period +
|
|
content.config.independentPilots.firstLookRealMinutes * MS_PER_MINUTE
|
|
);
|
|
}
|
|
|
|
/** The next board period's first-look end: when a waiting pilot looks again. */
|
|
function nextBoardLook(t: RealTime): RealTime {
|
|
return firstLookEnd(t) + boardPeriodMs(content.config);
|
|
}
|
|
|
|
/**
|
|
* Ids for what pilots do during catch-up come from the pilot and the moment,
|
|
* not chance, so replaying the same time gives the same save (decision 0.5.129).
|
|
*/
|
|
function replayRandom(pilotId: string, t: RealTime): RandomSource {
|
|
let n = 0;
|
|
return { id: () => `ind-${pilotId}-${t}-${n++}`, seed: () => 0 };
|
|
}
|
|
|
|
export interface CatchUpOptions {
|
|
/** Pilots decide only up to this time (the 7-day cap); arrivals always resolve. */
|
|
decideUntil: RealTime;
|
|
/** Stop after this many events (a chunk); the rest waits for the next call. */
|
|
maxEvents?: number;
|
|
/** The contract the player has open: pilots leave it alone (decision 0.5.126). */
|
|
viewedContractId?: string | null;
|
|
}
|
|
|
|
export interface CatchUpResult {
|
|
/** Arrivals resolved. */
|
|
resolved: number;
|
|
/** Pilot decisions made (jobs, deadheads and waits). */
|
|
decisions: number;
|
|
/** Every event up to `until` was processed. */
|
|
done: boolean;
|
|
/** How far the replay got. */
|
|
reached: RealTime;
|
|
}
|
|
|
|
/** The earliest decision due by `limit`, ties to the lower pilot id. */
|
|
function nextDecision(save: SaveDocument, limit: RealTime): SavedPilot | null {
|
|
let best: SavedPilot | null = null;
|
|
for (const pilot of save.pilots) {
|
|
const t = pilot.nextDecisionAt;
|
|
if (t === null || t > limit || pilot.mode !== "independent") continue;
|
|
if (!best || t < best.nextDecisionAt! || (t === best.nextDecisionAt && pilot.id < best.id)) {
|
|
best = pilot;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function nextArrival(save: SaveDocument, limit: RealTime): RealTime | null {
|
|
let earliest: RealTime | null = null;
|
|
for (const d of save.dispatches) {
|
|
if (d.status === "in-flight" && d.arrivalAt <= limit) {
|
|
earliest = earliest === null ? d.arrivalAt : Math.min(earliest, d.arrivalAt);
|
|
}
|
|
}
|
|
return earliest;
|
|
}
|
|
|
|
/**
|
|
* One Independent pilot's decision point at `t` (CR-021 §2). Rest, the shop
|
|
* and the first-look window push it later; otherwise the sim chooses, and the
|
|
* choice goes through the same commands the player uses.
|
|
*/
|
|
function decide(save: SaveDocument, pilot: SavedPilot, t: RealTime, viewed: string | null): void {
|
|
const ship = save.ships.find((s) => s.id === pilot.assignedShipId);
|
|
if (pilot.mode !== "independent" || !ship || save.player.tutorialActive) {
|
|
pilot.nextDecisionAt = null;
|
|
return;
|
|
}
|
|
// Flying (the player sent them): the arrival schedules the next decision.
|
|
if (pilot.status === "in-flight" || ship.status === "in-flight" || !ship.locationId) {
|
|
pilot.nextDecisionAt = null;
|
|
return;
|
|
}
|
|
if (isResting(pilot.restEndsAt, t)) return scheduleDecision(pilot, pilot.restEndsAt!);
|
|
if (isInShop(ship, t)) return scheduleDecision(pilot, ship.inShopUntil!);
|
|
if (t < firstLookEnd(t)) {
|
|
pilot.nextDecisionAt = firstLookEnd(t);
|
|
return;
|
|
}
|
|
|
|
const random = replayRandom(pilot.id, t);
|
|
const board = locationBoard(save, ship.locationId, random, t).contracts;
|
|
const action = chooseAction(
|
|
{
|
|
ship: { ...shipState(ship), tank: { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice } },
|
|
pilot: pilotState(pilot),
|
|
orders: pilot.orders,
|
|
board,
|
|
now: t,
|
|
credits: save.company.credits,
|
|
spendingFloor: save.company.npcSpendingFloor,
|
|
discoveredIds: discoveredIds(save),
|
|
emptyPeriods: pilot.emptyPeriods,
|
|
viewedContractId: viewed,
|
|
},
|
|
content,
|
|
);
|
|
|
|
if (action.kind === "wait") {
|
|
if (ship.locationId !== pilot.orders.homeLocationId) pilot.emptyPeriods++;
|
|
pilot.nextDecisionAt = nextBoardLook(t);
|
|
return;
|
|
}
|
|
// The same commands, and so the same checks, as the player's (CR-021 §3). The
|
|
// sim chose with the same rules, so a refusal is a bug; the pilot then waits.
|
|
try {
|
|
if (action.buyFu > 0) refuelShip(save, { shipId: ship.id, amountFu: action.buyFu }, t);
|
|
if (action.kind === "take") {
|
|
dispatchContract(
|
|
save,
|
|
{ contractId: action.contractId, shipId: ship.id, pilotId: pilot.id },
|
|
random,
|
|
t,
|
|
{ independent: true },
|
|
);
|
|
} else {
|
|
dispatchReposition(
|
|
save,
|
|
{ shipId: ship.id, pilotId: pilot.id, destinationId: action.destinationId },
|
|
random,
|
|
t,
|
|
{ independent: true },
|
|
);
|
|
}
|
|
} catch {
|
|
pilot.nextDecisionAt = nextBoardLook(t);
|
|
return;
|
|
}
|
|
pilot.emptyPeriods = 0;
|
|
pilot.nextDecisionAt = null;
|
|
}
|
|
|
|
/**
|
|
* Replays time up to `until` as an event queue (decision 0.5.129): arrivals
|
|
* and Independent pilots' decision points in time order; at the same moment
|
|
* arrivals first, then pilots by id. Each arrival resolves at its own time, so
|
|
* the result doesn't depend on how the time is split.
|
|
*/
|
|
export function catchUp(
|
|
save: SaveDocument,
|
|
until: RealTime,
|
|
options: CatchUpOptions,
|
|
): CatchUpResult {
|
|
const maxEvents = options.maxEvents ?? Number.POSITIVE_INFINITY;
|
|
const decideLimit = Math.min(until, options.decideUntil);
|
|
let resolved = 0;
|
|
let decisions = 0;
|
|
let reached = Number.NEGATIVE_INFINITY;
|
|
for (let events = 0; events < maxEvents; events++) {
|
|
const arrival = nextArrival(save, until);
|
|
const pilot = nextDecision(save, decideLimit);
|
|
const decisionAt = pilot?.nextDecisionAt ?? null;
|
|
if (arrival === null && decisionAt === null) {
|
|
// Pilots who would have decided after the cap pick up from `until`.
|
|
if (decideLimit < until) {
|
|
for (const p of save.pilots) {
|
|
if (p.nextDecisionAt !== null && p.nextDecisionAt < until) p.nextDecisionAt = until;
|
|
}
|
|
}
|
|
return { resolved, decisions, done: true, reached: until };
|
|
}
|
|
if (arrival !== null && (decisionAt === null || arrival <= decisionAt)) {
|
|
const landed = save.dispatches.filter(
|
|
(d) => d.status === "in-flight" && d.arrivalAt <= arrival,
|
|
);
|
|
resolved += resolveArrivals(save, arrival);
|
|
for (const d of landed) {
|
|
const flyer = save.pilots.find((p) => p.id === d.pilotId);
|
|
if (flyer?.mode === "independent") {
|
|
scheduleDecision(flyer, Math.max(arrival, flyer.restEndsAt ?? 0));
|
|
}
|
|
}
|
|
reached = arrival;
|
|
} else {
|
|
decide(save, pilot!, decisionAt!, options.viewedContractId ?? null);
|
|
decisions++;
|
|
reached = decisionAt!;
|
|
}
|
|
}
|
|
return { resolved, decisions, done: false, reached };
|
|
}
|
|
|
|
/** The decision cap for a catch-up starting from the last saved time (decision 0.5.129). */
|
|
export function decideUntil(save: SaveDocument): RealTime {
|
|
return save.lastTimeSeen + content.config.independentPilots.maxCatchUpRealDays * DAY_MS;
|
|
}
|