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