fix: resetGame button doesn't fully reset game state
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s

This commit is contained in:
2026-06-12 09:05:35 +02:00
parent 608d4c4ff7
commit 4b8cdb97d7
6 changed files with 376 additions and 14 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# Circular Dependencies # Circular Dependencies
Generated: 2026-06-11T14:10:00.590Z Generated: 2026-06-12T05:02:18.108Z
Found: 4 circular chain(s) — these MUST be fixed before modifying involved files. Found: 4 circular chain(s) — these MUST be fixed before modifying involved files.
1. 1) data/guardian-encounters.ts > data/guardian-procedural.ts 1. 1) data/guardian-encounters.ts > data/guardian-procedural.ts
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"_meta": { "_meta": {
"generated": "2026-06-11T14:09:58.499Z", "generated": "2026-06-12T05:02:13.512Z",
"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."
}, },
+2
View File
@@ -241,6 +241,7 @@ Mana-Loop/
│ │ │ │ ├── paused-conversion-dedup.test.ts │ │ │ │ ├── paused-conversion-dedup.test.ts
│ │ │ │ ├── persistence.test.ts │ │ │ │ ├── persistence.test.ts
│ │ │ │ ├── regression-fixes.test.ts │ │ │ │ ├── regression-fixes.test.ts
│ │ │ │ ├── reset-game-comprehensive.test.ts
│ │ │ │ ├── room-utils-floor-state.test.ts │ │ │ │ ├── room-utils-floor-state.test.ts
│ │ │ │ ├── room-utils.test.ts │ │ │ │ ├── room-utils.test.ts
│ │ │ │ ├── spell-cast-floorhp-guard.test.ts │ │ │ │ ├── spell-cast-floorhp-guard.test.ts
@@ -380,6 +381,7 @@ Mana-Loop/
│ │ │ │ ├── combat-actions.ts │ │ │ │ ├── combat-actions.ts
│ │ │ │ ├── combat-damage.ts │ │ │ │ ├── combat-damage.ts
│ │ │ │ ├── combat-descent-actions.ts │ │ │ │ ├── combat-descent-actions.ts
│ │ │ │ ├── combat-reset.ts
│ │ │ │ ├── combat-state.types.ts │ │ │ │ ├── combat-state.types.ts
│ │ │ │ ├── combatStore.ts │ │ │ │ ├── combatStore.ts
│ │ │ │ ├── crafting-equipment-tick.ts │ │ │ │ ├── crafting-equipment-tick.ts
@@ -0,0 +1,303 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { useGameStore } from '../stores/gameStore';
import { useManaStore } from '../stores/manaStore';
import { useCombatStore } from '../stores/combatStore';
import { usePrestigeStore } from '../stores/prestigeStore';
import { useCraftingStore } from '../stores/craftingStore';
import { useAttunementStore } from '../stores/attunementStore';
import { useDisciplineStore } from '../stores/discipline-slice';
import { useUIStore } from '../stores/uiStore';
// Exact localStorage keys matching each store's persist config `name`
const ALL_STORE_KEYS = [
'mana-loop-game-storage',
'mana-loop-mana',
'mana-loop-combat',
'mana-loop-prestige',
'mana-loop-crafting',
'mana-loop-attunements',
'mana-loop-discipline-store',
'mana-loop-ui-storage',
] as const;
function clearAllPersistedState() {
for (const key of ALL_STORE_KEYS) {
localStorage.removeItem(key);
}
}
/**
* Set non-default state across ALL stores to simulate a game in progress.
* This modifies every field that should be reset by resetGame().
*/
function setNonDefaultState() {
// Game store: advance time
useGameStore.setState({ day: 15, hour: 12 });
// Mana store: add mana, unlock elements
useManaStore.setState((s) => ({
rawMana: 500,
meditateTicks: 100,
totalManaGathered: 10000,
elements: {
...s.elements,
fire: { current: 50, max: 100, baseMax: 100, unlocked: true },
water: { current: 30, max: 100, baseMax: 100, unlocked: true },
},
}));
// Combat store: change many fields
useCombatStore.setState({
currentFloor: 25,
floorHP: 500,
floorMaxHP: 2000,
maxFloorReached: 25,
activeSpell: 'fireBolt',
currentAction: 'climb',
castProgress: 0.5,
spireMode: true,
clearedFloors: { 1: true, 2: true },
climbDirection: 'up' as const,
isDescending: false,
weaponCastProgress: { primary: 0.3 },
comboHitCount: 5,
floorHitCount: 10,
meleeSwordProgress: { sword1: 0.2 },
guardianShield: 100,
guardianShieldMax: 200,
guardianBarrier: 50,
guardianBarrierMax: 100,
totalSpellsCast: 500,
totalDamageDealt: 9999,
totalCraftsCompleted: 50,
});
// Prestige store: add insight, upgrades, pacts
usePrestigeStore.setState({
insight: 1000,
totalInsight: 5000,
loopCount: 3,
prestigeUpgrades: { manaWell: 2, manaFlow: 1 },
defeatedGuardians: [10, 20, 30],
signedPacts: [10, 20],
});
// Attunement store: level up
useAttunementStore.setState({
attunements: {
enchanter: { id: 'enchanter', active: true, level: 5, experience: 1000 },
invoker: { id: 'invoker', active: true, level: 3, experience: 500 },
},
});
// Discipline store: add disciplines and XP
useDisciplineStore.setState({
disciplines: {
rawManaMastery: { id: 'rawManaMastery', xp: 500, paused: false },
},
activeIds: ['rawManaMastery'],
totalXP: 500,
concurrentLimit: 2,
processedPerks: ['somePerk'],
});
// Crafting store: add designs, unlock effects, add loot
useCraftingStore.setState((s) => ({
designProgress: {
designId: 'design-1',
progress: 5,
required: 10,
name: 'My Design',
equipmentType: 'basicStaff',
effects: [{ effectId: 'spell_fireBolt', stacks: 1, actualCost: 30 }],
},
enchantmentDesigns: [{ id: 'design-1', name: 'My Design', equipmentType: 'basicStaff', effects: [], totalCapacityCost: 30, totalStacks: 1 }],
unlockedEffects: ['spell_fireBolt', 'spell_iceBolt'],
unlockedRecipes: ['recipe1', 'recipe2'],
lootInventory: {
materials: { ironOre: 100, manaCrystalDust: 50 },
blueprints: ['bp1', 'bp2'],
},
}));
// UI store: set game over, pause
useUIStore.setState({
paused: true,
gameOver: true,
victory: true,
logs: ['some log message'],
});
}
// ═══════════════════════════════════════════════════════════════════════════════
// COMPREHENSIVE RESET GAME TESTS
// ═══════════════════════════════════════════════════════════════════════════════
describe('resetGame comprehensive', () => {
beforeEach(() => {
clearAllPersistedState();
});
afterEach(() => {
clearAllPersistedState();
});
it('should reset ALL game store fields to initial values', () => {
setNonDefaultState();
useGameStore.getState().resetGame();
const game = useGameStore.getState();
expect(game.day).toBe(1);
expect(game.hour).toBe(0);
expect(game.incursionStrength).toBe(0);
expect(game.containmentWards).toBe(0);
expect(game.initialized).toBe(true);
});
it('should reset ALL mana store fields to initial values', () => {
setNonDefaultState();
useGameStore.getState().resetGame();
const mana = useManaStore.getState();
expect(mana.rawMana).toBeGreaterThanOrEqual(0);
expect(mana.rawMana).toBeLessThanOrEqual(20); // starting mana should be small
expect(mana.meditateTicks).toBe(0);
expect(mana.totalManaGathered).toBe(0);
expect(mana.elementRegen).toEqual({});
// Only transference should be unlocked (or base elements depending on config)
for (const [key, elem] of Object.entries(mana.elements)) {
// Fire and water should NOT be unlocked (they were unlocked by setNonDefaultState)
if (key === 'fire' || key === 'water') {
// After reset, these should be locked (not unlocked)
// The initial state only has certain elements unlocked
expect(elem.current).toBeGreaterThanOrEqual(0);
expect(elem.max).toBeGreaterThan(0);
}
}
});
it('should reset ALL combat store fields to initial values', () => {
setNonDefaultState();
useGameStore.getState().resetGame();
const combat = useCombatStore.getState();
expect(combat.currentFloor).toBe(1);
expect(combat.maxFloorReached).toBe(1);
expect(combat.activeSpell).toBe('manaBolt');
expect(combat.currentAction).toBe('meditate');
expect(combat.castProgress).toBe(0);
// These fields should also be reset but might be missed by resetCombat:
expect(combat.spireMode).toBe(false);
expect(combat.clearedFloors).toEqual({});
expect(combat.climbDirection).toBeNull();
expect(combat.weaponCastProgress).toEqual({});
expect(combat.comboHitCount).toBe(0);
expect(combat.floorHitCount).toBe(0);
expect(combat.meleeSwordProgress).toEqual({});
expect(combat.guardianShield).toBe(0);
expect(combat.guardianShieldMax).toBe(0);
expect(combat.guardianBarrier).toBe(0);
expect(combat.guardianBarrierMax).toBe(0);
expect(combat.totalSpellsCast).toBe(0);
expect(combat.totalDamageDealt).toBe(0);
expect(combat.totalCraftsCompleted).toBe(0);
});
it('should reset ALL prestige store fields to initial values', () => {
setNonDefaultState();
useGameStore.getState().resetGame();
const prestige = usePrestigeStore.getState();
expect(prestige.insight).toBe(0);
expect(prestige.totalInsight).toBe(0);
expect(prestige.loopCount).toBe(0);
expect(prestige.prestigeUpgrades).toEqual({});
expect(prestige.defeatedGuardians).toEqual([]);
expect(prestige.signedPacts).toEqual([]);
expect(prestige.pactRitualFloor).toBeNull();
expect(prestige.pactRitualProgress).toBe(0);
});
it('should reset ALL attunement store fields to initial values', () => {
setNonDefaultState();
useGameStore.getState().resetGame();
const att = useAttunementStore.getState();
// Should only have enchanter at level 1
expect(Object.keys(att.attunements).length).toBe(1);
expect(att.attunements.enchanter).toBeDefined();
expect(att.attunements.enchanter.level).toBe(1);
expect(att.attunements.enchanter.experience).toBe(0);
expect(att.attunements.enchanter.active).toBe(true);
// Invoker should not be present
expect(att.attunements.invoker).toBeUndefined();
});
it('should reset ALL discipline store fields to initial values', () => {
setNonDefaultState();
useGameStore.getState().resetGame();
const disc = useDisciplineStore.getState();
expect(disc.disciplines).toEqual({});
expect(disc.activeIds).toEqual([]);
expect(disc.totalXP).toBe(0);
expect(disc.processedPerks).toEqual([]);
});
it('should reset ALL crafting store fields to initial values', () => {
setNonDefaultState();
useGameStore.getState().resetGame();
const crafting = useCraftingStore.getState();
expect(crafting.designProgress).toBeNull();
expect(crafting.designProgress2).toBeNull();
expect(crafting.enchantmentDesigns).toEqual([]);
expect(crafting.unlockedEffects).toEqual([]);
expect(crafting.unlockedRecipes).toEqual([]);
expect(crafting.lootInventory.materials).toEqual({});
expect(crafting.lootInventory.blueprints).toEqual([]);
// Should still have the starting equipment
expect(Object.keys(crafting.equipmentInstances).length).toBe(3); // staff, shirt, shoes
expect(crafting.equippedInstances.mainHand).not.toBeNull();
expect(crafting.equippedInstances.body).not.toBeNull();
expect(crafting.equippedInstances.feet).not.toBeNull();
});
it('should reset ALL UI store fields to initial values', () => {
setNonDefaultState();
useGameStore.getState().resetGame();
const ui = useUIStore.getState();
expect(ui.paused).toBe(false);
expect(ui.gameOver).toBe(false);
expect(ui.victory).toBe(false);
expect(ui.logs.length).toBe(1); // fresh game starts with one log message
});
it('should clear all localStorage keys and not leave stale data', () => {
setNonDefaultState();
// First, force all stores to persist by calling setState on each
// (simulating what happens during normal gameplay)
const gameState = useGameStore.getState();
useGameStore.setState({ day: gameState.day });
useGameStore.getState().resetGame();
// After reset, localStorage should contain the reset state, not the old state
const combatRaw = localStorage.getItem('mana-loop-combat');
if (combatRaw) {
const combatParsed = JSON.parse(combatRaw);
expect(combatParsed.state.currentFloor).toBe(1);
}
const manaRaw = localStorage.getItem('mana-loop-mana');
if (manaRaw) {
const manaParsed = JSON.parse(manaRaw);
expect(manaParsed.state.totalManaGathered).toBe(0);
}
});
});
+67
View File
@@ -0,0 +1,67 @@
// ─── Combat Reset Helper ───────────────────────────────────────────────────────
// Generates the full default combat store state for resetCombat().
// Keeps combatStore.ts under the 400-line limit.
import type { RuntimeActiveGolem } from '../types';
import { getFloorMaxHP } from '../utils';
import { generateSpireFloorState, getRoomsForFloor } from '../utils/spire-utils';
import { makeInitialSpells } from './combat-actions';
import type { CombatState } from './combat-state.types';
export function createDefaultCombatState(
startFloor: number,
spellsToKeep: string[] = [],
): Partial<CombatState> {
const startSpells = makeInitialSpells(spellsToKeep);
const seed = startFloor * 12345;
const rooms = getRoomsForFloor(startFloor, seed);
const startRoom = generateSpireFloorState(startFloor, 0, rooms, 0);
return {
currentFloor: startFloor,
floorHP: getFloorMaxHP(startFloor),
floorMaxHP: getFloorMaxHP(startFloor),
maxFloorReached: startFloor,
activeSpell: 'manaBolt',
currentAction: 'meditate',
castProgress: 0,
spireMode: false,
currentRoom: startRoom,
clearedFloors: {},
climbDirection: null,
isDescending: false,
startFloor,
exitFloor: startFloor,
currentRoomIndex: 0,
roomsPerFloor: rooms,
runId: 0,
descentPeak: null,
roomResetState: {},
clearedRooms: {},
isDescentComplete: false,
golemancy: {
golemDesigns: {},
golemLoadout: [],
activeGolems: [] as RuntimeActiveGolem[],
lastSummonFloor: 0,
},
equipmentSpellStates: [],
weaponCastProgress: {},
comboHitCount: 0,
floorHitCount: 0,
meleeSwordProgress: {},
guardianShield: 0,
guardianShieldMax: 0,
guardianBarrier: 0,
guardianBarrierMax: 0,
spells: startSpells,
activityLog: [],
achievements: {
unlocked: [],
progress: {},
},
totalSpellsCast: 0,
totalDamageDealt: 0,
totalCraftsCompleted: 0,
};
}
+2 -12
View File
@@ -14,6 +14,7 @@ import type { CombatStore } from './combat-state.types';
import { import {
enterDescentMode, advanceRoomOrFloor, onEnterRoomDescend, createEnterSpireMode, enterDescentMode, advanceRoomOrFloor, onEnterRoomDescend, createEnterSpireMode,
} from './combat-descent-actions'; } from './combat-descent-actions';
import { createDefaultCombatState } from './combat-reset';
import { import {
onEnterLibraryRoom, tickNonCombatRoom, skipNonCombatRoom, stayLongerInRoom, onEnterLibraryRoom, tickNonCombatRoom, skipNonCombatRoom, stayLongerInRoom,
} from './non-combat-room-actions'; } from './non-combat-room-actions';
@@ -329,18 +330,7 @@ export const useCombatStore = create<CombatStore>()(
}, },
resetCombat: (startFloor: number, spellsToKeep: string[] = []) => { resetCombat: (startFloor: number, spellsToKeep: string[] = []) => {
const startSpells = makeInitialSpells(spellsToKeep); set(createDefaultCombatState(startFloor, spellsToKeep));
set({
currentFloor: startFloor,
floorHP: getFloorMaxHP(startFloor),
floorMaxHP: getFloorMaxHP(startFloor),
maxFloorReached: startFloor,
activeSpell: 'manaBolt',
currentAction: 'meditate',
castProgress: 0,
spells: startSpells,
});
}, },
}), }),