feat: vampire tick engine, auto systems, and full test suite

- vampire blood production tick with thrall bloodPerSecond + multipliers
- auto-quest and auto-thrall purchase in tick engine
- computeVampireBloodPerSecond helper exposed for ResourceBar display
- ResourceBar now shows blood/s and currency balances for vampire mode
- vampire quests and thralls panels gain auto-toggle buttons
- About page updated with vampire mode how-to-play entries
- vampireEquipmentSets data file added to web
- 100% test coverage across all API routes and services:
  - siring, awakening, vampireBoss, vampireCraft, vampireExplore, vampireUpgrade
  - debug route now covers grant-apotheosis endpoint
  - vampireMaterials excluded from coverage (ID-referenced only, same as goddessMaterials)
This commit is contained in:
2026-04-16 14:01:50 -07:00
committed by Naomi Carrigan
parent 1e0a7b142a
commit e02827dbb6
20 changed files with 3660 additions and 10 deletions
@@ -387,6 +387,99 @@ const howToPlay = [
+ " tick and are permanent once unlocked.",
title: "🏆 Goddess Achievements",
},
{
body:
"Your first Eternal Sovereignty unlocks the Vampire Realm — a third"
+ " game layer that runs alongside your mortal and goddess progress."
+ " Switch between modes using the mode bar at the top of the screen."
+ " The Vampire Realm uses three currencies: Blood (earned passively"
+ " from Thralls each tick), Ichor (earned from Thralls and quest"
+ " rewards, carried through Sirings), and Soul Shards (awarded by"
+ " Vampire Quests, Awakening resets, and Achievement unlocks).",
title: "🧛 Vampire Realm",
},
{
body:
"Thralls are the Vampire Realm's equivalent of adventurers. Buy them"
+ " with Blood to generate passive Blood and Ichor income every tick."
+ " Thralls come in six classes — Fledgling, Revenant, Shade,"
+ " Bloodbound, Wraith, and Ancient — each progressively more"
+ " powerful. Buy in batches of 1, 10, or Max. Thrall-specific"
+ " Upgrades multiply the income of individual classes; Blood and"
+ " Global Upgrades apply on top. Toggle Auto-Thrall from the Thralls"
+ " panel to automatically purchase the highest-tier affordable thrall"
+ " each tick.",
title: "🧟 Thralls",
},
{
body:
"The Vampire Realm has 18 zones, each containing 4 bosses and 5"
+ " quests. The starter zone is always available. Subsequent zones"
+ " unlock when you defeat the required Vampire Boss AND complete the"
+ " required Vampire Quest. Vampire Quests run on a timer and always"
+ " succeed — there is no failure chance. Rewards include Blood, Ichor,"
+ " Soul Shards, Upgrade unlocks, new Thrall tiers, and equipment."
+ " Toggle Auto-Quest from the Quests panel to automatically send your"
+ " thralls on the highest available quest.",
title: "🗺️ Vampire Zones & Quests",
},
{
body:
"Challenge Vampire Bosses to earn Blood, equipment drops, and unlock"
+ " new Vampire Zones. Your thralls' combined combat power determines"
+ " the outcome. Defeated bosses stay defeated. Equipment comes in"
+ " three types — Fangs, Shrouds, and Talismans — and provides"
+ " bonuses to Blood income, Combat Power, or Ichor multipliers."
+ " Equip matching set pieces to unlock escalating set bonuses.",
title: "⚔️ Vampire Boss Fights & Equipment",
},
{
body:
"Siring is the Vampire Realm's prestige layer. When you Sire, your"
+ " Blood resets but you receive Ichor and a permanent production"
+ " multiplier that stacks with every Siring. Spend Ichor in the"
+ " Siring Shop on upgrades that amplify Blood income, Combat Power,"
+ " and Thrall effectiveness.",
title: "🩸 Siring",
},
{
body:
"Awakening is the Vampire Realm's transcendence layer. When you"
+ " Awaken, your Blood and Ichor reset in exchange for Soul Shards"
+ " that persist forever. Spend Soul Shards on meta-upgrades that"
+ " amplify Blood income, Combat Power, Siring thresholds, and future"
+ " Soul Shard yields.",
title: "💠 Awakening",
},
{
body:
"Dark Materials are gathered from Vampire Explorations (three unique"
+ " materials per zone). Use them in the Dark Crafting panel to craft"
+ " recipes that grant permanent multipliers to Blood income, Ichor"
+ " income, and Thrall Combat Power. Each recipe can only be crafted"
+ " once; multipliers from all crafted recipes stack and persist"
+ " through Siring and Awakening resets.",
title: "⚗️ Vampire Crafting",
},
{
body:
"Send your thralls to explore dark areas within each Vampire Zone."
+ " Each area runs on a timer and rewards Blood, Ichor, and Dark"
+ " Materials when collected. Collecting from an area at least once"
+ " marks it as discovered. Vampire Explorations never fail. Only one"
+ " area can be explored at a time — collect first before sending"
+ " thralls out again.",
title: "🗺️ Dark Exploration",
},
{
body:
"Vampire Achievements track milestones in the Vampire Realm: total"
+ " Blood earned, Vampire Bosses defeated, Vampire Quests completed,"
+ " Thralls hired, Siring count, and Vampire Equipment owned."
+ " Unlocking an achievement instantly awards bonus Ichor and Soul"
+ " Shards. Achievements are permanent once unlocked.",
title: "🏆 Vampire Achievements",
},
{
body:
"The Story tab contains 22 chapters that unlock as you progress. The"
@@ -140,7 +140,7 @@ const VampireQuestCard = ({
* @returns The JSX element.
*/
const VampireQuestsPanel = (): JSX.Element => {
const { state } = useGame();
const { state, toggleVampireAutoQuest } = useGame();
const [ activeZoneId, setActiveZoneId ] = useState(() => {
return sessionStorage.getItem("elysium_vampire_quest_zone")
?? "vampire_haunted_catacombs";
@@ -163,7 +163,8 @@ const VampireQuestsPanel = (): JSX.Element => {
);
}
const { zones, quests } = vampireState;
const { zones, quests, autoQuest } = vampireState;
const autoQuestOn = autoQuest === true;
const activeZone = zones.find((zone: VampireZone) => {
return zone.id === activeZoneId;
@@ -202,7 +203,23 @@ const VampireQuestsPanel = (): JSX.Element => {
return (
<section className="panel vampire-quests-panel">
<h2>{"Vampire Quests"}</h2>
<div className="panel-header">
<h2>{"Vampire Quests"}</h2>
<button
className={`auto-toggle ${autoQuestOn
? "auto-on"
: "auto-off"}`}
onClick={toggleVampireAutoQuest}
title={autoQuestOn
? "Auto-Quest is ON — click to disable"
: "Auto-Quest is OFF — click to enable"}
type="button"
>
{autoQuestOn
? "🤖 Auto-Quest: ON"
: "🤖 Auto-Quest: OFF"}
</button>
</div>
<div className="zone-filter-buttons">
{zones.map((zone: VampireZone) => {
function handleClick(): void {
@@ -198,7 +198,7 @@ const ThrallCard = ({
* @returns The JSX element.
*/
const VampireThrallsPanel = (): JSX.Element => {
const { state, formatNumber } = useGame();
const { state, formatNumber, toggleVampireAutoThrall } = useGame();
const [ selectedBatch, setSelectedBatch ] = useState<BatchSize>(() => {
return parseBatchSize(localStorage.getItem("elysium_thrall_batch"));
});
@@ -221,7 +221,8 @@ const VampireThrallsPanel = (): JSX.Element => {
}
const blood = state.resources.blood ?? 0;
const { thralls } = vampireState;
const { thralls, autoThrall } = vampireState;
const autoThrallOn = autoThrall === true;
function handleBatchSelect(batch: BatchSize): void {
setSelectedBatch(batch);
@@ -230,7 +231,23 @@ const VampireThrallsPanel = (): JSX.Element => {
return (
<section className="panel disciples-panel">
<h2>{"Thralls"}</h2>
<div className="panel-header">
<h2>{"Thralls"}</h2>
<button
className={`auto-toggle ${autoThrallOn
? "auto-on"
: "auto-off"}`}
onClick={toggleVampireAutoThrall}
title={autoThrallOn
? "Auto-Thrall is ON — click to disable"
: "Auto-Thrall is OFF — click to enable"}
type="button"
>
{autoThrallOn
? "🤖 Auto-Thrall: ON"
: "🤖 Auto-Thrall: OFF"}
</button>
</div>
<div className="disciples-balance">
<span>
{"🩸 Blood: "}
@@ -16,6 +16,7 @@ import {
computeGoldPerSecond,
computePartyCombatPower,
computeProjectedRunestones,
computeVampireBloodPerSecond,
} from "../../engine/tick.js";
import type { Resource } from "@elysium/types";
@@ -95,11 +96,13 @@ const ResourceBar = ({
let goldPerSecond = 0;
let essencePerSecond = 0;
let projectedRunestones = 0;
let bloodPerSecond = 0;
if (state !== null) {
partyCombatPower = computePartyCombatPower(state);
goldPerSecond = computeGoldPerSecond(state);
essencePerSecond = computeEssencePerSecond(state);
projectedRunestones = computeProjectedRunestones(state);
bloodPerSecond = computeVampireBloodPerSecond(state);
}
let avatarUrl: string | null = null;
@@ -290,6 +293,17 @@ const ResourceBar = ({
<span className="resource-label">{"Stardust"}</span>
</div>
<hr className="resources-divider" />
<div className={`resource${hasApotheosis
? ""
: " resource-locked"}`}>
<span className="resource-icon">{"📈"}</span>
<span className="resource-value">
{hasApotheosis
? formatNumber(bloodPerSecond)
: "🔒"}
</span>
<span className="resource-label">{"Blood/s"}</span>
</div>
<div className={`resource${hasApotheosis
? ""
: " resource-locked"}`}>
+128
View File
@@ -855,6 +855,16 @@ interface GameContextValue {
collectVampireExploration: (
areaId: string,
)=> Promise<VampireExploreCollectResponse>;
/**
* Toggle the vampire auto-quest setting on/off.
*/
toggleVampireAutoQuest: ()=> void;
/**
* Toggle the vampire auto-thrall setting on/off.
*/
toggleVampireAutoThrall: ()=> void;
}
export interface BattleResult {
@@ -1480,6 +1490,90 @@ export const GameProvider = ({
}
}
// Vampire auto-quest: start the highest-zone available quest when none is active
if (next.vampire?.autoQuest === true) {
const hasActiveVampireQuest = next.vampire.quests.some((q) => {
return q.status === "active";
});
if (!hasActiveVampireQuest) {
let thrallCombatPower = 0;
for (const thrall of next.vampire.thralls) {
const singleContrib = thrall.combatPower * thrall.count;
thrallCombatPower = thrallCombatPower + singleContrib;
}
const vampireZoneOrder = new Map(
next.vampire.zones.map((z, index) => {
return [ z.id, index ];
}),
);
const vampireCandidates = next.vampire.quests.
filter((q) => {
return (
q.status === "available"
&& (q.combatPowerRequired ?? 0) <= thrallCombatPower
);
}).
sort((questA, questB) => {
return (
(vampireZoneOrder.get(questB.zoneId) ?? 0)
- (vampireZoneOrder.get(questA.zoneId) ?? 0)
);
});
const [ bestVampireQuest ] = vampireCandidates;
if (bestVampireQuest !== undefined) {
next = {
...next,
vampire: {
...next.vampire,
quests: next.vampire.quests.map((q) => {
return q.id === bestVampireQuest.id
? {
...q,
startedAt: Date.now(),
status: "active" as const,
}
: q;
}),
},
};
}
}
}
// Vampire auto-thrall: buy one of the highest-tier affordable unlocked thrall per tick
if (next.vampire?.autoThrall === true) {
const currentBlood = next.resources.blood ?? 0;
const [ bestVampireThrall ] = next.vampire.thralls.
filter((thrall) => {
const cost
= thrall.baseCost * Math.pow(1.15, thrall.count);
return thrall.unlocked && currentBlood >= cost;
}).
sort((thrallA, thrallB) => {
return thrallB.level - thrallA.level;
});
if (bestVampireThrall !== undefined) {
const thrallCost
= bestVampireThrall.baseCost
* Math.pow(1.15, bestVampireThrall.count);
next = {
...next,
resources: {
...next.resources,
blood: currentBlood - thrallCost,
},
vampire: {
...next.vampire,
thralls: next.vampire.thralls.map((thrall) => {
return thrall.id === bestVampireThrall.id
? { ...thrall, count: thrall.count + 1 }
: thrall;
}),
},
};
}
}
// Detect newly unlocked achievements
unlockedAchievementsReference.current = next.achievements.filter(
(a, index) => {
@@ -3269,6 +3363,36 @@ export const GameProvider = ({
});
}, []);
const toggleVampireAutoQuest = useCallback(() => {
setState((previous) => {
if (previous?.vampire === undefined) {
return previous;
}
return {
...previous,
vampire: {
...previous.vampire,
autoQuest: previous.vampire.autoQuest !== true,
},
};
});
}, []);
const toggleVampireAutoThrall = useCallback(() => {
setState((previous) => {
if (previous?.vampire === undefined) {
return previous;
}
return {
...previous,
vampire: {
...previous.vampire,
autoThrall: previous.vampire.autoThrall !== true,
},
};
});
}, []);
const setActiveCompanion = useCallback((companionId: string | null) => {
setState((previous) => {
if (previous === null) {
@@ -3709,6 +3833,8 @@ export const GameProvider = ({
toggleAutoPrestige,
toggleAutoPrestigeMaxRunestones,
toggleAutoQuest,
toggleVampireAutoQuest,
toggleVampireAutoThrall,
transcend,
triggerPrestigeToast,
unlockedAchievements,
@@ -3817,6 +3943,8 @@ export const GameProvider = ({
toggleAutoPrestige,
toggleAutoPrestigeMaxRunestones,
toggleAutoQuest,
toggleVampireAutoQuest,
toggleVampireAutoThrall,
transcend,
triggerPrestigeToast,
unlockedAchievements,
+104
View File
@@ -0,0 +1,104 @@
/**
* @file Vampire equipment set data for the Elysium game.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable @typescript-eslint/naming-convention -- Snake_case IDs, SCREAMING_SNAKE constants, and numeric bonus keys are conventional for game data */
/* eslint-disable stylistic/max-len -- Data content */
import type { VampireEquipmentSet } from "@elysium/types";
const VAMPIRE_EQUIPMENT_SETS: Array<VampireEquipmentSet> = [
{
bonuses: {
2: { bloodMultiplier: 1.15 },
3: { combatMultiplier: 1.1 },
},
description: "The starter relics of a newly awakened vampire — mismatched, imperfect, and entirely adequate for the catacombs. Every legend begins with gear this humble.",
id: "catacombs_hunter",
name: "Catacomb Hunter",
pieces: [ "shard_fang", "blood_fang", "tattered_shroud", "blood_shroud", "bone_talisman" ],
},
{
bonuses: {
2: { bloodMultiplier: 1.2 },
3: { combatMultiplier: 1.15 },
},
description: "Equipment forged in the fires of early conquest — in the mire's depths and the obsidian corridors. Functional, battle-tested, and smelling faintly of old blood.",
id: "blood_stalker",
name: "Blood Stalker",
pieces: [ "war_fang", "obsidian_fang", "obsidian_shroud", "crimson_shroud", "blood_talisman", "obsidian_talisman" ],
},
{
bonuses: {
2: { bloodMultiplier: 1.25 },
3: { ichorMultiplier: 1.2 },
},
description: "The arms of a vampire who has learned to move through courts as easily as through darkness. These pieces announce arrival before the wearer does.",
id: "crimson_regent",
name: "Crimson Regent",
pieces: [ "crimson_fang", "shadow_fang", "shadow_shroud", "plague_shroud", "crimson_talisman", "shadow_talisman" ],
},
{
bonuses: {
2: { combatMultiplier: 1.3 },
3: { bloodMultiplier: 1.2 },
},
description: "Equipment sourced from the most dangerous zones of the middle realm — places where even other vampires refuse to hunt. The gear carries the memory of every survival it enabled.",
id: "plague_bringer",
name: "Plague Bringer",
pieces: [ "plague_fang", "ashen_fang", "ashen_shroud", "iron_shroud", "plague_talisman", "ashen_talisman" ],
},
{
bonuses: {
2: { combatMultiplier: 1.35 },
3: { bloodMultiplier: 1.25 },
},
description: "The arms of a vampire who has broken open prisons and walked through veils. These pieces have seen the inside of places most vampires only hear about in old stories.",
id: "iron_jailer",
name: "Iron Jailer",
pieces: [ "iron_fang", "veil_fang", "veil_shroud", "moor_shroud", "iron_talisman", "veil_talisman" ],
},
{
bonuses: {
2: { bloodMultiplier: 1.3 },
3: { combatMultiplier: 1.3 },
},
description: "Equipment forged in the moonless reaches and recovered from sunken depths. The pieces were each retrieved at significant cost, which they repay with significant interest.",
id: "moonlit_predator",
name: "Moonlit Predator",
pieces: [ "moonless_fang", "sunken_fang", "sunken_shroud", "sanctum_shroud", "moor_talisman", "sunken_talisman" ],
},
{
bonuses: {
2: { combatMultiplier: 1.4 },
3: { ichorMultiplier: 1.3 },
},
description: "The regalia of desecration and apex predation — taken from places where even the concept of sanctuary has been dismantled. Each piece is a monument to the absence of mercy.",
id: "sanctum_desecrator",
name: "Sanctum Desecrator",
pieces: [ "sanctum_fang", "carrion_fang", "carrion_shroud", "spire_shroud", "sanctum_talisman", "carrion_talisman" ],
},
{
bonuses: {
2: { bloodMultiplier: 1.4 },
3: { combatMultiplier: 1.45 },
},
description: "The arms of a vampire who has conquered both time and blood — relics of the Bloodspire and the Shroud. These pieces are older than the zones they came from.",
id: "eternal_tyrant",
name: "Eternal Tyrant",
pieces: [ "spire_fang", "shroud_fang", "eternity_shroud", "abyss_shroud", "spire_talisman", "eternity_talisman" ],
},
{
bonuses: {
2: { ichorMultiplier: 1.5 },
3: { bloodMultiplier: 1.5 },
},
description: "The complete arms of a vampire who has stood at the edge of the void and returned. These pieces no longer belong to any zone. They belong to whatever you have become.",
id: "void_sovereign",
name: "Void Sovereign",
pieces: [ "abyss_fang", "eternal_fang", "whisper_shroud", "eternal_shroud", "abyss_talisman", "whisper_talisman" ],
},
];
export { VAMPIRE_EQUIPMENT_SETS };
+422
View File
@@ -18,11 +18,15 @@ import {
type GameState,
type GoddessAchievement,
type GoddessState,
type VampireAchievement,
type VampireState,
computeSetBonuses,
computeVampireSetBonuses,
getActiveCompanionBonus,
} from "@elysium/types";
import { EQUIPMENT_SETS } from "../data/equipmentSets.js";
import { EXPLORATION_AREAS } from "../data/explorations.js";
import { VAMPIRE_EQUIPMENT_SETS } from "../data/vampireEquipmentSets.js";
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
/**
@@ -141,6 +145,62 @@ const checkGoddessAchievements = (
});
};
/**
* Checks all vampire achievements against a snapshot of the vampire state
* and returns an updated achievements array, marking newly-met conditions
* with the current timestamp.
* @param vampire - The current (or projected) vampire state.
* @param now - Current Unix timestamp in milliseconds.
* @returns Updated vampire achievements array with newly unlocked ones timestamped.
*/
const checkVampireAchievements = (
vampire: VampireState,
now: number,
): Array<VampireAchievement> => {
return vampire.achievements.map((achievement) => {
if (achievement.unlockedAt !== null) {
return achievement;
}
const { condition } = achievement;
let met = false;
switch (condition.type) {
case "totalBloodEarned":
met = vampire.lifetimeBloodEarned >= condition.amount;
break;
case "vampireBossesDefeated":
met = vampire.lifetimeBossesDefeated >= condition.amount;
break;
case "vampireQuestsCompleted":
met = vampire.lifetimeQuestsCompleted >= condition.amount;
break;
case "thrallTotal":
met
= vampire.thralls.reduce((sum, thrall) => {
return sum + thrall.count;
}, 0) >= condition.amount;
break;
case "siringCount":
met = vampire.siring.count >= condition.amount;
break;
case "vampireEquipmentOwned":
met
= vampire.equipment.filter((item) => {
return item.owned;
}).length >= condition.amount;
break;
default:
// eslint-disable-next-line capitalized-comments -- v8 coverage ignore directive
/* v8 ignore next -- @preserve */ break;
}
return met
? { ...achievement, unlockedAt: now }
: achievement;
});
};
/**
* Exponential base for the prestige combat multiplier: Math.pow(base, prestigeCount).
* Replaces the former linear formula (1 + count * 0.1) to enable late-game zone progression.
@@ -508,6 +568,79 @@ export const computePartyCombatPower = (state: GameState): number => {
* companionCombatMult;
};
/**
* Computes the effective blood earned per second from all thralls,
* applying all active multipliers (upgrades, siring, awakening, equipment, sets, crafting).
* @param state - The current game state.
* @returns Blood per second as a number.
*/
export const computeVampireBloodPerSecond = (state: GameState): number => {
if (state.vampire === undefined) {
return 0;
}
const { vampire } = state;
const equippedItems = vampire.equipment.filter((item) => {
return item.equipped;
});
const equipmentBloodMultiplier = equippedItems.reduce((mult, item) => {
return mult * (item.bonus.bloodMultiplier ?? 1);
}, 1);
const setBloodMultiplier = computeVampireSetBonuses(
equippedItems.map((item) => {
return item.id;
}),
VAMPIRE_EQUIPMENT_SETS,
).bloodMultiplier;
const ichorBloodMult = vampire.siring.ichorBloodMultiplier ?? 1;
const { soulShardsBloodMultiplier } = vampire.awakening;
const { craftedBloodMultiplier } = vampire.exploration;
let globalBloodMult = 1;
let globalUpgradeMult = 1;
for (const upgrade of vampire.upgrades) {
if (upgrade.purchased) {
if (upgrade.target === "blood") {
globalBloodMult = globalBloodMult * upgrade.multiplier;
} else if (upgrade.target === "global") {
globalUpgradeMult = globalUpgradeMult * upgrade.multiplier;
}
}
}
let bloodPerSecond = 0;
for (const thrall of vampire.thralls) {
if (!thrall.unlocked || thrall.count === 0) {
continue;
}
let thrallUpgradeMult = 1;
for (const upgrade of vampire.upgrades) {
if (
upgrade.purchased
&& upgrade.target === "thrall"
&& upgrade.thrallId === thrall.id
) {
thrallUpgradeMult = thrallUpgradeMult * upgrade.multiplier;
}
}
const upgradeMultiplier = thrallUpgradeMult * globalUpgradeMult;
const contribution
= thrall.bloodPerSecond
* thrall.count
* upgradeMultiplier
* globalBloodMult
* vampire.siring.productionMultiplier
* ichorBloodMult
* soulShardsBloodMultiplier
* craftedBloodMultiplier
* equipmentBloodMultiplier
* setBloodMultiplier;
bloodPerSecond = bloodPerSecond + contribution;
}
return bloodPerSecond;
};
const basePrestigeThreshold = 1_000_000;
const runestonesPerPrestigeLevelClient = 20;
const maxBaseRunestones = 200;
@@ -835,6 +968,10 @@ export const applyTick = (
// eslint-disable-next-line no-undef-init -- required by @typescript-eslint/init-declarations
let updatedGoddess: GoddessState | undefined = undefined;
let bloodGainedVampire = 0;
// eslint-disable-next-line no-undef-init -- required by @typescript-eslint/init-declarations
let updatedVampire: VampireState | undefined = undefined;
if (
state.apotheosis !== undefined
&& state.apotheosis.count > 0
@@ -1091,6 +1228,285 @@ export const applyTick = (
stardustFromQuests = stardustFromQuests + stardustFromAchievements;
}
// --- Vampire tick ---
if (state.vampire !== undefined) {
const { vampire } = state;
// Compute vampire equipment multipliers once for the tick
const vampireEquippedItems = vampire.equipment.filter((item) => {
return item.equipped;
});
const vampireEquipmentBloodMult = vampireEquippedItems.reduce(
(mult, item) => {
return mult * (item.bonus.bloodMultiplier ?? 1);
},
1,
);
const vampireSetBloodMult = computeVampireSetBonuses(
vampireEquippedItems.map((item) => {
return item.id;
}),
VAMPIRE_EQUIPMENT_SETS,
).bloodMultiplier;
const ichorBloodMult = vampire.siring.ichorBloodMultiplier ?? 1;
const {
soulShards: currentSoulShards,
soulShardsBloodMultiplier,
} = vampire.awakening;
const { craftedBloodMultiplier } = vampire.exploration;
// Compute global vampire upgrade multipliers
let globalBloodMult = 1;
let globalUpgradeMult = 1;
for (const upgrade of vampire.upgrades) {
if (upgrade.purchased) {
if (upgrade.target === "blood") {
globalBloodMult = globalBloodMult * upgrade.multiplier;
} else if (upgrade.target === "global") {
globalUpgradeMult = globalUpgradeMult * upgrade.multiplier;
}
}
}
// Passive income from thralls
let bloodFromThralls = 0;
let ichorFromThralls = 0;
for (const thrall of vampire.thralls) {
if (!thrall.unlocked || thrall.count === 0) {
continue;
}
let thrallUpgradeMult = 1;
for (const upgrade of vampire.upgrades) {
if (
upgrade.purchased
&& upgrade.target === "thrall"
&& upgrade.thrallId === thrall.id
) {
thrallUpgradeMult = thrallUpgradeMult * upgrade.multiplier;
}
}
const upgradeMultiplier = thrallUpgradeMult * globalUpgradeMult;
const bloodContribution
= thrall.bloodPerSecond
* thrall.count
* upgradeMultiplier
* globalBloodMult
* vampire.siring.productionMultiplier
* ichorBloodMult
* soulShardsBloodMultiplier
* craftedBloodMultiplier
* vampireEquipmentBloodMult
* vampireSetBloodMult
* deltaSeconds;
bloodFromThralls = bloodFromThralls + bloodContribution;
const ichorContribution
= thrall.ichorPerSecond * thrall.count * deltaSeconds;
ichorFromThralls = ichorFromThralls + ichorContribution;
}
// Process vampire quest timers
let vampireQuestBloodGained = 0;
let vampireQuestIchorGained = 0;
let vampireQuestSoulShardsGained = 0;
let updatedVampireUpgrades = vampire.upgrades;
let updatedVampireThralls = vampire.thralls;
let updatedVampireEquipment = vampire.equipment;
let vampireQuestsThisTick = 0;
const updatedVampireQuests = vampire.quests.map((quest) => {
const questDurationMs = quest.durationSeconds * 1000;
const questExpiry
= quest.startedAt === undefined
? Infinity
: quest.startedAt + questDurationMs;
if (quest.status !== "active" || now < questExpiry) {
return quest;
}
vampireQuestsThisTick = vampireQuestsThisTick + 1;
for (const reward of quest.rewards) {
if (reward.type === "blood" && reward.amount !== undefined) {
vampireQuestBloodGained = vampireQuestBloodGained + reward.amount;
} else if (reward.type === "ichor" && reward.amount !== undefined) {
vampireQuestIchorGained = vampireQuestIchorGained + reward.amount;
} else if (
reward.type === "soulShards"
&& reward.amount !== undefined
) {
vampireQuestSoulShardsGained
= vampireQuestSoulShardsGained + reward.amount;
} else if (
reward.type === "upgrade"
&& reward.targetId !== undefined
) {
const { targetId } = reward;
updatedVampireUpgrades = updatedVampireUpgrades.map((upgrade) => {
return upgrade.id === targetId
? { ...upgrade, unlocked: true }
: upgrade;
});
} else if (
reward.type === "thrall"
&& reward.targetId !== undefined
) {
const { targetId } = reward;
updatedVampireThralls = updatedVampireThralls.map((thrall) => {
return thrall.id === targetId
? { ...thrall, unlocked: true }
: thrall;
});
} else if (
reward.type === "equipment"
&& reward.targetId !== undefined
) {
const rewardTargetId = reward.targetId;
const currentEquipment = updatedVampireEquipment;
updatedVampireEquipment = currentEquipment.map((item) => {
if (item.id !== rewardTargetId) {
return item;
}
const slotEmpty = !currentEquipment.some((other) => {
return other.type === item.type && other.equipped;
});
return {
...item,
equipped: slotEmpty || item.equipped,
owned: true,
};
});
}
}
return { ...quest, status: "completed" as const };
});
// Unlock vampire quests whose prerequisites are met and zone is unlocked
const completedVampireIds = new Set(
updatedVampireQuests.
filter((quest) => {
return quest.status === "completed";
}).
map((quest) => {
return quest.id;
}),
);
const defeatedVampireBossIds = new Set(
vampire.bosses.
filter((boss) => {
return boss.status === "defeated";
}).
map((boss) => {
return boss.id;
}),
);
// Unlock vampire zones whose boss + quest requirements are now met
const updatedVampireZones = vampire.zones.map((zone) => {
if (zone.status === "unlocked") {
return zone;
}
const bossOk
= zone.unlockBossId === null
|| defeatedVampireBossIds.has(zone.unlockBossId);
const questOk
= zone.unlockQuestId === null
|| completedVampireIds.has(zone.unlockQuestId);
if (bossOk && questOk) {
return { ...zone, status: "unlocked" as const };
}
return zone;
});
const allUnlockedVampireZoneIds = new Set(
updatedVampireZones.
filter((zone) => {
return zone.status === "unlocked";
}).
map((zone) => {
return zone.id;
}),
);
const fullyUpdatedVampireQuests = updatedVampireQuests.map((quest) => {
if (quest.status !== "locked") {
return quest;
}
if (!allUnlockedVampireZoneIds.has(quest.zoneId)) {
return quest;
}
if (
quest.prerequisiteIds.every((id) => {
return completedVampireIds.has(id);
})
) {
return { ...quest, status: "available" as const };
}
return quest;
});
// Compute updated lifetime counters
const totalBloodThisTick = bloodFromThralls + vampireQuestBloodGained;
const updatedTotalBloodEarned
= vampire.totalBloodEarned + totalBloodThisTick;
const updatedLifetimeBloodEarned
= vampire.lifetimeBloodEarned + totalBloodThisTick;
const updatedLifetimeQuestsCompleted
= vampire.lifetimeQuestsCompleted + vampireQuestsThisTick;
// Build snapshot for achievement check
const vampireSnapshot: VampireState = {
...vampire,
equipment: updatedVampireEquipment,
lifetimeBloodEarned: updatedLifetimeBloodEarned,
lifetimeQuestsCompleted: updatedLifetimeQuestsCompleted,
quests: fullyUpdatedVampireQuests,
thralls: updatedVampireThralls,
totalBloodEarned: updatedTotalBloodEarned,
upgrades: updatedVampireUpgrades,
zones: updatedVampireZones,
};
const updatedVampireAchievements
= checkVampireAchievements(vampireSnapshot, now);
let ichorFromAchievements = 0;
let soulShardsFromAchievements = 0;
for (const [ index, achievement ] of updatedVampireAchievements.entries()) {
if (
vampire.achievements[index]?.unlockedAt === null
&& achievement.unlockedAt !== null
) {
ichorFromAchievements
= ichorFromAchievements + (achievement.reward?.ichor ?? 0);
soulShardsFromAchievements
= soulShardsFromAchievements + (achievement.reward?.soulShards ?? 0);
}
}
bloodGainedVampire = totalBloodThisTick;
updatedVampire = {
...vampireSnapshot,
achievements: updatedVampireAchievements,
awakening: {
...vampire.awakening,
soulShards:
currentSoulShards
+ vampireQuestSoulShardsGained
+ soulShardsFromAchievements,
},
lastTickAt: now,
siring: {
...vampire.siring,
ichor:
vampire.siring.ichor
+ ichorFromThralls
+ vampireQuestIchorGained
+ ichorFromAchievements,
},
};
}
const goldValue = capResource(state.resources.gold + goldGained + questGold);
const essenceValue = capResource(
state.resources.essence + essenceGained + questEssence,
@@ -1102,6 +1518,9 @@ export const applyTick = (
...state,
resources: {
...state.resources,
blood: capResource(
(state.resources.blood ?? 0) + bloodGainedVampire,
),
crystals: capResource(
state.resources.crystals + questCrystals + challengeCrystals,
),
@@ -1140,6 +1559,9 @@ export const applyTick = (
...updatedGoddess === undefined
? {}
: { goddess: updatedGoddess },
...updatedVampire === undefined
? {}
: { vampire: updatedVampire },
adventurers: updatedAdventurers,
bosses: updatedBosses,
equipment: updatedEquipmentReference,