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
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m23s

This commit is contained in:
2026-05-30 22:28:45 +02:00
parent 737a23bec3
commit e4f4b297e8
10 changed files with 289 additions and 165 deletions
+4 -63
View File
@@ -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<CraftingStore>()(
persist(
@@ -179,65 +173,12 @@ export const useCraftingStore = create<CraftingStore>()(
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) => {
+20 -69
View File
@@ -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<GameCoordinatorStore>()(
}
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<GameCoordinatorStore>()(
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<GameCoordinatorStore>()(
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<GameCoordinatorStore>()(
}
}
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<GameCoordinatorStore>()(
// 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,
@@ -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<string, unknown>) => 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<string, { current: number; max: number; unlocked: boolean }>) => {
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 };
}
@@ -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<CraftingState>) => 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;
}