feat: vampire boss panel and thralls panel with combat simulation

Implements VampireBossPanel (zone filtering, HP bar, battle modal with
rewards/casualties) and VampireThrallsPanel (batch buy with geometric
cost scaling). Wires challengeVampireBoss, dismissVampireBattle, and
buyVampireThrall into GameContext with correct sort-key ordering.
This commit is contained in:
2026-04-16 12:17:45 -07:00
committed by Naomi Carrigan
parent 3e34701d32
commit bd88eecda5
4 changed files with 922 additions and 6 deletions
@@ -0,0 +1,273 @@
/**
* @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 } = 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 } = vampireState;
function handleBatchSelect(batch: BatchSize): void {
setSelectedBatch(batch);
localStorage.setItem("elysium_thrall_batch", String(batch));
}
return (
<section className="panel disciples-panel">
<h2>{"Thralls"}</h2>
<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 };