351 lines
12 KiB
TypeScript
351 lines
12 KiB
TypeScript
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);
|
||
}
|