198 lines
6.8 KiB
TypeScript
198 lines
6.8 KiB
TypeScript
import { content } from "@deadhead/content";
|
||
import type { SaveDocument } from "@deadhead/schema";
|
||
import {
|
||
boardPeriodIndex,
|
||
effectiveStats,
|
||
pilotLevel,
|
||
resolveDispatch,
|
||
skillPointsAvailable,
|
||
type RealTime,
|
||
type Resolution,
|
||
} from "@deadhead/sim";
|
||
import { logEntry, pruneLogbook } from "./logbook";
|
||
import { discoveredIds } from "./state";
|
||
|
||
/**
|
||
* Resolves every in-flight dispatch that has arrived by `now`, in arrival
|
||
* order (Steps 6.1, 7.2): credits (net of pilot pay and loan repayment), Auto
|
||
* top-up (CR-015), XP,
|
||
* duty and rest, ship and pilot locations, tutorial progress and discovery.
|
||
* A resolved dispatch is never resolved again, so running this twice in a
|
||
* row changes nothing the second time. Returns how many were resolved.
|
||
*/
|
||
export function resolveArrivals(save: SaveDocument, now: RealTime): number {
|
||
const due = save.dispatches
|
||
.filter((d) => d.status === "in-flight" && d.arrivalAt <= now)
|
||
.sort((a, b) => a.arrivalAt - b.arrivalAt);
|
||
if (due.length === 0) return 0;
|
||
|
||
const discovered = discoveredIds(save);
|
||
let resolved = 0;
|
||
for (const dispatch of due) {
|
||
const pilot = save.pilots.find((p) => p.id === dispatch.pilotId);
|
||
if (!pilot) throw new Error(`Dispatch ${dispatch.id} has no pilot`);
|
||
const ship = dispatch.shipId ? save.ships.find((s) => s.id === dispatch.shipId) : undefined;
|
||
const model = ship ? content.shipModel(ship.modelId) : undefined;
|
||
const result = resolveDispatch(
|
||
{
|
||
record: dispatch.record,
|
||
pilotIsAvatar: pilot.isAvatar,
|
||
discoveredIds: discovered,
|
||
// Each arrival repays against the balance left by the previous one.
|
||
loanBalance: save.company.loanBalance,
|
||
pilotDutyHours: pilot.dutyHours,
|
||
pilotSkills: pilot.skills,
|
||
now,
|
||
event: dispatch.eventApplied,
|
||
fuel:
|
||
ship && model
|
||
? {
|
||
tank: { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice },
|
||
capacityFu: effectiveStats(model, ship.upgrades, content).tankCapacityFu,
|
||
refuelPolicy: ship.refuelPolicy,
|
||
credits: save.company.credits,
|
||
}
|
||
: undefined,
|
||
},
|
||
content,
|
||
);
|
||
if (result.status !== "resolved") continue;
|
||
const r = result.resolution;
|
||
resolved++;
|
||
|
||
Object.assign(dispatch, { status: "resolved", resolution: r, resolvedAt: now });
|
||
save.company.credits += r.credits;
|
||
save.company.loanBalance -= r.loanRepayment;
|
||
// Auto top-up on landing (decision 0.5.97).
|
||
if (r.topUp && ship) {
|
||
save.company.credits -= r.topUp.cost;
|
||
ship.fuelFu = r.topUp.tank.fuelFu;
|
||
ship.fuelAvgPrice = r.topUp.tank.avgPrice;
|
||
}
|
||
|
||
const levelBefore = pilotLevel(pilot.xp, content.config);
|
||
pilot.xp += r.pilotXp;
|
||
pilot.status = "idle";
|
||
// Duty and mandatory rest from the arrival time (decisions 0.5.42–0.5.45).
|
||
pilot.dutyHours = r.pilotDutyHours;
|
||
if (r.pilotRestEndsAt !== null) pilot.restEndsAt = r.pilotRestEndsAt;
|
||
// Only unassigned pilots store a location (decision 0.5.27): the tutorial avatar.
|
||
if (pilot.assignedShipId === null) pilot.locationId = r.shipLocationId;
|
||
|
||
if (ship) {
|
||
ship.status = "idle";
|
||
ship.locationId = r.shipLocationId;
|
||
ship.lastArrivalAt = Math.max(ship.lastArrivalAt, dispatch.arrivalAt);
|
||
}
|
||
if (dispatch.tutorial) {
|
||
save.player.loanerLocationId = r.shipLocationId;
|
||
save.player.tutorialRunsCompleted++;
|
||
} else if (dispatch.record.contract) {
|
||
save.stats.contractDeliveries++;
|
||
if (dispatch.record.contract.priority) save.stats.priorityDeliveries++;
|
||
}
|
||
const discovers = r.discoversDestination && !discovered.has(dispatch.record.destinationId);
|
||
if (discovers) {
|
||
save.discoveredLocations.push({
|
||
locationId: dispatch.record.destinationId,
|
||
discoveredAt: now,
|
||
});
|
||
discovered.add(dispatch.record.destinationId);
|
||
}
|
||
logArrival(save, dispatch, r, now, discovers, levelBefore, pilot);
|
||
}
|
||
return resolved;
|
||
}
|
||
|
||
/** One arrival in the logbook (decision 0.5.141): the flight and what came with it. */
|
||
function logArrival(
|
||
save: SaveDocument,
|
||
dispatch: SaveDocument["dispatches"][number],
|
||
r: Resolution,
|
||
now: RealTime,
|
||
discovers: boolean,
|
||
levelBefore: number,
|
||
pilot: SaveDocument["pilots"][number],
|
||
): void {
|
||
const { record } = dispatch;
|
||
const contract = record.contract;
|
||
const who = { pilotId: pilot.id, shipId: dispatch.shipId };
|
||
logEntry(save, `${dispatch.id}:flight`, {
|
||
at: now,
|
||
...who,
|
||
kind: "flight",
|
||
dispatchId: dispatch.id,
|
||
deadhead: contract === null,
|
||
tutorial: dispatch.tutorial,
|
||
independent: dispatch.independent,
|
||
originId: record.originId,
|
||
destinationId: record.destinationId,
|
||
goodId: contract?.goodId ?? null,
|
||
massTonnes: contract?.massTonnes ?? null,
|
||
shipper: contract?.shipper ?? null,
|
||
payout: Math.max(0, r.credits + r.pilotPay + r.loanRepayment),
|
||
pilotPay: r.pilotPay,
|
||
fuelCost: record.fuelCost,
|
||
loanRepayment: r.loanRepayment,
|
||
xp: r.pilotXp,
|
||
scriptedId: contract?.scriptedId ?? null,
|
||
});
|
||
if (r.topUp && r.topUp.boughtFu > 0) {
|
||
logEntry(save, `${dispatch.id}:top-up`, {
|
||
at: now,
|
||
...who,
|
||
kind: "refuel",
|
||
locationId: r.topUp.locationId,
|
||
boughtFu: r.topUp.boughtFu,
|
||
cost: r.topUp.cost,
|
||
source: "top-up",
|
||
});
|
||
}
|
||
if (r.pilotRestEndsAt !== null) {
|
||
logEntry(save, `${dispatch.id}:rest`, {
|
||
at: now,
|
||
...who,
|
||
kind: "rest",
|
||
until: r.pilotRestEndsAt,
|
||
});
|
||
}
|
||
if (discovers) {
|
||
logEntry(save, `${dispatch.id}:discovery`, {
|
||
at: now,
|
||
...who,
|
||
kind: "discovery",
|
||
locationId: record.destinationId,
|
||
});
|
||
}
|
||
const level = pilotLevel(pilot.xp, content.config);
|
||
if (level > levelBefore) {
|
||
logEntry(save, `${dispatch.id}:level`, {
|
||
at: now,
|
||
...who,
|
||
kind: "level-up",
|
||
level,
|
||
skillPoints: skillPointsAvailable(level, pilot.skillPointsSpent, content.config),
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Keeps the save small (CR-014 §2, Step 7.3): drops boards and accepted
|
||
* contracts older than the board retention, and resolved dispatches the player
|
||
* has seen that are older than the dispatch retention. In-flight dispatches,
|
||
* unseen summaries and the goal counters are never touched.
|
||
*/
|
||
export function pruneSave(save: SaveDocument, now: RealTime): void {
|
||
const cutoffPeriod = boardPeriodIndex(now, content.config) - content.config.boardRetentionPeriods;
|
||
save.contractBoards = save.contractBoards.filter((b) => b.periodIndex >= cutoffPeriod);
|
||
save.acceptedContracts = save.acceptedContracts.filter((a) => a.periodIndex >= cutoffPeriod);
|
||
const cutoff = now - content.config.savedDispatchRetentionRealHours * 3_600_000;
|
||
pruneLogbook(save, now);
|
||
save.dispatches = save.dispatches.filter(
|
||
(d) =>
|
||
d.status === "in-flight" ||
|
||
d.acknowledgedAt === null ||
|
||
(d.resolvedAt ?? d.arrivalAt) >= cutoff,
|
||
);
|
||
}
|