128 lines
4.7 KiB
TypeScript
128 lines
4.7 KiB
TypeScript
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<Record<string, number>>;
|
||
|
||
/**
|
||
* 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<ShipState, "inShopUntil">, 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) };
|
||
}
|