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
+4 -6
View File
@@ -54,7 +54,9 @@ import { StoryToast } from "./storyToast.js";
import { TranscendencePanel } from "./transcendencePanel.js";
import { UpgradePanel } from "./upgradePanel.js";
import { VampireAchievementsPanel } from "./vampireAchievementsPanel.js";
import { VampireBossPanel } from "./vampireBossPanel.js";
import { VampireQuestsPanel } from "./vampireQuestsPanel.js";
import { VampireThrallsPanel } from "./vampireThrallsPanel.js";
import { VampireZonesPanel } from "./vampireZonesPanel.js";
type Mode = "mortal" | "goddess" | "vampire";
@@ -486,9 +488,7 @@ const GameLayout = (): JSX.Element => {
}
{activeMode === "vampire"
&& activeVampireTab === "vampire-bosses"
&& <div className="vampire-placeholder">
<p>{"🩸 Vampire Bosses coming soon..."}</p>
</div>
&& <VampireBossPanel />
}
{activeMode === "vampire"
&& activeVampireTab === "vampire-quests"
@@ -496,9 +496,7 @@ const GameLayout = (): JSX.Element => {
}
{activeMode === "vampire"
&& activeVampireTab === "thralls"
&& <div className="vampire-placeholder">
<p>{"🧟 Thralls coming soon..."}</p>
</div>
&& <VampireThrallsPanel />
}
{activeMode === "vampire"
&& activeVampireTab === "vampire-equipment"
@@ -0,0 +1,504 @@
/**
* @file Vampire Boss panel — challenge vampire realm bosses.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines -- Panel with sub-component, modal, and zone filter */
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- Boss card requires many conditional render paths */
/* eslint-disable max-statements -- Panel requires many variable declarations */
/* eslint-disable react/no-multi-comp -- Sub-components are tightly coupled to this panel */
import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js";
import type {
VampireBoss,
VampireBossChallengeResponse,
VampireZone,
} from "@elysium/types";
interface VampireBossCardProperties {
readonly boss: VampireBoss;
readonly onChallenge: (bossId: string)=> void;
readonly isChallenging: boolean;
readonly unlockHint: string | undefined;
readonly formatNumber: (n: number)=> string;
readonly formatInteger: (n: number)=> string;
}
/**
* Renders a single vampire boss card.
* @param props - The boss card properties.
* @param props.boss - The boss data.
* @param props.onChallenge - Callback to challenge this boss.
* @param props.isChallenging - Whether this boss is currently being challenged.
* @param props.unlockHint - Optional hint for how to unlock this boss.
* @param props.formatNumber - The number formatting utility function.
* @param props.formatInteger - The integer formatting utility function.
* @returns The JSX element.
*/
const VampireBossCard = ({
boss,
onChallenge,
isChallenging,
unlockHint,
formatNumber,
formatInteger,
}: VampireBossCardProperties): JSX.Element => {
const canChallenge
= (boss.status === "available" || boss.status === "in_progress")
&& !isChallenging;
const hpRatio = boss.currentHp / boss.maxHp;
const hpPercent = hpRatio * 100;
function handleChallenge(): void {
onChallenge(boss.id);
}
return (
<div className={`boss-card boss-${boss.status}`}>
<div className="boss-info">
<h3>{boss.name}</h3>
<p>{boss.description}</p>
{boss.status === "locked" && unlockHint !== undefined
? <p className="unlock-hint">{unlockHint}</p>
: null}
{boss.siringRequirement > 0
? <p className="consecration-requirement">
{"🩸 Requires Siring "}
{boss.siringRequirement}
</p>
: null}
</div>
{boss.status !== "locked" && boss.status !== "defeated"
? <div className="boss-hp">
<div className="hp-bar">
<div
className="hp-fill"
style={{ width: `${hpPercent.toFixed(1)}%` }}
/>
</div>
<span className="hp-text">
{formatNumber(boss.currentHp)}
{" / "}
{formatNumber(boss.maxHp)}
{" HP"}
</span>
</div>
: null}
<div className="boss-meta">
<span className="boss-dps">
{"💢 Boss DPS: "}
{formatNumber(boss.damagePerSecond)}
</span>
</div>
<div className="boss-rewards">
{boss.bloodReward > 0
&& <span>
{"🩸 "}
{formatNumber(boss.bloodReward)}
{" Blood"}
</span>
}
{boss.ichorReward > 0
&& <span>
{"💧 "}
{formatInteger(boss.ichorReward)}
{" Ichor"}
</span>
}
{boss.soulShardsReward > 0
&& <span>
{"💠 "}
{formatInteger(boss.soulShardsReward)}
{" Soul Shards"}
</span>
}
{boss.equipmentRewards.length > 0
&& <span>
{"🦇 "}
{boss.equipmentRewards.length}
{" Equipment"}
</span>
}
{boss.status !== "defeated"
&& boss.bountyIchor > 0
&& boss.bountyIchorClaimed !== true
? <span className="boss-bounty">
{"💧 "}
{boss.bountyIchor}
{" Ichor (first kill)"}
</span>
: null}
</div>
{boss.status === "available" || boss.status === "in_progress"
? <button
className="attack-button"
disabled={!canChallenge}
onClick={handleChallenge}
type="button"
>
{isChallenging
? "🩸 Hunting…"
: "🩸 Challenge"}
</button>
: null}
{boss.status === "defeated"
? <span className="boss-badge defeated">{"☠️ Defeated"}</span>
: null}
</div>
);
};
interface VampireBattleModalProperties {
readonly result: VampireBossChallengeResponse;
readonly onDismiss: ()=> void;
readonly formatNumber: (n: number)=> string;
readonly formatInteger: (n: number)=> string;
}
/**
* Renders the vampire battle result modal overlay.
* @param props - The modal properties.
* @param props.result - The battle result data.
* @param props.onDismiss - Callback to dismiss the modal.
* @param props.formatNumber - The number formatting utility function.
* @param props.formatInteger - The integer formatting utility function.
* @returns The JSX element.
*/
const VampireBattleModal = ({
result,
onDismiss,
formatNumber,
formatInteger,
}: VampireBattleModalProperties): JSX.Element => {
return (
<div aria-modal="true" className="battle-modal-overlay" role="dialog">
<div className="battle-modal">
<h2 className="battle-modal-title">
{result.won
? "🩸 Victory!"
: "💀 Defeated!"}
</h2>
<div className="battle-stats">
<div className="battle-stat">
<span className="stat-label">{"⚔️ Your Thrall DPS"}</span>
<span className="stat-value">{formatNumber(result.partyDPS)}</span>
</div>
<div className="battle-stat">
<span className="stat-label">{"💢 Boss DPS"}</span>
<span className="stat-value">{formatNumber(result.bossDPS)}</span>
</div>
<div className="battle-stat">
<span className="stat-label">{"❤️ Boss HP Before"}</span>
<span className="stat-value">
{formatNumber(result.bossHpBefore)}
{" / "}
{formatNumber(result.bossMaxHp)}
</span>
</div>
<div className="battle-stat">
<span className="stat-label">{"❤️ Boss HP After"}</span>
<span className="stat-value">{formatNumber(result.bossNewHp)}</span>
</div>
<div className="battle-stat">
<span className="stat-label">{"🛡️ Thrall HP Remaining"}</span>
<span className="stat-value">
{formatNumber(result.partyHpRemaining)}
{" / "}
{formatNumber(result.partyMaxHp)}
</span>
</div>
</div>
{result.won && result.rewards !== undefined
? <div className="battle-rewards">
<h3>{"Rewards"}</h3>
{result.rewards.blood > 0
? <p>
{"🩸 "}
{formatNumber(result.rewards.blood)}
{" Blood"}
</p>
: null}
{result.rewards.ichor > 0
? <p>
{"💧 "}
{formatInteger(result.rewards.ichor)}
{" Ichor"}
</p>
: null}
{result.rewards.soulShards > 0
? <p>
{"💠 "}
{formatInteger(result.rewards.soulShards)}
{" Soul Shards"}
</p>
: null}
{result.rewards.bountyIchor > 0
? <p className="bounty-reward">
{"💧 "}
{formatInteger(result.rewards.bountyIchor)}
{" Ichor (first kill bonus!)"}
</p>
: null}
{result.rewards.upgradeIds.length > 0
? <p>
{"🔓 "}
{result.rewards.upgradeIds.length}
{" Upgrade(s) unlocked"}
</p>
: null}
{result.rewards.equipmentIds.length > 0
? <p>
{"🦇 "}
{result.rewards.equipmentIds.length}
{" Equipment item(s) gained"}
</p>
: null}
</div>
: null}
{result.casualties !== undefined && result.casualties.length > 0
? <div className="battle-casualties">
<h3>{"Casualties"}</h3>
{result.casualties.map((casualty) => {
return (
<p key={casualty.thrallId}>
{casualty.killed}
{" "}
{casualty.thrallId}
{" lost"}
</p>
);
})}
</div>
: null}
<button
className="dismiss-button"
onClick={onDismiss}
type="button"
>
{"Dismiss"}
</button>
</div>
</div>
);
};
/**
* Renders the Vampire Boss panel with zone filtering and battle result modal.
* @returns The JSX element.
*/
const VampireBossPanel = (): JSX.Element => {
const {
state,
challengeVampireBoss,
vampireBattleResult,
dismissVampireBattle,
formatNumber,
formatInteger,
} = useGame();
const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
null,
);
const [ activeZoneId, setActiveZoneId ] = useState<string | null>(() => {
return sessionStorage.getItem("elysium_vampire_boss_zone");
});
if (state === null) {
return (
<section className="panel">
<p>{"Loading..."}</p>
</section>
);
}
const { vampire } = state;
if (vampire === undefined) {
return (
<section className="panel">
<p>{"The Vampire expansion is not yet unlocked."}</p>
</section>
);
}
const { bosses, quests, zones } = vampire;
async function handleChallenge(bossId: string): Promise<void> {
setChallengingBossId(bossId);
try {
await challengeVampireBoss(bossId);
} finally {
setChallengingBossId(null);
}
}
function handleChallengeClick(bossId: string): void {
void handleChallenge(bossId);
}
function handleZoneSelect(zoneId: string): void {
setActiveZoneId(zoneId);
sessionStorage.setItem("elysium_vampire_boss_zone", zoneId);
}
function handleShowAll(): void {
setActiveZoneId(null);
sessionStorage.removeItem("elysium_vampire_boss_zone");
}
const filteredBosses = activeZoneId === null
? bosses
: bosses.filter((boss) => {
return boss.zoneId === activeZoneId;
});
const bossUnlockHints = new Map<string, string>();
for (const zone of zones) {
const { id: zoneId, unlockBossId, unlockQuestId } = zone;
const zoneBosses = bosses.filter((boss) => {
return boss.zoneId === zoneId;
});
for (let index = 0; index < zoneBosses.length; index = index + 1) {
const boss = zoneBosses[index];
if (boss === undefined || boss.status !== "locked") {
continue;
}
if (index === 0) {
const parts: Array<string> = [];
if (unlockBossId !== null) {
const gateBoss = bosses.find((candidate) => {
return candidate.id === unlockBossId;
});
if (gateBoss !== undefined) {
parts.push(`🩸 Defeat: ${gateBoss.name}`);
}
}
if (unlockQuestId !== null) {
const gateQuest = quests.find((candidate) => {
return candidate.id === unlockQuestId;
});
if (gateQuest !== undefined) {
parts.push(`📜 Complete: ${gateQuest.name}`);
}
}
if (parts.length > 0) {
bossUnlockHints.set(boss.id, parts.join(" & "));
}
} else {
const previousBoss = zoneBosses[index - 1];
if (previousBoss !== undefined) {
bossUnlockHints.set(boss.id, `🩸 Defeat: ${previousBoss.name} first`);
}
}
}
}
const activeZoneData: VampireZone | undefined = activeZoneId === null
? undefined
: zones.find((zone) => {
return zone.id === activeZoneId;
});
return (
<section className="panel vampire-boss-panel">
<div className="panel-header">
<h2>{"🩸 Vampire Bosses"}</h2>
</div>
<div className="zone-selector">
<button
className={`zone-tab${activeZoneId === null
? " zone-tab-active"
: ""}`}
onClick={handleShowAll}
type="button"
>
{"All Zones"}
</button>
{zones.map((zone) => {
function handleSelect(): void {
handleZoneSelect(zone.id);
}
return (
<button
className={`zone-tab${zone.id === activeZoneId
? " zone-tab-active"
: ""}`}
key={zone.id}
onClick={handleSelect}
title={zone.description}
type="button"
>
<span aria-hidden="true">{zone.emoji}</span>
<span className="zone-name">{zone.name}</span>
</button>
);
})}
</div>
{activeZoneData?.status === "locked"
? <div className="exploration-zone-locked-hint">
<p>{"🔒 This zone is locked."}</p>
{activeZoneData.unlockBossId === null
? null
: <p>
{"🩸 Defeat: "}
{bosses.find((boss) => {
return boss.id === activeZoneData.unlockBossId;
})?.name ?? activeZoneData.unlockBossId}
</p>}
{activeZoneData.unlockQuestId === null
? null
: <p>
{"📜 Complete: "}
{quests.find((quest) => {
return quest.id === activeZoneData.unlockQuestId;
})?.name ?? activeZoneData.unlockQuestId}
</p>}
</div>
: null}
<div className="boss-list">
{filteredBosses.map((boss) => {
return (
<VampireBossCard
boss={boss}
formatInteger={formatInteger}
formatNumber={formatNumber}
isChallenging={challengingBossId === boss.id}
key={boss.id}
onChallenge={handleChallengeClick}
unlockHint={bossUnlockHints.get(boss.id)}
/>
);
})}
{filteredBosses.length === 0
? <p className="empty-zone">{"No bosses to show in this zone."}</p>
: null}
</div>
{vampireBattleResult === null
? null
: <VampireBattleModal
formatInteger={formatInteger}
formatNumber={formatNumber}
onDismiss={dismissVampireBattle}
result={vampireBattleResult}
/>}
</section>
);
};
export { VampireBossPanel };
@@ -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 };
+141
View File
@@ -22,6 +22,7 @@ import {
type GameState,
type GoddessBossChallengeResponse,
type GoddessExploreCollectResponse,
type VampireBossChallengeResponse,
type LoginBonusResult,
type NumberFormat,
type Quest,
@@ -49,6 +50,7 @@ import {
buyPrestigeUpgrade as buyPrestigeUpgradeApi,
challengeBoss as challengeBossApi,
challengeGoddessBoss as challengeGoddessBossApi,
challengeVampireBoss as challengeVampireBossApi,
collectExploration as collectExplorationApi,
collectGoddessExploration as collectGoddessExplorationApi,
consecrate as consecrateApi,
@@ -768,6 +770,26 @@ interface GameContextValue {
collectGoddessExploration: (
areaId: string,
)=> Promise<GoddessExploreCollectResponse>;
/**
* Challenge a vampire boss — runs full server-side vampire combat simulation.
*/
challengeVampireBoss: (bossId: string)=> Promise<void>;
/**
* Vampire battle result to display (null when no battle pending).
*/
vampireBattleResult: VampireBossChallengeResponse | null;
/**
* Dismiss the vampire battle result modal.
*/
dismissVampireBattle: ()=> void;
/**
* Buy one or more thralls (client-side blood deduction).
*/
buyVampireThrall: (thrallId: string, quantity: number)=> void;
}
export interface BattleResult {
@@ -820,6 +842,8 @@ export const GameProvider = ({
const [ bossError, setBossError ] = useState<string | null>(null);
const [ goddessBattleResult, setGoddessBattleResult ]
= useState<GoddessBossChallengeResponse | null>(null);
const [ vampireBattleResult, setVampireBattleResult ]
= useState<VampireBossChallengeResponse | null>(null);
const [ showConsecrationToast, setShowConsecrationToast ] = useState(false);
const [ showEnlightenmentToast, setShowEnlightenmentToast ] = useState(false);
const syncErrorTimerReference = useRef<ReturnType<typeof setTimeout> | null>(
@@ -2085,6 +2109,115 @@ export const GameProvider = ({
setGoddessBattleResult(null);
}, []);
const challengeVampireBoss = useCallback(async(bossId: string) => {
setVampireBattleResult(null);
try {
const result = await challengeVampireBossApi({ bossId });
if (result.signature !== undefined) {
signatureReference.current = result.signature;
localStorage.setItem("elysium_save_signature", result.signature);
}
setState((previous) => {
if (previous?.vampire === undefined) {
return previous;
}
const updatedBosses = previous.vampire.bosses.map((boss) => {
return boss.id === bossId
? {
...boss,
currentHp: result.bossNewHp,
status: result.won
? ("defeated" as const)
: ("available" as const),
}
: boss;
});
const updatedThralls = result.casualties === undefined
? previous.vampire.thralls
: previous.vampire.thralls.map((thrall) => {
const casualty = result.casualties?.find((c) => {
return c.thrallId === thrall.id;
});
return casualty === undefined
? thrall
: {
...thrall,
count: Math.max(0, thrall.count - casualty.killed),
};
});
return {
...previous,
resources: {
...previous.resources,
blood: (previous.resources.blood ?? 0)
+ (result.rewards?.blood ?? 0),
},
vampire: {
...previous.vampire,
awakening: {
...previous.vampire.awakening,
soulShards: previous.vampire.awakening.soulShards
+ (result.rewards?.soulShards ?? 0),
},
bosses: updatedBosses,
siring: {
...previous.vampire.siring,
ichor: previous.vampire.siring.ichor
+ (result.rewards?.ichor ?? 0),
},
thralls: updatedThralls,
},
};
});
setVampireBattleResult(result);
} catch (error_: unknown) {
logError("challenge_vampire_boss", error_);
}
}, []);
const dismissVampireBattle = useCallback(() => {
setVampireBattleResult(null);
}, []);
const buyVampireThrall = useCallback(
(thrallId: string, quantity: number) => {
setState((previous) => {
if (previous?.vampire === undefined) {
return previous;
}
const thrall = previous.vampire.thralls.find((t) => {
return t.id === thrallId;
});
if (thrall === undefined) {
return previous;
}
const geometric = thrall.baseCost * (1 - Math.pow(1.15, quantity));
const normalised = geometric / (1 - 1.15);
const totalCost = normalised * Math.pow(1.15, thrall.count);
const currentBlood = previous.resources.blood ?? 0;
if (currentBlood < totalCost) {
return previous;
}
return {
...previous,
resources: {
...previous.resources,
blood: currentBlood - totalCost,
},
vampire: {
...previous.vampire,
thralls: previous.vampire.thralls.map((t) => {
return t.id === thrallId
? { ...t, count: t.count + quantity }
: t;
}),
},
};
});
},
[],
);
const consecrate = useCallback(async() => {
try {
const result = await consecrateApi({});
@@ -3046,8 +3179,10 @@ export const GameProvider = ({
buyGoddessUpgrade,
buyPrestigeUpgrade,
buyUpgrade,
buyVampireThrall,
challengeBoss,
challengeGoddessBoss,
challengeVampireBoss,
collectExploration,
collectGoddessExploration,
completeChapter,
@@ -3071,6 +3206,7 @@ export const GameProvider = ({
dismissPrestigeToast,
dismissStoryChapter,
dismissTranscendenceToast,
dismissVampireBattle,
enableNotifications,
enableSounds,
enlighten,
@@ -3124,6 +3260,7 @@ export const GameProvider = ({
unlockedAchievements,
unlockedCodexEntryIds,
unlockedStoryChapterIds,
vampireBattleResult,
};
}, [
apotheosis,
@@ -3141,8 +3278,10 @@ export const GameProvider = ({
buyGoddessUpgrade,
buyPrestigeUpgrade,
buyUpgrade,
buyVampireThrall,
challengeBoss,
challengeGoddessBoss,
challengeVampireBoss,
collectExploration,
collectGoddessExploration,
completeChapter,
@@ -3166,6 +3305,7 @@ export const GameProvider = ({
dismissPrestigeToast,
dismissStoryChapter,
dismissTranscendenceToast,
dismissVampireBattle,
enableNotifications,
enableSounds,
enlighten,
@@ -3218,6 +3358,7 @@ export const GameProvider = ({
unlockedAchievements,
unlockedCodexEntryIds,
unlockedStoryChapterIds,
vampireBattleResult,
]);
return (