fix: resolve 3 critical bugs — #354 attunement ReferenceError, #353 preparation mana exploit, #352 golem mana wipe
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m1s

- #354: unlockAttunement now uses _get() instead of undefined 'state' variable
- #353: startPreparing now deducts raw mana from the mana store after validation
- #352: processGolemAttacks/processBasicAttack accept current mana as params instead of initializing to 0/{}
- Updated golem-combat-actions.test.ts to pass new currentRawMana/currentElements params
- Added regression tests for all 3 bugs (16 new tests, all passing)
This commit is contained in:
2026-06-10 20:49:46 +02:00
parent 43bb53e0b4
commit 33be133813
12 changed files with 490 additions and 14 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# Circular Dependencies
Generated: 2026-06-10T11:09:41.397Z
Generated: 2026-06-10T11:13:04.646Z
Found: 3 circular chain(s) — these MUST be fixed before modifying involved files.
1. 1) stores/golem-combat-actions.ts > stores/golem-combat-helpers.ts
+1 -1
View File
@@ -1,6 +1,6 @@
{
"_meta": {
"generated": "2026-06-10T11:09:38.970Z",
"generated": "2026-06-10T11:13:02.589Z",
"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
@@ -198,6 +198,9 @@ Mana-Loop/
│ │ │ │ ├── achievements.test.ts
│ │ │ │ ├── activity-log.test.ts
│ │ │ │ ├── attunement-conversion-fix.test.ts
│ │ │ │ ├── bug-352-golem-mana-wipe.test.ts
│ │ │ │ ├── bug-353-preparation-mana.test.ts
│ │ │ │ ├── bug-354-unlock-attunement.test.ts
│ │ │ │ ├── bug-fixes.test.ts
│ │ │ │ ├── combat-actions.test.ts
│ │ │ │ ├── combat-utils.test.ts
@@ -0,0 +1,263 @@
// ─── Regression Test: Issue #352 ──────────────────────────────────────────────
// Golem combat wipes all mana when no enemies are alive
//
// Bug: processGolemAttacks initialized rawMana=0 and elements={} as local
// accumulators. When no enemies were alive, processBasicAttack never called
// onDamageDealt, so these zero/empty values were returned. The caller in
// combat-actions.ts then overwrote the game's actual mana state with zeros.
//
// Fix: processGolemAttacks and processBasicAttack now accept currentRawMana
// and currentElements as parameters and initialize from them instead of 0/{}.
import { describe, it, expect } from 'vitest';
import { processGolemAttacks } from '../stores/golem-combat-actions';
import { processBasicAttack } from '../stores/golem-combat-helpers';
import type { EnemyState, RuntimeActiveGolem } from '../types';
// ─── Helpers ──────────────────────────────────────────────────────────────────
function makeEnemy(overrides: Partial<EnemyState> = {}): EnemyState {
return {
id: 'enemy-1',
name: 'Test Enemy',
hp: 0, // Dead enemy — this triggers the bug
maxHP: 100,
armor: 0.2,
dodgeChance: 0,
element: 'fire',
activeEffects: [],
effectiveArmor: 0.2,
...overrides,
};
}
function makeAliveEnemy(overrides: Partial<EnemyState> = {}): EnemyState {
return {
id: 'enemy-1',
name: 'Test Enemy',
hp: 100,
maxHP: 100,
armor: 0.2,
dodgeChance: 0,
element: 'fire',
activeEffects: [],
effectiveArmor: 0.2,
...overrides,
};
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('Issue #352 — Golem combat mana wipe', () => {
const testRawMana = 500;
const testElements: Record<string, { current: number; max: number; unlocked: boolean }> = {
fire: { current: 200, max: 500, unlocked: true },
water: { current: 100, max: 500, unlocked: true },
};
describe('processBasicAttack preserves mana when no targets', () => {
it('should return current rawMana (not 0) when no enemies are alive', () => {
const result = processBasicAttack(
{
frame: { baseDamage: 10, armorPierce: 0.15, element: 'fire', aoeTargets: 1 },
bonusArmorPierce: 0,
enchantmentEffects: [],
enemyElement: 'fire',
getTargetEnemy: () => null, // No living target
getTargetEnemies: () => [],
onDamageDealt: () => ({ rawMana: testRawMana, elements: testElements }),
applyDamageToRoom: () => ({ floorHP: 50, floorMaxHP: 100, roomCleared: false }),
onApplyEnchantmentEffects: () => {},
},
testRawMana,
testElements,
);
// rawMana should be preserved, not wiped to 0
expect(result.rawMana).toBe(testRawMana);
});
it('should return current elements (not {}) when no enemies are alive', () => {
const result = processBasicAttack(
{
frame: { baseDamage: 10, armorPierce: 0.15, element: 'fire', aoeTargets: 1 },
bonusArmorPierce: 0,
enchantmentEffects: [],
enemyElement: 'fire',
getTargetEnemy: () => null,
getTargetEnemies: () => [],
onDamageDealt: () => ({ rawMana: testRawMana, elements: testElements }),
applyDamageToRoom: () => ({ floorHP: 50, floorMaxHP: 100, roomCleared: false }),
onApplyEnchantmentEffects: () => {},
},
testRawMana,
testElements,
);
// elements should be preserved, not wiped to {}
expect(result.elements).toEqual(testElements);
});
it('should return current rawMana when AoE attack has no targets', () => {
const result = processBasicAttack(
{
frame: { baseDamage: 10, armorPierce: 0.15, element: 'fire', aoeTargets: 3 },
bonusArmorPierce: 0,
enchantmentEffects: [],
enemyElement: 'fire',
getTargetEnemy: () => null,
getTargetEnemies: () => [], // No living enemies for AoE
onDamageDealt: () => ({ rawMana: 0, elements: {} }),
applyDamageToRoom: () => ({ floorHP: 50, floorMaxHP: 100, roomCleared: false }),
onApplyEnchantmentEffects: () => {},
},
testRawMana,
testElements,
);
// When no targets for AoE, mana should be preserved
expect(result.rawMana).toBe(testRawMana);
expect(result.elements).toEqual(testElements);
});
});
describe('processGolemAttacks preserves mana when no golems attack', () => {
it('should return current rawMana when golem has no attack progress', () => {
// Golem with attackProgress=0 won't enter the while loop
const activeGolem: RuntimeActiveGolem = {
designId: 'test_golem',
summonedFloor: 1,
attackProgress: 0, // Not ready to attack
roomsRemaining: 3,
currentMana: 50,
spellCastIndex: 0,
};
const serializedDesign = {
id: 'test_golem',
name: 'Test Golem',
coreId: 'basic',
frameId: 'earth',
mindCircuitId: 'simple',
enchantmentIds: [],
selectedManaTypes: [],
selectedSpells: [],
};
const result = processGolemAttacks(
[activeGolem],
{ test_golem: serializedDesign },
() => ({ rawMana: 0, elements: {} }),
() => ({ floorHP: 50, floorMaxHP: 100, roomCleared: false }),
'fire',
() => null,
() => [],
() => {},
testRawMana,
testElements,
);
// rawMana should be preserved, not wiped to 0
expect(result.rawMana).toBe(testRawMana);
});
it('should return current elements when golem has no attack progress', () => {
const activeGolem: RuntimeActiveGolem = {
designId: 'test_golem',
summonedFloor: 1,
attackProgress: 0,
roomsRemaining: 3,
currentMana: 50,
spellCastIndex: 0,
};
const serializedDesign = {
id: 'test_golem',
name: 'Test Golem',
coreId: 'basic',
frameId: 'earth',
mindCircuitId: 'simple',
enchantmentIds: [],
selectedManaTypes: [],
selectedSpells: [],
};
const result = processGolemAttacks(
[activeGolem],
{ test_golem: serializedDesign },
() => ({ rawMana: 0, elements: {} }),
() => ({ floorHP: 50, floorMaxHP: 100, roomCleared: false }),
'fire',
() => null,
() => [],
() => {},
testRawMana,
testElements,
);
// elements should be preserved, not wiped to {}
expect(result.elements).toEqual(testElements);
});
it('should return current rawMana when no active golems', () => {
const result = processGolemAttacks(
[], // No golems
{},
() => ({ rawMana: 0, elements: {} }),
() => ({ floorHP: 50, floorMaxHP: 100, roomCleared: false }),
'fire',
() => null,
() => [],
() => {},
testRawMana,
testElements,
);
expect(result.rawMana).toBe(testRawMana);
expect(result.elements).toEqual(testElements);
});
});
describe('processGolemAttacks still works when enemies are alive', () => {
it('should process damage and update mana when enemies are alive', () => {
const aliveEnemy = makeAliveEnemy();
const activeGolem: RuntimeActiveGolem = {
designId: 'test_golem',
summonedFloor: 1,
attackProgress: 1.0,
roomsRemaining: 3,
currentMana: 50,
spellCastIndex: 0,
};
const serializedDesign = {
id: 'test_golem',
name: 'Test Golem',
coreId: 'basic',
frameId: 'earth',
mindCircuitId: 'simple',
enchantmentIds: [],
selectedManaTypes: [],
selectedSpells: [],
};
const updatedElements = { fire: { current: 210, max: 500, unlocked: true } };
const result = processGolemAttacks(
[activeGolem],
{ test_golem: serializedDesign },
() => ({ rawMana: testRawMana + 5, elements: updatedElements }),
() => ({ floorHP: 50, floorMaxHP: 100, roomCleared: false }),
'fire',
() => aliveEnemy,
() => [aliveEnemy],
() => {},
testRawMana,
testElements,
);
// When enemies are alive, mana should be updated from onDamageDealt callback
expect(result.rawMana).toBe(testRawMana + 5);
});
});
});
@@ -0,0 +1,129 @@
// ─── Regression Test: Issue #353 ──────────────────────────────────────────────
// Preparation mana never deducted — free equipment preparation exploit
//
// Bug: startPreparing() validated rawMana >= costs.manaTotal but never called
// useManaStore.setState() to deduct the cost. cancelPreparation() refunds
// manaCostPaid, so canceling after preparing created mana from nothing.
//
// Fix: Added useManaStore.setState() call to deduct costs in startPreparing().
import { describe, it, expect, beforeEach } from 'vitest';
import { useManaStore } from '../stores/manaStore';
import { useCraftingStore } from '../stores/craftingStore';
import { startPreparing, cancelPreparation } from '../crafting-actions/preparation-actions';
describe('Issue #353 — Preparation mana deduction', () => {
beforeEach(() => {
localStorage.removeItem('mana-loop-mana');
localStorage.removeItem('mana-loop-crafting');
useManaStore.getState().resetMana({});
useCraftingStore.getState().resetCrafting?.();
});
it('should deduct raw mana when starting preparation', () => {
// Set up: give the player plenty of raw mana
useManaStore.setState({ rawMana: 10000 });
// Create a basic equipment instance in the crafting store
const testInstanceId = 'test-staff-1';
const testEquipment = {
instanceId: testInstanceId,
typeId: 'basicStaff',
name: 'Test Staff',
enchantments: [],
usedCapacity: 0,
totalCapacity: 50,
rarity: 'common' as const,
quality: 1,
tags: [],
};
useCraftingStore.setState((s) => ({
equipmentInstances: { ...s.equipmentInstances, [testInstanceId]: testEquipment },
}));
const rawManaBefore = useManaStore.getState().rawMana;
const get = () => useCraftingStore.getState();
const set = (partial: any) => useCraftingStore.setState(partial);
const result = startPreparing(testInstanceId, rawManaBefore, get, set);
expect(result).toBe(true);
// Mana should have been deducted
const rawManaAfter = useManaStore.getState().rawMana;
expect(rawManaAfter).toBeLessThan(rawManaBefore);
});
it('should not deduct mana when starting preparation fails (insufficient mana)', () => {
// Set up: give the player zero raw mana
useManaStore.setState({ rawMana: 0 });
const testInstanceId = 'test-staff-2';
const testEquipment = {
instanceId: testInstanceId,
typeId: 'basicStaff',
name: 'Test Staff',
enchantments: [],
usedCapacity: 0,
totalCapacity: 50,
rarity: 'common' as const,
quality: 1,
tags: [],
};
useCraftingStore.setState((s) => ({
equipmentInstances: { ...s.equipmentInstances, [testInstanceId]: testEquipment },
}));
const rawManaBefore = useManaStore.getState().rawMana;
const get = () => useCraftingStore.getState();
const set = (partial: any) => useCraftingStore.setState(partial);
const result = startPreparing(testInstanceId, rawManaBefore, get, set);
expect(result).toBe(false);
// Mana should NOT have changed
expect(useManaStore.getState().rawMana).toBe(rawManaBefore);
});
it('cancelPreparation should not create mana (no exploit)', () => {
// Set up: give the player exactly 5000 raw mana
const initialMana = 5000;
useManaStore.setState({ rawMana: initialMana });
const testInstanceId = 'test-staff-3';
const testEquipment = {
instanceId: testInstanceId,
typeId: 'basicStaff',
name: 'Test Staff',
enchantments: [],
usedCapacity: 0,
totalCapacity: 50,
rarity: 'common' as const,
quality: 1,
tags: [],
};
useCraftingStore.setState((s) => ({
equipmentInstances: { ...s.equipmentInstances, [testInstanceId]: testEquipment },
}));
const get = () => useCraftingStore.getState();
const set = (partial: any) => useCraftingStore.setState(partial);
// Start preparation (deducts mana)
const result = startPreparing(testInstanceId, initialMana, get, set);
expect(result).toBe(true);
const manaAfterPrepare = useManaStore.getState().rawMana;
expect(manaAfterPrepare).toBeLessThan(initialMana);
// Cancel preparation (refunds proportionally)
cancelPreparation(get, set);
const manaAfterCancel = useManaStore.getState().rawMana;
// After cancel, mana should be LESS than or equal to initial (not more!)
// The refund is partial (50% for spent progress), so player loses some mana
expect(manaAfterCancel).toBeLessThanOrEqual(initialMana);
});
});
@@ -0,0 +1,57 @@
// ─── Regression Test: Issue #354 ──────────────────────────────────────────────
// unlockAttunement crashes with ReferenceError — 'state' is undefined
//
// Bug: unlockAttunement referenced `state.attunements[attunementId]?.active`
// but `state` was not in scope. The function signature is
// `unlockAttunement(attunementId, defeatedGuardians)` — no `state` parameter.
//
// Fix: Use `_get()` to read current state instead of referencing undefined `state`.
import { describe, it, expect, beforeEach } from 'vitest';
import { useAttunementStore } from '../stores/attunementStore';
describe('Issue #354 — unlockAttunement ReferenceError', () => {
beforeEach(() => {
// Reset to initial state: only enchanter active
useAttunementStore.getState().resetAttunements();
// Clear localStorage to avoid persisted state leaking between tests
localStorage.removeItem('mana-loop-attunements');
});
it('should not throw ReferenceError when checking if attunement is already active', () => {
// This was the crash: calling unlockAttunement on an already-active attunement
// would throw "ReferenceError: state is not defined"
expect(() => {
useAttunementStore.getState().unlockAttunement('enchanter', []);
}).not.toThrow();
});
it('should return false when attunement is already active', () => {
const result = useAttunementStore.getState().unlockAttunement('enchanter', []);
expect(result).toBe(false);
});
it('should return false for unknown attunement', () => {
const result = useAttunementStore.getState().unlockAttunement('unknown', []);
expect(result).toBe(false);
});
it('should return false for invoker when floor 10 guardian not defeated', () => {
const result = useAttunementStore.getState().unlockAttunement('invoker', []);
expect(result).toBe(false);
});
it('should unlock invoker when floor 10 guardian is defeated', () => {
const result = useAttunementStore.getState().unlockAttunement('invoker', [10]);
expect(result).toBe(true);
const state = useAttunementStore.getState();
expect(state.attunements['invoker']?.active).toBe(true);
expect(state.attunements['invoker']?.level).toBe(1);
});
it('should return false for fabricator (gating not implemented)', () => {
const result = useAttunementStore.getState().unlockAttunement('fabricator', [10, 20, 30]);
expect(result).toBe(false);
});
});
@@ -26,6 +26,9 @@ export function startPreparing(
if (rawMana < costs.manaTotal) return false;
// Deduct the preparation mana cost from the player's raw mana pool
useManaStore.setState((s) => ({ rawMana: s.rawMana - costs.manaTotal }));
set({
preparationProgress: CraftingPrep.initializePreparationProgress(
equipmentInstanceId,
+2 -1
View File
@@ -80,7 +80,8 @@ export const useAttunementStore = create<AttunementStoreState>()(
unlockAttunement: (attunementId: string, defeatedGuardians: number[]) => {
const def = ATTUNEMENTS_DEF[attunementId];
if (!def) return false;
if (state.attunements[attunementId]?.active) return false;
const currentState = _get();
if (currentState.attunements[attunementId]?.active) return false;
// Check unlock conditions
if (attunementId === 'invoker') {
+2
View File
@@ -323,6 +323,8 @@ export function processCombatTick(
getFloorElement(currentFloor),
get,
set,
rawMana,
elements,
);
rawMana = golemResult.rawMana;
elements = golemResult.elements;
@@ -96,6 +96,8 @@ function runGolemAttacks(
() => enemy,
() => [enemy],
() => {},
100,
{},
);
return { result, capturedDamage };
}
@@ -136,6 +138,8 @@ describe('processGolemAttacks - spell damage and mana cost (fixes #1, #2)', () =
() => enemy,
() => [enemy],
() => {},
100,
{},
);
expect(spellCastCount).toBeGreaterThan(0);
@@ -160,6 +164,8 @@ describe('processGolemAttacks - spell damage and mana cost (fixes #1, #2)', () =
() => enemy,
() => [enemy],
() => {},
100,
{},
);
// Should fall back to basic attack since mana is insufficient
@@ -245,6 +251,8 @@ describe('processGolemAttacks - enchantment effects (fix #4)', () => {
() => enemy,
() => [enemy],
(enemyId, effects) => { appliedEffects.push({ enemyId, effects }); },
100,
{},
);
expect(appliedEffects.length).toBeGreaterThan(0);
@@ -267,6 +275,8 @@ describe('processGolemAttacks - enchantment effects (fix #4)', () => {
() => enemy,
() => [enemy],
(_, effects) => { appliedEffects.push(effects); },
100,
{},
);
const allEffectTypes = appliedEffects.flat().map(e => e.type);
@@ -291,6 +301,8 @@ describe('processGolemAttacks - enchantment effects (fix #4)', () => {
() => enemy,
() => [enemy],
() => { effectsCalled = true; },
100,
{},
);
expect(effectsCalled).toBe(false);
+5 -3
View File
@@ -248,9 +248,11 @@ export function processGolemAttacks(
getTargetEnemy: () => EnemyState | null,
getTargetEnemies: () => EnemyState[],
onApplyEnchantmentEffects: (enemyId: string, effects: GolemEnchantmentEffect[]) => void,
currentRawMana: number,
currentElements: Record<string, { current: number; max: number; unlocked: boolean }>,
): GolemCombatResult {
let rawMana = 0;
let elements: Record<string, { current: number; max: number; unlocked: boolean }> = {};
let rawMana = currentRawMana;
let elements: Record<string, { current: number; max: number; unlocked: boolean }> = { ...currentElements };
let floorHP = 0;
let floorMaxHP = 0;
const logMessages: string[] = [];
@@ -321,7 +323,7 @@ export function processGolemAttacks(
onDamageDealt,
applyDamageToRoom,
onApplyEnchantmentEffects,
});
}, rawMana, elements);
rawMana = basicResult.rawMana;
elements = basicResult.elements;
floorHP = basicResult.floorHP;
+7 -3
View File
@@ -95,9 +95,9 @@ export interface BasicAttackResult {
* AoE frames distribute damage across up to frame.aoeTargets enemies (spec §11).
* Single-target frames attack the lowest-HP enemy.
*/
export function processBasicAttack(ctx: BasicAttackContext): BasicAttackResult {
let rawMana = 0;
let elements: Record<string, { current: number; max: number; unlocked: boolean }> = {};
export function processBasicAttack(ctx: BasicAttackContext, currentRawMana: number, currentElements: Record<string, { current: number; max: number; unlocked: boolean }>): BasicAttackResult {
let rawMana = currentRawMana;
let elements: Record<string, { current: number; max: number; unlocked: boolean }> = { ...currentElements };
let floorHP = 0;
let floorMaxHP = 0;
let totalDamageDealt = 0;
@@ -174,6 +174,8 @@ export function processGolemAttacksFromStore(
enemyElement: string,
get: () => CombatStore,
set: (s: Partial<CombatState>) => void,
currentRawMana: number,
currentElements: Record<string, { current: number; max: number; unlocked: boolean }>,
): GolemCombatResult {
return processGolemAttacks(
activeGolems,
@@ -212,5 +214,7 @@ export function processGolemAttacksFromStore(
});
set({ currentRoom: { ...room, enemies: updatedEnemies } });
},
currentRawMana,
currentElements,
);
}