import { upgradeMaxLevel, upgradePercentPerLevel, type GameConfig, type ShipModel, type Upgrade, } from "@deadhead/content"; import { MS_PER_MINUTE, type RealTime } from "./clock"; import type { ShipState } from "./dispatch"; /** Installed upgrade levels by upgrade id; a missing upgrade is level 0. */ export type UpgradeLevels = Readonly>; /** * A ship's stats after upgrades (decision 0.5.63): its model with the four * upgradable model stats adjusted, plus its own handling time per tonne. */ export interface EffectiveStats extends ShipModel { handlingGameHoursPerTonne: number; } /** * Applies installed upgrades to a model's base stats: each level adds the * upgrade's percent (after model overrides) of the base stat, linearly. * Levels above the model's max for an upgrade are clamped. */ export function effectiveStats( model: ShipModel, levels: UpgradeLevels, content: { upgrades: readonly Upgrade[]; config: GameConfig }, ): EffectiveStats { const stats: EffectiveStats = { ...model, // The model's own loading time if it has one (the Lighter, CR-012), else the game-wide base. handlingGameHoursPerTonne: model.handlingGameHoursPerTonne ?? content.config.handlingGameHoursPerTonne, }; for (const upgrade of content.upgrades) { const level = Math.min(levels[upgrade.id] ?? 0, upgradeMaxLevel(upgrade, model.id)); if (level <= 0) continue; const base = stats[upgrade.stat]; stats[upgrade.stat] = base * (1 + (upgradePercentPerLevel(upgrade, model.id) * level) / 100); } return stats; } /** Whether a ship is in the upgrade shop at real time `now` (until, not at, in-shop-until). */ export function isInShop(ship: Pick, now: RealTime): boolean { return ship.inShopUntil != null && now < ship.inShopUntil; } /** The next level an upgrade would reach on a ship, or null when it is maxed. */ export function nextUpgradeLevel( upgrade: Upgrade, modelId: string, levels: UpgradeLevels, ): number | null { const next = (levels[upgrade.id] ?? 0) + 1; return next > upgradeMaxLevel(upgrade, modelId) ? null : next; } /** Cost of an upgrade level: a percent of the ship model's price, in whole credits. */ export function upgradeCost(model: ShipModel, level: number, config: GameConfig): number { const entry = config.upgradeLevels[level - 1]; if (!entry) throw new Error(`No upgrade level ${level}`); return Math.round((model.price * entry.costPercentOfPrice) / 100); } /** Real milliseconds a ship spends in the shop for an upgrade level. */ export function upgradeShopMs(level: number, config: GameConfig): number { const entry = config.upgradeLevels[level - 1]; if (!entry) throw new Error(`No upgrade level ${level}`); return entry.shopRealMinutes * MS_PER_MINUTE; } /** Every reason `validateUpgrade` can fail, in check order (decisions 0.5.64, 0.5.65). */ export const UPGRADE_FAILURES = [ "tutorial-active", "not-upgradable", "ship-not-idle", "ship-in-shop", "not-at-hq", "upgrade-maxed", "insufficient-funds", ] as const; export type UpgradeFailure = (typeof UPGRADE_FAILURES)[number]; export interface UpgradeProposal { ship: ShipState; upgrade: Upgrade; /** Whether the player has a bay where the ship is. */ atHq: boolean; tutorialActive: boolean; credits: number; now: RealTime; } export type UpgradeCheck = | { ok: true; level: number; cost: number; shopUntil: RealTime } | { ok: false; reason: UpgradeFailure }; /** * Validates starting one level of an upgrade (decisions 0.5.64–0.5.66): tutorial * over, a purchasable model (not the loaner), idle and out of the shop, at an HQ, * not maxed, affordable. On success, gives the new level, its cost and when the * ship leaves the shop. */ export function validateUpgrade( proposal: UpgradeProposal, content: { shipModels: readonly ShipModel[]; config: GameConfig }, ): UpgradeCheck { const { ship, upgrade, atHq, tutorialActive, credits, now } = proposal; const fail = (reason: UpgradeFailure): UpgradeCheck => ({ ok: false, reason }); const model = content.shipModels.find((m) => m.id === ship.modelId); if (!model) throw new Error(`Unknown ship model ${ship.modelId}`); if (tutorialActive) return fail("tutorial-active"); if (!model.purchasable) return fail("not-upgradable"); if (ship.inFlight) return fail("ship-not-idle"); if (isInShop(ship, now)) return fail("ship-in-shop"); if (!atHq) return fail("not-at-hq"); const level = nextUpgradeLevel(upgrade, model.id, ship.upgrades ?? {}); if (level === null) return fail("upgrade-maxed"); const cost = upgradeCost(model, level, content.config); if (credits < cost) return fail("insufficient-funds"); return { ok: true, level, cost, shopUntil: now + upgradeShopMs(level, content.config) }; }