generated from nhcarrigan/template
4c297f1ce1
- #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
342 lines
9.8 KiB
TypeScript
342 lines
9.8 KiB
TypeScript
/**
|
||
* @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 };
|