deadhead-space-freight-tycoon/packages/engine/src/game/catchup.ts

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;
}