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

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