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
+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 (