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 # Circular Dependencies
Generated: 2026-05-28T16:14:24.376Z Generated: 2026-05-28T16:38:33.627Z
No circular dependencies found. ✅ No circular dependencies found. ✅
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"_meta": { "_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.", "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." "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/ │ │ ├── hooks/
│ │ │ └── useGameDerived.ts │ │ │ └── useGameDerived.ts
│ │ ├── stores/ │ │ ├── stores/
│ │ │ ├── pipelines/
│ │ │ │ └── pact-ritual.ts
│ │ │ ├── attunementStore.ts │ │ │ ├── attunementStore.ts
│ │ │ ├── combat-actions.ts │ │ │ ├── combat-actions.ts
│ │ │ ├── combat-state.types.ts │ │ │ ├── combat-state.types.ts
@@ -365,6 +367,7 @@ Mana-Loop/
│ │ │ ├── activity-log.ts │ │ │ ├── activity-log.ts
│ │ │ ├── combat-utils.ts │ │ │ ├── combat-utils.ts
│ │ │ ├── discipline-math.ts │ │ │ ├── discipline-math.ts
│ │ │ ├── element-cap-bonus.ts
│ │ │ ├── enemy-generator.ts │ │ │ ├── enemy-generator.ts
│ │ │ ├── enemy-utils.ts │ │ │ ├── enemy-utils.ts
│ │ │ ├── floor-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', () => { it('should reactivate a discipline with elements after deactivating it', () => {
useDisciplineStore.getState().activate('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().activeIds).toContain('attune-fire');
@@ -45,7 +45,7 @@ describe('DisciplineStore — reactivate after deactivate (bug #163)', () => {
expect(useDisciplineStore.getState().activeIds).not.toContain('attune-fire'); expect(useDisciplineStore.getState().activeIds).not.toContain('attune-fire');
useDisciplineStore.getState().activate('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().activeIds).toContain('attune-fire');
expect(useDisciplineStore.getState().disciplines['attune-fire'].paused).toBe(false); 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', () => { it('should not activate capacity discipline when mana type is locked', () => {
// capacity disciplines now require the mana type to be unlocked // capacity disciplines now require the mana type to be unlocked
useDisciplineStore.getState().activate('attune-fire', { 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'); expect(useDisciplineStore.getState().activeIds).not.toContain('attune-fire');
}); });
it('should activate capacity discipline when mana type element is unlocked', () => { it('should activate capacity discipline when mana type element is unlocked', () => {
useDisciplineStore.getState().activate('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().activeIds).toContain('attune-fire');
}); });
@@ -57,7 +57,7 @@ describe('DisciplineStore', () => {
it('should activate when required element is unlocked', () => { it('should activate when required element is unlocked', () => {
useDisciplineStore.getState().activate('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().activeIds).toContain('attune-fire');
}); });
+7 -6
View File
@@ -2,6 +2,7 @@
// Separate file to avoid exceeding the 400-line limit in craftingStore.ts. // Separate file to avoid exceeding the 400-line limit in craftingStore.ts.
import type { EquipmentCraftingProgress } from './types'; import type { EquipmentCraftingProgress } from './types';
import type { ElementState } from './types';
import type { FabricatorRecipe } from './data/fabricator-recipes'; import type { FabricatorRecipe } from './data/fabricator-recipes';
import { FABRICATOR_RECIPES } from './data/fabricator-recipes'; import { FABRICATOR_RECIPES } from './data/fabricator-recipes';
import { useManaStore } from './stores/manaStore'; import { useManaStore } from './stores/manaStore';
@@ -26,7 +27,7 @@ export function checkFabricatorCosts(
recipe: FabricatorRecipe, recipe: FabricatorRecipe,
materials: Record<string, number>, materials: Record<string, number>,
rawMana: number, rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>, elements: Record<string, ElementState>,
): FabricatorCostCheck { ): FabricatorCostCheck {
const missingMaterials: Record<string, number> = {}; const missingMaterials: Record<string, number> = {};
let canCraft = true; let canCraft = true;
@@ -52,13 +53,13 @@ export function checkFabricatorCosts(
export interface ManaDeduction { export interface ManaDeduction {
rawMana: number; rawMana: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>; elements: Record<string, ElementState>;
} }
export function deductFabricatorMana( export function deductFabricatorMana(
recipe: FabricatorRecipe, recipe: FabricatorRecipe,
rawMana: number, rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>, elements: Record<string, ElementState>,
): ManaDeduction | null { ): ManaDeduction | null {
if (recipe.manaType === 'raw') { if (recipe.manaType === 'raw') {
if (rawMana < recipe.manaCost) return null; if (rawMana < recipe.manaCost) return null;
@@ -84,14 +85,14 @@ export function deductFabricatorMana(
export interface ManaRefund { export interface ManaRefund {
rawMana: number; rawMana: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>; elements: Record<string, ElementState>;
} }
export function refundFabricatorMana( export function refundFabricatorMana(
recipe: FabricatorRecipe, recipe: FabricatorRecipe,
refundAmount: number, refundAmount: number,
rawMana: number, rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>, elements: Record<string, ElementState>,
): ManaRefund { ): ManaRefund {
if (recipe.manaType === 'raw') { if (recipe.manaType === 'raw') {
return { rawMana: rawMana + refundAmount, elements }; return { rawMana: rawMana + refundAmount, elements };
@@ -149,7 +150,7 @@ export interface CraftMaterialResult {
success: boolean; success: boolean;
newMaterials: Record<string, number>; newMaterials: Record<string, number>;
newRawMana: number; newRawMana: number;
newElements: Record<string, { current: number; max: number; unlocked: boolean }>; newElements: Record<string, ElementState>;
logMessage: string; 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 { computeDisciplineEffects } from '../effects/discipline-effects';
import { computeMaxMana, computeRegen, getMeditationBonus, getIncursionStrength, calcInsight } from '../utils'; 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 { useUIStore } from './uiStore';
import { usePrestigeStore } from './prestigeStore'; import { usePrestigeStore } from './prestigeStore';
import { useManaStore } from './manaStore'; import { useManaStore } from './manaStore';
@@ -204,40 +206,18 @@ export const useGameStore = create<GameCoordinatorStore>()(
} }
// Pact ritual // Pact ritual
if (ctx.prestige.pactRitualFloor !== null) { const pactResult = processPactRitual(
const guardian = getGuardianForFloor(ctx.prestige.pactRitualFloor); ctx.prestige.pactRitualFloor,
if (guardian) { ctx.prestige.pactRitualProgress,
const pactAffinity = Math.min(0.9, ctx.prestige.signedPacts,
(ctx.prestige.prestigeUpgrades.pactAffinity || 0) * 0.1 + (disciplineEffects.bonuses.pactAffinityBonus || 0)); ctx.prestige.defeatedGuardians,
const requiredTime = guardian.pactTime * (1 - pactAffinity); ctx.prestige.prestigeUpgrades.pactAffinity || 0,
disciplineEffects.bonuses.pactAffinityBonus || 0,
if (ctx.prestige.pactRitualProgress + HOURS_PER_TICK >= requiredTime) { );
addLog(`📜 Pact signed with ${guardian.name}! You have gained their boons.`); if (pactResult.writes) {
writes.prestige = { ...(writes.prestige || {}), ...pactResult.writes };
// 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,
};
}
}
} }
pactResult.logs.forEach(l => addLog(l));
// Discipline tick // Discipline tick
const disciplineResult = useDisciplineStore.getState().processTick({ 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 // Combat — delegate to combatStore
if (ctx.combat.currentAction === 'climb') { if (ctx.combat.currentAction === 'climb') {
const combatResult = useCombatStore.getState().processCombatTick( const combatResult = useCombatStore.getState().processCombatTick(
+42 -5
View File
@@ -39,6 +39,13 @@ export interface ManaActions {
setElementMax: (max: number) => void; setElementMax: (max: number) => void;
craftComposite: (target: string, recipe: string[]) => Result<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 // Helper for gameStore coordination
processConvertAction: (rawMana: number) => { rawMana: number; elements: Record<string, ElementState> } | null; processConvertAction: (rawMana: number) => { rawMana: number; elements: Record<string, ElementState> } | null;
@@ -64,6 +71,7 @@ export const useManaStore = create<ManaStore>()(
{ {
current: 0, current: 0,
max: 10, max: 10,
baseMax: 10,
unlocked: BASE_UNLOCKED_ELEMENTS.includes(k), unlocked: BASE_UNLOCKED_ELEMENTS.includes(k),
} }
]) ])
@@ -148,10 +156,27 @@ export const useManaStore = create<ManaStore>()(
setElementMax: (max: number) => { setElementMax: (max: number) => {
set((state) => ({ 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[]) => { craftComposite: (target: string, recipe: string[]) => {
const state = get(); const state = get();
const costs: Record<string, number> = {}; const costs: Record<string, number> = {};
@@ -162,12 +187,13 @@ export const useManaStore = create<ManaStore>()(
} }
const newElems = { ...state.elements }; const newElems = { ...state.elements };
const baseMax = state.elements[target]?.baseMax ?? 10;
for (const [r, amt] of Object.entries(costs)) { for (const [r, amt] of Object.entries(costs)) {
newElems[r] = { ...newElems[r], current: newElems[r].current - amt }; newElems[r] = { ...newElems[r], current: newElems[r].current - amt };
} }
const targetElem = newElems[target]; 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 }); set({ elements: newElems });
return okVoid(); return okVoid();
}, },
@@ -191,7 +217,7 @@ export const useManaStore = create<ManaStore>()(
resetMana: ( resetMana: (
prestigeUpgrades: Record<string, number>, 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; const startingMana = 10 + (prestigeUpgrades.manaStart || 0) * 10;
set({ rawMana: startingMana, meditateTicks: 0, totalManaGathered: 0, elements: makeInitialElements(elementMax, prestigeUpgrades) }); set({ rawMana: startingMana, meditateTicks: 0, totalManaGathered: 0, elements: makeInitialElements(elementMax, prestigeUpgrades) });
}, },
@@ -199,8 +225,19 @@ export const useManaStore = create<ManaStore>()(
{ {
storage: createSafeStorage(), storage: createSafeStorage(),
name: 'mana-loop-mana', name: 'mana-loop-mana',
version: 1, version: 2,
partialize: (state) => ({ rawMana: state.rawMana, meditateTicks: state.meditateTicks, totalManaGathered: state.totalManaGathered, elements: state.elements }), 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> = {}; const elements: Record<string, ElementState> = {};
for (const k of Object.keys(ELEMENTS)) { for (const k of Object.keys(ELEMENTS)) {
const isUnlocked = BASE_UNLOCKED_ELEMENTS.includes(k); 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; 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 { export interface ElementState {
current: number; current: number;
max: 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; 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;
}