feat: overhaul mana conversion system to unified regen-deduction model
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m2s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m2s
- New files: element-distance.ts, conversion-costs.ts, conversion-rates.ts - All conversion types (discipline, attunement, pact) use unified formula - Conversion costs scale exponentially by element tier (10^(d+1) raw, 10*(d+1) per component) - Costs deducted from regen, not from mana pool - Auto-pause on insufficient regen with UI warning - Meditation boosts conversion rates (reduced by distance) - Attunement levels provide +50% multiplicative bonus per level - Guardian pacts provide +0.15/hr base rate + invoker level bonus - Removed convertMana, processConvertAction, craftComposite from manaStore - Stats tab shows per-element conversion breakdown with formulas - ManaDisplay shows per-element net regen rates - All 916 tests pass, all files under 400 lines
This commit is contained in:
@@ -7,6 +7,8 @@ import type { ComputedEffects } from '../effects/upgrade-effects.types';
|
||||
|
||||
import { computeDisciplineEffects } from '../effects/discipline-effects';
|
||||
import { computeMaxMana, computeRegen, getMeditationBonus, getIncursionStrength, calcInsight } from '../utils';
|
||||
import { getElementDistance } from '../utils/element-distance';
|
||||
import { computeConversionRates } from '../utils/conversion-rates';
|
||||
import { mergePerElementCapBonuses } from '../utils/element-cap-bonus';
|
||||
import { processPactRitual } from './pipelines/pact-ritual';
|
||||
import { buildCombatCallbacks } from './pipelines/combat-tick';
|
||||
@@ -24,6 +26,7 @@ import { createStartNewLoop } from './gameLoopActions';
|
||||
import { buildTickContext, applyTickWrites } from './tick-pipeline';
|
||||
import { processEnchantingTicks } from './pipelines/enchanting-tick';
|
||||
import { buildGolemCombatPipeline } from './pipelines/golem-combat';
|
||||
import { getGuardianForFloor } from '../data/guardian-encounters';
|
||||
|
||||
import type { TickContext, TickWrites } from './tick-pipeline';
|
||||
import type { GameCoordinatorState } from './gameStore.types';
|
||||
@@ -161,123 +164,56 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
||||
meditateTicks = 0;
|
||||
}
|
||||
|
||||
let totalConversionPerTick = 0;
|
||||
let rawManaDelta = 0;
|
||||
let elements = { ...ctx.mana.elements };
|
||||
Object.entries(ctx.attunement.attunements).forEach(([id, state]) => {
|
||||
if (!state.active) return;
|
||||
const def = ATTUNEMENTS_DEF[id];
|
||||
if (!def || def.conversionRate <= 0 || !def.primaryManaType) return;
|
||||
const scaledRate = getAttunementConversionRate(id, state.level || 1);
|
||||
const conversionThisTick = scaledRate * HOURS_PER_TICK;
|
||||
totalConversionPerTick += conversionThisTick;
|
||||
// Deduct raw mana to pay for the conversion — without this, attunements produce free element mana
|
||||
rawManaDelta -= conversionThisTick;
|
||||
if (elements[def.primaryManaType]) {
|
||||
if (!elements[def.primaryManaType].unlocked) {
|
||||
elements[def.primaryManaType] = { ...elements[def.primaryManaType], unlocked: true };
|
||||
}
|
||||
elements[def.primaryManaType].current = Math.min(
|
||||
elements[def.primaryManaType].max,
|
||||
elements[def.primaryManaType].current + conversionThisTick,
|
||||
);
|
||||
}
|
||||
// ── Unified Conversion System ─────────────────────────────────────
|
||||
const { pactElementMap, grossRegen } = buildConversionParams(ctx.prestige.signedPacts, ctx.attunement.attunements);
|
||||
const invokerLevel = ctx.attunement.attunements.invoker?.active ? (ctx.attunement.attunements.invoker.level || 1) : 0;
|
||||
const conversionResult = computeConversionRates({
|
||||
disciplineEffects, attunements: ctx.attunement.attunements,
|
||||
signedPacts: ctx.prestige.signedPacts, pactElementMap, invokerLevel,
|
||||
meditationMultiplier, grossRegen, rawGrossRegen: baseRegen,
|
||||
});
|
||||
|
||||
const effectiveRegen = Math.max(0, baseRegen * (1 - incursionStrength) * meditationMultiplier - totalConversionPerTick);
|
||||
// Apply conversion results: produce element mana from conversions
|
||||
let rawMana = ctx.mana.rawMana;
|
||||
let elements = { ...ctx.mana.elements };
|
||||
|
||||
const rawAfterConversion = ctx.mana.rawMana + rawManaDelta;
|
||||
const regenFromMeditation = Math.max(0, effectiveRegen * HOURS_PER_TICK);
|
||||
const roomLeft = Math.max(0, maxMana - Math.max(0, rawAfterConversion));
|
||||
// Only count regen that actually fits below the cap (fix #224)
|
||||
const actualRegenAdded = Math.floor(Math.min(regenFromMeditation, roomLeft) * 1000) / 1000;
|
||||
let rawMana = Math.max(0, Math.min(rawAfterConversion + effectiveRegen * HOURS_PER_TICK, maxMana));
|
||||
let totalManaGathered = ctx.mana.totalManaGathered + Math.max(0, actualRegenAdded);
|
||||
|
||||
if (ctx.combat.currentAction === 'convert') {
|
||||
const convertResult = useManaStore.getState().processConvertAction(rawMana);
|
||||
if (convertResult) {
|
||||
rawMana = convertResult.rawMana;
|
||||
elements = convertResult.elements;
|
||||
// Log paused conversions
|
||||
for (const [elem, entry] of Object.entries(conversionResult.rates)) {
|
||||
if (entry.paused && entry.pauseReason) {
|
||||
addLog(`⚠️ PAUSED: ${elem} conversion — ${entry.pauseReason}`);
|
||||
}
|
||||
}
|
||||
|
||||
const pactResult = processPactRitual(
|
||||
ctx.prestige.pactRitualFloor,
|
||||
ctx.prestige.pactRitualProgress,
|
||||
ctx.prestige.signedPacts,
|
||||
ctx.prestige.defeatedGuardians,
|
||||
ctx.prestige.prestigeUpgrades.pactAffinity || 0,
|
||||
disciplineEffects.bonuses.pactAffinityBonus || 0,
|
||||
);
|
||||
if (pactResult.writes) {
|
||||
writes.prestige = { ...(writes.prestige || {}), ...pactResult.writes };
|
||||
// Apply produced element mana (from active conversions)
|
||||
for (const [elem, entry] of Object.entries(conversionResult.rates)) {
|
||||
if (entry.paused || entry.finalRate <= 0 || !elements[elem]) continue;
|
||||
if (!elements[elem].unlocked) elements[elem] = { ...elements[elem], unlocked: true };
|
||||
elements[elem] = { ...elements[elem], current: Math.min(elements[elem].max, elements[elem].current + entry.finalRate * HOURS_PER_TICK) };
|
||||
}
|
||||
// Net raw regen = gross regen - conversion drains - incursion
|
||||
const netRawRegen = Math.max(0, baseRegen * (1 - incursionStrength) * meditationMultiplier - conversionResult.totalRawDrain);
|
||||
const actualRegen = Math.floor(Math.min(netRawRegen * HOURS_PER_TICK, maxMana - rawMana) * 1000) / 1000;
|
||||
rawMana = Math.max(0, Math.min(rawMana + netRawRegen * HOURS_PER_TICK, maxMana));
|
||||
let totalManaGathered = ctx.mana.totalManaGathered + Math.max(0, actualRegen);
|
||||
|
||||
const pactResult = processPactRitual(ctx.prestige.pactRitualFloor, ctx.prestige.pactRitualProgress, ctx.prestige.signedPacts, ctx.prestige.defeatedGuardians, ctx.prestige.prestigeUpgrades.pactAffinity || 0, disciplineEffects.bonuses.pactAffinityBonus || 0);
|
||||
if (pactResult.writes) writes.prestige = { ...(writes.prestige || {}), ...pactResult.writes };
|
||||
pactResult.logs.forEach(l => addLog(l));
|
||||
|
||||
const disciplineResult = useDisciplineStore.getState().processTick({
|
||||
rawMana,
|
||||
elements,
|
||||
});
|
||||
rawMana = disciplineResult.rawMana;
|
||||
elements = disciplineResult.elements;
|
||||
const dr = useDisciplineStore.getState().processTick({ rawMana, elements });
|
||||
rawMana = dr.rawMana; elements = dr.elements;
|
||||
if (dr.autoPausedNames.length > 0) addLog('⏸️ Auto-paused (insufficient mana): ' + dr.autoPausedNames.join(', '));
|
||||
rawMana = Math.min(rawMana, computeMaxMana({ prestigeUpgrades: ctx.prestige.prestigeUpgrades }, undefined, computeDisciplineEffects()));
|
||||
|
||||
// Log auto-paused disciplines for better UX feedback (fix #244)
|
||||
if (disciplineResult.autoPausedNames.length > 0) {
|
||||
const names = disciplineResult.autoPausedNames.join(', ');
|
||||
addLog('⏸️ Auto-paused (insufficient mana): ' + names);
|
||||
}
|
||||
|
||||
// Recompute maxMana after discipline XP gains so clamping uses updated value (fix #246)
|
||||
const updatedDisciplineEffects = computeDisciplineEffects();
|
||||
const updatedMaxMana = computeMaxMana(
|
||||
{ prestigeUpgrades: ctx.prestige.prestigeUpgrades },
|
||||
undefined,
|
||||
updatedDisciplineEffects,
|
||||
);
|
||||
rawMana = Math.min(rawMana, updatedMaxMana);
|
||||
|
||||
for (const [targetElem, conv] of Object.entries(disciplineEffects.conversions)) {
|
||||
const conversionAmount = conv.rate * HOURS_PER_TICK;
|
||||
let canConvert = true;
|
||||
for (const srcType of conv.sourceManaTypes) {
|
||||
if (srcType === 'raw') {
|
||||
if (rawMana < conversionAmount) { canConvert = false; break; }
|
||||
} else if (!elements[srcType] || !elements[srcType].unlocked || elements[srcType].current < conversionAmount) {
|
||||
canConvert = false; break;
|
||||
}
|
||||
}
|
||||
if (!canConvert) continue;
|
||||
// Re-check against actual remaining mana to prevent negative values
|
||||
// when multiple disciplines share the same source
|
||||
for (const srcType of conv.sourceManaTypes) {
|
||||
if (srcType === 'raw' && rawMana < conversionAmount) { canConvert = false; break; }
|
||||
if (srcType !== 'raw' && elements[srcType] && elements[srcType].current < conversionAmount) { canConvert = false; break; }
|
||||
}
|
||||
if (!canConvert) continue;
|
||||
for (const srcType of conv.sourceManaTypes) {
|
||||
if (srcType === 'raw') {
|
||||
rawMana -= conversionAmount;
|
||||
} else if (elements[srcType]) {
|
||||
elements[srcType] = { ...elements[srcType], current: Math.max(0, elements[srcType].current - conversionAmount) };
|
||||
}
|
||||
}
|
||||
if (elements[targetElem]) {
|
||||
elements[targetElem] = {
|
||||
...elements[targetElem],
|
||||
current: Math.min(elements[targetElem].max, elements[targetElem].current + conversionAmount),
|
||||
};
|
||||
}
|
||||
}
|
||||
if (disciplineResult.unlockedEffects.length > 0) {
|
||||
useCraftingStore.getState().unlockEffects(disciplineResult.unlockedEffects);
|
||||
for (const effectId of disciplineResult.unlockedEffects) {
|
||||
if (dr.unlockedEffects.length > 0) {
|
||||
useCraftingStore.getState().unlockEffects(dr.unlockedEffects);
|
||||
for (const effectId of dr.unlockedEffects) {
|
||||
addLog('Discipline insight unlocked: ' + effectId);
|
||||
}
|
||||
}
|
||||
if (disciplineResult.unlockedRecipes.length > 0) {
|
||||
useCraftingStore.getState().unlockRecipes(disciplineResult.unlockedRecipes);
|
||||
for (const recipeId of disciplineResult.unlockedRecipes) {
|
||||
if (dr.unlockedRecipes.length > 0) {
|
||||
useCraftingStore.getState().unlockRecipes(dr.unlockedRecipes);
|
||||
for (const recipeId of dr.unlockedRecipes) {
|
||||
addLog('Fabricator recipe unlocked: ' + recipeId);
|
||||
}
|
||||
}
|
||||
@@ -295,7 +231,7 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
||||
}
|
||||
}
|
||||
|
||||
// Combat — delegate to combatStore
|
||||
// Combat
|
||||
if (ctx.combat.currentAction === 'climb') {
|
||||
const combatCbs = buildCombatCallbacks({ ctx, effects, maxMana, addLog, useCombatStore, usePrestigeStore });
|
||||
const roomEnemies = ctx.combat.currentRoom?.enemies ?? [];
|
||||
@@ -303,36 +239,27 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
||||
const activeEnemy = combatCbs.applyMageBarrierRecharge(primaryEnemy) ?? primaryEnemy;
|
||||
const defCtx = { roomType: ctx.combat.currentRoom?.roomType ?? 'combat', enemy: activeEnemy };
|
||||
const golemPipeline = buildGolemCombatPipeline(addLog);
|
||||
|
||||
// Build equipped swords map for melee auto-attack (spec §3.1)
|
||||
const equippedSwords: Record<string, import('../types').EquipmentInstance> = {};
|
||||
for (const [slot, instanceId] of Object.entries(ctx.crafting.equippedInstances || {})) {
|
||||
if (!instanceId) continue;
|
||||
const inst = ctx.crafting.equipmentInstances?.[instanceId];
|
||||
if (!inst) continue;
|
||||
const eqType = EQUIPMENT_TYPES[inst.typeId];
|
||||
if (eqType?.category === 'sword') {
|
||||
equippedSwords[instanceId] = inst;
|
||||
}
|
||||
for (const [slot, iid] of Object.entries(ctx.crafting.equippedInstances || {})) {
|
||||
if (!iid) continue;
|
||||
const inst = ctx.crafting.equipmentInstances?.[iid];
|
||||
if (inst && EQUIPMENT_TYPES[inst.typeId]?.category === 'sword') equippedSwords[iid] = inst;
|
||||
}
|
||||
|
||||
const combatResult = useCombatStore.getState().processCombatTick(
|
||||
const cr = useCombatStore.getState().processCombatTick(
|
||||
rawMana, elements, maxMana, 1,
|
||||
combatCbs.onFloorCleared,
|
||||
combatCbs.makeOnDamageDealt(() => rawMana, () => elements, defCtx, addLog),
|
||||
ctx.prestige.signedPacts,
|
||||
{ activeGolems: golemPipeline.activeGolems },
|
||||
golemPipeline.golemApplyDamageToRoom,
|
||||
(dmg, enemy, roomType, addLogFn, bypassArmor, bypassBarrier) => applyEnemyDefensesFromPipeline(
|
||||
dmg, enemy, roomType, addLogFn, bypassArmor, bypassBarrier,
|
||||
),
|
||||
(dmg, enemy, roomType, addLogFn, bypassArmor, bypassBarrier) =>
|
||||
applyEnemyDefensesFromPipeline(dmg, enemy, roomType, addLogFn, bypassArmor, bypassBarrier),
|
||||
equippedSwords,
|
||||
);
|
||||
rawMana = combatResult.rawMana;
|
||||
elements = combatResult.elements;
|
||||
totalManaGathered += combatResult.totalManaGathered || 0;
|
||||
if (combatResult.logMessages) combatResult.logMessages.forEach(msg => addLog(msg));
|
||||
writes.combat = { ...(writes.combat || {}), currentFloor: combatResult.currentFloor, floorHP: combatResult.floorHP, floorMaxHP: combatResult.floorMaxHP, maxFloorReached: combatResult.maxFloorReached, castProgress: combatResult.castProgress, equipmentSpellStates: combatResult.equipmentSpellStates, golemancy: { ...(writes.combat?.golemancy || ctx.combat.golemancy), activeGolems: combatResult.activeGolems }, meleeSwordProgress: combatResult.meleeSwordProgress, currentRoom: combatResult.currentRoom };
|
||||
rawMana = cr.rawMana; elements = cr.elements;
|
||||
totalManaGathered += cr.totalManaGathered || 0;
|
||||
if (cr.logMessages) cr.logMessages.forEach(msg => addLog(msg));
|
||||
writes.combat = { ...(writes.combat || {}), currentFloor: cr.currentFloor, floorHP: cr.floorHP, floorMaxHP: cr.floorMaxHP, maxFloorReached: cr.maxFloorReached, castProgress: cr.castProgress, equipmentSpellStates: cr.equipmentSpellStates, golemancy: { ...(writes.combat?.golemancy || ctx.combat.golemancy), activeGolems: cr.activeGolems }, meleeSwordProgress: cr.meleeSwordProgress, currentRoom: cr.currentRoom };
|
||||
}
|
||||
|
||||
if (ctx.combat.currentAction === 'craft') {
|
||||
@@ -358,12 +285,7 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
||||
|
||||
// Phase 3: Write
|
||||
writes.game = { day, hour, incursionStrength };
|
||||
writes.mana = {
|
||||
rawMana,
|
||||
meditateTicks,
|
||||
totalManaGathered,
|
||||
elements,
|
||||
};
|
||||
writes.mana = { rawMana, meditateTicks, totalManaGathered, elements };
|
||||
|
||||
applyTickWrites(writes, storeSetters);
|
||||
} catch (error: unknown) {
|
||||
@@ -396,3 +318,27 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
/** Build pact element map and gross regen for the unified conversion system */
|
||||
function buildConversionParams(
|
||||
signedPacts: number[],
|
||||
attunements: Record<string, { active: boolean; level: number }>,
|
||||
): { pactElementMap: Record<number, string>; grossRegen: Record<string, number> } {
|
||||
const pactElementMap: Record<number, string> = {};
|
||||
for (const floor of signedPacts) {
|
||||
const guardian = getGuardianForFloor(floor);
|
||||
if (guardian?.element?.length) {
|
||||
pactElementMap[floor] = guardian.element[0];
|
||||
}
|
||||
}
|
||||
const grossRegen: Record<string, number> = {};
|
||||
for (const [id, state] of Object.entries(attunements)) {
|
||||
if (!state.active) continue;
|
||||
const def = ATTUNEMENTS_DEF[id];
|
||||
if (def?.primaryManaType) {
|
||||
grossRegen[def.primaryManaType] = (grossRegen[def.primaryManaType] || 0)
|
||||
+ getAttunementConversionRate(id, state.level || 1);
|
||||
}
|
||||
}
|
||||
return { pactElementMap, grossRegen };
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// ─── Mana Store ───────────────────────────────────────────────────────────────
|
||||
// Handles raw mana, elements, meditation, and mana regeneration
|
||||
//
|
||||
// NEW MODEL: All conversion is passive through the unified conversion system.
|
||||
// convertMana, processConvertAction, and craftComposite are removed (no-ops for save compat).
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { ELEMENTS, MANA_PER_ELEMENT, BASE_UNLOCKED_ELEMENTS } from '../constants';
|
||||
import { ELEMENTS, BASE_UNLOCKED_ELEMENTS } from '../constants';
|
||||
import type { ElementState } from '../types';
|
||||
import { ok, okVoid, fail, ErrorCode } from '../utils/result';
|
||||
import { createSafeStorage } from '../utils/safe-persist';
|
||||
@@ -32,27 +35,14 @@ export interface ManaActions {
|
||||
resetMeditateTicks: () => void;
|
||||
|
||||
// Elements
|
||||
convertMana: (element: string, amount: number) => Result<{ converted: number }>;
|
||||
unlockElement: (element: string, cost: number) => Result<void>;
|
||||
addElementMana: (element: string, amount: number, max: number) => void;
|
||||
spendElementMana: (element: string, amount: number) => Result<void>;
|
||||
setElementMax: (max: number) => void;
|
||||
craftComposite: (target: string, recipe: string[]) => Result<void>;
|
||||
|
||||
/**
|
||||
* Compute and apply per-element max from baseMax + bonuses.
|
||||
* Caller provides the bonus map (elementCap_* from disciplines/equipment).
|
||||
* This sets max = baseMax + bonus for each element, preventing double-counting.
|
||||
*/
|
||||
computeElementMaxWithBonuses: (perElementBonuses: Record<string, number>) => void;
|
||||
|
||||
// Helper for gameStore coordination
|
||||
processConvertAction: (rawMana: number) => { rawMana: number; elements: Record<string, ElementState> } | null;
|
||||
|
||||
// Reset
|
||||
resetMana: (
|
||||
prestigeUpgrades: Record<string, number>,
|
||||
) => void;
|
||||
resetMana: (prestigeUpgrades: Record<string, number>) => void;
|
||||
}
|
||||
|
||||
// ─── Combined Mana Store Type ────────────────────────────────────────────────
|
||||
@@ -106,25 +96,6 @@ export const useManaStore = create<ManaStore>()(
|
||||
incrementMeditateTicks: () => set((s) => ({ meditateTicks: s.meditateTicks + 1 })),
|
||||
resetMeditateTicks: () => set({ meditateTicks: 0 }),
|
||||
|
||||
convertMana: (element: string, amount: number) => {
|
||||
const state = get();
|
||||
const elem = state.elements[element];
|
||||
if (!elem?.unlocked) return fail(ErrorCode.ELEMENT_NOT_UNLOCKED, `Element ${element} is not unlocked`);
|
||||
|
||||
const cost = MANA_PER_ELEMENT * amount;
|
||||
if (state.rawMana < cost) return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${cost} raw mana, have ${state.rawMana}`);
|
||||
if (elem.current >= elem.max) return fail(ErrorCode.ELEMENT_MAX_CAPACITY, `Element ${element} is at max capacity`);
|
||||
|
||||
const canConvert = Math.min(amount, Math.floor(state.rawMana / MANA_PER_ELEMENT), elem.max - elem.current);
|
||||
if (canConvert <= 0) return fail(ErrorCode.INVALID_INPUT, 'Cannot convert 0 or negative amount');
|
||||
|
||||
set({
|
||||
rawMana: state.rawMana - canConvert * MANA_PER_ELEMENT,
|
||||
elements: { ...state.elements, [element]: { ...elem, current: elem.current + canConvert } },
|
||||
});
|
||||
return ok({ converted: canConvert });
|
||||
},
|
||||
|
||||
unlockElement: (element: string, cost: number) => {
|
||||
const state = get();
|
||||
if (state.elements[element]?.unlocked) return fail(ErrorCode.INVALID_INPUT, `Element ${element} is already unlocked`);
|
||||
@@ -177,46 +148,7 @@ export const useManaStore = create<ManaStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
craftComposite: (target: string, recipe: string[]) => {
|
||||
const state = get();
|
||||
const costs: Record<string, number> = {};
|
||||
recipe.forEach(r => { costs[r] = (costs[r] || 0) + 1; });
|
||||
|
||||
for (const [r, amt] of Object.entries(costs)) {
|
||||
if ((state.elements[r]?.current || 0) < amt) return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${amt} ${r} mana, have ${state.elements[r]?.current || 0}`);
|
||||
}
|
||||
|
||||
const newElems = { ...state.elements };
|
||||
const baseMax = state.elements[target]?.baseMax ?? 10;
|
||||
for (const [r, amt] of Object.entries(costs)) {
|
||||
newElems[r] = { ...newElems[r], current: newElems[r].current - amt };
|
||||
}
|
||||
|
||||
const targetElem = newElems[target];
|
||||
newElems[target] = { ...(targetElem || { current: 0, max: 10, baseMax: 10, unlocked: false }), current: (targetElem?.current || 0) + 1, unlocked: true, baseMax };
|
||||
set({ elements: newElems });
|
||||
return okVoid();
|
||||
},
|
||||
|
||||
processConvertAction: (rawMana: number) => {
|
||||
const state = get();
|
||||
const elements = { ...state.elements };
|
||||
|
||||
const unlockedElements = Object.entries(elements).filter(([, e]) => e.unlocked && e.current < e.max);
|
||||
if (unlockedElements.length === 0 || rawMana < MANA_PER_ELEMENT) return null;
|
||||
|
||||
unlockedElements.sort((a, b) => (b[1].max - b[1].current) - (a[1].max - a[1].current));
|
||||
const [targetId, targetState] = unlockedElements[0];
|
||||
const canConvert = Math.min(Math.floor(rawMana / MANA_PER_ELEMENT), targetState.max - targetState.current);
|
||||
if (canConvert <= 0) return null;
|
||||
|
||||
rawMana -= canConvert * MANA_PER_ELEMENT;
|
||||
return { rawMana, elements: { ...elements, [targetId]: { ...targetState, current: targetState.current + canConvert } } };
|
||||
},
|
||||
|
||||
resetMana: (
|
||||
prestigeUpgrades: Record<string, number>,
|
||||
) => {
|
||||
resetMana: (prestigeUpgrades: Record<string, number>) => {
|
||||
const elementMax = 10 + (prestigeUpgrades.elementalAttune || 0) * 25;
|
||||
const startingMana = 10 + (prestigeUpgrades.manaStart || 0) * 10;
|
||||
set({ rawMana: startingMana, meditateTicks: 0, totalManaGathered: 0, elements: makeInitialElements(elementMax, prestigeUpgrades) });
|
||||
@@ -228,7 +160,6 @@ export const useManaStore = create<ManaStore>()(
|
||||
version: 2,
|
||||
partialize: (state) => ({ rawMana: state.rawMana, meditateTicks: state.meditateTicks, totalManaGathered: state.totalManaGathered, elements: state.elements }),
|
||||
migrate: (persistedState: any, _version) => {
|
||||
// Migration: add baseMax to elements that don't have it
|
||||
if (persistedState && persistedState.elements) {
|
||||
for (const k of Object.keys(persistedState.elements)) {
|
||||
if (persistedState.elements[k].baseMax === undefined) {
|
||||
|
||||
Reference in New Issue
Block a user