generated from nhcarrigan/template
e02827dbb6
- 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)
1666 lines
54 KiB
TypeScript
1666 lines
54 KiB
TypeScript
/**
|
|
* @file Game tick engine for Elysium.
|
|
* @copyright nhcarrigan
|
|
* @license Naomi's Public License
|
|
* @author Naomi Carrigan
|
|
*/
|
|
/* eslint-disable @typescript-eslint/naming-convention -- Snake_case IDs and SCREAMING_SNAKE are conventional for game data */
|
|
/* eslint-disable complexity -- Tick engine is inherently complex with many game systems */
|
|
/* eslint-disable max-lines-per-function -- Tick engine processes many systems in one pass for performance */
|
|
/* eslint-disable max-statements -- Tick engine requires many state variables across all game systems */
|
|
/* eslint-disable max-lines -- Engine file necessarily exceeds line limit */
|
|
/* eslint-disable import/group-exports -- Exports appear alongside their definitions for readability */
|
|
/* eslint-disable import/exports-last -- Exports appear alongside their definitions for readability */
|
|
/* eslint-disable max-nested-callbacks -- Tick engine requires nested array operations for game logic */
|
|
import {
|
|
type Achievement,
|
|
type Equipment,
|
|
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";
|
|
|
|
/**
|
|
* Checks all achievements against the current game state and returns an updated
|
|
* achievements array, marking newly-met conditions with the current timestamp.
|
|
* @param state - The current game state to check achievements against.
|
|
* @returns Updated achievements array with newly unlocked achievements timestamped.
|
|
*/
|
|
const checkAchievements = (state: GameState): Array<Achievement> => {
|
|
const now = Date.now();
|
|
return state.achievements.map((achievement) => {
|
|
if (achievement.unlockedAt !== null) {
|
|
return achievement;
|
|
}
|
|
|
|
const { condition } = achievement;
|
|
let met = false;
|
|
|
|
switch (condition.type) {
|
|
case "totalGoldEarned":
|
|
met = state.player.lifetimeGoldEarned >= condition.amount;
|
|
break;
|
|
case "totalClicks":
|
|
met = state.player.totalClicks >= condition.amount;
|
|
break;
|
|
case "bossesDefeated":
|
|
met
|
|
= state.bosses.filter((boss) => {
|
|
return boss.status === "defeated";
|
|
}).length >= condition.amount;
|
|
break;
|
|
case "questsCompleted":
|
|
met
|
|
= state.quests.filter((quest) => {
|
|
return quest.status === "completed";
|
|
}).length >= condition.amount;
|
|
break;
|
|
case "adventurerTotal":
|
|
met
|
|
= state.adventurers.reduce((sum, adventurer) => {
|
|
return sum + adventurer.count;
|
|
}, 0) >= condition.amount;
|
|
break;
|
|
case "prestigeCount":
|
|
met = state.prestige.count >= condition.amount;
|
|
break;
|
|
case "equipmentOwned":
|
|
met
|
|
= state.equipment.filter((item) => {
|
|
return item.owned;
|
|
}).length >= condition.amount;
|
|
break;
|
|
default:
|
|
/* V8 ignore next -- @preserve */ break;
|
|
}
|
|
|
|
return met
|
|
? { ...achievement, unlockedAt: now }
|
|
: achievement;
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Checks all goddess achievements against a snapshot of the goddess state
|
|
* and returns an updated achievements array, marking newly-met conditions
|
|
* with the current timestamp.
|
|
* @param goddess - The current (or projected) goddess state.
|
|
* @param now - Current Unix timestamp in milliseconds.
|
|
* @returns Updated goddess achievements array with newly unlocked ones timestamped.
|
|
*/
|
|
const checkGoddessAchievements = (
|
|
goddess: GoddessState,
|
|
now: number,
|
|
): Array<GoddessAchievement> => {
|
|
return goddess.achievements.map((achievement) => {
|
|
if (achievement.unlockedAt !== null) {
|
|
return achievement;
|
|
}
|
|
|
|
const { condition } = achievement;
|
|
let met = false;
|
|
|
|
switch (condition.type) {
|
|
case "totalPrayersEarned":
|
|
met = goddess.lifetimePrayersEarned >= condition.amount;
|
|
break;
|
|
case "goddessBossesDefeated":
|
|
met = goddess.lifetimeBossesDefeated >= condition.amount;
|
|
break;
|
|
case "goddessQuestsCompleted":
|
|
met = goddess.lifetimeQuestsCompleted >= condition.amount;
|
|
break;
|
|
case "discipleTotal":
|
|
met
|
|
= goddess.disciples.reduce((sum, disciple) => {
|
|
return sum + disciple.count;
|
|
}, 0) >= condition.amount;
|
|
break;
|
|
case "consecrationCount":
|
|
met = goddess.consecration.count >= condition.amount;
|
|
break;
|
|
case "goddessEquipmentOwned":
|
|
met
|
|
= goddess.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;
|
|
});
|
|
};
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export const PRESTIGE_COMBAT_BASE = 4;
|
|
|
|
/**
|
|
* Maximum value any resource can accumulate to. Beyond this JS floats lose all useful precision.
|
|
*/
|
|
export const RESOURCE_CAP = 1e300;
|
|
|
|
/**
|
|
* Probability of quest failure per zone — scales from 4% (early game) to 15% (end game).
|
|
* On failure the quest resets to "available" with no rewards; the player must wait the
|
|
* full duration again on their next attempt.
|
|
*/
|
|
export const zoneFailureChance: Record<string, number> = {
|
|
abyssal_trench: 0.09,
|
|
astral_void: 0.08,
|
|
celestial_reaches: 0.08,
|
|
cosmic_maelstrom: 0.15,
|
|
crystalline_spire: 0.11,
|
|
eternal_throne: 0.12,
|
|
frozen_peaks: 0.05,
|
|
infernal_court: 0.1,
|
|
infinite_expanse: 0.14,
|
|
primeval_sanctum: 0.15,
|
|
primordial_chaos: 0.13,
|
|
reality_forge: 0.14,
|
|
shadow_marshes: 0.06,
|
|
shattered_ruins: 0.05,
|
|
the_absolute: 0.15,
|
|
verdant_vale: 0.04,
|
|
void_sanctum: 0.11,
|
|
volcanic_depths: 0.07,
|
|
};
|
|
|
|
/**
|
|
* Caps a resource value at RESOURCE_CAP.
|
|
* @param value - The resource value to cap.
|
|
* @returns The capped value.
|
|
*/
|
|
const capResource = (value: number): number => {
|
|
return Math.min(value, RESOURCE_CAP);
|
|
};
|
|
|
|
/**
|
|
* Pure function — applies one game tick to the state.
|
|
* DeltaSeconds: time elapsed since last tick.
|
|
* Returns a new GameState (does not mutate the original).
|
|
* @param state - The current game state.
|
|
* @param deltaSeconds - Time elapsed since last tick in seconds.
|
|
* @returns A new GameState with the tick applied.
|
|
*/
|
|
/**
|
|
* Computes the effective gold earned per second across all adventurers,
|
|
* including all active multipliers (upgrades, prestige, equipment, etc.).
|
|
* @param state - The current game state.
|
|
* @returns Gold per second as a number.
|
|
*/
|
|
export const computeGoldPerSecond = (state: GameState): number => {
|
|
const equippedItems: Array<Equipment> = state.equipment.filter((item) => {
|
|
return item.equipped;
|
|
});
|
|
const equipmentGoldMultiplier = equippedItems.reduce((mult, item) => {
|
|
return mult * (item.bonus.goldMultiplier ?? 1);
|
|
}, 1);
|
|
const setGoldMultiplier = computeSetBonuses(
|
|
equippedItems.map((item) => {
|
|
return item.id;
|
|
}),
|
|
EQUIPMENT_SETS,
|
|
).goldMultiplier;
|
|
|
|
const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1;
|
|
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
|
|
const craftedGoldMultiplier = state.exploration?.craftedGoldMultiplier ?? 1;
|
|
const companionBonus = getActiveCompanionBonus(
|
|
state.companions?.activeCompanionId,
|
|
state.companions?.unlockedCompanionIds ?? [],
|
|
);
|
|
const companionGoldMult
|
|
= companionBonus?.type === "passiveGold"
|
|
? 1 + companionBonus.value
|
|
: 1;
|
|
|
|
let goldPerSecond = 0;
|
|
for (const adventurer of state.adventurers) {
|
|
if (!adventurer.unlocked || adventurer.count === 0) {
|
|
continue;
|
|
}
|
|
const upgradeMultiplier = state.upgrades.
|
|
filter((upgrade) => {
|
|
const isGlobal = upgrade.target === "global";
|
|
const isThisAdventurer
|
|
= upgrade.target === "adventurer"
|
|
&& upgrade.adventurerId === adventurer.id;
|
|
return upgrade.purchased && (isGlobal || isThisAdventurer);
|
|
}).
|
|
reduce((mult, upgrade) => {
|
|
return mult * upgrade.multiplier;
|
|
}, 1);
|
|
const contribution
|
|
= adventurer.goldPerSecond
|
|
* adventurer.count
|
|
* upgradeMultiplier
|
|
* state.prestige.productionMultiplier
|
|
* runestonesIncome
|
|
* echoIncome
|
|
* equipmentGoldMultiplier
|
|
* setGoldMultiplier
|
|
* craftedGoldMultiplier
|
|
* companionGoldMult;
|
|
goldPerSecond = goldPerSecond + contribution;
|
|
}
|
|
return goldPerSecond;
|
|
};
|
|
|
|
/**
|
|
* Computes the current essence per second for the given game state,
|
|
* applying all relevant multipliers (upgrades, prestige, echo, crafted, companion).
|
|
* @param state - The current game state.
|
|
* @returns The total essence per second.
|
|
*/
|
|
export const computeEssencePerSecond = (state: GameState): number => {
|
|
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
|
|
const craftedEssenceMultiplier
|
|
= state.exploration?.craftedEssenceMultiplier ?? 1;
|
|
const companionBonus = getActiveCompanionBonus(
|
|
state.companions?.activeCompanionId,
|
|
state.companions?.unlockedCompanionIds ?? [],
|
|
);
|
|
const companionEssenceMult
|
|
= companionBonus?.type === "essenceIncome"
|
|
? 1 + companionBonus.value
|
|
: 1;
|
|
|
|
let essencePerSecond = 0;
|
|
for (const adventurer of state.adventurers) {
|
|
if (!adventurer.unlocked || adventurer.count === 0) {
|
|
continue;
|
|
}
|
|
const upgradeMultiplier = state.upgrades.
|
|
filter((upgrade) => {
|
|
const isGlobal = upgrade.target === "global";
|
|
const isThisAdventurer
|
|
= upgrade.target === "adventurer"
|
|
&& upgrade.adventurerId === adventurer.id;
|
|
return upgrade.purchased && (isGlobal || isThisAdventurer);
|
|
}).
|
|
reduce((mult, upgrade) => {
|
|
return mult * upgrade.multiplier;
|
|
}, 1);
|
|
const contribution
|
|
= adventurer.essencePerSecond
|
|
* adventurer.count
|
|
* upgradeMultiplier
|
|
* state.prestige.productionMultiplier
|
|
* runestonesEssence
|
|
* craftedEssenceMultiplier
|
|
* companionEssenceMult;
|
|
essencePerSecond = essencePerSecond + contribution;
|
|
}
|
|
return essencePerSecond;
|
|
};
|
|
|
|
/**
|
|
* Computes the effective per-unit stats for a single adventurer type,
|
|
* applying all active multipliers (upgrades, prestige, equipment, echo,
|
|
* crafted, companion). The returned values represent what a single
|
|
* adventurer of this type currently contributes per second, matching the
|
|
* per-unit contribution used by computeGoldPerSecond and
|
|
* computeEssencePerSecond.
|
|
* @param state - The current game state.
|
|
* @param adventurerId - The ID of the adventurer to compute stats for.
|
|
* @returns Effective per-unit goldPerSecond, essencePerSecond, and combatPower.
|
|
*/
|
|
export const computeEffectiveAdventurerStats = (
|
|
state: GameState,
|
|
adventurerId: string,
|
|
): { combatPower: number; essencePerSecond: number; goldPerSecond: number } => {
|
|
const adventurer = state.adventurers.find((a) => {
|
|
return a.id === adventurerId;
|
|
});
|
|
|
|
/* V8 ignore next 3 -- @preserve */
|
|
if (adventurer === undefined) {
|
|
return { combatPower: 0, essencePerSecond: 0, goldPerSecond: 0 };
|
|
}
|
|
|
|
const upgradeMultiplier = state.upgrades.
|
|
filter((upgrade) => {
|
|
const isGlobal = upgrade.target === "global";
|
|
const isThisAdventurer
|
|
= upgrade.target === "adventurer"
|
|
&& upgrade.adventurerId === adventurerId;
|
|
return upgrade.purchased && (isGlobal || isThisAdventurer);
|
|
}).
|
|
reduce((mult, upgrade) => {
|
|
return mult * upgrade.multiplier;
|
|
}, 1);
|
|
|
|
const equippedItems = state.equipment.filter((item) => {
|
|
return item.equipped;
|
|
});
|
|
const equipmentGoldMultiplier = equippedItems.reduce((mult, item) => {
|
|
return mult * (item.bonus.goldMultiplier ?? 1);
|
|
}, 1);
|
|
const equipmentCombatMultiplier = equippedItems.reduce((mult, item) => {
|
|
return mult * (item.bonus.combatMultiplier ?? 1);
|
|
}, 1);
|
|
const equippedItemIds = equippedItems.map((item) => {
|
|
return item.id;
|
|
});
|
|
const setBonuses = computeSetBonuses(equippedItemIds, EQUIPMENT_SETS);
|
|
|
|
const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1;
|
|
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
|
|
const prestigeCombatMultiplier = PRESTIGE_COMBAT_BASE ** state.prestige.count;
|
|
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
|
|
const echoCombatMultiplier = state.transcendence?.echoCombatMultiplier ?? 1;
|
|
const craftedGoldMultiplier
|
|
= state.exploration?.craftedGoldMultiplier ?? 1;
|
|
const craftedEssenceMultiplier
|
|
= state.exploration?.craftedEssenceMultiplier ?? 1;
|
|
const craftedCombatMultiplier
|
|
= state.exploration?.craftedCombatMultiplier ?? 1;
|
|
|
|
const companionBonus = getActiveCompanionBonus(
|
|
state.companions?.activeCompanionId,
|
|
state.companions?.unlockedCompanionIds ?? [],
|
|
);
|
|
const companionGoldMult
|
|
= companionBonus?.type === "passiveGold"
|
|
? 1 + companionBonus.value
|
|
: 1;
|
|
const companionEssenceMult
|
|
= companionBonus?.type === "essenceIncome"
|
|
? 1 + companionBonus.value
|
|
: 1;
|
|
const companionCombatMult
|
|
= companionBonus?.type === "bossDamage"
|
|
? 1 + companionBonus.value
|
|
: 1;
|
|
|
|
const goldPerSecond
|
|
= adventurer.goldPerSecond
|
|
* upgradeMultiplier
|
|
* state.prestige.productionMultiplier
|
|
* runestonesIncome
|
|
* echoIncome
|
|
* equipmentGoldMultiplier
|
|
* setBonuses.goldMultiplier
|
|
* craftedGoldMultiplier
|
|
* companionGoldMult;
|
|
|
|
const essencePerSecond
|
|
= adventurer.essencePerSecond
|
|
* upgradeMultiplier
|
|
* state.prestige.productionMultiplier
|
|
* runestonesEssence
|
|
* craftedEssenceMultiplier
|
|
* companionEssenceMult;
|
|
|
|
const combatPower
|
|
= adventurer.combatPower
|
|
* upgradeMultiplier
|
|
* prestigeCombatMultiplier
|
|
* equipmentCombatMultiplier
|
|
* setBonuses.combatMultiplier
|
|
* echoCombatMultiplier
|
|
* craftedCombatMultiplier
|
|
* companionCombatMult;
|
|
|
|
return { combatPower, essencePerSecond, goldPerSecond };
|
|
};
|
|
|
|
/**
|
|
* Computes the party's total combat power, applying all active multipliers
|
|
* (upgrades, prestige, equipment, set bonuses, echo, crafted, companion).
|
|
* This mirrors the server-side calculatePartyStats in boss.ts and is the
|
|
* single source of truth for all combat-power checks in the client:
|
|
* - Displayed as "Combat Power" in the resource bar
|
|
* - Displayed as "Party DPS" in the boss panel
|
|
* - Used to gate quest availability
|
|
* Note: the active companion's bossDamage bonus is intentionally included
|
|
* here, as it applies to the full combat power calculation (boss fights and
|
|
* quest gating alike), matching the server-side behaviour.
|
|
* @param state - The current game state.
|
|
* @returns The total party combat power.
|
|
*/
|
|
export const computePartyCombatPower = (state: GameState): number => {
|
|
let globalMultiplier = 1;
|
|
for (const upgrade of state.upgrades) {
|
|
if (upgrade.purchased && upgrade.target === "global") {
|
|
globalMultiplier = globalMultiplier * upgrade.multiplier;
|
|
}
|
|
}
|
|
|
|
const prestigeMultiplier = PRESTIGE_COMBAT_BASE ** state.prestige.count;
|
|
|
|
const equipmentCombatMultiplier = state.equipment.
|
|
filter((item) => {
|
|
return item.equipped && item.bonus.combatMultiplier !== undefined;
|
|
}).
|
|
reduce((mult, item) => {
|
|
return mult * (item.bonus.combatMultiplier ?? 1);
|
|
}, 1);
|
|
|
|
const equippedItemIds = state.equipment.
|
|
filter((item) => {
|
|
return item.equipped;
|
|
}).
|
|
map((item) => {
|
|
return item.id;
|
|
});
|
|
const { combatMultiplier: setCombatMultiplier } = computeSetBonuses(
|
|
equippedItemIds,
|
|
EQUIPMENT_SETS,
|
|
);
|
|
|
|
const echoCombatMultiplier
|
|
= state.transcendence?.echoCombatMultiplier ?? 1;
|
|
const craftedCombatMultiplier
|
|
= state.exploration?.craftedCombatMultiplier ?? 1;
|
|
|
|
const companionBonus = getActiveCompanionBonus(
|
|
state.companions?.activeCompanionId,
|
|
state.companions?.unlockedCompanionIds ?? [],
|
|
);
|
|
const companionCombatMult
|
|
= companionBonus?.type === "bossDamage"
|
|
? 1 + companionBonus.value
|
|
: 1;
|
|
|
|
let partyCombatPower = 0;
|
|
for (const adventurer of state.adventurers) {
|
|
if (adventurer.count === 0) {
|
|
continue;
|
|
}
|
|
let adventurerMultiplier = 1;
|
|
for (const upgrade of state.upgrades) {
|
|
if (
|
|
upgrade.purchased
|
|
&& upgrade.target === "adventurer"
|
|
&& upgrade.adventurerId === adventurer.id
|
|
) {
|
|
adventurerMultiplier = adventurerMultiplier * upgrade.multiplier;
|
|
}
|
|
}
|
|
const contribution
|
|
= adventurer.combatPower
|
|
* adventurer.count
|
|
* adventurerMultiplier
|
|
* globalMultiplier
|
|
* prestigeMultiplier;
|
|
partyCombatPower = partyCombatPower + contribution;
|
|
}
|
|
|
|
return partyCombatPower
|
|
* equipmentCombatMultiplier
|
|
* setCombatMultiplier
|
|
* echoCombatMultiplier
|
|
* craftedCombatMultiplier
|
|
* 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;
|
|
|
|
/**
|
|
* Computes the projected runestone reward if the player were to prestige right now.
|
|
* Mirrors the server-side calculateRunestones formula exactly.
|
|
* @param state - The current game state.
|
|
* @returns The number of runestones the player would earn from a prestige now.
|
|
*/
|
|
export const computeProjectedRunestones = (state: GameState): number => {
|
|
const { count, purchasedUpgradeIds } = state.prestige;
|
|
const thresholdMult: number
|
|
= state.transcendence?.echoPrestigeThresholdMultiplier ?? 1;
|
|
const threshold
|
|
= basePrestigeThreshold * Math.pow(count + 1, 2.5) * thresholdMult;
|
|
const base = Math.min(
|
|
Math.floor(Math.cbrt(state.player.totalGoldEarned / threshold))
|
|
* runestonesPerPrestigeLevelClient,
|
|
maxBaseRunestones,
|
|
);
|
|
const gain1Mult = purchasedUpgradeIds.includes("runestone_gain_1")
|
|
? 1.25
|
|
: 1;
|
|
const gain2Mult = purchasedUpgradeIds.includes("runestone_gain_2")
|
|
? 1.5
|
|
: 1;
|
|
const runestoneMult = gain1Mult * gain2Mult;
|
|
const echoMult: number
|
|
= state.transcendence?.echoPrestigeRunestoneMultiplier ?? 1;
|
|
return Math.floor(base * runestoneMult * echoMult);
|
|
};
|
|
|
|
/**
|
|
* Pure function — applies one game tick to the state.
|
|
* DeltaSeconds: time elapsed since last tick.
|
|
* Returns a new GameState (does not mutate the original).
|
|
* @param state - The current game state.
|
|
* @param deltaSeconds - Time elapsed since last tick in seconds.
|
|
* @returns A new GameState with the tick applied.
|
|
*/
|
|
export const applyTick = (
|
|
state: GameState,
|
|
deltaSeconds: number,
|
|
): GameState => {
|
|
const equippedItems: Array<Equipment> = state.equipment.filter((item) => {
|
|
return item.equipped;
|
|
});
|
|
const equipmentGoldMultiplier = equippedItems.reduce((mult, item) => {
|
|
return mult * (item.bonus.goldMultiplier ?? 1);
|
|
}, 1);
|
|
const setGoldMultiplier = computeSetBonuses(
|
|
equippedItems.map((item) => {
|
|
return item.id;
|
|
}),
|
|
EQUIPMENT_SETS,
|
|
).goldMultiplier;
|
|
|
|
const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1;
|
|
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
|
|
const runestonesCrystal = state.prestige.runestonesCrystalMultiplier ?? 1;
|
|
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
|
|
const craftedGoldMultiplier = state.exploration?.craftedGoldMultiplier ?? 1;
|
|
const craftedEssenceMultiplier
|
|
= state.exploration?.craftedEssenceMultiplier ?? 1;
|
|
|
|
const companionBonus = getActiveCompanionBonus(
|
|
state.companions?.activeCompanionId,
|
|
state.companions?.unlockedCompanionIds ?? [],
|
|
);
|
|
const companionGoldMult
|
|
= companionBonus?.type === "passiveGold"
|
|
? 1 + companionBonus.value
|
|
: 1;
|
|
const companionEssenceMult
|
|
= companionBonus?.type === "essenceIncome"
|
|
? 1 + companionBonus.value
|
|
: 1;
|
|
const companionQuestTimeReduction
|
|
= companionBonus?.type === "questTime"
|
|
? companionBonus.value
|
|
: 0;
|
|
|
|
let goldGained = 0;
|
|
let essenceGained = 0;
|
|
|
|
for (const adventurer of state.adventurers) {
|
|
if (!adventurer.unlocked || adventurer.count === 0) {
|
|
continue;
|
|
}
|
|
|
|
const upgradeMultiplier = state.upgrades.
|
|
filter((upgrade) => {
|
|
const isGlobal = upgrade.target === "global";
|
|
const isThisAdventurer
|
|
= upgrade.target === "adventurer"
|
|
&& upgrade.adventurerId === adventurer.id;
|
|
return upgrade.purchased && (isGlobal || isThisAdventurer);
|
|
}).
|
|
reduce((mult, upgrade) => {
|
|
return mult * upgrade.multiplier;
|
|
}, 1);
|
|
|
|
const prestige = state.prestige.productionMultiplier;
|
|
|
|
const goldPerTick
|
|
= adventurer.goldPerSecond
|
|
* adventurer.count
|
|
* upgradeMultiplier
|
|
* prestige
|
|
* runestonesIncome
|
|
* echoIncome
|
|
* equipmentGoldMultiplier
|
|
* setGoldMultiplier
|
|
* craftedGoldMultiplier
|
|
* companionGoldMult
|
|
* deltaSeconds;
|
|
goldGained = goldGained + goldPerTick;
|
|
|
|
const essencePerTick
|
|
= adventurer.essencePerSecond
|
|
* adventurer.count
|
|
* upgradeMultiplier
|
|
* prestige
|
|
* runestonesEssence
|
|
* craftedEssenceMultiplier
|
|
* companionEssenceMult
|
|
* deltaSeconds;
|
|
essenceGained = essenceGained + essencePerTick;
|
|
}
|
|
|
|
// Complete active quests and apply their rewards
|
|
const now = Date.now();
|
|
let questGold = 0;
|
|
let questEssence = 0;
|
|
let questCrystals = 0;
|
|
|
|
let updatedUpgrades = state.upgrades;
|
|
let updatedAdventurers = state.adventurers;
|
|
let updatedEquipmentReference = state.equipment;
|
|
|
|
const updatedQuests = state.quests.map((quest) => {
|
|
const effectiveQuestMs
|
|
= quest.durationSeconds * (1 - companionQuestTimeReduction) * 1000;
|
|
if (
|
|
quest.status !== "active"
|
|
|| quest.startedAt === undefined
|
|
|| now < quest.startedAt + effectiveQuestMs
|
|
) {
|
|
return quest;
|
|
}
|
|
|
|
const failureChance = zoneFailureChance[quest.zoneId] ?? 0.2;
|
|
if (Math.random() < failureChance) {
|
|
const { startedAt: _dropped, ...questWithoutStartedAt } = quest;
|
|
return {
|
|
...questWithoutStartedAt,
|
|
lastFailedAt: now,
|
|
status: "available" as const,
|
|
};
|
|
}
|
|
|
|
for (const reward of quest.rewards) {
|
|
if (reward.type === "gold" && reward.amount !== undefined) {
|
|
questGold = questGold + reward.amount;
|
|
} else if (reward.type === "essence" && reward.amount !== undefined) {
|
|
questEssence = questEssence + reward.amount;
|
|
} else if (reward.type === "crystals" && reward.amount !== undefined) {
|
|
const crystalAmount = reward.amount;
|
|
const crystalGain = crystalAmount * runestonesCrystal;
|
|
questCrystals = questCrystals + crystalGain;
|
|
} else if (reward.type === "upgrade" && reward.targetId !== undefined) {
|
|
updatedUpgrades = updatedUpgrades.map((upgrade) => {
|
|
return upgrade.id === reward.targetId
|
|
? { ...upgrade, unlocked: true }
|
|
: upgrade;
|
|
});
|
|
} else if (
|
|
reward.type === "adventurer"
|
|
&& reward.targetId !== undefined
|
|
) {
|
|
updatedAdventurers = updatedAdventurers.map((adventurer) => {
|
|
return adventurer.id === reward.targetId
|
|
? { ...adventurer, unlocked: true }
|
|
: adventurer;
|
|
});
|
|
} else if (reward.type === "equipment" && reward.targetId !== undefined) {
|
|
const rewardTargetId = reward.targetId;
|
|
const currentEquipment = updatedEquipmentReference;
|
|
updatedEquipmentReference = 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 quests whose prerequisites are now all completed and whose zone is unlocked
|
|
const completedIds = new Set(
|
|
updatedQuests.
|
|
filter((quest) => {
|
|
return quest.status === "completed";
|
|
}).
|
|
map((quest) => {
|
|
return quest.id;
|
|
}),
|
|
);
|
|
const fullyUpdatedQuests = updatedQuests.map((quest) => {
|
|
if (quest.status !== "locked") {
|
|
return quest;
|
|
}
|
|
const questZone = state.zones.find((searchZone) => {
|
|
return searchZone.id === quest.zoneId;
|
|
});
|
|
if (questZone?.status === "locked") {
|
|
return quest;
|
|
}
|
|
if (
|
|
quest.prerequisiteIds.every((id) => {
|
|
return completedIds.has(id);
|
|
})
|
|
) {
|
|
return { ...quest, status: "available" as const };
|
|
}
|
|
return quest;
|
|
});
|
|
|
|
/*
|
|
* Unlock zones whose both conditions are now satisfied after quest completion:
|
|
* (1) the gate boss has been defeated, (2) the gate quest is now completed
|
|
*/
|
|
const updatedZones = state.zones.map((zone) => {
|
|
if (zone.status === "unlocked") {
|
|
return zone;
|
|
}
|
|
const bossOk
|
|
= zone.unlockBossId === null
|
|
|| state.bosses.some((boss) => {
|
|
return boss.id === zone.unlockBossId && boss.status === "defeated";
|
|
});
|
|
const questOk
|
|
= zone.unlockQuestId === null || completedIds.has(zone.unlockQuestId);
|
|
if (bossOk && questOk) {
|
|
return { ...zone, status: "unlocked" as const };
|
|
}
|
|
return zone;
|
|
});
|
|
|
|
// Activate the first boss in any zone that just became unlocked this tick
|
|
const newlyUnlockedZoneIds = new Set(
|
|
updatedZones.
|
|
filter((zone) => {
|
|
const wasLocked
|
|
= state.zones.find((originalZone) => {
|
|
return originalZone.id === zone.id;
|
|
})?.status === "locked";
|
|
return zone.status === "unlocked" && wasLocked;
|
|
}).
|
|
map((zone) => {
|
|
return zone.id;
|
|
}),
|
|
);
|
|
let updatedBosses = state.bosses;
|
|
if (newlyUnlockedZoneIds.size > 0) {
|
|
updatedBosses = state.bosses.map((boss) => {
|
|
if (newlyUnlockedZoneIds.has(boss.zoneId)) {
|
|
const zoneBosses = state.bosses.filter((zoneBoss) => {
|
|
return zoneBoss.zoneId === boss.zoneId;
|
|
});
|
|
const [ firstBoss ] = zoneBosses;
|
|
if (firstBoss?.id === boss.id && boss.status === "locked") {
|
|
return { ...boss, status: "available" as const };
|
|
}
|
|
}
|
|
return boss;
|
|
});
|
|
}
|
|
|
|
// Count quests newly completed this tick and update daily challenge progress
|
|
const newlyCompletedQuestCount = updatedQuests.filter((quest, index) => {
|
|
const wasNotCompleted = state.quests[index]?.status !== "completed";
|
|
return quest.status === "completed" && wasNotCompleted;
|
|
}).length;
|
|
|
|
let updatedDailyChallenges = state.dailyChallenges;
|
|
let challengeCrystals = 0;
|
|
if (updatedDailyChallenges !== undefined && newlyCompletedQuestCount > 0) {
|
|
const result = updateChallengeProgress(
|
|
updatedDailyChallenges,
|
|
"questsCompleted",
|
|
newlyCompletedQuestCount,
|
|
);
|
|
updatedDailyChallenges = result.updatedChallenges;
|
|
challengeCrystals = result.crystalsAwarded;
|
|
}
|
|
|
|
// Auto-unlock adventurer-specific upgrades when their adventurer is recruited
|
|
updatedUpgrades = updatedUpgrades.map((upgrade) => {
|
|
if (upgrade.unlocked || upgrade.adventurerId === undefined) {
|
|
return upgrade;
|
|
}
|
|
const adventurer = updatedAdventurers.find((a) => {
|
|
return a.id === upgrade.adventurerId;
|
|
});
|
|
return adventurer !== undefined && adventurer.count > 0
|
|
? { ...upgrade, unlocked: true }
|
|
: upgrade;
|
|
});
|
|
|
|
// --- Goddess tick ---
|
|
let prayersGained = 0;
|
|
let divinityGained = 0;
|
|
let stardustFromQuests = 0;
|
|
// 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
|
|
&& state.goddess !== undefined
|
|
) {
|
|
const { goddess } = state;
|
|
|
|
// Collect all multipliers for prayers/divinity income
|
|
const consecMult = goddess.consecration.productionMultiplier;
|
|
const divinityPrayersMult
|
|
= goddess.consecration.divinityPrayersMultiplier ?? 1;
|
|
const divinityDisciplesMult
|
|
= goddess.consecration.divinityDisciplesMultiplier ?? 1;
|
|
const stardustPrayersMult = goddess.enlightenment.stardustPrayersMultiplier;
|
|
const craftedPrayersMult = goddess.exploration.craftedPrayersMultiplier;
|
|
const craftedDivinityMult = goddess.exploration.craftedDivinityMultiplier;
|
|
|
|
let globalPrayersMult = 1;
|
|
for (const upgrade of goddess.upgrades) {
|
|
if (upgrade.purchased && upgrade.target === "prayers") {
|
|
globalPrayersMult = globalPrayersMult * upgrade.multiplier;
|
|
}
|
|
}
|
|
|
|
for (const disciple of goddess.disciples) {
|
|
if (disciple.count === 0) {
|
|
continue;
|
|
}
|
|
let discipleUpgradeMult = 1;
|
|
for (const upgrade of goddess.upgrades) {
|
|
if (
|
|
upgrade.purchased
|
|
&& upgrade.target === "disciple"
|
|
&& upgrade.discipleId === disciple.id
|
|
) {
|
|
discipleUpgradeMult = discipleUpgradeMult * upgrade.multiplier;
|
|
}
|
|
}
|
|
const prayersTick
|
|
= disciple.prayersPerSecond
|
|
* disciple.count
|
|
* discipleUpgradeMult
|
|
* globalPrayersMult
|
|
* consecMult
|
|
* divinityPrayersMult
|
|
* divinityDisciplesMult
|
|
* stardustPrayersMult
|
|
* craftedPrayersMult
|
|
* deltaSeconds;
|
|
prayersGained = prayersGained + prayersTick;
|
|
const divinityTick
|
|
= disciple.divinityPerSecond
|
|
* disciple.count
|
|
* discipleUpgradeMult
|
|
* consecMult
|
|
* divinityDisciplesMult
|
|
* craftedDivinityMult
|
|
* deltaSeconds;
|
|
divinityGained = divinityGained + divinityTick;
|
|
}
|
|
|
|
// Process goddess quest timers
|
|
let goddessQuestPrayersGained = 0;
|
|
let goddessQuestDivinityGained = 0;
|
|
let updatedGoddessDisciples = goddess.disciples;
|
|
let updatedGoddessEquipment = goddess.equipment;
|
|
let updatedGoddessUpgrades = goddess.upgrades;
|
|
let goddessQuestsThisTick = 0;
|
|
|
|
const updatedGoddessQuests = goddess.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;
|
|
}
|
|
|
|
goddessQuestsThisTick = goddessQuestsThisTick + 1;
|
|
for (const reward of quest.rewards) {
|
|
if (reward.type === "prayers" && reward.amount !== undefined) {
|
|
goddessQuestPrayersGained
|
|
= goddessQuestPrayersGained + reward.amount;
|
|
} else if (
|
|
reward.type === "divinity"
|
|
&& reward.amount !== undefined
|
|
) {
|
|
goddessQuestDivinityGained
|
|
= goddessQuestDivinityGained + reward.amount;
|
|
} else if (
|
|
reward.type === "stardust"
|
|
&& reward.amount !== undefined
|
|
) {
|
|
stardustFromQuests = stardustFromQuests + reward.amount;
|
|
} else if (
|
|
reward.type === "upgrade"
|
|
&& reward.targetId !== undefined
|
|
) {
|
|
const { targetId } = reward;
|
|
updatedGoddessUpgrades = updatedGoddessUpgrades.map((upgrade) => {
|
|
return upgrade.id === targetId
|
|
? { ...upgrade, unlocked: true }
|
|
: upgrade;
|
|
});
|
|
} else if (
|
|
reward.type === "disciple"
|
|
&& reward.targetId !== undefined
|
|
) {
|
|
const { targetId } = reward;
|
|
updatedGoddessDisciples = updatedGoddessDisciples.map((disciple) => {
|
|
return disciple.id === targetId
|
|
? { ...disciple, unlocked: true }
|
|
: disciple;
|
|
});
|
|
} else if (
|
|
reward.type === "equipment"
|
|
&& reward.targetId !== undefined
|
|
) {
|
|
const rewardTargetId = reward.targetId;
|
|
const currentEquipment = updatedGoddessEquipment;
|
|
updatedGoddessEquipment = 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 goddess quests whose prerequisites are now all completed
|
|
const completedGoddessIds = new Set(
|
|
updatedGoddessQuests.
|
|
filter((quest) => {
|
|
return quest.status === "completed";
|
|
}).
|
|
map((quest) => {
|
|
return quest.id;
|
|
}),
|
|
);
|
|
const defeatedBossIds = new Set(
|
|
goddess.bosses.
|
|
filter((boss) => {
|
|
return boss.status === "defeated";
|
|
}).
|
|
map((boss) => {
|
|
return boss.id;
|
|
}),
|
|
);
|
|
// Unlock goddess zones whose boss + quest requirements are now met
|
|
const updatedGoddessZones = goddess.zones.map((zone) => {
|
|
if (zone.status === "unlocked") {
|
|
return zone;
|
|
}
|
|
const bossOk
|
|
= zone.unlockBossId === null
|
|
|| defeatedBossIds.has(zone.unlockBossId);
|
|
const questOk
|
|
= zone.unlockQuestId === null
|
|
|| completedGoddessIds.has(zone.unlockQuestId);
|
|
if (bossOk && questOk) {
|
|
return { ...zone, status: "unlocked" as const };
|
|
}
|
|
return zone;
|
|
});
|
|
|
|
const fullyUpdatedGoddessZones = updatedGoddessZones;
|
|
const allUnlockedGoddessZoneIds = new Set(
|
|
fullyUpdatedGoddessZones.
|
|
filter((zone) => {
|
|
return zone.status === "unlocked";
|
|
}).
|
|
map((zone) => {
|
|
return zone.id;
|
|
}),
|
|
);
|
|
|
|
const fullyUpdatedGoddessQuests = updatedGoddessQuests.map((quest) => {
|
|
if (quest.status !== "locked") {
|
|
return quest;
|
|
}
|
|
if (!allUnlockedGoddessZoneIds.has(quest.zoneId)) {
|
|
return quest;
|
|
}
|
|
if (
|
|
quest.prerequisiteIds.every((id) => {
|
|
return completedGoddessIds.has(id);
|
|
})
|
|
) {
|
|
return { ...quest, status: "available" as const };
|
|
}
|
|
return quest;
|
|
});
|
|
|
|
// Compute updated lifetime counters
|
|
const totalPrayersThisTick
|
|
= prayersGained + goddessQuestPrayersGained;
|
|
const updatedTotalPrayersEarned
|
|
= goddess.totalPrayersEarned + totalPrayersThisTick;
|
|
const updatedLifetimePrayersEarned
|
|
= goddess.lifetimePrayersEarned + totalPrayersThisTick;
|
|
const updatedLifetimeQuestsCompleted
|
|
= goddess.lifetimeQuestsCompleted + goddessQuestsThisTick;
|
|
|
|
// Build snapshot for achievement check
|
|
const goddessSnapshot: GoddessState = {
|
|
...goddess,
|
|
disciples: updatedGoddessDisciples,
|
|
equipment: updatedGoddessEquipment,
|
|
lifetimeBossesDefeated: goddess.lifetimeBossesDefeated,
|
|
lifetimePrayersEarned: updatedLifetimePrayersEarned,
|
|
lifetimeQuestsCompleted: updatedLifetimeQuestsCompleted,
|
|
quests: fullyUpdatedGoddessQuests,
|
|
upgrades: updatedGoddessUpgrades,
|
|
zones: fullyUpdatedGoddessZones,
|
|
};
|
|
const updatedGoddessAchievements
|
|
= checkGoddessAchievements(goddessSnapshot, now);
|
|
let divinityFromAchievements = 0;
|
|
let stardustFromAchievements = 0;
|
|
for (const [ index, achievement ] of updatedGoddessAchievements.entries()) {
|
|
if (
|
|
goddess.achievements[index]?.unlockedAt === null
|
|
&& achievement.unlockedAt !== null
|
|
) {
|
|
divinityFromAchievements
|
|
= divinityFromAchievements + (achievement.reward?.divinity ?? 0);
|
|
stardustFromAchievements
|
|
= stardustFromAchievements + (achievement.reward?.stardust ?? 0);
|
|
}
|
|
}
|
|
|
|
updatedGoddess = {
|
|
...goddessSnapshot,
|
|
achievements: updatedGoddessAchievements,
|
|
lastTickAt: now,
|
|
totalPrayersEarned: updatedTotalPrayersEarned,
|
|
};
|
|
|
|
// Include quest divinity + achievement divinity in this tick's total
|
|
divinityGained
|
|
= divinityGained
|
|
+ goddessQuestDivinityGained
|
|
+ divinityFromAchievements;
|
|
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,
|
|
);
|
|
const totalGoldEarnedValue
|
|
= state.player.totalGoldEarned + goldGained + questGold;
|
|
|
|
const partialState: GameState = {
|
|
...state,
|
|
resources: {
|
|
...state.resources,
|
|
blood: capResource(
|
|
(state.resources.blood ?? 0) + bloodGainedVampire,
|
|
),
|
|
crystals: capResource(
|
|
state.resources.crystals + questCrystals + challengeCrystals,
|
|
),
|
|
divinity: capResource(
|
|
(state.resources.divinity ?? 0) + divinityGained,
|
|
),
|
|
essence: essenceValue,
|
|
gold: goldValue,
|
|
prayers: capResource(
|
|
(state.resources.prayers ?? 0) + prayersGained,
|
|
),
|
|
stardust: capResource(
|
|
(state.resources.stardust ?? 0) + stardustFromQuests,
|
|
),
|
|
},
|
|
...updatedDailyChallenges === undefined
|
|
? {}
|
|
: { dailyChallenges: updatedDailyChallenges },
|
|
...newlyUnlockedZoneIds.size === 0 || state.exploration === undefined
|
|
? {}
|
|
: {
|
|
exploration: {
|
|
...state.exploration,
|
|
areas: state.exploration.areas.map((area) => {
|
|
const areaDefinition = EXPLORATION_AREAS.find((definition) => {
|
|
return definition.id === area.id;
|
|
});
|
|
return areaDefinition !== undefined
|
|
&& newlyUnlockedZoneIds.has(areaDefinition.zoneId)
|
|
&& area.status === "locked"
|
|
? { ...area, status: "available" as const }
|
|
: area;
|
|
}),
|
|
},
|
|
},
|
|
...updatedGoddess === undefined
|
|
? {}
|
|
: { goddess: updatedGoddess },
|
|
...updatedVampire === undefined
|
|
? {}
|
|
: { vampire: updatedVampire },
|
|
adventurers: updatedAdventurers,
|
|
bosses: updatedBosses,
|
|
equipment: updatedEquipmentReference,
|
|
lastTickAt: now,
|
|
player: {
|
|
...state.player,
|
|
totalGoldEarned: totalGoldEarnedValue,
|
|
},
|
|
quests: fullyUpdatedQuests,
|
|
upgrades: updatedUpgrades,
|
|
zones: updatedZones,
|
|
};
|
|
|
|
// Check achievements and apply crystal and runestone rewards for newly unlocked ones
|
|
const updatedAchievements = checkAchievements(partialState);
|
|
let crystalsFromAchievements = 0;
|
|
let runestonesFromAchievements = 0;
|
|
for (const [ index, achievement ] of updatedAchievements.entries()) {
|
|
const wasLocked = state.achievements[index]?.unlockedAt === null;
|
|
const isNowUnlocked = achievement.unlockedAt !== null;
|
|
if (wasLocked && isNowUnlocked) {
|
|
crystalsFromAchievements
|
|
= crystalsFromAchievements + (achievement.reward?.crystals ?? 0);
|
|
runestonesFromAchievements
|
|
= runestonesFromAchievements + (achievement.reward?.runestones ?? 0);
|
|
}
|
|
}
|
|
|
|
return {
|
|
...partialState,
|
|
achievements: updatedAchievements,
|
|
prestige: {
|
|
...partialState.prestige,
|
|
runestones:
|
|
partialState.prestige.runestones + runestonesFromAchievements,
|
|
},
|
|
resources: {
|
|
...partialState.resources,
|
|
crystals: capResource(
|
|
partialState.resources.crystals + crystalsFromAchievements,
|
|
),
|
|
},
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Calculates the effective click power, including upgrades and equipped trinkets.
|
|
* @param state - The current game state.
|
|
* @returns The calculated click power value.
|
|
*/
|
|
export const calculateClickPower = (state: GameState): number => {
|
|
const clickMultiplier = state.upgrades.
|
|
filter((upgrade) => {
|
|
return upgrade.purchased && upgrade.target === "click";
|
|
}).
|
|
reduce((mult, upgrade) => {
|
|
return mult * upgrade.multiplier;
|
|
}, 1);
|
|
|
|
const equippedItems = state.equipment.filter((item) => {
|
|
return item.equipped;
|
|
});
|
|
const equipmentClickMultiplier = equippedItems.
|
|
filter((item) => {
|
|
return item.bonus.clickMultiplier !== undefined;
|
|
}).
|
|
reduce((mult, item) => {
|
|
return mult * (item.bonus.clickMultiplier ?? 1);
|
|
}, 1);
|
|
const setClickMultiplier = computeSetBonuses(
|
|
equippedItems.map((item) => {
|
|
return item.id;
|
|
}),
|
|
EQUIPMENT_SETS,
|
|
).clickMultiplier;
|
|
|
|
const runestonesClick = state.prestige.runestonesClickMultiplier ?? 1;
|
|
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
|
|
const craftedClickMultiplier = state.exploration?.craftedClickMultiplier ?? 1;
|
|
|
|
const companionClickBonus = getActiveCompanionBonus(
|
|
state.companions?.activeCompanionId,
|
|
state.companions?.unlockedCompanionIds ?? [],
|
|
);
|
|
const companionClickMult
|
|
= companionClickBonus?.type === "clickGold"
|
|
? 1 + companionClickBonus.value
|
|
: 1;
|
|
|
|
return (
|
|
state.baseClickPower
|
|
* clickMultiplier
|
|
* state.prestige.productionMultiplier
|
|
* runestonesClick
|
|
* echoIncome
|
|
* equipmentClickMultiplier
|
|
* setClickMultiplier
|
|
* craftedClickMultiplier
|
|
* companionClickMult
|
|
);
|
|
};
|