deadhead-space-freight-tycoon/packages/engine/src/game/fleet.ts

351 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}