fix: Elemental Mana Capacity disciplines now increase element capacity
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m23s

- Add optional baseMax field to ElementState to track prestige-derived max separately from bonuses
- Add computeElementMaxWithBonuses action to manaStore that computes max = baseMax + per-element bonus
- Apply per-element cap bonuses from disciplines and equipment in game tick (elementCap_* keys)
- Fix resetMana to use correct prestige key (elementalAttune instead of nonexistent elemMax)
- Add store migration (v1->v2) to populate baseMax for existing saved games
- Extract pact ritual processing to pipelines/pact-ritual.ts
- Extract element cap bonus utilities to utils/element-cap-bonus.ts
- Fix inline element types in crafting-fabricator.ts
- Update test fixtures to include baseMax in element literals

Fixes #185
This commit is contained in:
2026-05-28 19:49:47 +02:00
parent 8fef73d233
commit 6355cf308b
11 changed files with 183 additions and 51 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# Circular Dependencies
Generated: 2026-05-28T16:14:24.376Z
Generated: 2026-05-28T16:38:33.627Z
No circular dependencies found. ✅
+1 -1
View File
@@ -1,6 +1,6 @@
{
"_meta": {
"generated": "2026-05-28T16:14:22.630Z",
"generated": "2026-05-28T16:38:31.819Z",
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
},
+3
View File
@@ -333,6 +333,8 @@ Mana-Loop/
│ │ ├── hooks/
│ │ │ └── useGameDerived.ts
│ │ ├── stores/
│ │ │ ├── pipelines/
│ │ │ │ └── pact-ritual.ts
│ │ │ ├── attunementStore.ts
│ │ │ ├── combat-actions.ts
│ │ │ ├── combat-state.types.ts
@@ -365,6 +367,7 @@ Mana-Loop/
│ │ │ ├── activity-log.ts
│ │ │ ├── combat-utils.ts
│ │ │ ├── discipline-math.ts
│ │ │ ├── element-cap-bonus.ts
│ │ │ ├── enemy-generator.ts
│ │ │ ├── enemy-utils.ts
│ │ │ ├── floor-utils.ts
@@ -37,7 +37,7 @@ describe('DisciplineStore — reactivate after deactivate (bug #163)', () => {
it('should reactivate a discipline with elements after deactivating it', () => {
useDisciplineStore.getState().activate('attune-fire', {
elements: { fire: { unlocked: true, current: 100, max: 100 } },
elements: { fire: { unlocked: true, current: 100, max: 100, baseMax: 100 } },
});
expect(useDisciplineStore.getState().activeIds).toContain('attune-fire');
@@ -45,7 +45,7 @@ describe('DisciplineStore — reactivate after deactivate (bug #163)', () => {
expect(useDisciplineStore.getState().activeIds).not.toContain('attune-fire');
useDisciplineStore.getState().activate('attune-fire', {
elements: { fire: { unlocked: true, current: 100, max: 100 } },
elements: { fire: { unlocked: true, current: 100, max: 100, baseMax: 100 } },
});
expect(useDisciplineStore.getState().activeIds).toContain('attune-fire');
expect(useDisciplineStore.getState().disciplines['attune-fire'].paused).toBe(false);
@@ -35,14 +35,14 @@ describe('DisciplineStore', () => {
it('should not activate capacity discipline when mana type is locked', () => {
// capacity disciplines now require the mana type to be unlocked
useDisciplineStore.getState().activate('attune-fire', {
elements: { fire: { unlocked: false, current: 100, max: 100 } },
elements: { fire: { unlocked: false, current: 100, max: 100, baseMax: 100 } },
});
expect(useDisciplineStore.getState().activeIds).not.toContain('attune-fire');
});
it('should activate capacity discipline when mana type element is unlocked', () => {
useDisciplineStore.getState().activate('attune-fire', {
elements: { fire: { unlocked: true, current: 100, max: 100 } },
elements: { fire: { unlocked: true, current: 100, max: 100, baseMax: 100 } },
});
expect(useDisciplineStore.getState().activeIds).toContain('attune-fire');
});
@@ -57,7 +57,7 @@ describe('DisciplineStore', () => {
it('should activate when required element is unlocked', () => {
useDisciplineStore.getState().activate('attune-fire', {
elements: { fire: { unlocked: true, current: 100, max: 100 } },
elements: { fire: { unlocked: true, current: 100, max: 100, baseMax: 100 } },
});
expect(useDisciplineStore.getState().activeIds).toContain('attune-fire');
});
+7 -6
View File
@@ -2,6 +2,7 @@
// Separate file to avoid exceeding the 400-line limit in craftingStore.ts.
import type { EquipmentCraftingProgress } from './types';
import type { ElementState } from './types';
import type { FabricatorRecipe } from './data/fabricator-recipes';
import { FABRICATOR_RECIPES } from './data/fabricator-recipes';
import { useManaStore } from './stores/manaStore';
@@ -26,7 +27,7 @@ export function checkFabricatorCosts(
recipe: FabricatorRecipe,
materials: Record<string, number>,
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
elements: Record<string, ElementState>,
): FabricatorCostCheck {
const missingMaterials: Record<string, number> = {};
let canCraft = true;
@@ -52,13 +53,13 @@ export function checkFabricatorCosts(
export interface ManaDeduction {
rawMana: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
elements: Record<string, ElementState>;
}
export function deductFabricatorMana(
recipe: FabricatorRecipe,
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
elements: Record<string, ElementState>,
): ManaDeduction | null {
if (recipe.manaType === 'raw') {
if (rawMana < recipe.manaCost) return null;
@@ -84,14 +85,14 @@ export function deductFabricatorMana(
export interface ManaRefund {
rawMana: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
elements: Record<string, ElementState>;
}
export function refundFabricatorMana(
recipe: FabricatorRecipe,
refundAmount: number,
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
elements: Record<string, ElementState>,
): ManaRefund {
if (recipe.manaType === 'raw') {
return { rawMana: rawMana + refundAmount, elements };
@@ -149,7 +150,7 @@ export interface CraftMaterialResult {
success: boolean;
newMaterials: Record<string, number>;
newRawMana: number;
newElements: Record<string, { current: number; max: number; unlocked: boolean }>;
newElements: Record<string, ElementState>;
logMessage: string;
}
+28 -33
View File
@@ -12,6 +12,8 @@ import type { ComputedEffects } from '../effects/upgrade-effects.types';
import { computeDisciplineEffects } from '../effects/discipline-effects';
import { computeMaxMana, computeRegen, getMeditationBonus, getIncursionStrength, calcInsight } from '../utils';
import { mergePerElementCapBonuses } from '../utils/element-cap-bonus';
import { processPactRitual } from './pipelines/pact-ritual';
import { useUIStore } from './uiStore';
import { usePrestigeStore } from './prestigeStore';
import { useManaStore } from './manaStore';
@@ -204,40 +206,18 @@ export const useGameStore = create<GameCoordinatorStore>()(
}
// Pact ritual
if (ctx.prestige.pactRitualFloor !== null) {
const guardian = getGuardianForFloor(ctx.prestige.pactRitualFloor);
if (guardian) {
const pactAffinity = Math.min(0.9,
(ctx.prestige.prestigeUpgrades.pactAffinity || 0) * 0.1 + (disciplineEffects.bonuses.pactAffinityBonus || 0));
const requiredTime = guardian.pactTime * (1 - pactAffinity);
if (ctx.prestige.pactRitualProgress + HOURS_PER_TICK >= requiredTime) {
addLog(`📜 Pact signed with ${guardian.name}! You have gained their boons.`);
// Unlock mana types granted by this guardian
const manaStore = useManaStore.getState();
for (const manaType of guardian.unlocksMana || []) {
const result = manaStore.unlockElement(manaType, 0);
if (result.success) {
addLog(`${manaType.charAt(0).toUpperCase() + manaType.slice(1)} mana unlocked!`);
}
}
writes.prestige = {
...(writes.prestige || {}),
signedPacts: [...ctx.prestige.signedPacts, ctx.prestige.pactRitualFloor],
defeatedGuardians: ctx.prestige.defeatedGuardians.filter(f => f !== ctx.prestige.pactRitualFloor),
pactRitualFloor: null,
pactRitualProgress: 0,
};
} else {
writes.prestige = {
...(writes.prestige || {}),
pactRitualProgress: ctx.prestige.pactRitualProgress + HOURS_PER_TICK,
};
}
}
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));
// Discipline tick
const disciplineResult = useDisciplineStore.getState().processTick({
@@ -294,6 +274,21 @@ export const useGameStore = create<GameCoordinatorStore>()(
}
}
// Apply per-element capacity bonuses from disciplines and equipment
const perElementCapBonuses = mergePerElementCapBonuses(
disciplineEffects.bonuses,
equipmentEffects.bonuses,
);
useManaStore.getState().computeElementMaxWithBonuses(perElementCapBonuses);
// Sync updated max/baseMax from mana store into tick elements snapshot
const manaStateAfter = useManaStore.getState();
for (const [ek, es] of Object.entries(manaStateAfter.elements)) {
if (elements[ek]) {
elements[ek] = { ...elements[ek], max: es.max, baseMax: es.baseMax };
}
}
// Combat — delegate to combatStore
if (ctx.combat.currentAction === 'climb') {
const combatResult = useCombatStore.getState().processCombatTick(
+42 -5
View File
@@ -39,6 +39,13 @@ export interface ManaActions {
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;
@@ -64,6 +71,7 @@ export const useManaStore = create<ManaStore>()(
{
current: 0,
max: 10,
baseMax: 10,
unlocked: BASE_UNLOCKED_ELEMENTS.includes(k),
}
])
@@ -148,10 +156,27 @@ export const useManaStore = create<ManaStore>()(
setElementMax: (max: number) => {
set((state) => ({
elements: Object.fromEntries(Object.entries(state.elements).map(([k, v]) => [k, { ...v, max }])) as Record<string, ElementState>,
elements: Object.fromEntries(Object.entries(state.elements).map(([k, v]) => [k, { ...v, max, baseMax: v.baseMax ?? max }])) as Record<string, ElementState>,
}));
},
computeElementMaxWithBonuses: (perElementBonuses: Record<string, number>) => {
set((state) => {
const newElements = { ...state.elements };
let changed = false;
for (const [element, bonus] of Object.entries(perElementBonuses)) {
if (newElements[element] && bonus > 0) {
const newMax = (newElements[element].baseMax ?? newElements[element].max) + bonus;
if (newElements[element].max !== newMax) {
newElements[element] = { ...newElements[element], max: newMax };
changed = true;
}
}
}
return changed ? { elements: newElements } : state;
});
},
craftComposite: (target: string, recipe: string[]) => {
const state = get();
const costs: Record<string, number> = {};
@@ -162,12 +187,13 @@ export const useManaStore = create<ManaStore>()(
}
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, unlocked: false }), current: (targetElem?.current || 0) + 1, unlocked: true };
newElems[target] = { ...(targetElem || { current: 0, max: 10, baseMax: 10, unlocked: false }), current: (targetElem?.current || 0) + 1, unlocked: true, baseMax };
set({ elements: newElems });
return okVoid();
},
@@ -191,7 +217,7 @@ export const useManaStore = create<ManaStore>()(
resetMana: (
prestigeUpgrades: Record<string, number>,
) => {
const elementMax = 10 + (prestigeUpgrades.elemMax || 0) * 5;
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) });
},
@@ -199,8 +225,19 @@ export const useManaStore = create<ManaStore>()(
{
storage: createSafeStorage(),
name: 'mana-loop-mana',
version: 1,
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) {
persistedState.elements[k].baseMax = persistedState.elements[k].max ?? 10;
}
}
}
return persistedState;
},
}
)
);
@@ -214,7 +251,7 @@ export function makeInitialElements(
const elements: Record<string, ElementState> = {};
for (const k of Object.keys(ELEMENTS)) {
const isUnlocked = BASE_UNLOCKED_ELEMENTS.includes(k);
elements[k] = { current: isUnlocked ? elemStart : 0, max: elementMax, unlocked: isUnlocked };
elements[k] = { current: isUnlocked ? elemStart : 0, max: elementMax, baseMax: elementMax, unlocked: isUnlocked };
}
return elements;
}
@@ -0,0 +1,55 @@
// ─── Pact Ritual Pipeline Phase ───────────────────────────────────────────────
// Processes pact ritual signing during the game tick.
import { useManaStore } from '../manaStore';
import { getGuardianForFloor } from '../../data/guardian-encounters';
import { HOURS_PER_TICK } from '../../constants';
export interface PactRitualResult {
writes: {
signedPacts?: number[];
deficientGuardians?: number[];
pactRitualFloor: number | null;
pactRitualProgress: number;
} | null;
logs: string[];
}
/**
* Process pact ritual progression. Advances progress and completes signing
* when enough enough hours have accumulated.
*/
export function processPactRitual(pactRitualFloor: number | null, pactRitualProgress: number, signedPacts: number[], defeatedGuardians: number[], pactAffinityUpgrade: number, pactAffinityBonus: number): PactRitualResult {
if (pactRitualFloor === null) return { writes: null, logs: [] };
const logs: string[] = [];
const guardian = getGuardianForFloor(pactRitualFloor);
if (!guardian) return { writes: null, logs: [] };
const pactAffinity = Math.min(0.9, pactAffinityUpgrade * 0.1 + pactAffinityBonus);
const requiredTime = guardian.pactTime * (1 - pactAffinity);
if (pactRitualProgress + HOURS_PER_TICK >= requiredTime) {
logs.push(`📜 Pact signed with ${guardian.name}! You have gained their boons.`);
const manaStore = useManaStore.getState();
for (const manaType of guardian.unlocksMana || []) {
const result = manaStore.unlockElement(manaType, 0);
if (result.success) {
logs.push(`${manaType.charAt(0).toUpperCase() + manaType.slice(1)} mana unlocked!`);
}
}
return {
writes: {
signedPacts: [...signedPacts, pactRitualFloor],
defeatedGuardians: defeatedGuardians.filter(f => f !== pactRitualFloor),
pactRitualFloor: null,
pactRitualProgress: 0,
},
logs,
};
}
return {
writes: { pactRitualFloor, pactRitualProgress: pactRitualProgress + HOURS_PER_TICK, signedPacts, defeatedGuardians },
logs,
};
}
+3
View File
@@ -16,5 +16,8 @@ export interface ElementDef {
export interface ElementState {
current: number;
max: number;
/** Base max from prestige upgrades (elementalAttune), without discipline/equipment bonuses.
* If not set, falls back to max (legacy behavior for backwards compat). */
baseMax?: number;
unlocked: boolean;
}
+38
View File
@@ -0,0 +1,38 @@
// ─── Element Capacity Bonus Utilities ─────────────────────────────────────────
// Extracts and merges per-element capacity bonuses from discipline and equipment
// effects for application during the game tick.
/**
* Extract elementCap_* bonuses from a bonus record.
* Returns a map of element name → bonus amount.
*/
export function extractElementCapBonuses(
bonuses: Record<string, number>
): Record<string, number> {
const result: Record<string, number> = {};
for (const [key, value] of Object.entries(bonuses)) {
if (key.startsWith('elementCap_') && value > 0) {
const element = key.replace('elementCap_', '');
result[element] = (result[element] || 0) + value;
}
}
return result;
}
/**
* Merge elementCap_* bonuses from discipline and equipment effects
* into a single per-element bonus map.
*/
export function mergePerElementCapBonuses(
disciplineBonuses: Record<string, number>,
equipmentBonuses: Record<string, number>,
): Record<string, number> {
const merged: Record<string, number> = {};
for (const [element, value] of Object.entries(extractElementCapBonuses(disciplineBonuses))) {
merged[element] = (merged[element] || 0) + value;
}
for (const [element, value] of Object.entries(extractElementCapBonuses(equipmentBonuses))) {
merged[element] = (merged[element] || 0) + value;
}
return merged;
}