Files
elysium/apps/web/src/components/game/adventurerPanel.tsx
T
hikari 8a332dc9ce
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m2s
CI / Lint, Build & Test (pull_request) Failing after 1m8s
fix: show effective post-multiplier stats on adventurer cards (#154)
Adds computeEffectiveAdventurerStats to tick.ts to calculate per-unit
gold/s, essence/s, and combat power with all active multipliers applied
(upgrades, prestige, equipment, echo, crafted, companions). Updates
AdventurerCard to display these effective values so players can see the
true contribution of each adventurer rather than raw base stats.
2026-03-25 17:13:00 -07:00

309 lines
8.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 Adventurer panel component for hiring and managing adventurers.
* @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 -- Complex component with many render paths */
import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js";
import { computeEffectiveAdventurerStats } from "../../engine/tick.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js";
import type { Adventurer } from "@elysium/types";
type BatchSize = 1 | 5 | 10 | 25 | 100 | "max";
const batchOptions: Array<BatchSize> = [ 1, 5, 10, 25, 100, "max" ];
/**
* Parses a localStorage string back into a valid BatchSize, defaulting to 1.
* @param stored - The raw string from localStorage (or null if absent).
* @returns A valid BatchSize value.
*/
const parseBatchSize = (stored: string | null): BatchSize => {
if (stored === "max") {
return "max";
}
const numeric = Number(stored);
if (numeric === 5) {
return 5;
}
if (numeric === 10) {
return 10;
}
if (numeric === 25) {
return 25;
}
if (numeric === 100) {
return 100;
}
return 1;
};
/**
* Computes the total cost to buy a batch of adventurers.
* @param adventurer - The adventurer to buy.
* @param quantity - The number to buy.
* @returns The total gold cost.
*/
const computeBatchCost = (adventurer: Adventurer, quantity: number): number => {
let total = 0;
for (let index = 0; index < quantity; index = index + 1) {
const cost = adventurer.baseCost * Math.pow(1.15, adventurer.count + index);
total = total + cost;
}
return total;
};
/**
* Computes the maximum number of adventurers affordable with given gold.
* @param adventurer - The adventurer type.
* @param gold - The available gold.
* @returns The maximum affordable quantity.
*/
const computeMaxAffordable = (adventurer: Adventurer, gold: number): number => {
let total = 0;
let quantity = 0;
for (let index = 0; index < 100_000; index = index + 1) {
const cost = adventurer.baseCost * Math.pow(1.15, adventurer.count + index);
if (total + cost > gold) {
break;
}
total = total + cost;
quantity = quantity + 1;
}
return quantity;
};
interface EffectiveAdventurerStats {
readonly combatPower: number;
readonly essencePerSecond: number;
readonly goldPerSecond: number;
}
interface AdventurerCardProperties {
readonly adventurer: Adventurer;
readonly currentGold: number;
readonly batchSize: BatchSize;
readonly unlockHint: string | undefined;
readonly formatNumber: (n: number)=> string;
readonly effectiveStats: EffectiveAdventurerStats;
}
/**
* Renders a single adventurer card with buy controls.
* @param props - The adventurer card properties.
* @param props.adventurer - The adventurer data.
* @param props.currentGold - The current gold available.
* @param props.batchSize - The selected batch size.
* @param props.unlockHint - Optional quest name that unlocks this adventurer.
* @param props.formatNumber - The number formatting utility function.
* @param props.effectiveStats - The post-multiplier per-unit stats.
* @returns The JSX element.
*/
const AdventurerCard = ({
adventurer,
currentGold,
batchSize,
unlockHint,
formatNumber,
effectiveStats,
}: AdventurerCardProperties): JSX.Element => {
const { buyAdventurer } = useGame();
const resolvedQuantity
= batchSize === "max"
? computeMaxAffordable(adventurer, currentGold)
: batchSize;
const cost = computeBatchCost(adventurer, resolvedQuantity);
const canAfford = resolvedQuantity > 0 && currentGold >= cost;
function handleBuy(): void {
buyAdventurer(adventurer.id, resolvedQuantity);
}
const maxSuffix
= batchSize === "max" && resolvedQuantity > 0
? ` (×${String(resolvedQuantity)})`
: "";
const buttonLabel = adventurer.unlocked
? `🪙 ${formatNumber(Math.ceil(cost))}${maxSuffix}`
: "🔒 Locked";
return (
<div className={`adventurer-card ${adventurer.unlocked
? ""
: "locked"}`}>
<img
alt={adventurer.name}
className="card-thumbnail"
src={cdnImage("adventurers", adventurer.id)}
/>
<div className="adventurer-info">
<h3>{adventurer.name}</h3>
<p>
{formatNumber(effectiveStats.goldPerSecond)}
{" gold/s each"}
</p>
{adventurer.essencePerSecond > 0
&& <p>
{formatNumber(effectiveStats.essencePerSecond)}
{" essence/s each"}
</p>
}
<p>
{formatNumber(effectiveStats.combatPower)}
{" combat power each"}
</p>
</div>
<div className="adventurer-count">
{"×"}
{adventurer.count}
</div>
<button
className="buy-button"
disabled={!canAfford || !adventurer.unlocked}
onClick={handleBuy}
type="button"
>
{buttonLabel}
</button>
{!adventurer.unlocked && unlockHint !== undefined
? <p className="unlock-hint">
{"📜 Complete: "}
{unlockHint}
</p>
: null}
</div>
);
};
/**
* Renders the adventurer panel with all available adventurers.
* @returns The JSX element.
*/
const AdventurerPanel = (): JSX.Element => {
const { state, formatNumber, toggleAutoAdventurer } = useGame();
const [ showLocked, setShowLocked ] = useState(true);
const [ batchSize, setBatchSize ] = useState<BatchSize>(() => {
return parseBatchSize(localStorage.getItem("elysium_batch_size"));
});
if (state === null) {
return (
<section className="panel">
<p>{"Loading..."}</p>
</section>
);
}
const locked = state.adventurers.filter((adventurer) => {
return !adventurer.unlocked;
});
const visible = showLocked
? state.adventurers
: state.adventurers.filter((adventurer) => {
return adventurer.unlocked;
});
const adventurerUnlockHints = new Map<string, string>();
for (const quest of state.quests) {
for (const reward of quest.rewards) {
if (reward.type === "adventurer" && reward.targetId !== undefined) {
adventurerUnlockHints.set(reward.targetId, quest.name);
}
}
}
const autoAdventurerUnlocked = state.prestige.purchasedUpgradeIds.includes(
"auto_adventurer",
);
const autoAdventurerOn = state.autoAdventurer === true;
function handleToggle(): void {
setShowLocked((current) => {
return !current;
});
}
return (
<section className="panel adventurer-panel">
<div className="panel-header">
<h2>{"Adventurers"}</h2>
<div className="panel-header-controls">
{autoAdventurerUnlocked
? <button
className={`auto-toggle-btn ${
autoAdventurerOn
? "auto-toggle-on"
: "auto-toggle-off"
}`}
onClick={toggleAutoAdventurer}
title={
"Automatically purchase the highest-tier"
+ " affordable adventurer"
}
type="button"
>
{"🤖 Auto: "}
{autoAdventurerOn
? "ON"
: "OFF"}
</button>
: null
}
<LockToggle
lockedCount={locked.length}
onToggle={handleToggle}
showLocked={showLocked}
/>
</div>
</div>
<div className="batch-selector">
{batchOptions.map((option) => {
function handleBatchSelect(): void {
setBatchSize(option);
localStorage.setItem("elysium_batch_size", String(option));
}
return (
<button
className={`batch-button ${batchSize === option
? "active"
: ""}`}
key={option}
onClick={handleBatchSelect}
type="button"
>
{option === "max"
? "xMax"
: `x${String(option)}`}
</button>
);
})}
</div>
<div className="adventurer-list">
{visible.map((adventurer) => {
return (
<AdventurerCard
adventurer={adventurer}
batchSize={batchSize}
currentGold={state.resources.gold}
effectiveStats={computeEffectiveAdventurerStats(
state,
adventurer.id,
)}
formatNumber={formatNumber}
key={adventurer.id}
unlockHint={adventurerUnlockHints.get(adventurer.id)}
/>
);
})}
</div>
</section>
);
};
export { AdventurerPanel };