315 lines
11 KiB
TypeScript
315 lines
11 KiB
TypeScript
import { content } from "@deadhead/content";
|
|
import type {
|
|
Company,
|
|
Dispatch,
|
|
GoalState,
|
|
Pilot,
|
|
PlayerState,
|
|
SaveDocument,
|
|
Ship,
|
|
} from "@deadhead/schema";
|
|
import {
|
|
completedGoals,
|
|
dutyLimit,
|
|
effectiveStats,
|
|
fuelPriceAt,
|
|
isInShop,
|
|
isResting,
|
|
MS_PER_HOUR,
|
|
pilotLevel,
|
|
skillPointsAvailable,
|
|
type GoalProgress,
|
|
type PilotState,
|
|
type RealTime,
|
|
type ShipState,
|
|
} from "@deadhead/sim";
|
|
import { fail } from "./errors";
|
|
import { radioCalls } from "./events";
|
|
|
|
type SavedShip = SaveDocument["ships"][number];
|
|
type SavedPilot = SaveDocument["pilots"][number];
|
|
type SavedDispatch = SaveDocument["dispatches"][number];
|
|
|
|
/** The id the tutorial loaner uses in dispatch records (it is not an owned ship). */
|
|
export const LOANER_SHIP_ID = "loaner";
|
|
|
|
const START_LOCATION_IDS = new Set(
|
|
content.locations.filter((l) => l.startsDiscovered).map((l) => l.id),
|
|
);
|
|
|
|
export function companyView(save: SaveDocument): Company {
|
|
const { id, name, credits, loanBalance } = save.company;
|
|
return { id, name, credits, loanBalance };
|
|
}
|
|
|
|
export function requireShip(save: SaveDocument, shipId: string): SavedShip {
|
|
const ship = save.ships.find((s) => s.id === shipId);
|
|
if (!ship) throw fail("not-found", "No such ship.");
|
|
return ship;
|
|
}
|
|
|
|
export function requirePilot(save: SaveDocument, pilotId: string): SavedPilot {
|
|
const pilot = save.pilots.find((p) => p.id === pilotId);
|
|
if (!pilot) throw fail("not-found", "No such pilot.");
|
|
return pilot;
|
|
}
|
|
|
|
export function hasBayAt(save: SaveDocument, locationId: string): boolean {
|
|
return save.bays.some((b) => b.locationId === locationId);
|
|
}
|
|
|
|
export function discoveredIds(save: SaveDocument): Set<string> {
|
|
return new Set(save.discoveredLocations.map((d) => d.locationId));
|
|
}
|
|
|
|
/** Purchases are blocked while the tutorial runs (decision 0.5.16, CR-001). */
|
|
export function requireTutorialOver(save: SaveDocument, what: string): void {
|
|
if (save.player.tutorialActive) {
|
|
throw fail("tutorial-active", `Finish the company runs before ${what}.`);
|
|
}
|
|
}
|
|
|
|
/** Deducts credits, or refuses with `insufficient-funds` without changing anything. */
|
|
export function spend(save: SaveDocument, amount: number, what: string): void {
|
|
if (save.company.credits < amount) {
|
|
throw fail("insufficient-funds", `Not enough credits for ${what} (${amount} needed).`);
|
|
}
|
|
save.company.credits -= amount;
|
|
}
|
|
|
|
/**
|
|
* A new ship's tank: full, bought at `locationId`'s price, on Manual refueling
|
|
* (decisions 0.5.111, 0.5.112).
|
|
*/
|
|
export function deliveredTank(
|
|
modelId: string,
|
|
locationId: string,
|
|
): Pick<SavedShip, "fuelFu" | "fuelAvgPrice" | "refuelPolicy"> {
|
|
const model = content.shipModel(modelId);
|
|
const location = content.location(locationId);
|
|
if (!model || !location)
|
|
throw new Error(`Unknown ship model or location: ${modelId}, ${locationId}`);
|
|
return {
|
|
fuelFu: model.tankCapacityFu,
|
|
fuelAvgPrice: fuelPriceAt(location, content.config),
|
|
refuelPolicy: "manual",
|
|
};
|
|
}
|
|
|
|
export function shipState(ship: SavedShip): ShipState {
|
|
return {
|
|
id: ship.id,
|
|
modelId: ship.modelId,
|
|
// An in-flight ship has no location; validation reports it as not idle first.
|
|
locationId: ship.locationId ?? "",
|
|
inFlight: ship.status === "in-flight",
|
|
upgrades: ship.upgrades,
|
|
inShopUntil: ship.inShopUntil,
|
|
tank: { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice },
|
|
hullConditionPct: ship.hullConditionPct,
|
|
};
|
|
}
|
|
|
|
export function pilotState(pilot: SavedPilot): PilotState {
|
|
return {
|
|
id: pilot.id,
|
|
isAvatar: pilot.isAvatar,
|
|
assignedShipId: pilot.assignedShipId,
|
|
inFlight: pilot.status === "in-flight",
|
|
skills: pilot.skills,
|
|
restEndsAt: pilot.restEndsAt,
|
|
};
|
|
}
|
|
|
|
export function toApiDispatch(dispatch: SavedDispatch): Dispatch {
|
|
return { ...dispatch.record, status: dispatch.status, resolvedAt: dispatch.resolvedAt };
|
|
}
|
|
|
|
/** An assigned pilot is wherever its ship is; an unassigned one stores its own location. */
|
|
export function toApiPilot(save: SaveDocument, pilot: SavedPilot, now: RealTime): Pilot {
|
|
const resting = isResting(pilot.restEndsAt, now);
|
|
const level = pilotLevel(pilot.xp, content.config);
|
|
const ship = pilot.assignedShipId
|
|
? save.ships.find((s) => s.id === pilot.assignedShipId)
|
|
: undefined;
|
|
const shipLocation = ship?.locationId ?? null;
|
|
return {
|
|
id: pilot.id,
|
|
name: pilot.name,
|
|
isAvatar: pilot.isAvatar,
|
|
isVeteran: pilot.isVeteran,
|
|
xp: pilot.xp,
|
|
level,
|
|
skills: pilot.skills,
|
|
skillPointsAvailable: skillPointsAvailable(level, pilot.skillPointsSpent, content.config),
|
|
assignedShipId: pilot.assignedShipId,
|
|
status: pilot.status,
|
|
locationId:
|
|
pilot.status === "in-flight" ? null : pilot.assignedShipId ? shipLocation : pilot.locationId,
|
|
dutyHours: pilot.dutyHours,
|
|
dutyLimit: dutyLimit(pilot.skills, content.config),
|
|
resting,
|
|
restEndsAt: resting ? pilot.restEndsAt : null,
|
|
restingInShopUntil:
|
|
ship && ship.shopPilotId === pilot.id && isInShop(ship, now) ? ship.inShopUntil : null,
|
|
mode: pilot.mode,
|
|
orders: pilot.orders,
|
|
};
|
|
}
|
|
|
|
/** A ship with its upgrades and effective stats (CR-009). */
|
|
export function toApiShip(save: SaveDocument, ship: SavedShip, now: RealTime): Ship {
|
|
const model = content.shipModel(ship.modelId);
|
|
if (!model) throw new Error(`Ship ${ship.id} has unknown model ${ship.modelId}`);
|
|
const stats = effectiveStats(model, ship.upgrades, content);
|
|
const active = save.dispatches.find((d) => d.shipId === ship.id && d.status === "in-flight");
|
|
return {
|
|
id: ship.id,
|
|
modelId: ship.modelId,
|
|
name: ship.name,
|
|
status: ship.status,
|
|
locationId: ship.status === "in-flight" ? null : ship.locationId,
|
|
dispatchId: active?.id ?? null,
|
|
pilotId: save.pilots.find((p) => p.assignedShipId === ship.id)?.id ?? null,
|
|
lastArrivalAt: ship.lastArrivalAt,
|
|
upgrades: ship.upgrades,
|
|
stats: {
|
|
cruiseSpeedKmPerGameHour: stats.cruiseSpeedKmPerGameHour,
|
|
cargoCapacityTonnes: stats.cargoCapacityTonnes,
|
|
tankCapacityFu: stats.tankCapacityFu,
|
|
burnFuPerMillionKm: stats.burnFuPerMillionKm,
|
|
overheadGameHours: stats.overheadGameHours,
|
|
handlingGameHoursPerTonne: stats.handlingGameHoursPerTonne,
|
|
},
|
|
inShopUntil: isInShop(ship, now) ? ship.inShopUntil : null,
|
|
shop:
|
|
ship.shopUpgradeId && ship.shopLevel !== null && ship.inShopUntil !== null
|
|
? { upgradeId: ship.shopUpgradeId, level: ship.shopLevel, until: ship.inShopUntil }
|
|
: null,
|
|
fuelFu: ship.fuelFu,
|
|
fuelAvgPrice: ship.fuelAvgPrice,
|
|
refuelPolicy: ship.refuelPolicy,
|
|
hullConditionPct: ship.hullConditionPct,
|
|
repairing: ship.shopRepair && isInShop(ship, now),
|
|
};
|
|
}
|
|
|
|
/** The deal can be accepted now (decision 0.5.30). */
|
|
export function isDealAvailable(save: SaveDocument): boolean {
|
|
const { player } = save;
|
|
const runInFlight = save.dispatches.some((d) => d.tutorial && d.status === "in-flight");
|
|
return (
|
|
player.tutorialActive &&
|
|
!player.dealAccepted &&
|
|
player.tutorialRunsCompleted >= content.config.tutorialRunsBeforeDeal &&
|
|
// Wherever the loaner is (decision 0.5.108): accepting puts the avatar at Earth.
|
|
!runInFlight
|
|
);
|
|
}
|
|
|
|
/** The facts goals are checked against (Step 10A.10). */
|
|
export function goalProgress(save: SaveDocument): GoalProgress {
|
|
return {
|
|
ownShipDeliveries: save.stats.contractDeliveries,
|
|
skillPointsSpent: save.pilots.reduce((sum, p) => sum + p.skillPointsSpent, 0),
|
|
shipsOwned: save.ships.length,
|
|
marketShipsBought: save.ships.filter((s) => !s.fromDeal).length,
|
|
pilotsHired: save.pilots.filter((p) => !p.isAvatar).length,
|
|
priorityDeliveries: save.stats.priorityDeliveries,
|
|
locationsDiscovered: save.discoveredLocations.filter(
|
|
(d) => !START_LOCATION_IDS.has(d.locationId),
|
|
).length,
|
|
dealAccepted: save.player.dealAccepted,
|
|
loanBalance: save.company.loanBalance,
|
|
};
|
|
}
|
|
|
|
/** Every goal's status in content order; none while the tutorial runs (decision 0.5.34). */
|
|
export function goalStates(save: SaveDocument): GoalState[] {
|
|
if (save.player.tutorialActive) return [];
|
|
const complete = new Set(completedGoals(content.onboardingGoals, goalProgress(save)));
|
|
const claimed = new Set(save.claimedGoals.map((g) => g.goalId));
|
|
return content.onboardingGoals.map((goal) => ({
|
|
id: goal.id,
|
|
status: claimed.has(goal.id) ? "claimed" : complete.has(goal.id) ? "ready" : "incomplete",
|
|
}));
|
|
}
|
|
|
|
/** Everything the client needs (Step 8.5's state). */
|
|
/**
|
|
* When the fleet goes quiet (decision 0.5.137): the last manual decision plus
|
|
* the threshold. Independent pilots start nothing at or after it.
|
|
*/
|
|
export function sleepsAt(save: SaveDocument, now: RealTime): RealTime {
|
|
const { lastManualDecisionAt, sleepAfterHours } = save.company;
|
|
return (lastManualDecisionAt ?? now) + sleepAfterHours * MS_PER_HOUR;
|
|
}
|
|
|
|
export function playerState(save: SaveDocument, now: RealTime): PlayerState {
|
|
const active = save.dispatches.filter((d) => d.status === "in-flight");
|
|
const discovered = discoveredIds(save);
|
|
const tutorialRun = active.some((d) => d.tutorial);
|
|
return {
|
|
playerId: save.saveId,
|
|
company: companyView(save),
|
|
ships: save.ships.map((s) => toApiShip(save, s, now)),
|
|
pilots: save.pilots.map((p) => toApiPilot(save, p, now)),
|
|
bays: save.bays.map((b) => ({ id: b.id, locationId: b.locationId })),
|
|
locations: content.locations.map((l) => ({ id: l.id, discovered: discovered.has(l.id) })),
|
|
tutorial:
|
|
save.player.tutorialActive && save.player.loanerLocationId
|
|
? {
|
|
loanerLocationId: save.player.loanerLocationId as "earth" | "moon",
|
|
runInFlight: tutorialRun,
|
|
runsCompleted: save.player.tutorialRunsCompleted,
|
|
dealAvailable: isDealAvailable(save),
|
|
}
|
|
: null,
|
|
activeDispatches: active.map(toApiDispatch),
|
|
summaries: save.dispatches
|
|
.filter((d) => d.status === "resolved" && d.acknowledgedAt === null)
|
|
.map((d) => ({
|
|
dispatchId: d.id,
|
|
shipId: d.record.shipId,
|
|
pilotId: d.pilotId,
|
|
originId: d.record.originId,
|
|
destinationId: d.record.destinationId,
|
|
tutorial: d.tutorial,
|
|
independent: d.independent,
|
|
deadhead: d.record.contract === null,
|
|
fuelCost: d.record.fuelCost,
|
|
topUp: d.resolution?.topUp
|
|
? {
|
|
locationId: d.resolution.topUp.locationId,
|
|
boughtFu: d.resolution.topUp.boughtFu,
|
|
cost: d.resolution.topUp.cost,
|
|
}
|
|
: null,
|
|
goodId: d.record.contract?.goodId ?? null,
|
|
massTonnes: d.record.contract?.massTonnes ?? null,
|
|
shipper: d.record.contract?.shipper ?? null,
|
|
credits: d.resolution?.credits ?? 0,
|
|
loanRepayment: d.resolution?.loanRepayment ?? 0,
|
|
pilotPay: d.resolution?.pilotPay ?? 0,
|
|
pilotXp: d.resolution?.pilotXp ?? 0,
|
|
discoveredLocationId: d.resolution?.discoversDestination ? d.record.destinationId : null,
|
|
scriptedId: d.record.contract?.scriptedId ?? null,
|
|
restEndsAt: d.resolution?.pilotRestEndsAt ?? null,
|
|
resolvedAt: d.resolvedAt ?? d.arrivalAt,
|
|
event: d.eventApplied,
|
|
})),
|
|
goals: goalStates(save),
|
|
welcomeSeen: save.player.welcomeSeen,
|
|
radioCalls: radioCalls(save, now),
|
|
fleetSleep: {
|
|
afterHours: save.company.sleepAfterHours,
|
|
sleepsAt: sleepsAt(save, now),
|
|
asleep: now >= sleepsAt(save, now),
|
|
},
|
|
dispatchOffice: {
|
|
owned: save.company.dispatchOffice,
|
|
spendingFloor: save.company.npcSpendingFloor,
|
|
},
|
|
};
|
|
}
|