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 { TranscendencePanel } from "./transcendencePanel.js";
import { UpgradePanel } from "./upgradePanel.js"; import { UpgradePanel } from "./upgradePanel.js";
import { VampireAchievementsPanel } from "./vampireAchievementsPanel.js"; import { VampireAchievementsPanel } from "./vampireAchievementsPanel.js";
import { VampireBossPanel } from "./vampireBossPanel.js";
import { VampireQuestsPanel } from "./vampireQuestsPanel.js"; import { VampireQuestsPanel } from "./vampireQuestsPanel.js";
import { VampireThrallsPanel } from "./vampireThrallsPanel.js";
import { VampireZonesPanel } from "./vampireZonesPanel.js"; import { VampireZonesPanel } from "./vampireZonesPanel.js";
type Mode = "mortal" | "goddess" | "vampire"; type Mode = "mortal" | "goddess" | "vampire";
@@ -486,9 +488,7 @@ const GameLayout = (): JSX.Element => {
} }
{activeMode === "vampire" {activeMode === "vampire"
&& activeVampireTab === "vampire-bosses" && activeVampireTab === "vampire-bosses"
&& <div className="vampire-placeholder"> && <VampireBossPanel />
<p>{"🩸 Vampire Bosses coming soon..."}</p>
</div>
} }
{activeMode === "vampire" {activeMode === "vampire"
&& activeVampireTab === "vampire-quests" && activeVampireTab === "vampire-quests"
@@ -496,9 +496,7 @@ const GameLayout = (): JSX.Element => {
} }
{activeMode === "vampire" {activeMode === "vampire"
&& activeVampireTab === "thralls" && activeVampireTab === "thralls"
&& <div className="vampire-placeholder"> && <VampireThrallsPanel />
<p>{"🧟 Thralls coming soon..."}</p>
</div>
} }
{activeMode === "vampire" {activeMode === "vampire"
&& activeVampireTab === "vampire-equipment" && 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 GameState,
type GoddessBossChallengeResponse, type GoddessBossChallengeResponse,
type GoddessExploreCollectResponse, type GoddessExploreCollectResponse,
type VampireBossChallengeResponse,
type LoginBonusResult, type LoginBonusResult,
type NumberFormat, type NumberFormat,
type Quest, type Quest,
@@ -49,6 +50,7 @@ import {
buyPrestigeUpgrade as buyPrestigeUpgradeApi, buyPrestigeUpgrade as buyPrestigeUpgradeApi,
challengeBoss as challengeBossApi, challengeBoss as challengeBossApi,
challengeGoddessBoss as challengeGoddessBossApi, challengeGoddessBoss as challengeGoddessBossApi,
challengeVampireBoss as challengeVampireBossApi,
collectExploration as collectExplorationApi, collectExploration as collectExplorationApi,
collectGoddessExploration as collectGoddessExplorationApi, collectGoddessExploration as collectGoddessExplorationApi,
consecrate as consecrateApi, consecrate as consecrateApi,
@@ -768,6 +770,26 @@ interface GameContextValue {
collectGoddessExploration: ( collectGoddessExploration: (
areaId: string, areaId: string,
)=> Promise<GoddessExploreCollectResponse>; )=> 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 { export interface BattleResult {
@@ -820,6 +842,8 @@ export const GameProvider = ({
const [ bossError, setBossError ] = useState<string | null>(null); const [ bossError, setBossError ] = useState<string | null>(null);
const [ goddessBattleResult, setGoddessBattleResult ] const [ goddessBattleResult, setGoddessBattleResult ]
= useState<GoddessBossChallengeResponse | null>(null); = useState<GoddessBossChallengeResponse | null>(null);
const [ vampireBattleResult, setVampireBattleResult ]
= useState<VampireBossChallengeResponse | null>(null);
const [ showConsecrationToast, setShowConsecrationToast ] = useState(false); const [ showConsecrationToast, setShowConsecrationToast ] = useState(false);
const [ showEnlightenmentToast, setShowEnlightenmentToast ] = useState(false); const [ showEnlightenmentToast, setShowEnlightenmentToast ] = useState(false);
const syncErrorTimerReference = useRef<ReturnType<typeof setTimeout> | null>( const syncErrorTimerReference = useRef<ReturnType<typeof setTimeout> | null>(
@@ -2085,6 +2109,115 @@ export const GameProvider = ({
setGoddessBattleResult(null); 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() => { const consecrate = useCallback(async() => {
try { try {
const result = await consecrateApi({}); const result = await consecrateApi({});
@@ -3046,8 +3179,10 @@ export const GameProvider = ({
buyGoddessUpgrade, buyGoddessUpgrade,
buyPrestigeUpgrade, buyPrestigeUpgrade,
buyUpgrade, buyUpgrade,
buyVampireThrall,
challengeBoss, challengeBoss,
challengeGoddessBoss, challengeGoddessBoss,
challengeVampireBoss,
collectExploration, collectExploration,
collectGoddessExploration, collectGoddessExploration,
completeChapter, completeChapter,
@@ -3071,6 +3206,7 @@ export const GameProvider = ({
dismissPrestigeToast, dismissPrestigeToast,
dismissStoryChapter, dismissStoryChapter,
dismissTranscendenceToast, dismissTranscendenceToast,
dismissVampireBattle,
enableNotifications, enableNotifications,
enableSounds, enableSounds,
enlighten, enlighten,
@@ -3124,6 +3260,7 @@ export const GameProvider = ({
unlockedAchievements, unlockedAchievements,
unlockedCodexEntryIds, unlockedCodexEntryIds,
unlockedStoryChapterIds, unlockedStoryChapterIds,
vampireBattleResult,
}; };
}, [ }, [
apotheosis, apotheosis,
@@ -3141,8 +3278,10 @@ export const GameProvider = ({
buyGoddessUpgrade, buyGoddessUpgrade,
buyPrestigeUpgrade, buyPrestigeUpgrade,
buyUpgrade, buyUpgrade,
buyVampireThrall,
challengeBoss, challengeBoss,
challengeGoddessBoss, challengeGoddessBoss,
challengeVampireBoss,
collectExploration, collectExploration,
collectGoddessExploration, collectGoddessExploration,
completeChapter, completeChapter,
@@ -3166,6 +3305,7 @@ export const GameProvider = ({
dismissPrestigeToast, dismissPrestigeToast,
dismissStoryChapter, dismissStoryChapter,
dismissTranscendenceToast, dismissTranscendenceToast,
dismissVampireBattle,
enableNotifications, enableNotifications,
enableSounds, enableSounds,
enlighten, enlighten,
@@ -3218,6 +3358,7 @@ export const GameProvider = ({
unlockedAchievements, unlockedAchievements,
unlockedCodexEntryIds, unlockedCodexEntryIds,
unlockedStoryChapterIds, unlockedStoryChapterIds,
vampireBattleResult,
]); ]);
return ( return (