diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 2bc0f3d..e9b24fb 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,8 +1,8 @@ # Circular Dependencies -Generated: 2026-05-25T13:20:07.523Z +Generated: 2026-05-25T15:37:17.998Z Found: 6 circular chain(s) — these MUST be fixed before modifying involved files. -1. Processed 135 files (1.5s) (2 warnings) +1. Processed 135 files (1.7s) (2 warnings) 2. 1) utils/floor-utils.ts > utils/room-utils.ts > utils/enemy-utils.ts 3. 2) utils/floor-utils.ts > utils/room-utils.ts 4. 3) stores/gameStore.ts > stores/gameActions.ts diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index 6a877c3..be1bee4 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-05-25T13:20:05.764Z", + "generated": "2026-05-25T15:37:16.139Z", "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 aa88abb..2c4660a 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -199,6 +199,10 @@ Mana-Loop/ │ │ │ ├── crafting-utils-equipment.test.ts │ │ │ ├── crafting-utils-recipe.test.ts │ │ │ ├── crafting-utils-time.test.ts +│ │ │ ├── cross-module-combat-meditation.test.ts +│ │ │ ├── cross-module-helpers.ts +│ │ │ ├── cross-module-lifecycle-consistency.test.ts +│ │ │ ├── cross-module-prestige-discipline.test.ts │ │ │ ├── discipline-math.test.ts │ │ │ ├── discipline-prerequisites.test.ts │ │ │ ├── enemy-barrier-utils.test.ts diff --git a/src/lib/game/__tests__/cross-module-combat-meditation.test.ts b/src/lib/game/__tests__/cross-module-combat-meditation.test.ts new file mode 100644 index 0000000..17b2bd3 --- /dev/null +++ b/src/lib/game/__tests__/cross-module-combat-meditation.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useGameStore } from '../stores/gameStore'; +import { useManaStore } from '../stores/manaStore'; +import { useCombatStore } from '../stores/combatStore'; +import { useUIStore } from '../stores/uiStore'; +import { MAX_DAY } from '../constants'; +import { getFloorMaxHP } from '../utils'; +import { resetAllStores, tickN } from './cross-module-helpers'; + +describe('Cross-Module: Combat & Meditation', () => { + beforeEach(resetAllStores); + + describe('combat floor clearing via tick', () => { + it('should advance floor when climb action deals enough damage', () => { + useManaStore.setState({ rawMana: 9999 }); + useCombatStore.setState({ + currentAction: 'climb', + currentFloor: 1, + floorHP: getFloorMaxHP(1), + floorMaxHP: getFloorMaxHP(1), + activeSpell: 'manaBolt', + }); + + tickN(500); + + expect(useCombatStore.getState().currentFloor).toBeGreaterThan(1); + const { floorHP, floorMaxHP } = useCombatStore.getState(); + expect(floorHP).toBeGreaterThanOrEqual(0); + expect(floorHP).toBeLessThanOrEqual(floorMaxHP); + }); + + it('should not advance floor when action is meditate', () => { + useCombatStore.setState({ + currentAction: 'meditate', + currentFloor: 1, + floorHP: getFloorMaxHP(1), + activeSpell: 'manaBolt', + }); + + tickN(50); + + expect(useCombatStore.getState().currentFloor).toBe(1); + expect(useCombatStore.getState().maxFloorReached).toBe(1); + }); + + it('should track maxFloorReached across multiple floors', () => { + useCombatStore.setState({ + currentAction: 'climb', + currentFloor: 1, + floorHP: getFloorMaxHP(1), + floorMaxHP: getFloorMaxHP(1), + activeSpell: 'manaBolt', + }); + + tickN(500); + + const combat = useCombatStore.getState(); + expect(combat.maxFloorReached).toBeGreaterThanOrEqual(combat.currentFloor); + expect(combat.maxFloorReached).toBeGreaterThan(1); + }); + + it('should cap maxFloorReached at 100', () => { + useCombatStore.setState({ + currentAction: 'climb', + currentFloor: 100, + floorHP: getFloorMaxHP(100), + floorMaxHP: getFloorMaxHP(100), + maxFloorReached: 100, + activeSpell: 'manaBolt', + }); + + tickN(100); + + expect(useCombatStore.getState().currentFloor).toBe(100); + expect(useCombatStore.getState().maxFloorReached).toBe(100); + }); + + it('should update maxFloorReached and reduce mana after climbing', () => { + useManaStore.setState({ rawMana: 9999 }); + useCombatStore.setState({ + currentAction: 'climb', + currentFloor: 1, + floorHP: getFloorMaxHP(1), + floorMaxHP: getFloorMaxHP(1), + activeSpell: 'manaBolt', + }); + + tickN(500); + + expect(useCombatStore.getState().maxFloorReached).toBeGreaterThan(1); + expect(useManaStore.getState().rawMana).toBeLessThan(9999); + expect(useGameStore.getState().day).toBeGreaterThanOrEqual(1); + }); + }); + + describe('meditation mana regen flow', () => { + it('should increase raw mana over time while meditating', () => { + useCombatStore.setState({ currentAction: 'meditate' }); + useManaStore.setState({ rawMana: 10 }); + + tickN(20); + + expect(useManaStore.getState().rawMana).toBeGreaterThan(10); + }); + + it('should track meditateTicks in mana store during meditation', () => { + useCombatStore.setState({ currentAction: 'meditate' }); + + tickN(5); + + expect(useManaStore.getState().meditateTicks).toBe(5); + }); + + it('should reset meditateTicks when action changes from meditate', () => { + useCombatStore.setState({ currentAction: 'meditate' }); + tickN(5); + expect(useManaStore.getState().meditateTicks).toBe(5); + + useCombatStore.setState({ currentAction: 'climb' }); + tickN(1); + expect(useManaStore.getState().meditateTicks).toBe(0); + }); + + it('should boost mana regen with higher meditateTicks', () => { + resetAllStores(); + useCombatStore.setState({ currentAction: 'meditate' }); + useManaStore.setState({ rawMana: 10 }); + tickN(5); + const after5 = useManaStore.getState().rawMana; + + resetAllStores(); + useCombatStore.setState({ currentAction: 'meditate' }); + useManaStore.setState({ rawMana: 10 }); + tickN(50); + const after50 = useManaStore.getState().rawMana; + + expect(after50 - 10).toBeGreaterThan(after5 - 10); + }); + }); + + describe('incursion strength affecting mana regen', () => { + it('should reduce mana regen during high incursion', () => { + resetAllStores(); + useGameStore.setState({ day: 1, hour: 0 }); + useCombatStore.setState({ currentAction: 'meditate' }); + useManaStore.setState({ rawMana: 10 }); + tickN(10); + const lowIncursionMana = useManaStore.getState().rawMana; + + resetAllStores(); + useGameStore.setState({ day: MAX_DAY, hour: 23 }); + useCombatStore.setState({ currentAction: 'meditate' }); + useManaStore.setState({ rawMana: 10 }); + tickN(10); + const highIncursionMana = useManaStore.getState().rawMana; + + expect(highIncursionMana).toBeLessThan(lowIncursionMana); + }); + }); + + describe('convert action via tick', () => { + it('should convert raw mana to elements when action is convert', () => { + useManaStore.getState().unlockElement('fire', 0); + useManaStore.setState({ rawMana: 500 }); + useCombatStore.setState({ currentAction: 'convert' }); + + tickN(10); + + expect(useManaStore.getState().elements.fire.current).toBeGreaterThan(0); + }); + + it('should increase element mana when converting', () => { + useManaStore.getState().unlockElement('fire', 0); + useManaStore.setState({ rawMana: 500 }); + useCombatStore.setState({ currentAction: 'convert' }); + + tickN(10); + + expect(useManaStore.getState().elements.fire.current).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/lib/game/__tests__/cross-module-helpers.ts b/src/lib/game/__tests__/cross-module-helpers.ts new file mode 100644 index 0000000..2b1a13e --- /dev/null +++ b/src/lib/game/__tests__/cross-module-helpers.ts @@ -0,0 +1,114 @@ +import { useGameStore } from '../stores/gameStore'; +import { useManaStore, makeInitialElements } from '../stores/manaStore'; +import { useCombatStore, makeInitialSpells } from '../stores/combatStore'; +import { usePrestigeStore } from '../stores/prestigeStore'; +import { useUIStore } from '../stores/uiStore'; +import { useDisciplineStore } from '../stores/discipline-slice'; +import { useAttunementStore } from '../stores/attunementStore'; +import { useCraftingStore } from '../stores/craftingStore'; +import { getFloorMaxHP } from '../utils'; + +export function resetAllStores() { + useUIStore.setState({ + paused: false, + gameOver: false, + victory: false, + logs: [], + }); + + useGameStore.setState({ + day: 1, + hour: 0, + incursionStrength: 0, + containmentWards: 0, + initialized: true, + }); + + useManaStore.setState({ + rawMana: 100, + meditateTicks: 0, + totalManaGathered: 0, + elements: makeInitialElements(50, {}), + }); + + useCombatStore.setState({ + currentFloor: 1, + floorHP: getFloorMaxHP(1), + floorMaxHP: getFloorMaxHP(1), + maxFloorReached: 1, + activeSpell: 'manaBolt', + currentAction: 'meditate', + castProgress: 0, + spireMode: false, + currentRoom: { roomType: 'combat', enemies: [] }, + clearedFloors: {}, + climbDirection: null, + isDescending: false, + golemancy: { enabledGolems: [], summonedGolems: [], lastSummonFloor: 0 }, + equipmentSpellStates: [], + comboHitCount: 0, + floorHitCount: 0, + spells: makeInitialSpells(), + activityLog: [], + achievements: { unlocked: [], progress: {} }, + totalSpellsCast: 0, + totalDamageDealt: 0, + totalCraftsCompleted: 0, + }); + + usePrestigeStore.setState({ + loopCount: 0, + insight: 0, + totalInsight: 0, + loopInsight: 0, + prestigeUpgrades: {}, + pactSlots: 1, + defeatedGuardians: [], + signedPacts: [], + signedPactDetails: {}, + pactRitualFloor: null, + pactRitualProgress: 0, + }); + + useDisciplineStore.setState({ + disciplines: {}, + activeIds: [], + concurrentLimit: 1, + totalXP: 0, + processedPerks: [], + }); + + useAttunementStore.setState({ + attunements: {}, + }); + + useCraftingStore.setState({ + designProgress: null, + designProgress2: null, + preparationProgress: null, + applicationProgress: null, + equipmentCraftingProgress: null, + enchantmentDesigns: [], + unlockedEffects: [], + equippedInstances: {}, + equipmentInstances: {}, + lootInventory: { + materials: {}, + blueprints: [], + }, + enchantmentSelection: { + selectedEquipmentType: null, + selectedEffects: [], + designName: '', + selectedDesign: null, + selectedEquipmentInstance: null, + }, + lastError: null, + }); +} + +export function tickN(n: number) { + for (let i = 0; i < n; i++) { + useGameStore.getState().tick(); + } +} diff --git a/src/lib/game/__tests__/cross-module-lifecycle-consistency.test.ts b/src/lib/game/__tests__/cross-module-lifecycle-consistency.test.ts new file mode 100644 index 0000000..3cf9c83 --- /dev/null +++ b/src/lib/game/__tests__/cross-module-lifecycle-consistency.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useGameStore } from '../stores/gameStore'; +import { useManaStore } from '../stores/manaStore'; +import { useCombatStore } from '../stores/combatStore'; +import { usePrestigeStore } from '../stores/prestigeStore'; +import { useUIStore } from '../stores/uiStore'; +import { MAX_DAY } from '../constants'; +import { resetAllStores, tickN } from './cross-module-helpers'; + +describe('Cross-Module: Lifecycle & Consistency', () => { + beforeEach(resetAllStores); + + describe('full loop lifecycle', () => { + it('should progress through multiple days without errors', () => { + expect(() => { + tickN(1000); + }).not.toThrow(); + }); + + it('should end the loop when day exceeds MAX_DAY', () => { + useGameStore.setState({ day: MAX_DAY, hour: 23.9 }); + + tickN(5); + + expect(useUIStore.getState().gameOver).toBe(true); + expect(usePrestigeStore.getState().loopInsight).toBeGreaterThan(0); + }); + + it('should generate log messages during the loop', () => { + usePrestigeStore.setState({ defeatedGuardians: [10], insight: 1000 }); + useCombatStore.setState({ currentAction: 'climb' }); + + tickN(100); + + const logs = useUIStore.getState().logs; + expect(Array.isArray(logs)).toBe(true); + }); + }); + + describe('store consistency after many ticks', () => { + it('should keep rawMana within [0, maxMana] after many ticks', () => { + useManaStore.setState({ rawMana: 50 }); + + tickN(500); + + const mana = useManaStore.getState().rawMana; + expect(mana).toBeGreaterThanOrEqual(0); + expect(mana).toBeLessThanOrEqual(200); + }); + + it('should keep floorHP within [0, floorMaxHP] after many ticks', () => { + useCombatStore.setState({ currentAction: 'climb' }); + + tickN(200); + + const { floorHP, floorMaxHP } = useCombatStore.getState(); + expect(floorHP).toBeGreaterThanOrEqual(0); + expect(floorHP).toBeLessThanOrEqual(floorMaxHP); + }); + + it('should keep currentFloor within [1, 100] after many ticks', () => { + useCombatStore.setState({ currentAction: 'climb' }); + + tickN(1000); + + const { currentFloor } = useCombatStore.getState(); + expect(currentFloor).toBeGreaterThanOrEqual(1); + expect(currentFloor).toBeLessThanOrEqual(100); + }); + + it('should keep day within [1, MAX_DAY + 1] during normal play', () => { + tickN(100); + + const { day } = useGameStore.getState(); + expect(day).toBeGreaterThanOrEqual(1); + expect(day).toBeLessThanOrEqual(MAX_DAY + 1); + }); + + it('should keep hour within [0, 24) after any number of ticks', () => { + tickN(999); + + const { hour } = useGameStore.getState(); + expect(hour).toBeGreaterThanOrEqual(0); + expect(hour).toBeLessThan(24); + }); + + it('should keep incursionStrength within [0, 0.95]', () => { + tickN(2000); + + const { incursionStrength } = useGameStore.getState(); + expect(incursionStrength).toBeGreaterThanOrEqual(0); + expect(incursionStrength).toBeLessThanOrEqual(0.95); + }); + }); + + describe('pause and gameOver consistency', () => { + it('should not change any store state when paused', () => { + useUIStore.setState({ paused: true }); + useManaStore.setState({ rawMana: 50 }); + useCombatStore.setState({ currentFloor: 5, currentAction: 'climb' }); + + const manaBefore = useManaStore.getState().rawMana; + const floorBefore = useCombatStore.getState().currentFloor; + const dayBefore = useGameStore.getState().day; + + tickN(10); + + expect(useManaStore.getState().rawMana).toBe(manaBefore); + expect(useCombatStore.getState().currentFloor).toBe(floorBefore); + expect(useGameStore.getState().day).toBe(dayBefore); + }); + + it('should not change any store state when gameOver', () => { + useUIStore.setState({ gameOver: true }); + useManaStore.setState({ rawMana: 50 }); + + const manaBefore = useManaStore.getState().rawMana; + const dayBefore = useGameStore.getState().day; + + tickN(10); + + expect(useManaStore.getState().rawMana).toBe(manaBefore); + expect(useGameStore.getState().day).toBe(dayBefore); + }); + }); +}); diff --git a/src/lib/game/__tests__/cross-module-prestige-discipline.test.ts b/src/lib/game/__tests__/cross-module-prestige-discipline.test.ts new file mode 100644 index 0000000..9b37519 --- /dev/null +++ b/src/lib/game/__tests__/cross-module-prestige-discipline.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useGameStore } from '../stores/gameStore'; +import { useManaStore } from '../stores/manaStore'; +import { useCombatStore } from '../stores/combatStore'; +import { usePrestigeStore } from '../stores/prestigeStore'; +import { useUIStore } from '../stores/uiStore'; +import { useDisciplineStore } from '../stores/discipline-slice'; +import { resetAllStores, tickN } from './cross-module-helpers'; + +describe('Cross-Module: Prestige & Discipline', () => { + beforeEach(resetAllStores); + + describe('prestige loop reset', () => { + it('should reset game day/hour on startNewLoop', () => { + useGameStore.setState({ day: 30, hour: 12, incursionStrength: 0.5 }); + usePrestigeStore.setState({ loopInsight: 100, insight: 50 }); + + useGameStore.getState().startNewLoop(); + + expect(useGameStore.getState().day).toBe(1); + expect(useGameStore.getState().hour).toBe(0); + expect(useGameStore.getState().incursionStrength).toBe(0); + }); + + it('should preserve insight across loop reset', () => { + usePrestigeStore.setState({ insight: 200, loopInsight: 50 }); + + useGameStore.getState().startNewLoop(); + + expect(usePrestigeStore.getState().insight).toBeGreaterThanOrEqual(250); + }); + + it('should increment loop count on startNewLoop', () => { + usePrestigeStore.setState({ loopCount: 2, insight: 500 }); + + useGameStore.getState().startNewLoop(); + + expect(usePrestigeStore.getState().loopCount).toBe(3); + }); + + it('should clear defeatedGuardians and signedPacts on new loop', () => { + usePrestigeStore.setState({ + defeatedGuardians: [10, 20, 30], + signedPacts: [10], + insight: 1000, + }); + + useGameStore.getState().startNewLoop(); + + expect(usePrestigeStore.getState().defeatedGuardians).toEqual([]); + expect(usePrestigeStore.getState().signedPacts).toEqual([]); + }); + + it('should clear pact ritual state on new loop', () => { + usePrestigeStore.setState({ + pactRitualFloor: 50, + pactRitualProgress: 10, + insight: 1000, + }); + + useGameStore.getState().startNewLoop(); + + expect(usePrestigeStore.getState().pactRitualFloor).toBeNull(); + expect(usePrestigeStore.getState().pactRitualProgress).toBe(0); + }); + + it('should reset combat floor on new loop', () => { + usePrestigeStore.setState({ insight: 1000 }); + useCombatStore.setState({ currentFloor: 50, maxFloorReached: 50 }); + + useGameStore.getState().startNewLoop(); + + expect(useCombatStore.getState().currentFloor).toBe(1); + expect(useCombatStore.getState().maxFloorReached).toBe(1); + }); + + it('should clear gameOver and victory flags on new loop', () => { + useUIStore.setState({ gameOver: true, victory: false }); + usePrestigeStore.setState({ insight: 1000 }); + + useGameStore.getState().startNewLoop(); + + expect(useUIStore.getState().gameOver).toBe(false); + expect(useUIStore.getState().victory).toBe(false); + }); + + it('should reset mana on new loop', () => { + usePrestigeStore.setState({ insight: 1000 }); + useManaStore.setState({ + rawMana: 5, + totalManaGathered: 9999, + }); + + useGameStore.getState().startNewLoop(); + + expect(useManaStore.getState().rawMana).toBeGreaterThan(5); + expect(useManaStore.getState().totalManaGathered).toBe(0); + }); + + it('should preserve prestige upgrades across loop reset', () => { + usePrestigeStore.setState({ + insight: 1000, + prestigeUpgrades: { manaWell: 3, spireKey: 1 }, + }); + + useGameStore.getState().startNewLoop(); + + expect(usePrestigeStore.getState().prestigeUpgrades.manaWell).toBe(3); + expect(usePrestigeStore.getState().prestigeUpgrades.spireKey).toBe(1); + }); + }); + + describe('discipline mana drain and XP via tick', () => { + it('should accrue discipline XP when enough mana', () => { + useDisciplineStore.getState().activate('raw-mastery'); + useManaStore.setState({ rawMana: 99999 }); + + tickN(10); + + expect(useDisciplineStore.getState().totalXP).toBeGreaterThan(0); + }); + + it('should drain raw mana for raw-type disciplines', () => { + useDisciplineStore.getState().activate('raw-mastery'); + useManaStore.setState({ rawMana: 99999 }); + + tickN(10); + + expect(useDisciplineStore.getState().totalXP).toBeGreaterThan(0); + const disc = useDisciplineStore.getState().disciplines['raw-mastery']; + expect(disc).toBeDefined(); + expect(disc!.xp).toBeGreaterThan(0); + }); + + it('should pause discipline when insufficient mana', () => { + useDisciplineStore.getState().activate('raw-mastery'); + useManaStore.setState({ rawMana: 0 }); + + tickN(5); + + const disc = useDisciplineStore.getState().disciplines['raw-mastery']; + if (disc) { + expect(disc.paused).toBe(true); + } else { + expect(useDisciplineStore.getState().totalXP).toBe(0); + } + }); + + it('should respect concurrent limit', () => { + useDisciplineStore.getState().activate('raw-mastery'); + useManaStore.setState({ rawMana: 99999 }); + + tickN(10); + + expect(useDisciplineStore.getState().totalXP).toBeGreaterThan(0); + const activeIds = useDisciplineStore.getState().activeIds; + expect(activeIds.length).toBeLessThanOrEqual( + useDisciplineStore.getState().concurrentLimit, + ); + }); + }); + + describe('pact ritual completion via tick', () => { + it('should complete pact ritual after enough ticks', () => { + usePrestigeStore.setState({ + defeatedGuardians: [10], + pactRitualFloor: 10, + pactRitualProgress: 0, + signedPacts: [], + pactSlots: 2, + }); + + tickN(500); + + expect(usePrestigeStore.getState().signedPacts).toContain(10); + expect(usePrestigeStore.getState().pactRitualFloor).toBeNull(); + expect(usePrestigeStore.getState().pactRitualProgress).toBe(0); + }); + + it('should not advance pact ritual when not active', () => { + usePrestigeStore.setState({ pactRitualFloor: null }); + + tickN(50); + + expect(usePrestigeStore.getState().pactRitualProgress).toBe(0); + }); + }); +});