From 6355cf308bc043c97312255c2825632e431d7661 Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Thu, 28 May 2026 19:49:47 +0200 Subject: [PATCH] fix: Elemental Mana Capacity disciplines now increase element capacity - 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 --- docs/circular-deps.txt | 2 +- docs/dependency-graph.json | 2 +- docs/project-structure.txt | 3 + .../discipline-reactivate-bug.test.ts | 4 +- .../store-actions-discipline.test.ts | 6 +- src/lib/game/crafting-fabricator.ts | 13 ++-- src/lib/game/stores/gameStore.ts | 61 +++++++++---------- src/lib/game/stores/manaStore.ts | 47 ++++++++++++-- src/lib/game/stores/pipelines/pact-ritual.ts | 55 +++++++++++++++++ src/lib/game/types/elements.ts | 3 + src/lib/game/utils/element-cap-bonus.ts | 38 ++++++++++++ 11 files changed, 183 insertions(+), 51 deletions(-) create mode 100644 src/lib/game/stores/pipelines/pact-ritual.ts create mode 100644 src/lib/game/utils/element-cap-bonus.ts diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 24765e9..f5abd9a 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,4 +1,4 @@ # Circular Dependencies -Generated: 2026-05-28T16:14:24.376Z +Generated: 2026-05-28T16:38:33.627Z No circular dependencies found. ✅ diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index 2ad1a6a..3c5d9a3 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -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." }, diff --git a/docs/project-structure.txt b/docs/project-structure.txt index faead81..b0238f6 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -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 diff --git a/src/lib/game/__tests__/discipline-reactivate-bug.test.ts b/src/lib/game/__tests__/discipline-reactivate-bug.test.ts index 7fb06da..6551e95 100644 --- a/src/lib/game/__tests__/discipline-reactivate-bug.test.ts +++ b/src/lib/game/__tests__/discipline-reactivate-bug.test.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); diff --git a/src/lib/game/__tests__/store-actions-discipline.test.ts b/src/lib/game/__tests__/store-actions-discipline.test.ts index 4864c6e..71ecdaf 100644 --- a/src/lib/game/__tests__/store-actions-discipline.test.ts +++ b/src/lib/game/__tests__/store-actions-discipline.test.ts @@ -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'); }); diff --git a/src/lib/game/crafting-fabricator.ts b/src/lib/game/crafting-fabricator.ts index 7f34310..1d439c5 100644 --- a/src/lib/game/crafting-fabricator.ts +++ b/src/lib/game/crafting-fabricator.ts @@ -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, rawMana: number, - elements: Record, + elements: Record, ): FabricatorCostCheck { const missingMaterials: Record = {}; let canCraft = true; @@ -52,13 +53,13 @@ export function checkFabricatorCosts( export interface ManaDeduction { rawMana: number; - elements: Record; + elements: Record; } export function deductFabricatorMana( recipe: FabricatorRecipe, rawMana: number, - elements: Record, + elements: Record, ): 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; + elements: Record; } export function refundFabricatorMana( recipe: FabricatorRecipe, refundAmount: number, rawMana: number, - elements: Record, + elements: Record, ): ManaRefund { if (recipe.manaType === 'raw') { return { rawMana: rawMana + refundAmount, elements }; @@ -149,7 +150,7 @@ export interface CraftMaterialResult { success: boolean; newMaterials: Record; newRawMana: number; - newElements: Record; + newElements: Record; logMessage: string; } diff --git a/src/lib/game/stores/gameStore.ts b/src/lib/game/stores/gameStore.ts index 6705f0a..9a056b1 100644 --- a/src/lib/game/stores/gameStore.ts +++ b/src/lib/game/stores/gameStore.ts @@ -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()( } // 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()( } } + // 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( diff --git a/src/lib/game/stores/manaStore.ts b/src/lib/game/stores/manaStore.ts index 33366d3..2fa1b03 100755 --- a/src/lib/game/stores/manaStore.ts +++ b/src/lib/game/stores/manaStore.ts @@ -39,6 +39,13 @@ export interface ManaActions { setElementMax: (max: number) => void; craftComposite: (target: string, recipe: string[]) => Result; + /** + * 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) => void; + // Helper for gameStore coordination processConvertAction: (rawMana: number) => { rawMana: number; elements: Record } | null; @@ -64,6 +71,7 @@ export const useManaStore = create()( { current: 0, max: 10, + baseMax: 10, unlocked: BASE_UNLOCKED_ELEMENTS.includes(k), } ]) @@ -148,10 +156,27 @@ export const useManaStore = create()( setElementMax: (max: number) => { set((state) => ({ - elements: Object.fromEntries(Object.entries(state.elements).map(([k, v]) => [k, { ...v, max }])) as Record, + elements: Object.fromEntries(Object.entries(state.elements).map(([k, v]) => [k, { ...v, max, baseMax: v.baseMax ?? max }])) as Record, })); }, + computeElementMaxWithBonuses: (perElementBonuses: Record) => { + 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 = {}; @@ -162,12 +187,13 @@ export const useManaStore = create()( } 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()( resetMana: ( prestigeUpgrades: Record, ) => { - 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()( { 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 = {}; 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; } diff --git a/src/lib/game/stores/pipelines/pact-ritual.ts b/src/lib/game/stores/pipelines/pact-ritual.ts new file mode 100644 index 0000000..9e6247d --- /dev/null +++ b/src/lib/game/stores/pipelines/pact-ritual.ts @@ -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, + }; +} diff --git a/src/lib/game/types/elements.ts b/src/lib/game/types/elements.ts index 2c1b279..858d691 100644 --- a/src/lib/game/types/elements.ts +++ b/src/lib/game/types/elements.ts @@ -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; } diff --git a/src/lib/game/utils/element-cap-bonus.ts b/src/lib/game/utils/element-cap-bonus.ts new file mode 100644 index 0000000..b1874a7 --- /dev/null +++ b/src/lib/game/utils/element-cap-bonus.ts @@ -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 +): Record { + const result: Record = {}; + 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, + equipmentBonuses: Record, +): Record { + const merged: Record = {}; + 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; +}