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:
@@ -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