generated from nhcarrigan/template
e02827dbb6
- vampire blood production tick with thrall bloodPerSecond + multipliers - auto-quest and auto-thrall purchase in tick engine - computeVampireBloodPerSecond helper exposed for ResourceBar display - ResourceBar now shows blood/s and currency balances for vampire mode - vampire quests and thralls panels gain auto-toggle buttons - About page updated with vampire mode how-to-play entries - vampireEquipmentSets data file added to web - 100% test coverage across all API routes and services: - siring, awakening, vampireBoss, vampireCraft, vampireExplore, vampireUpgrade - debug route now covers grant-apotheosis endpoint - vampireMaterials excluded from coverage (ID-referenced only, same as goddessMaterials)
291 lines
8.0 KiB
TypeScript
291 lines
8.0 KiB
TypeScript
/**
|
||
* @file Thralls panel component for purchasing vampire thralls.
|
||
* @copyright nhcarrigan
|
||
* @license Naomi's Public License
|
||
* @author Naomi Carrigan
|
||
*/
|
||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||
/* eslint-disable react/no-multi-comp -- ThrallCard sub-component is tightly coupled */
|
||
/* eslint-disable complexity -- ThrallCard has inherent branching for batch/afford logic */
|
||
import { type JSX, useState } from "react";
|
||
import { useGame } from "../../context/gameContext.js";
|
||
import type { VampireThrall } from "@elysium/types";
|
||
|
||
type BatchSize = 1 | 10 | "max";
|
||
const batchOptions: Array<BatchSize> = [ 1, 10, "max" ];
|
||
|
||
const growthRate = 1.15;
|
||
|
||
/**
|
||
* Computes the total blood cost to buy a batch of thralls.
|
||
* @param thrall - The thrall tier to purchase.
|
||
* @param quantity - The number to buy.
|
||
* @returns The total blood cost.
|
||
*/
|
||
const computeBatchCost = (
|
||
thrall: VampireThrall,
|
||
quantity: number,
|
||
): number => {
|
||
let total = 0;
|
||
for (let index = 0; index < quantity; index = index + 1) {
|
||
const exponent = thrall.count + index;
|
||
const cost = thrall.baseCost * Math.pow(growthRate, exponent);
|
||
total = total + cost;
|
||
}
|
||
return total;
|
||
};
|
||
|
||
/**
|
||
* Computes the maximum number of thralls affordable with the available blood.
|
||
* @param thrall - The thrall tier.
|
||
* @param blood - The available blood balance.
|
||
* @returns The maximum affordable quantity.
|
||
*/
|
||
const computeMaxAffordable = (
|
||
thrall: VampireThrall,
|
||
blood: number,
|
||
): number => {
|
||
let total = 0;
|
||
let quantity = 0;
|
||
for (let index = 0; index < 100_000; index = index + 1) {
|
||
const exponent = thrall.count + index;
|
||
const cost = thrall.baseCost * Math.pow(growthRate, exponent);
|
||
if (total + cost > blood) {
|
||
break;
|
||
}
|
||
total = total + cost;
|
||
quantity = quantity + 1;
|
||
}
|
||
return quantity;
|
||
};
|
||
|
||
/**
|
||
* 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";
|
||
}
|
||
if (stored === "10") {
|
||
return 10;
|
||
}
|
||
return 1;
|
||
};
|
||
|
||
interface ThrallCardProperties {
|
||
readonly thrall: VampireThrall;
|
||
readonly blood: number;
|
||
readonly selectedBatch: BatchSize;
|
||
}
|
||
|
||
/**
|
||
* Renders a single thrall purchase card.
|
||
* @param props - The component properties.
|
||
* @param props.thrall - The thrall tier to display.
|
||
* @param props.blood - The player's current blood balance.
|
||
* @param props.selectedBatch - The active batch size selection.
|
||
* @returns The JSX element.
|
||
*/
|
||
const ThrallCard = ({
|
||
thrall,
|
||
blood,
|
||
selectedBatch,
|
||
}: ThrallCardProperties): JSX.Element => {
|
||
const { buyVampireThrall, formatNumber } = useGame();
|
||
|
||
const maxAffordable = computeMaxAffordable(thrall, blood);
|
||
const effectiveBatch = selectedBatch === "max"
|
||
? maxAffordable
|
||
: selectedBatch;
|
||
const batchCost = computeBatchCost(thrall, effectiveBatch);
|
||
const canAffordBatch = blood >= batchCost && effectiveBatch > 0;
|
||
|
||
const singleCost = computeBatchCost(thrall, 1);
|
||
|
||
function handleBuy(): void {
|
||
if (effectiveBatch > 0) {
|
||
buyVampireThrall(thrall.id, effectiveBatch);
|
||
}
|
||
}
|
||
|
||
function getBuyButtonLabel(): string {
|
||
if (selectedBatch === "max") {
|
||
if (maxAffordable === 0) {
|
||
return "Can't Afford";
|
||
}
|
||
return `Buy Max (×${String(maxAffordable)})`;
|
||
}
|
||
return `Buy ×${String(effectiveBatch)}`;
|
||
}
|
||
|
||
return (
|
||
<div className={`disciple-card ${thrall.unlocked
|
||
? ""
|
||
: "disciple-locked"}`}>
|
||
<div className="disciple-header">
|
||
<div className="disciple-title">
|
||
<h3>{thrall.name}</h3>
|
||
<span className="disciple-class">{thrall.class}</span>
|
||
</div>
|
||
<span className="disciple-count">
|
||
{"×"}
|
||
{formatNumber(thrall.count)}
|
||
</span>
|
||
</div>
|
||
<div className="disciple-income">
|
||
{thrall.bloodPerSecond > 0
|
||
&& <span className="income-tag">
|
||
{"🩸 "}
|
||
{formatNumber(thrall.bloodPerSecond)}
|
||
{"/s blood"}
|
||
</span>
|
||
}
|
||
{thrall.ichorPerSecond > 0
|
||
&& <span className="income-tag">
|
||
{"💧 "}
|
||
{formatNumber(thrall.ichorPerSecond)}
|
||
{"/s ichor"}
|
||
</span>
|
||
}
|
||
<span className="combat-power-tag">
|
||
{"⚔️ "}
|
||
{formatNumber(thrall.combatPower)}
|
||
{" combat power each"}
|
||
</span>
|
||
</div>
|
||
<div className="disciple-cost">
|
||
<span className="cost-label">
|
||
{"Next: 🩸 "}
|
||
{formatNumber(singleCost)}
|
||
</span>
|
||
{selectedBatch !== 1
|
||
&& effectiveBatch > 0
|
||
&& <span className="cost-label">
|
||
{selectedBatch === "max"
|
||
? "Max"
|
||
: String(selectedBatch)}
|
||
{" (×"}
|
||
{String(effectiveBatch)}
|
||
{"): 🩸 "}
|
||
{formatNumber(batchCost)}
|
||
</span>
|
||
}
|
||
</div>
|
||
{thrall.unlocked
|
||
? <button
|
||
className="buy-disciple-button"
|
||
disabled={!canAffordBatch}
|
||
onClick={handleBuy}
|
||
title={
|
||
canAffordBatch
|
||
? undefined
|
||
: `Need 🩸 ${formatNumber(batchCost)} blood`
|
||
}
|
||
type="button"
|
||
>
|
||
{getBuyButtonLabel()}
|
||
</button>
|
||
: <span className="disciple-badge locked">{"🔒 Locked"}</span>
|
||
}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* Renders the thralls panel for purchasing vampire thralls.
|
||
* @returns The JSX element.
|
||
*/
|
||
const VampireThrallsPanel = (): JSX.Element => {
|
||
const { state, formatNumber, toggleVampireAutoThrall } = useGame();
|
||
const [ selectedBatch, setSelectedBatch ] = useState<BatchSize>(() => {
|
||
return parseBatchSize(localStorage.getItem("elysium_thrall_batch"));
|
||
});
|
||
|
||
if (state === null) {
|
||
return (
|
||
<section className="panel">
|
||
<p>{"Loading..."}</p>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
const vampireState = state.vampire;
|
||
if (vampireState === undefined) {
|
||
return (
|
||
<section className="panel">
|
||
<p>{"Vampire expansion not yet unlocked."}</p>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
const blood = state.resources.blood ?? 0;
|
||
const { thralls, autoThrall } = vampireState;
|
||
const autoThrallOn = autoThrall === true;
|
||
|
||
function handleBatchSelect(batch: BatchSize): void {
|
||
setSelectedBatch(batch);
|
||
localStorage.setItem("elysium_thrall_batch", String(batch));
|
||
}
|
||
|
||
return (
|
||
<section className="panel disciples-panel">
|
||
<div className="panel-header">
|
||
<h2>{"Thralls"}</h2>
|
||
<button
|
||
className={`auto-toggle ${autoThrallOn
|
||
? "auto-on"
|
||
: "auto-off"}`}
|
||
onClick={toggleVampireAutoThrall}
|
||
title={autoThrallOn
|
||
? "Auto-Thrall is ON — click to disable"
|
||
: "Auto-Thrall is OFF — click to enable"}
|
||
type="button"
|
||
>
|
||
{autoThrallOn
|
||
? "🤖 Auto-Thrall: ON"
|
||
: "🤖 Auto-Thrall: OFF"}
|
||
</button>
|
||
</div>
|
||
<div className="disciples-balance">
|
||
<span>
|
||
{"🩸 Blood: "}
|
||
<strong>{formatNumber(blood)}</strong>
|
||
</span>
|
||
</div>
|
||
<div className="batch-selector">
|
||
{batchOptions.map((batch) => {
|
||
function handleClick(): void {
|
||
handleBatchSelect(batch);
|
||
}
|
||
return <button
|
||
className={`batch-button ${selectedBatch === batch
|
||
? "active"
|
||
: ""}`}
|
||
key={String(batch)}
|
||
onClick={handleClick}
|
||
type="button"
|
||
>
|
||
{batch === "max"
|
||
? "Max"
|
||
: `×${String(batch)}`}
|
||
</button>;
|
||
})}
|
||
</div>
|
||
<div className="disciples-list">
|
||
{thralls.map((thrall: VampireThrall) => {
|
||
return <ThrallCard
|
||
blood={blood}
|
||
key={thrall.id}
|
||
selectedBatch={selectedBatch}
|
||
thrall={thrall}
|
||
/>;
|
||
})}
|
||
</div>
|
||
</section>
|
||
);
|
||
};
|
||
|
||
export { VampireThrallsPanel };
|