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