import { content } from "@deadhead/content"; import type { Bay, Pilot, SaveDocument, Ship } from "@deadhead/schema"; import { effectiveStats, emergencyFuel, fuelPriceAt, isResting, pilotLevel, refuel, restEndsAt, skillPointsAvailable, validateUpgrade, type RealTime, } from "@deadhead/sim"; import type { RandomSource } from "../core"; import { fail } from "./errors"; import { UPGRADE_MESSAGES, VALIDATION_MESSAGES } from "./messages"; import { pilotName, shipName } from "./names"; import { deliveredTank, discoveredIds, hasBayAt, requirePilot, requireShip, requireTutorialOver, shipState, spend, toApiPilot, toApiShip, } from "./state"; /** Leases a bay at a discovered location for its one-time price (Step 6.6). */ export function leaseBay( save: SaveDocument, locationId: string, random: RandomSource, now: RealTime, ): Bay { requireTutorialOver(save, "leasing a bay"); const location = content.location(locationId); if (!location) throw fail("not-found", `No location "${locationId}".`); if (!save.discoveredLocations.some((d) => d.locationId === locationId)) { throw fail("location-undiscovered", `Deliver to ${location.name} before leasing a bay there.`); } if (hasBayAt(save, locationId)) { throw fail("bay-already-leased", `You already have a bay at ${location.name}.`); } spend(save, location.bayLeasePrice, `a bay at ${location.name}`); const bay = { id: random.id(), locationId, createdAt: now }; save.bays.push(bay); return { id: bay.id, locationId }; } /** Buys a ship model, delivered idle and uncrewed to one of the bays (Steps 6.5, 10B.6; decision 0.5.35). */ export function buyShip( save: SaveDocument, input: { modelId: string; bayId: string }, random: RandomSource, now: RealTime, ): Ship { requireTutorialOver(save, "buying a ship"); const model = content.shipModel(input.modelId); if (!model?.purchasable) throw fail("not-found", `No ship model "${input.modelId}" for sale.`); const bay = save.bays.find((b) => b.id === input.bayId); if (!bay) throw fail("not-found", "You have no such bay."); spend(save, model.price, `a ${model.name}`); const ship: SaveDocument["ships"][number] = { id: random.id(), modelId: model.id, name: shipName(save.player.worldSeed, save.ships.length), status: "idle", locationId: bay.locationId, upgrades: {}, inShopUntil: null, shopUpgradeId: null, shopLevel: null, fromDeal: false, createdAt: now, lastArrivalAt: now, // Delivered with a full tank, included in the price (decision 0.5.94). ...deliveredTank(model.id, bay.locationId), }; save.ships.push(ship); return toApiShip(save, ship, now); } /** Hires a level-0 pilot at one of the bays (Step 6.8, decision 0.5.22). */ export function hirePilot( save: SaveDocument, locationId: string, random: RandomSource, now: RealTime, ): Pilot { requireTutorialOver(save, "hiring pilots"); if (!hasBayAt(save, locationId)) { throw fail("no-bay-at-location", "Pilots can only be hired at one of your bays."); } spend(save, content.config.pilotHireCost, "a new pilot"); const hired = save.pilots.filter((p) => !p.isAvatar).length; const pilot: SaveDocument["pilots"][number] = { id: random.id(), name: pilotName(save.player.worldSeed, hired), isAvatar: false, isVeteran: false, xp: 0, skills: {}, skillPointsSpent: 0, assignedShipId: null, locationId, status: "idle", dutyHours: 0, restEndsAt: null, createdAt: now, }; save.pilots.push(pilot); return toApiPilot(save, pilot, now); } /** * Assigns an idle, unassigned pilot to an idle, uncrewed ship at the same * location. The avatar flies the loaner during the tutorial (decision 0.5.23). */ export function assignPilot( save: SaveDocument, input: { pilotId: string; shipId: string }, now: RealTime, ): Pilot { const pilot = requirePilot(save, input.pilotId); const ship = requireShip(save, input.shipId); if (pilot.isAvatar && save.player.tutorialActive) { throw fail("avatar-in-tutorial", "You fly the company loaner until the tutorial ends."); } if (pilot.status === "in-flight") throw fail("pilot-not-idle", VALIDATION_MESSAGES["pilot-not-idle"]); if (pilot.assignedShipId !== null) { throw fail("pilot-already-assigned", "That pilot already has a ship. Unassign them first."); } if (ship.status === "in-flight") throw fail("ship-not-idle", VALIDATION_MESSAGES["ship-not-idle"]); if (ship.locationId !== pilot.locationId) { throw fail("ship-not-idle-here", "The pilot and the ship must be at the same location."); } if (save.pilots.some((p) => p.assignedShipId === ship.id)) { throw fail("ship-crewed", "That ship already has a pilot."); } Object.assign(pilot, { assignedShipId: ship.id, locationId: null }); return toApiPilot(save, pilot, now); } /** Unassigns a pilot from its ship, only where the player has a bay (decision 0.5.2). */ export function unassignPilot(save: SaveDocument, pilotId: string, now: RealTime): Pilot { const pilot = requirePilot(save, pilotId); if (pilot.assignedShipId === null) throw fail("pilot-not-assigned-to-ship", "That pilot has no ship."); if (pilot.status === "in-flight") throw fail("pilot-not-idle", VALIDATION_MESSAGES["pilot-not-idle"]); const ship = requireShip(save, pilot.assignedShipId); if (ship.locationId === null || !hasBayAt(save, ship.locationId)) { throw fail("no-bay-at-location", "Pilots can only leave their ship at one of your bays."); } Object.assign(pilot, { assignedShipId: null, locationId: ship.locationId }); return toApiPilot(save, pilot, now); } /** Rests an idle pilot now, at any duty level (CR-004): duty resets and they can't fly until it ends. */ export function restPilot(save: SaveDocument, pilotId: string, now: RealTime): Pilot { requireTutorialOver(save, "resting a pilot"); const pilot = requirePilot(save, pilotId); if (pilot.status === "in-flight") throw fail("pilot-not-idle", VALIDATION_MESSAGES["pilot-not-idle"]); if (isResting(pilot.restEndsAt, now)) throw fail("pilot-resting", "That pilot is already resting."); Object.assign(pilot, { dutyHours: 0, restEndsAt: restEndsAt(now, content.config) }); return toApiPilot(save, pilot, now); } /** Spends one available skill point on a skill below its max level (Step 6.12). */ export function spendSkillPoint( save: SaveDocument, input: { pilotId: string; skillId: string }, now: RealTime, ): Pilot { const pilot = requirePilot(save, input.pilotId); const skill = content.skill(input.skillId); if (!skill) throw fail("unknown-skill", `No skill "${input.skillId}".`); const level = pilotLevel(pilot.xp, content.config); if (skillPointsAvailable(level, pilot.skillPointsSpent, content.config) <= 0) { throw fail("no-skill-points", "That pilot has no skill points to spend."); } const current = pilot.skills[skill.id] ?? 0; if (current >= skill.maxLevel) throw fail("skill-maxed", `${skill.name} is already at its maximum level.`); pilot.skills = { ...pilot.skills, [skill.id]: current + 1 }; pilot.skillPointsSpent += 1; return toApiPilot(save, pilot, now); } /** * Starts the next level of an upgrade on a ship (CR-009): validate, charge, * raise the level now, and keep the ship in the shop until the work is done. */ export function startUpgrade( save: SaveDocument, input: { shipId: string; upgradeId: string }, now: RealTime, ): Ship { const upgrade = content.upgrade(input.upgradeId); if (!upgrade) throw fail("not-found", `No upgrade "${input.upgradeId}".`); const ship = requireShip(save, input.shipId); const check = validateUpgrade( { ship: shipState(ship), upgrade, atHq: ship.locationId !== null && hasBayAt(save, ship.locationId), tutorialActive: save.player.tutorialActive, credits: save.company.credits, now, }, content, ); if (!check.ok) throw fail(check.reason, UPGRADE_MESSAGES[check.reason]); save.company.credits -= check.cost; ship.upgrades = { ...ship.upgrades, [upgrade.id]: check.level }; Object.assign(ship, { inShopUntil: check.shopUntil, shopUpgradeId: upgrade.id, shopLevel: check.level, }); return toApiShip(save, ship, now); } /** * Marks "while you were away" summaries as seen (Step 6.13): only resolved, * unseen dispatches. Clears finished upgrade jobs the player has now seen * (CR-009); ships still in the shop keep theirs. Returns how many changed. */ export function acknowledge( save: SaveDocument, input: { dispatchIds: string[]; shipIds?: string[] }, now: RealTime, ): number { const ids = new Set(input.dispatchIds); let count = 0; for (const d of save.dispatches) { if (ids.has(d.id) && d.status === "resolved" && d.acknowledgedAt === null) { d.acknowledgedAt = now; count++; } } const shipIds = new Set(input.shipIds ?? []); for (const ship of save.ships) { if ( shipIds.has(ship.id) && ship.shopUpgradeId !== null && ship.inShopUntil !== null && ship.inShopUntil <= now ) { Object.assign(ship, { shopUpgradeId: null, shopLevel: null }); count++; } } return count; } type SavedShip = SaveDocument["ships"][number]; /** An idle ship's location; refuelling happens only where the ship stands. */ function idleAt(ship: SavedShip) { const location = ship.status === "idle" && ship.locationId ? content.location(ship.locationId) : undefined; if (!location) throw fail("ship-not-idle-here", "The ship has to be idle at a location to take on fuel."); return location; } function tankCapacity(ship: SavedShip): number { return effectiveStats(content.shipModel(ship.modelId)!, ship.upgrades, content).tankCapacityFu; } /** * Buys fuel where the ship is (decision 0.5.97): fills the tank, or adds * `amountFu` (never past full), at the local price. Refused if the player * can't pay for all of it. */ export function refuelShip( save: SaveDocument, input: { shipId: string; amountFu?: number }, now: RealTime, ): Ship { const ship = requireShip(save, input.shipId); const location = idleAt(ship); if (!location.sellsFuel) throw fail("no-fuel-here", `${location.name} sells no fuel.`); const capacity = tankCapacity(ship); const space = capacity - ship.fuelFu; if (space <= 1e-9) throw fail("tank-full", "The tank is already full."); const bought = refuel( { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice }, capacity, input.amountFu ?? space, fuelPriceAt(location, content.config), ); spend(save, bought.cost, "fuel"); ship.fuelFu = bought.tank.fuelFu; ship.fuelAvgPrice = bought.tank.avgPrice; return toApiShip(save, ship, now); } /** Auto top-up on landing, or Manual (decision 0.5.97). */ export function setRefuelPolicy( save: SaveDocument, input: { shipId: string; policy: "auto" | "manual" }, now: RealTime, ): Ship { const ship = requireShip(save, input.shipId); ship.refuelPolicy = input.policy; return toApiShip(save, ship, now); } /** * Emergency fuel (decision 0.5.99): only when the sim offers it. Adds fuel for * the cheapest way out at 2.5 × the local price, and that cost to the loan. */ export function orderEmergencyFuel( save: SaveDocument, input: { shipId: string }, now: RealTime, ): Ship { const ship = requireShip(save, input.shipId); idleAt(ship); const offer = emergencyFuel( { ship: shipState(ship), credits: save.company.credits, discoveredIds: discoveredIds(save) }, content, ); if (!offer) { throw fail( "emergency-fuel-unavailable", "Emergency fuel is only for a ship that can't reach anywhere when you can't afford the fuel.", ); } const bought = refuel( { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice }, tankCapacity(ship), offer.amountFu, offer.price, ); ship.fuelFu = bought.tank.fuelFu; ship.fuelAvgPrice = bought.tank.avgPrice; save.company.loanBalance += offer.cost; return toApiShip(save, ship, now); }