Files
elysium/apps/web/src/components/game/upgradePanel.tsx
T
hikari 4c297f1ce1
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m3s
CI / Lint, Build & Test (pull_request) Failing after 1m8s
fix: resolve sync inflation, signature mismatch, CP accuracy, auto-buy cap, unlock hints
- #147: Guard all patch functions with hasChanged before incrementing
  sync counter to prevent inflation on no-op patches
- #148: Clear stale HMAC signature after each boss fight so subsequent
  auto-saves do not send a mismatched signature
- #146: Auto-unlock adventurer-specific upgrades in applyTick when
  their adventurer count > 0; show recruit hint in upgrade panel
- #149: Add Essence/s row to resource bar dropdown
- #150: Fix broken auto-quest CP reduce formula; centralise via
  computePartyCombatPower which applies all multipliers correctly
- #151: Cap auto-buy at 100 for non-max-tier adventurers; max tier
  (highest level unlocked) remains uncapped
- #152: Export computePartyCombatPower from tick, applying global
  upgrades, prestige, equipment, set bonuses, echo, crafted, and
  companion multipliers; use it in resource bar and boss panel
2026-03-25 16:47:53 -07:00

342 lines
9.8 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.
/**
* @file Upgrade panel component for purchasing game upgrades.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */
/* eslint-disable max-statements -- UpgradePanel builds hints from three sources */
/* eslint-disable max-lines -- Upgrade panel with sub-component exceeds line limit */
import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js";
import type { Adventurer, Upgrade } from "@elysium/types";
interface UpgradeCardProperties {
readonly upgrade: Upgrade;
readonly currentGold: number;
readonly currentEssence: number;
readonly currentCrystals: number;
readonly unlockHint: string | undefined;
readonly formatNumber: (n: number)=> string;
readonly adventurers: ReadonlyArray<Adventurer>;
}
/**
* Renders a single upgrade card.
* @param props - The upgrade card properties.
* @param props.upgrade - The upgrade data.
* @param props.currentGold - The current gold amount.
* @param props.currentEssence - The current essence amount.
* @param props.currentCrystals - The current crystals amount.
* @param props.unlockHint - Optional hint for how to unlock this upgrade.
* @param props.formatNumber - The number formatting utility function.
* @param props.adventurers - The list of adventurers, used to resolve the affected adventurer name.
* @returns The JSX element.
*/
const UpgradeCard = ({
upgrade,
currentGold,
currentEssence,
currentCrystals,
unlockHint,
formatNumber,
adventurers,
}: UpgradeCardProperties): JSX.Element => {
const { buyUpgrade } = useGame();
const adventurerName = upgrade.adventurerId === undefined
? undefined
: adventurers.find((adventurer) => {
return adventurer.id === upgrade.adventurerId;
})?.name;
const canAfford
= currentGold >= upgrade.costGold
&& currentEssence >= upgrade.costEssence
&& currentCrystals >= upgrade.costCrystals;
function handleBuy(): void {
buyUpgrade(upgrade.id);
}
if (upgrade.unlocked && upgrade.purchased) {
return (
<div className="upgrade-card purchased">
<img
alt={upgrade.name}
className="card-thumbnail"
src={cdnImage("upgrades", upgrade.id)}
/>
<span className="upgrade-name">
{"✅ "}
{upgrade.name}
</span>
<span className="upgrade-desc">{upgrade.description}</span>
{adventurerName === undefined
? null
: <span className="upgrade-target">
{"🗡️ Affects: "}
{adventurerName}
</span>
}
</div>
);
}
if (upgrade.unlocked) {
return (
<div className="upgrade-card">
<img
alt={upgrade.name}
className="card-thumbnail"
src={cdnImage("upgrades", upgrade.id)}
/>
<div className="upgrade-info">
<h3>{upgrade.name}</h3>
<p>{upgrade.description}</p>
{adventurerName === undefined
? null
: <p className="upgrade-target">
{"🗡️ Affects: "}
{adventurerName}
</p>
}
<p className="upgrade-multiplier">
{"×"}
{upgrade.multiplier}
{" multiplier"}
</p>
</div>
<div className="upgrade-cost">
{upgrade.costGold > 0
&& <span>
{"🪙 "}
{formatNumber(upgrade.costGold)}
</span>
}
{upgrade.costEssence > 0
&& <span>
{"✨ "}
{formatNumber(upgrade.costEssence)}
</span>
}
{upgrade.costCrystals > 0
&& <span>
{"💎 "}
{formatNumber(upgrade.costCrystals)}
</span>
}
</div>
<button
className="buy-button"
disabled={!canAfford}
onClick={handleBuy}
type="button"
>
{"Buy"}
</button>
</div>
);
}
return (
<div className="upgrade-card locked">
<img
alt={upgrade.name}
className="card-thumbnail"
src={cdnImage("upgrades", upgrade.id)}
/>
<div className="upgrade-info">
<h3>
{"🔒 "}
{upgrade.name}
</h3>
<p>{upgrade.description}</p>
{adventurerName === undefined
? null
: <p className="upgrade-target">
{"🗡️ Affects: "}
{adventurerName}
</p>
}
<p className="upgrade-multiplier">
{"×"}
{upgrade.multiplier}
{" multiplier"}
</p>
</div>
<div className="upgrade-cost">
{upgrade.costGold > 0
&& <span>
{"🪙 "}
{formatNumber(upgrade.costGold)}
</span>
}
{upgrade.costEssence > 0
&& <span>
{"✨ "}
{formatNumber(upgrade.costEssence)}
</span>
}
{upgrade.costCrystals > 0
&& <span>
{"💎 "}
{formatNumber(upgrade.costCrystals)}
</span>
}
</div>
<span className="upgrade-locked-label">{"Locked"}</span>
{unlockHint === undefined
? null
: <p className="unlock-hint">{unlockHint}</p>
}
</div>
);
};
/**
* Renders the upgrade panel with all available, locked, and purchased upgrades.
* @returns The JSX element.
*/
const UpgradePanel = (): JSX.Element => {
const { state, formatNumber } = useGame();
const [ showLocked, setShowLocked ] = useState(true);
if (state === null) {
return (
<section className="panel">
<p>{"Loading..."}</p>
</section>
);
}
const { adventurers, bosses, quests, upgrades, resources } = state;
const purchased = upgrades.filter((upgrade) => {
return upgrade.purchased;
});
const available = upgrades.filter((upgrade) => {
return upgrade.unlocked && !upgrade.purchased;
});
const locked = upgrades.filter((upgrade) => {
return !upgrade.unlocked;
});
const upgradeUnlockHints = new Map<string, string>();
for (const { upgradeRewards, name: bossName } of bosses) {
for (const upgradeId of upgradeRewards) {
upgradeUnlockHints.set(upgradeId, `⚔️ Defeat: ${bossName}`);
}
}
for (const { rewards, name: questName } of quests) {
for (const reward of rewards) {
if (
reward.type === "upgrade"
&& reward.targetId !== undefined
&& !upgradeUnlockHints.has(reward.targetId)
) {
upgradeUnlockHints.set(reward.targetId, `📜 Complete: ${questName}`);
}
}
}
for (const upgrade of locked) {
if (
!upgradeUnlockHints.has(upgrade.id)
&& upgrade.adventurerId !== undefined
) {
const adventurerForHint = adventurers.find((a) => {
return a.id === upgrade.adventurerId;
});
if (adventurerForHint !== undefined) {
upgradeUnlockHints.set(
upgrade.id,
`🗡️ Recruit: ${adventurerForHint.name}`,
);
}
}
}
function handleToggle(): void {
setShowLocked((current) => {
return !current;
});
}
return (
<section className="panel upgrade-panel">
<div className="panel-header">
<h2>{"Upgrades"}</h2>
<LockToggle
lockedCount={locked.length}
onToggle={handleToggle}
showLocked={showLocked}
/>
</div>
<p className="upgrade-progress">
{purchased.length}
{" / "}
{upgrades.length}
{" purchased"}
</p>
<p className="upgrade-stacking-note">
{"💡 Upgrade multipliers stack multiplicatively — two ×2 upgrades"
+ " combine to give ×4, not ×3."}
</p>
{upgrades.length === 0
? <p className="empty-state">
{"No upgrades available yet — keep adventuring!"}
</p>
: <div className="upgrade-list">
{available.map((upgrade) => {
return (
<UpgradeCard
adventurers={adventurers}
currentCrystals={resources.crystals}
currentEssence={resources.essence}
currentGold={resources.gold}
formatNumber={formatNumber}
key={upgrade.id}
unlockHint={undefined}
upgrade={upgrade}
/>
);
})}
{purchased.map((upgrade) => {
return (
<UpgradeCard
adventurers={adventurers}
currentCrystals={resources.crystals}
currentEssence={resources.essence}
currentGold={resources.gold}
formatNumber={formatNumber}
key={upgrade.id}
unlockHint={undefined}
upgrade={upgrade}
/>
);
})}
{showLocked
? locked.map((upgrade) => {
return (
<UpgradeCard
adventurers={adventurers}
currentCrystals={resources.crystals}
currentEssence={resources.essence}
currentGold={resources.gold}
formatNumber={formatNumber}
key={upgrade.id}
unlockHint={upgradeUnlockHints.get(upgrade.id)}
upgrade={upgrade}
/>
);
})
: null}
</div>
}
</section>
);
};
export { UpgradePanel };