generated from nhcarrigan/template
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:
@@ -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 };
|
||||||
@@ -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 (
|
||||||
|
|||||||
Reference in New Issue
Block a user