Files
elysium/apps/web/src/components/game/vampireThrallsPanel.tsx
T
hikari 397169e3dc feat: expansion coming-soon preview with save-safe display data
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
2026-05-06 18:00:38 -07:00

293 lines
8.1 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, 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 };