diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index c8585d5..67d3be4 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,5 +1,5 @@ # 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. 1. 1) data/guardian-encounters.ts > data/guardian-procedural.ts diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index 064b973..1f21e7f 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_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.", "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 e47aaed..247ce92 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -241,6 +241,7 @@ Mana-Loop/ │ │ │ │ ├── paused-conversion-dedup.test.ts │ │ │ │ ├── persistence.test.ts │ │ │ │ ├── regression-fixes.test.ts +│ │ │ │ ├── reset-game-comprehensive.test.ts │ │ │ │ ├── room-utils-floor-state.test.ts │ │ │ │ ├── room-utils.test.ts │ │ │ │ ├── spell-cast-floorhp-guard.test.ts @@ -380,6 +381,7 @@ Mana-Loop/ │ │ │ │ ├── combat-actions.ts │ │ │ │ ├── combat-damage.ts │ │ │ │ ├── combat-descent-actions.ts +│ │ │ │ ├── combat-reset.ts │ │ │ │ ├── combat-state.types.ts │ │ │ │ ├── combatStore.ts │ │ │ │ ├── crafting-equipment-tick.ts diff --git a/src/lib/game/__tests__/reset-game-comprehensive.test.ts b/src/lib/game/__tests__/reset-game-comprehensive.test.ts new file mode 100644 index 0000000..bae83fb --- /dev/null +++ b/src/lib/game/__tests__/reset-game-comprehensive.test.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); + } + }); +}); diff --git a/src/lib/game/stores/combat-reset.ts b/src/lib/game/stores/combat-reset.ts new file mode 100644 index 0000000..9c8f645 --- /dev/null +++ b/src/lib/game/stores/combat-reset.ts @@ -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 { + 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, + }; +} diff --git a/src/lib/game/stores/combatStore.ts b/src/lib/game/stores/combatStore.ts index 46b9f7a..01fbebe 100644 --- a/src/lib/game/stores/combatStore.ts +++ b/src/lib/game/stores/combatStore.ts @@ -14,6 +14,7 @@ import type { CombatStore } from './combat-state.types'; import { enterDescentMode, advanceRoomOrFloor, onEnterRoomDescend, createEnterSpireMode, } from './combat-descent-actions'; +import { createDefaultCombatState } from './combat-reset'; import { onEnterLibraryRoom, tickNonCombatRoom, skipNonCombatRoom, stayLongerInRoom, } from './non-combat-room-actions'; @@ -329,18 +330,7 @@ export const useCombatStore = create()( }, resetCombat: (startFloor: number, spellsToKeep: string[] = []) => { - const startSpells = makeInitialSpells(spellsToKeep); - - set({ - currentFloor: startFloor, - floorHP: getFloorMaxHP(startFloor), - floorMaxHP: getFloorMaxHP(startFloor), - maxFloorReached: startFloor, - activeSpell: 'manaBolt', - currentAction: 'meditate', - castProgress: 0, - spells: startSpells, - }); + set(createDefaultCombatState(startFloor, spellsToKeep)); }, }),