Files
elysium/apps/web/src/components/game/upgradePanel.tsx
T
hikari 911e089a9e
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m9s
CI / Lint, Build & Test (push) Successful in 1m10s
feat: document upgrade stacking behaviour as multiplicative (#75)
## Summary

- Adds a `💡` stacking note directly in the upgrade panel below the progress counter so players see it without visiting the About page
- Updates the About panel's Upgrades how-to-play entry to replace the vague "compound with each other" with explicit multiplicative stacking language, including an example (two ×2 upgrades = ×4) and a note that global upgrades multiply on top of adventurer-specific ones

## Test plan

- [ ] Verify the stacking note appears in the upgrade panel below the progress counter
- [ ] Verify the About panel Upgrades entry reflects the updated wording
- [ ] Confirm lint, build, and tests all pass

Closes #60

Reviewed-on: #75
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-19 13:24:45 -07:00

324 lines
9.2 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 */
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}`);
}
}
}
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 };