deadhead-space-freight-tycoon/packages/sim/src/upgrades.ts

126 lines
4.6 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 {
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,
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) };
}