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

273 lines
9.8 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 { eventDueAt, nextEventDue, settleDueEvent } from "./events";
import { finishShop, refuelShip } from "./fleet";
import { logReadyGoals } from "./logbook";
import { scheduleDecision } from "./independent";
import { resolveArrivals } from "./resolve";
import { discoveredIds, pilotState, shipState, sleepsAt } 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;
}
/** The earliest open shop end by `limit` (decisions 0.5.135, 0.5.141). */
function nextShopEnd(save: SaveDocument, limit: RealTime): SaveDocument["ships"][number] | null {
let best: SaveDocument["ships"][number] | null = null;
for (const ship of save.ships) {
const t = ship.inShopUntil;
if (!ship.shopOpen || t === null || t > limit) continue;
if (!best || t < best.inShopUntil! || (t === best.inShopUntil && ship.id < best.id))
best = ship;
}
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, "pilot");
}
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): shop ends
* (decision 0.5.135), arrivals and Independent pilots' decision points in time
* order; at the same moment shop ends first, then arrivals, 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 {
// A save just migrated to version 9 counts its first catch-up as the last decision (CR-024 §3).
save.company.lastManualDecisionAt ??= until;
// Goals already ready before this catch-up count from when the save was last seen, so
// the time is the same however the catch-up is split (decision 0.5.141).
logReadyGoals(save, save.lastTimeSeen);
const maxEvents = options.maxEvents ?? Number.POSITIVE_INFINITY;
// Pilots decide before the 7-day cap and before the fleet goes quiet (decision 0.5.137).
const decideLimit = Math.min(until, options.decideUntil, sleepsAt(save, until) - 1);
let resolved = 0;
let decisions = 0;
let reached = Number.NEGATIVE_INFINITY;
for (let events = 0; events < maxEvents; events++) {
// A trip's event comes first at any moment: it can move the arrival (decision 0.5.143).
const due = nextEventDue(save, until);
if (due) {
const at = eventDueAt(due)!;
const shopFirst = nextShopEnd(save, until);
const arrivalFirst = nextArrival(save, until);
const decisionFirst = nextDecision(save, decideLimit)?.nextDecisionAt ?? null;
const earliest = Math.min(
shopFirst?.inShopUntil ?? Infinity,
arrivalFirst ?? Infinity,
decisionFirst ?? Infinity,
);
if (at <= earliest) {
settleDueEvent(save, due);
reached = at;
continue;
}
}
const shop = nextShopEnd(save, until);
const shopAt = shop?.inShopUntil ?? null;
const arrival = nextArrival(save, until);
const pilot = nextDecision(save, decideLimit);
const decisionAt = pilot?.nextDecisionAt ?? null;
if (
shopAt !== null &&
(arrival === null || shopAt <= arrival) &&
(decisionAt === null || shopAt <= decisionAt)
) {
finishShop(save, shop!);
logReadyGoals(save, shopAt);
reached = shopAt;
continue;
}
if (arrival === null && decisionAt === null) {
// Pilots who would have decided after the cap, or asleep, pick up from `until`:
// the next manual decision wakes them from there (decision 0.5.138).
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);
logReadyGoals(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;
}