From e4f4b297e836d1e237cc1bdfc7cf395416933252 Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Sat, 30 May 2026 22:28:45 +0200 Subject: [PATCH] fix: Bug fixes #218 #222 #220 #223 #215 #216 - attunement free mana, transference circular ref, guardian defeat tracking, discipline negative mana, guardian data, crafting refunds --- docs/circular-deps.txt | 2 +- docs/dependency-graph.json | 24 ++-- docs/project-structure.txt | 2 + src/lib/game/crafting-equipment.ts | 13 ++- .../game/data/disciplines/elemental-regen.ts | 2 +- src/lib/game/data/guardian-data.ts | 53 +++++---- src/lib/game/stores/craftingStore.ts | 67 +----------- src/lib/game/stores/gameStore.ts | 89 ++++----------- src/lib/game/stores/pipelines/combat-tick.ts | 99 +++++++++++++++++ .../stores/pipelines/equipment-crafting.ts | 103 ++++++++++++++++++ 10 files changed, 289 insertions(+), 165 deletions(-) create mode 100644 src/lib/game/stores/pipelines/combat-tick.ts create mode 100644 src/lib/game/stores/pipelines/equipment-crafting.ts diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index abb0d12..ff8cdf3 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,4 +1,4 @@ # Circular Dependencies -Generated: 2026-05-30T13:27:24.782Z +Generated: 2026-05-30T20:28:32.289Z No circular dependencies found. ✅ diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index 9df2d07..8e202df 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-05-30T13:27:23.117Z", + "generated": "2026-05-30T20:28:30.386Z", "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." }, @@ -198,9 +198,7 @@ ], "crafting-fabricator.ts": [ "data/fabricator-recipes.ts", - "stores/combatStore.ts", "stores/manaStore.ts", - "stores/uiStore.ts", "types.ts" ], "crafting-loot.ts": [ @@ -568,14 +566,13 @@ "crafting-actions/equipment-actions.ts", "crafting-actions/preparation-actions.ts", "crafting-design.ts", - "crafting-equipment.ts", - "crafting-fabricator.ts", "crafting-utils.ts", "stores/combatStore.ts", "stores/crafting-equipment-tick.ts", "stores/crafting-initial-state.ts", "stores/craftingStore.types.ts", "stores/manaStore.ts", + "stores/pipelines/equipment-crafting.ts", "stores/uiStore.ts", "types/equipmentSlot.ts", "utils/result.ts", @@ -637,10 +634,8 @@ "stores/gameStore.ts": [ "constants.ts", "data/attunements.ts", - "data/guardian-encounters.ts", "effects.ts", "effects/discipline-effects.ts", - "effects/special-effects.ts", "effects/upgrade-effects.types.ts", "stores/attunementStore.ts", "stores/combatStore.ts", @@ -650,6 +645,7 @@ "stores/gameLoopActions.ts", "stores/gameStore.types.ts", "stores/manaStore.ts", + "stores/pipelines/combat-tick.ts", "stores/pipelines/pact-ritual.ts", "stores/prestigeStore.ts", "stores/tick-pipeline.ts", @@ -681,6 +677,20 @@ "utils/result.ts", "utils/safe-persist.ts" ], + "stores/pipelines/combat-tick.ts": [ + "constants.ts", + "data/guardian-encounters.ts", + "effects/special-effects.ts", + "effects/upgrade-effects.types.ts" + ], + "stores/pipelines/equipment-crafting.ts": [ + "crafting-equipment.ts", + "crafting-fabricator.ts", + "stores/combatStore.ts", + "stores/craftingStore.types.ts", + "stores/manaStore.ts", + "stores/uiStore.ts" + ], "stores/pipelines/pact-ritual.ts": [ "constants.ts", "data/guardian-encounters.ts", diff --git a/docs/project-structure.txt b/docs/project-structure.txt index 7879cd7..d9b6770 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -349,6 +349,8 @@ Mana-Loop/ │ │ │ │ └── useGameDerived.ts │ │ │ ├── stores/ │ │ │ │ ├── pipelines/ +│ │ │ │ │ ├── combat-tick.ts +│ │ │ │ │ ├── equipment-crafting.ts │ │ │ │ │ └── pact-ritual.ts │ │ │ │ ├── attunementStore.ts │ │ │ │ ├── combat-actions.ts diff --git a/src/lib/game/crafting-equipment.ts b/src/lib/game/crafting-equipment.ts index e690c41..173721f 100644 --- a/src/lib/game/crafting-equipment.ts +++ b/src/lib/game/crafting-equipment.ts @@ -124,11 +124,20 @@ export interface CraftingCancelResult { logMessage: string; } -export function cancelEquipmentCrafting(blueprintId: string, manaSpent: number): CraftingCancelResult { +export function cancelEquipmentCrafting(blueprintId: string, manaSpent: number, currentProgress?: number, requiredProgress?: number): CraftingCancelResult { const recipe = CRAFTING_RECIPES[blueprintId]; if (!recipe) return { manaRefund: 0, logMessage: 'Invalid crafting recipe.' }; - const manaRefund = Math.floor(manaSpent * MANA_REFUND_RATE); + // Refund proportional to remaining progress: unspent portion + half of spent portion + let refundRate: number; + if (currentProgress !== undefined && requiredProgress && requiredProgress > 0) { + const remainingFraction = Math.max(0, (requiredProgress - currentProgress) / requiredProgress); + // Full refund for unspent progress, flat 50% for spent progress + refundRate = remainingFraction + (1 - remainingFraction) * MANA_REFUND_RATE; + } else { + refundRate = MANA_REFUND_RATE; + } + const manaRefund = Math.floor(manaSpent * refundRate); return { manaRefund, logMessage: `🚫 Equipment crafting cancelled. Refunded ${manaRefund} mana.` }; } diff --git a/src/lib/game/data/disciplines/elemental-regen.ts b/src/lib/game/data/disciplines/elemental-regen.ts index fd5c804..d23c663 100644 --- a/src/lib/game/data/disciplines/elemental-regen.ts +++ b/src/lib/game/data/disciplines/elemental-regen.ts @@ -89,6 +89,6 @@ export const elementalRegenDisciplines: DisciplineDefinition[] = [ difficultyFactor: 100, scalingFactor: 50, drainBase: 1, - sourceManaTypes: ['transference'], + sourceManaTypes: ['raw'], }), ]; diff --git a/src/lib/game/data/guardian-data.ts b/src/lib/game/data/guardian-data.ts index 21365fd..282c3a1 100644 --- a/src/lib/game/data/guardian-data.ts +++ b/src/lib/game/data/guardian-data.ts @@ -198,31 +198,36 @@ const TIER2: Record = { [{ type: 'freeze', value: 0.2 }], { shield: 900, shieldRegen: 22, barrier: 0.08, barrierRegen: 0.02 }, ), - 130: mk(130, '', ['blackflame'], '#8B2500', 0.32, 4.5, + 130: mk(130, '', ['metal', 'fire', 'earth'], '#8B2500', 0.32, 4.5, [ - { type: 'elementalDamage', value: 20, desc: '+20% BlackFlame damage' }, + { type: 'elementalDamage', value: 10, desc: '+10% Metal damage' }, + { type: 'elementalDamage', value: 10, desc: '+10% Fire damage' }, + { type: 'elementalDamage', value: 10, desc: '+10% Earth damage' }, { type: 'rawDamage', value: 10, desc: '+10% raw damage' }, ], - 'BlackFlame spells apply a curse that reduces enemy resistances by 15%', - [{ type: 'curse', value: 0.15 }, { type: 'burn', value: 0.15 }], + 'Multi-element BlackFlame spells apply a curse that reduces enemy resistances by 15%', + [{ type: 'curse', value: 0.15 }, { type: 'burn', value: 0.15 }, { type: 'armor_pierce', value: 0.2 }], { shield: 1000, shieldRegen: 25, healthRegen: 5, healthRegenIsPercent: true }, ), - 140: mk(140, '', ['radiantflames'], '#FFAA33', 0.25, 4.75, + 140: mk(140, '', ['sand', 'earth', 'water'], '#FFAA33', 0.25, 4.75, [ - { type: 'elementalDamage', value: 15, desc: '+15% Radiant Flames damage' }, - { type: 'insightGain', value: 10, desc: '+10% insight gain' }, + { type: 'elementalDamage', value: 10, desc: '+10% Sand damage' }, + { type: 'elementalDamage', value: 10, desc: '+10% Earth damage' }, + { type: 'elementalDamage', value: 10, desc: '+10% Water damage' }, ], - 'Radiant Flames spells blind enemies, reducing their accuracy and damage by 15%', - [{ type: 'blind', value: 0.15 }, { type: 'burn', value: 0.1 }], + 'Radiant Earth spells blind enemies, reducing their accuracy and damage by 15%', + [{ type: 'blind', value: 0.15 }, { type: 'armor_pierce', value: 0.1 }], { barrier: 0.12, barrierRegen: 0.03, healthRegen: 6, healthRegenIsPercent: true }, ), - 150: mk(150, '', ['miasma'], '#6B8E23', 0.28, 5.0, + 150: mk(150, '', ['lightning', 'fire', 'air'], '#6B8E23', 0.28, 5.0, [ - { type: 'elementalDamage', value: 15, desc: '+15% Miasma damage' }, + { type: 'elementalDamage', value: 10, desc: '+10% Lightning damage' }, + { type: 'elementalDamage', value: 10, desc: '+10% Fire damage' }, + { type: 'elementalDamage', value: 10, desc: '+10% Air damage' }, { type: 'maxMana', value: 200, desc: '+200 max mana' }, ], - 'Miasma spells corrode armor and spread plague in swarm rooms', - [{ type: 'corrosion', value: 0.2 }, { type: 'poison', value: 0.15 }], + 'Storm Lightning spells corrode armor and spread chain lightning in swarm rooms', + [{ type: 'chain', value: 2 }, { type: 'cast_speed', value: 0.1 }, { type: 'burn', value: 0.1 }], { shield: 1100, shieldRegen: 28, barrier: 0.05, barrierRegen: 0.01 }, ), 160: mk(160, '', ['shadowglass'], '#2C2C54', 0.33, 5.25, @@ -272,22 +277,26 @@ const TIER3: Record = { [{ type: 'resist_ignore', value: 0.4 }], { shield: 2500, shieldRegen: 60, barrier: 0.10, barrierRegen: 0.02, healthRegen: 6, healthRegenIsPercent: true }, ), - 200: mk(200, '', ['soul'], '#E8D5F5', 0.30, 7.0, + 200: mk(200, '', ['crystal', 'stellar', 'void'], '#E8D5F5', 0.35, 7.0, [ - { type: 'elementalDamage', value: 25, desc: '+25% Soul damage' }, - { type: 'rawDamage', value: 20, desc: '+20% raw damage' }, + { type: 'elementalDamage', value: 20, desc: '+20% Crystal damage' }, + { type: 'elementalDamage', value: 20, desc: '+20% Stellar damage' }, + { type: 'elementalDamage', value: 20, desc: '+20% Void damage' }, + { type: 'maxMana', value: 400, desc: '+400 max mana' }, ], - 'Soul spells bypass all defenses and shields', - [{ type: 'defense_pierce', value: 0.5 }, { type: 'mana_drain', value: 0.2 }], + 'Exotic convergence: Crystal/Stellar/Void spells bypass all defenses and shields', + [{ type: 'defense_pierce', value: 0.3 }, { type: 'resist_ignore', value: 0.2 }, { type: 'reflect', value: 0.1 }], { shield: 2800, shieldRegen: 55, barrier: 0.08, barrierRegen: 0.02 }, ), - 210: mk(210, '', ['time'], '#C5B99A', 0.32, 7.5, + 210: mk(210, '', ['soul', 'time', 'plasma'], '#C5B99A', 0.32, 7.5, [ - { type: 'elementalDamage', value: 20, desc: '+20% Time damage' }, + { type: 'elementalDamage', value: 15, desc: '+15% Soul damage' }, + { type: 'elementalDamage', value: 15, desc: '+15% Time damage' }, + { type: 'elementalDamage', value: 15, desc: '+15% Plasma damage' }, { type: 'castingSpeed', value: 20, desc: '+20% casting speed' }, ], - 'Time spells slow enemies by 30% and reduce dodge by 20%', - [{ type: 'slow', value: 0.3 }, { type: 'temporal_snap', value: 0.15 }], + 'Astral convergence: Soul/Time/Plasma spells slow and pierce all defenses', + [{ type: 'defense_pierce', value: 0.3 }, { type: 'slow', value: 0.2 }, { type: 'chain', value: 2 }], { barrier: 0.15, barrierRegen: 0.04, healthRegen: 9, healthRegenIsPercent: true }, ), 220: mk(220, '', ['plasma'], '#FF6B9D', 0.28, 8.0, diff --git a/src/lib/game/stores/craftingStore.ts b/src/lib/game/stores/craftingStore.ts index 193e414..e6fbdb8 100644 --- a/src/lib/game/stores/craftingStore.ts +++ b/src/lib/game/stores/craftingStore.ts @@ -11,19 +11,13 @@ import { useCombatStore } from './combatStore'; import { useUIStore } from './uiStore'; import * as ApplicationActions from '../crafting-actions/application-actions'; import * as PreparationActions from '../crafting-actions/preparation-actions'; -import * as CraftingEquipment from '../crafting-equipment'; import { equipItem as equipItemAction, unequipItem as unequipItemAction } from '../crafting-actions/equipment-actions'; import { ErrorCode } from '../utils/result'; import { createSafeStorage } from '../utils/safe-persist'; import { createDefaultCraftingState } from './crafting-initial-state'; -import { - getFabricatorRecipe, - deductFabricatorMana, - deductMaterials, - makeFabricatorProgress, -} from '../crafting-fabricator'; import { craftMaterial as craftMaterialAction } from '../crafting-actions/crafting-material-actions'; import { processEquipmentCraftingTick } from './crafting-equipment-tick'; +import { startCraftingEquipment, cancelEquipmentCrafting, startFabricatorCrafting } from './pipelines/equipment-crafting'; export const useCraftingStore = create()( persist( @@ -179,65 +173,12 @@ export const useCraftingStore = create()( useCombatStore.setState({ currentAction: 'meditate' }); }, - startCraftingEquipment: (blueprintId: string) => { - const state = get(); - const rawMana = useManaStore.getState().rawMana; - const currentAction = useCombatStore.getState().currentAction; - const check = CraftingEquipment.canStartEquipmentCrafting( - blueprintId, - state.lootInventory.blueprints.includes(blueprintId), - state.lootInventory.materials, - rawMana, - currentAction, - ); - if (!check.canCraft) return false; - const result = CraftingEquipment.initializeEquipmentCrafting( - blueprintId, - state.lootInventory.materials, - rawMana, - ); - set((s) => ({ - lootInventory: { ...s.lootInventory, materials: result.newMaterials }, - equipmentCraftingProgress: result.progress, - })); - useManaStore.setState((s) => ({ rawMana: s.rawMana - result.manaCost })); - useCombatStore.setState({ currentAction: 'craft' }); - return true; - }, + startCraftingEquipment: (blueprintId: string) => startCraftingEquipment(blueprintId, get, set), - cancelEquipmentCrafting: () => { - const progress = get().equipmentCraftingProgress; - if (!progress) return; - const cancelResult = CraftingEquipment.cancelEquipmentCrafting(progress.blueprintId, progress.manaSpent); - set({ equipmentCraftingProgress: null }); - useManaStore.setState((s) => ({ rawMana: s.rawMana + cancelResult.manaRefund })); - useCombatStore.setState({ currentAction: 'meditate' }); - useUIStore.getState().addLog(cancelResult.logMessage); - }, + cancelEquipmentCrafting: () => cancelEquipmentCrafting(get, set), // Fabricator crafting — uses elemental mana instead of raw mana - startFabricatorCrafting: (recipeId: string) => { - const state = get(); - const currentAction = useCombatStore.getState().currentAction; - if (currentAction !== 'meditate') return false; - - const recipe = getFabricatorRecipe(recipeId); - if (!recipe) return false; - - const rawMana = useManaStore.getState().rawMana; - const elements = useManaStore.getState().elements; - - const deducted = deductFabricatorMana(recipe, rawMana, elements); - if (!deducted) return false; - - const newMaterials = deductMaterials(recipe, state.lootInventory.materials); - const progress = makeFabricatorProgress(recipeId, recipe.equipmentTypeId, recipe.craftTime, recipe.manaCost); - - useManaStore.setState({ rawMana: deducted.rawMana, elements: deducted.elements }); - set((s) => ({ lootInventory: { ...s.lootInventory, materials: newMaterials }, equipmentCraftingProgress: progress })); - useCombatStore.setState({ currentAction: 'craft' }); - return true; - }, + startFabricatorCrafting: (recipeId: string) => startFabricatorCrafting(recipeId, get, set), // Material crafting — instant crafting of materials craftMaterial: (recipeId: string) => { diff --git a/src/lib/game/stores/gameStore.ts b/src/lib/game/stores/gameStore.ts index beba3f9..d07f774 100644 --- a/src/lib/game/stores/gameStore.ts +++ b/src/lib/game/stores/gameStore.ts @@ -2,8 +2,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { HOURS_PER_TICK, MAX_DAY } from '../constants'; -import { getGuardianForFloor } from '../data/guardian-encounters'; -import { hasSpecial, SPECIAL_EFFECTS } from '../effects/special-effects'; import { computeEquipmentEffects } from '../effects'; import type { ComputedEffects } from '../effects/upgrade-effects.types'; @@ -11,6 +9,7 @@ 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 { buildCombatCallbacks } from './pipelines/combat-tick'; import { useUIStore } from './uiStore'; import { usePrestigeStore } from './prestigeStore'; import { useManaStore } from './manaStore'; @@ -158,6 +157,7 @@ export const useGameStore = create()( } let totalConversionPerTick = 0; + let rawManaDelta = 0; let elements = { ...ctx.mana.elements }; Object.entries(ctx.attunement.attunements).forEach(([id, state]) => { if (!state.active) return; @@ -166,6 +166,8 @@ export const useGameStore = create()( 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 }; @@ -179,7 +181,8 @@ export const useGameStore = create()( const effectiveRegen = Math.max(0, baseRegen * (1 - incursionStrength) * meditationMultiplier - totalConversionPerTick); - let rawMana = Math.min(ctx.mana.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana); + const rawAfterConversion = ctx.mana.rawMana + rawManaDelta; + let rawMana = Math.max(0, Math.min(rawAfterConversion + effectiveRegen * HOURS_PER_TICK, maxMana)); let totalManaGathered = ctx.mana.totalManaGathered; if (ctx.combat.currentAction === 'convert') { @@ -221,11 +224,18 @@ export const useGameStore = create()( } } 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: elements[srcType].current - conversionAmount }; + elements[srcType] = { ...elements[srcType], current: Math.max(0, elements[srcType].current - conversionAmount) }; } } if (elements[targetElem]) { @@ -263,81 +273,22 @@ export const useGameStore = create()( // Combat — delegate to combatStore if (ctx.combat.currentAction === 'climb') { + const combatCbs = buildCombatCallbacks({ + ctx, effects, maxMana, addLog, useCombatStore, usePrestigeStore, + }); const combatResult = useCombatStore.getState().processCombatTick( - rawMana, - elements, - maxMana, - 1, - (floor, wasGuardian) => { - if (wasGuardian) { - const defeatedGuardian = getGuardianForFloor(floor); - addLog((defeatedGuardian?.name || 'Unknown') + ' defeated! Visit the Grimoire to sign a pact.'); - } else if (floor % 5 === 0) { - addLog('Floor ' + floor + ' cleared!'); - } - useCombatStore.setState({ guardianShield: 0, guardianShieldMax: 0, guardianBarrier: 0, guardianBarrierMax: 0 }); - }, - (damage) => { - let dmg = damage; - if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && ctx.combat.floorHP / ctx.combat.floorMaxHP < 0.25) { - dmg *= 2; - } - if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) { - dmg *= 1.5; - } - - const guardian = getGuardianForFloor(ctx.combat.currentFloor); - if (guardian && (guardian.shield || guardian.barrier || guardian.healthRegen)) { - let shield = ctx.combat.guardianShield; - let shieldMax = ctx.combat.guardianShieldMax; - let barrier = ctx.combat.guardianBarrier; - let barrierMax = ctx.combat.guardianBarrierMax; - - if (guardian.shieldRegen && shield < shieldMax) { - shield = Math.min(shieldMax, shield + guardian.shieldRegen * HOURS_PER_TICK); - } - if (guardian.barrierRegen && barrier < barrierMax) { - barrier = Math.min(barrierMax, barrier + guardian.barrierRegen * HOURS_PER_TICK); - } - - if (shield > 0 && dmg > 0) { - const absorb = Math.min(shield, dmg); - shield -= absorb; - dmg -= absorb; - } - - if (barrier > 0 && dmg > 0) { - dmg *= (1 - barrier); - } - - if (guardian.healthRegen && guardian.healthRegen > 0) { - const healAmount = guardian.healthRegenIsPercent - ? Math.floor(ctx.combat.floorMaxHP * guardian.healthRegen / 100 * HOURS_PER_TICK) - : Math.floor(guardian.healthRegen * HOURS_PER_TICK); - dmg -= healAmount; - } - - useCombatStore.setState({ - guardianShield: shield, - guardianShieldMax: shieldMax, - guardianBarrier: barrier, - guardianBarrierMax: barrierMax, - }); - } - - return { rawMana, elements, modifiedDamage: dmg }; - }, + rawMana, elements, maxMana, 1, + combatCbs.onFloorCleared, + combatCbs.makeOnDamageDealt(() => rawMana, () => elements), ctx.prestige.signedPacts, ); 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, diff --git a/src/lib/game/stores/pipelines/combat-tick.ts b/src/lib/game/stores/pipelines/combat-tick.ts new file mode 100644 index 0000000..65526a3 --- /dev/null +++ b/src/lib/game/stores/pipelines/combat-tick.ts @@ -0,0 +1,99 @@ +// ─── Combat Tick Callback Builder ───────────────────────────────────────────── +// Extracts the large combat callback lambdas from gameStore.ts tick() +// to keep the coordinator under the 400-line file limit. + +import { HOURS_PER_TICK } from '../../constants'; +import { getGuardianForFloor } from '../../data/guardian-encounters'; +import { hasSpecial, SPECIAL_EFFECTS } from '../../effects/special-effects'; +import type { ComputedEffects } from '../../effects/upgrade-effects.types'; + +interface BuildCombatCallbacksParams { + ctx: { + combat: { + floorHP: number; + floorMaxHP: number; + currentFloor: number; + guardianShield: number; + guardianShieldMax: number; + guardianBarrier: number; + guardianBarrierMax: number; + }; + }; + effects: ComputedEffects; + maxMana: number; + addLog: (msg: string) => void; + useCombatStore: { setState: (s: Record) => void }; + usePrestigeStore: { getState: () => { addDefeatedGuardian: (floor: number) => void } }; +} + +export function buildCombatCallbacks(params: BuildCombatCallbacksParams) { + const { ctx, effects, maxMana, addLog, useCombatStore, usePrestigeStore } = params; + + const onFloorCleared = (floor: number, wasGuardian: boolean) => { + if (wasGuardian) { + const defeatedGuardian = getGuardianForFloor(floor); + addLog((defeatedGuardian?.name || 'Unknown') + ' defeated! Visit the Grimoire to sign a pact.'); + usePrestigeStore.getState().addDefeatedGuardian(floor); + } else if (floor % 5 === 0) { + addLog('Floor ' + floor + ' cleared!'); + } + useCombatStore.setState({ guardianShield: 0, guardianShieldMax: 0, guardianBarrier: 0, guardianBarrierMax: 0 }); + }; + + // Returns a function matching the processCombatTick onDamageDealt signature. + // The returned function closes over the current tick's rawMana/elements references. + const makeOnDamageDealt = (rawManaRef: () => number, elementsRef: () => Record) => { + return (damage: number) => { + const rawMana = rawManaRef(); + const elements = elementsRef(); + let dmg = damage; + if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && ctx.combat.floorHP / ctx.combat.floorMaxHP < 0.25) { + dmg *= 2; + } + if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) { + dmg *= 1.5; + } + + const guardian = getGuardianForFloor(ctx.combat.currentFloor); + if (guardian && (guardian.shield || guardian.barrier || guardian.healthRegen)) { + let shield = ctx.combat.guardianShield; + const shieldMax = ctx.combat.guardianShieldMax; + let barrier = ctx.combat.guardianBarrier; + const barrierMax = ctx.combat.guardianBarrierMax; + + if (guardian.shieldRegen && shield < shieldMax) { + shield = Math.min(shieldMax, shield + guardian.shieldRegen * HOURS_PER_TICK); + } + if (guardian.barrierRegen && barrier < barrierMax) { + barrier = Math.min(barrierMax, barrier + guardian.barrierRegen * HOURS_PER_TICK); + } + + if (shield > 0 && dmg > 0) { + const absorb = Math.min(shield, dmg); + shield -= absorb; + dmg -= absorb; + } + if (barrier > 0 && dmg > 0) { + dmg *= (1 - barrier); + } + if (guardian.healthRegen && guardian.healthRegen > 0) { + const healAmount = guardian.healthRegenIsPercent + ? Math.floor(ctx.combat.floorMaxHP * guardian.healthRegen / 100 * HOURS_PER_TICK) + : Math.floor(guardian.healthRegen * HOURS_PER_TICK); + dmg -= healAmount; + } + + useCombatStore.setState({ + guardianShield: shield, + guardianShieldMax: shieldMax, + guardianBarrier: barrier, + guardianBarrierMax: barrierMax, + }); + } + + return { rawMana, elements, modifiedDamage: dmg }; + }; + }; + + return { onFloorCleared, makeOnDamageDealt }; +} diff --git a/src/lib/game/stores/pipelines/equipment-crafting.ts b/src/lib/game/stores/pipelines/equipment-crafting.ts new file mode 100644 index 0000000..47ed8d5 --- /dev/null +++ b/src/lib/game/stores/pipelines/equipment-crafting.ts @@ -0,0 +1,103 @@ +// ─── Equipment Crafting Pipeline ────────────────────────────────────────────── +// Extracted from craftingStore.ts to keep it under the 400-line file limit. +// Handles start/cancel of equipment crafting and fabricator crafting. + +import type { CraftingState } from '../craftingStore.types'; +import * as CraftingEquipment from '../../crafting-equipment'; +import { + getFabricatorRecipe, + deductFabricatorMana, + deductMaterials, + makeFabricatorProgress, +} from '../../crafting-fabricator'; +import { useManaStore } from '../manaStore'; +import { useCombatStore } from '../combatStore'; +import { useUIStore } from '../uiStore'; + +type GetFn = () => CraftingState; +type SetFn = (partial: Partial) => void; + +export function startCraftingEquipment( + blueprintId: string, + get: GetFn, + set: SetFn, +): boolean { + const state = get(); + const rawMana = useManaStore.getState().rawMana; + const currentAction = useCombatStore.getState().currentAction; + const check = CraftingEquipment.canStartEquipmentCrafting( + blueprintId, + state.lootInventory.blueprints.includes(blueprintId), + state.lootInventory.materials, + rawMana, + currentAction, + ); + if (!check.canCraft) return false; + const result = CraftingEquipment.initializeEquipmentCrafting( + blueprintId, + state.lootInventory.materials, + rawMana, + ); + set((s) => ({ + lootInventory: { ...s.lootInventory, materials: result.newMaterials }, + equipmentCraftingProgress: result.progress, + })); + useManaStore.setState((s) => ({ rawMana: s.rawMana - result.manaCost })); + useCombatStore.setState({ currentAction: 'craft' }); + return true; +} + +export function cancelEquipmentCrafting(get: GetFn, set: SetFn): void { + const progress = get().equipmentCraftingProgress; + if (!progress) return; + const cancelResult = CraftingEquipment.cancelEquipmentCrafting( + progress.blueprintId, + progress.manaSpent, + progress.progress, + progress.required, + ); + // Refund materials proportionally to remaining progress + const recipe = CraftingEquipment.getRecipe(progress.blueprintId); + if (recipe) { + const remainingFraction = progress.required > 0 + ? Math.max(0, (progress.required - progress.progress) / progress.required) + : 1; + const currentMaterials = get().lootInventory.materials; + const refundedMaterials = { ...currentMaterials }; + for (const [matId, amount] of Object.entries(recipe.materials)) { + const refundAmount = Math.floor(amount * remainingFraction); + if (refundAmount > 0) { + refundedMaterials[matId] = (refundedMaterials[matId] || 0) + refundAmount; + } + } + set({ equipmentCraftingProgress: null, lootInventory: { ...get().lootInventory, materials: refundedMaterials } }); + } else { + set({ equipmentCraftingProgress: null }); + } + useManaStore.setState((s) => ({ rawMana: s.rawMana + cancelResult.manaRefund })); + useCombatStore.setState({ currentAction: 'meditate' }); + useUIStore.getState().addLog(cancelResult.logMessage); +} + +export function startFabricatorCrafting(recipeId: string, get: GetFn, set: SetFn): boolean { + const state = get(); + const currentAction = useCombatStore.getState().currentAction; + if (currentAction !== 'meditate') return false; + + const recipe = getFabricatorRecipe(recipeId); + if (!recipe) return false; + + const rawMana = useManaStore.getState().rawMana; + const elements = useManaStore.getState().elements; + + const deducted = deductFabricatorMana(recipe, rawMana, elements); + if (!deducted) return false; + + const newMaterials = deductMaterials(recipe, state.lootInventory.materials); + const progress = makeFabricatorProgress(recipeId, recipe.equipmentTypeId, recipe.craftTime, recipe.manaCost); + + useManaStore.setState({ rawMana: deducted.rawMana, elements: deducted.elements }); + set((s) => ({ lootInventory: { ...s.lootInventory, materials: newMaterials }, equipmentCraftingProgress: progress })); + useCombatStore.setState({ currentAction: 'craft' }); + return true; +}