Files
elysium/apps/web/src/components/game/vampireThrallsPanel.tsx
T
hikari e02827dbb6 feat: vampire tick engine, auto systems, and full test suite
- 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)
2026-04-16 14:01:50 -07:00

291 lines
8.0 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 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 };