generated from nhcarrigan/template
397169e3dc
Add an expansion preview system so the Goddess and Vampire panels render their full content (zones, bosses, thralls, achievements, etc.) even when the expansion has not been unlocked, with all interactive elements visually disabled. - API /load now returns expansionPreview alongside game state, populated from initialGoddessState() and initialVampireState() — never part of the saved blob - LoadResponse type updated with expansionPreview field - gameContext exposes goddessPreview and vampirePreview, stored in separate state vars that never touch stateReference so saves are never polluted - gameLayout applies expansion-preview CSS class when viewing a locked expansion with preview data available, and shows the coming-soon banner - All 22 expansion panels updated to use state.vampire ?? vampirePreview and state.goddess ?? goddessPreview for display - CSS disables all buttons/inputs/selects inside .expansion-preview - apotheosis service patched to never auto-initialise goddess state — expansion remains locked until explicitly released
293 lines
8.1 KiB
TypeScript
293 lines
8.1 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, vampirePreview,
|
||
} = 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 ?? vampirePreview;
|
||
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 };
|