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