generated from nhcarrigan/template
8a332dc9ce
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.
309 lines
8.8 KiB
TypeScript
309 lines
8.8 KiB
TypeScript
/**
|
||
* @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 };
|