diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index fdccfc5..bcbb627 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,8 +1,8 @@ # Circular Dependencies -Generated: 2026-05-23T11:46:22.135Z +Generated: 2026-05-23T12:53:16.980Z Found: 4 circular chain(s) — these MUST be fixed before modifying involved files. -1. Processed 129 files (1.5s) (3 warnings) +1. Processed 129 files (1.4s) (3 warnings) 2. 1) stores/gameStore.ts > stores/gameActions.ts 3. 2) stores/gameStore.ts > stores/gameLoopActions.ts 4. 3) stores/gameStore.ts > stores/tick-pipeline.ts diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index bd7c21f..4ded6b5 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-05-23T11:46:20.479Z", + "generated": "2026-05-23T12:53:15.385Z", "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." }, @@ -628,7 +628,7 @@ "types.ts" ], "utils/pact-utils.ts": [ - "constants.ts" + "data/guardian-encounters.ts" ], "utils/result.ts": [], "utils/room-utils.ts": [ diff --git a/docs/project-structure.txt b/docs/project-structure.txt index 12e3e4a..c7a7dcd 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -208,6 +208,7 @@ Mana-Loop/ │ │ │ ├── mana-utils.test.ts │ │ │ ├── pact-utils.test.ts │ │ │ ├── regression-fixes.test.ts +│ │ │ ├── room-utils-floor-state.test.ts │ │ │ ├── room-utils.test.ts │ │ │ ├── spire-utils.test.ts │ │ │ ├── store-actions-combat-prestige.test.ts @@ -229,7 +230,6 @@ Mana-Loop/ │ │ │ │ └── utility-spells.ts │ │ │ ├── core.ts │ │ │ ├── elements.ts -│ │ │ ├── guardians.ts │ │ │ ├── index.ts │ │ │ ├── prestige.ts │ │ │ ├── rooms.ts @@ -296,6 +296,7 @@ Mana-Loop/ │ │ │ ├── enchantment-effects.ts │ │ │ ├── enchantment-types.ts │ │ │ ├── fabricator-recipes.ts +│ │ │ ├── guardian-data.ts │ │ │ ├── guardian-encounters.ts │ │ │ └── loot-drops.ts │ │ ├── effects/ diff --git a/src/components/game/tabs/GuardianPactsTab.test.ts b/src/components/game/tabs/GuardianPactsTab.test.ts index 1f38076..909cffd 100644 --- a/src/components/game/tabs/GuardianPactsTab.test.ts +++ b/src/components/game/tabs/GuardianPactsTab.test.ts @@ -29,8 +29,9 @@ describe('Tab barrel export', () => { describe('Guardian data', () => { it('all guardians have required fields', async () => { - const { GUARDIANS } = await import('@/lib/game/constants/guardians'); - for (const [floor, def] of Object.entries(GUARDIANS)) { + const { getGuardianForFloor } = await import('@/lib/game/data/guardian-encounters'); + for (const floor of [10, 20, 30, 40, 50, 60, 80, 90, 100]) { + const def = getGuardianForFloor(floor)!; expect(def.name).toBeTruthy(); expect(def.element).toBeTruthy(); expect(def.hp).toBeGreaterThan(0); @@ -49,10 +50,10 @@ describe('Guardian data', () => { }); it('guardians are defined at expected floors', async () => { - const { GUARDIANS } = await import('@/lib/game/constants/guardians'); + const { getGuardianForFloor } = await import('@/lib/game/data/guardian-encounters'); const expectedFloors = [10, 20, 30, 40, 50, 60, 80, 90, 100]; for (const floor of expectedFloors) { - expect(GUARDIANS[floor]).toBeDefined(); + expect(getGuardianForFloor(floor)).not.toBeNull(); } }); @@ -62,8 +63,9 @@ describe('Guardian data', () => { 'critChance', 'critDamage', 'spellEfficiency', 'manaGain', 'insightGain', 'studySpeed', 'prestigeInsight', ]; - const { GUARDIANS } = await import('@/lib/game/constants/guardians'); - for (const def of Object.values(GUARDIANS)) { + const { getGuardianForFloor } = await import('@/lib/game/data/guardian-encounters'); + for (const floor of [10, 20, 30, 40, 50, 60, 80, 90, 100]) { + const def = getGuardianForFloor(floor)!; for (const boon of def.boons) { expect(validBoonTypes).toContain(boon.type); expect(boon.value).toBeGreaterThan(0); diff --git a/src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx b/src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx index fe36088..620cc39 100644 --- a/src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx +++ b/src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx @@ -6,8 +6,7 @@ import { useCombatStore, useManaStore, usePrestigeStore, fmt, computeMaxMana, co import { computeDisciplineEffects } from '@/lib/game/effects/discipline-effects'; import { getUnifiedEffects } from '@/lib/game/effects'; import { useCraftingStore } from '@/lib/game/stores/craftingStore'; -import { GUARDIANS } from '@/lib/game/constants'; -import { getExtendedGuardian, isGuardianFloor } from '@/lib/game/data/guardian-encounters'; +import { getGuardianForFloor, isGuardianFloor } from '@/lib/game/data/guardian-encounters'; import { getRoomsForFloor, generateSpireFloorState } from '@/lib/game/utils/spire-utils'; import { SpireHeader } from './SpireHeader'; import { RoomDisplay } from './RoomDisplay'; @@ -122,7 +121,7 @@ export function SpireCombatPage() { setClearedFloor(currentFloor, true); if (wasGuardian) { - const guardian = GUARDIANS[currentFloor] || getExtendedGuardian(currentFloor); + const guardian = getGuardianForFloor(currentFloor); if (guardian) { addActivityLog('enemy_defeated', `⚔️ ${guardian.name} defeated!`, { enemyName: guardian.name, diff --git a/src/components/game/tabs/SpireCombatPage/SpireHeader.tsx b/src/components/game/tabs/SpireCombatPage/SpireHeader.tsx index b1b5d66..4d06692 100644 --- a/src/components/game/tabs/SpireCombatPage/SpireHeader.tsx +++ b/src/components/game/tabs/SpireCombatPage/SpireHeader.tsx @@ -5,8 +5,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; import { Mountain, ArrowUp, ArrowDown, LogOut } from 'lucide-react'; -import { GUARDIANS } from '@/lib/game/constants'; -import { isGuardianFloor, getExtendedGuardian } from '@/lib/game/data/guardian-encounters'; +import { getGuardianForFloor, isGuardianFloor } from '@/lib/game/data/guardian-encounters'; interface SpireHeaderProps { currentFloor: number; @@ -34,7 +33,7 @@ export function SpireHeader({ const maxFloorReached = useCombatStore((s) => s.maxFloorReached); const { insight } = usePrestigeStore((s) => ({ insight: s.insight })); - const guardian = GUARDIANS[currentFloor] || getExtendedGuardian(currentFloor); + const guardian = getGuardianForFloor(currentFloor); const isGuardian = isGuardianFloor(currentFloor); const hpPercent = floorMaxHP > 0 ? (floorHP / floorMaxHP) * 100 : 100; const roomProgress = totalRooms > 0 ? ((roomsCleared) / totalRooms) * 100 : 0; diff --git a/src/components/game/tabs/SpireSummaryTab.test.ts b/src/components/game/tabs/SpireSummaryTab.test.ts index dcf1453..056d4cc 100644 --- a/src/components/game/tabs/SpireSummaryTab.test.ts +++ b/src/components/game/tabs/SpireSummaryTab.test.ts @@ -27,23 +27,26 @@ describe('Tab barrel export', () => { // ─── Test: Guardian data ─────────────────────────────────────────────────────── -describe('Guardian constants', () => { - it('has 9 guardians defined', async () => { - const { GUARDIANS } = await import('@/lib/game/constants'); - expect(Object.keys(GUARDIANS).length).toBe(9); +describe('Guardian data', () => { + it('has 9 static guardians plus extended guardians', async () => { + const { getAllGuardianFloors } = await import('@/lib/game/data/guardian-encounters'); + const floors = getAllGuardianFloors(); + // 9 static + compound (110) + exotic (120,130,140) + combo (150-240) = 23 total + expect(floors.length).toBeGreaterThanOrEqual(9); }); - it('guardians are at expected floors', async () => { - const { GUARDIANS } = await import('@/lib/game/constants'); + it('guardians are at expected base floors', async () => { + const { getGuardianForFloor } = await import('@/lib/game/data/guardian-encounters'); const expectedFloors = [10, 20, 30, 40, 50, 60, 80, 90, 100]; for (const floor of expectedFloors) { - expect(GUARDIANS[floor]).toBeDefined(); + expect(getGuardianForFloor(floor)).not.toBeNull(); } }); - it('all guardians have required fields', async () => { - const { GUARDIANS } = await import('@/lib/game/constants'); - for (const [, def] of Object.entries(GUARDIANS)) { + it('all base guardians have required fields', async () => { + const { getGuardianForFloor } = await import('@/lib/game/data/guardian-encounters'); + for (const floor of [10, 20, 30, 40, 50, 60, 80, 90, 100]) { + const def = getGuardianForFloor(floor)!; expect(def.name).toBeTruthy(); expect(def.element).toBeTruthy(); expect(def.hp).toBeGreaterThan(0); @@ -53,9 +56,9 @@ describe('Guardian constants', () => { } }); - it('all guardians have unique elements', async () => { - const { GUARDIANS } = await import('@/lib/game/constants'); - const elements = Object.values(GUARDIANS).map((g) => g.element); + it('all base guardians have unique elements', async () => { + const { getGuardianForFloor } = await import('@/lib/game/data/guardian-encounters'); + const elements = [10, 20, 30, 40, 50, 60, 80, 90, 100].map((f) => getGuardianForFloor(f)!.element); const uniqueElements = new Set(elements); expect(uniqueElements.size).toBe(elements.length); }); diff --git a/src/lib/game/__tests__/pact-utils.test.ts b/src/lib/game/__tests__/pact-utils.test.ts index ee7fc21..885aac2 100644 --- a/src/lib/game/__tests__/pact-utils.test.ts +++ b/src/lib/game/__tests__/pact-utils.test.ts @@ -3,7 +3,7 @@ import { computePactMultiplier, computePactInsightMultiplier, } from '../utils/pact-utils'; -import { GUARDIANS } from '../constants'; +import { getGuardianForFloor } from '../data/guardian-encounters'; // Helper: compute actual multiplier values function getDamageMult(mult: number, extraPacts: number, mitigation: number): number { const numAdditional = extraPacts; @@ -41,14 +41,14 @@ describe('computePactMultiplier', () => { }); it('should return guardian damage multiplier for a single guardian pact', () => { - const floor10 = GUARDIANS[10]; + const floor10 = getGuardianForFloor(10)!; const result = computePactMultiplier({ signedPacts: [10] }); expect(result).toBe(floor10.damageMultiplier); }); it('should multiply damage multipliers for multiple guardian pacts', () => { - const floor10 = GUARDIANS[10]; - const floor20 = GUARDIANS[20]; + const floor10 = getGuardianForFloor(10)!; + const floor20 = getGuardianForFloor(20)!; const result = computePactMultiplier({ signedPacts: [10, 20] }); // With 2 pacts: baseMult = f10 * f20, then penalty = 0.5 * 1 = 0.5 // effectivePenalty = max(0, 0.5 - 0) = 0.5 @@ -58,8 +58,8 @@ describe('computePactMultiplier', () => { }); it('should apply interference mitigation to reduce penalty', () => { - const floor10 = GUARDIANS[10]; - const floor20 = GUARDIANS[20]; + const floor10 = getGuardianForFloor(10)!; + const floor20 = getGuardianForFloor(20)!; const baseMult = floor10.damageMultiplier * floor20.damageMultiplier; // No mitigation: penalty = 0.5, result = baseMult * 0.5 @@ -79,8 +79,8 @@ describe('computePactMultiplier', () => { }); it('should apply synergy bonus when mitigation exceeds 5', () => { - const floor10 = GUARDIANS[10]; - const floor20 = GUARDIANS[20]; + const floor10 = getGuardianForFloor(10)!; + const floor20 = getGuardianForFloor(20)!; const baseMult = floor10.damageMultiplier * floor20.damageMultiplier; // mitigation = 6: synergyBonus = (6-5)*0.1 = 0.1, result = baseMult * 1.1 @@ -92,8 +92,8 @@ describe('computePactMultiplier', () => { }); it('should scale synergy bonus with higher mitigation', () => { - const floor10 = GUARDIANS[10]; - const floor20 = GUARDIANS[20]; + const floor10 = getGuardianForFloor(10)!; + const floor20 = getGuardianForFloor(20)!; const baseMult = floor10.damageMultiplier * floor20.damageMultiplier; const mit5 = computePactMultiplier({ @@ -115,9 +115,9 @@ describe('computePactMultiplier', () => { }); it('should handle three pacts with penalty', () => { - const floor10 = GUARDIANS[10]; - const floor20 = GUARDIANS[20]; - const floor30 = GUARDIANS[30]; + const floor10 = getGuardianForFloor(10)!; + const floor20 = getGuardianForFloor(20)!; + const floor30 = getGuardianForFloor(30)!; const baseMult = floor10.damageMultiplier * floor20.damageMultiplier * floor30.damageMultiplier; // 3 pacts: numAdditional = 2, basePenalty = 1.0, effectivePenalty = 1.0 @@ -130,9 +130,9 @@ describe('computePactMultiplier', () => { }); it('should handle three pacts with partial mitigation', () => { - const floor10 = GUARDIANS[10]; - const floor20 = GUARDIANS[20]; - const floor30 = GUARDIANS[30]; + const floor10 = getGuardianForFloor(10)!; + const floor20 = getGuardianForFloor(20)!; + const floor30 = getGuardianForFloor(30)!; const baseMult = floor10.damageMultiplier * floor20.damageMultiplier * floor30.damageMultiplier; // 3 pacts: numAdditional = 2, basePenalty = 1.0 @@ -146,7 +146,7 @@ describe('computePactMultiplier', () => { }); it('should handle mix of guardian and non-guardian floors', () => { - const floor10 = GUARDIANS[10]; + const floor10 = getGuardianForFloor(10)!; // Non-guardian floors contribute nothing (no entry in GUARDIANS) const result = computePactMultiplier({ signedPacts: [5, 10] }); // Only floor 10 counts: baseMult = floor10.damageMultiplier @@ -181,22 +181,22 @@ describe('computePactInsightMultiplier', () => { }); it('should return guardian insight multiplier for a single guardian pact', () => { - const floor10 = GUARDIANS[10]; + const floor10 = getGuardianForFloor(10)!; const result = computePactInsightMultiplier({ signedPacts: [10] }); expect(result).toBe(floor10.insightMultiplier); }); it('should multiply insight multipliers for multiple guardian pacts', () => { - const floor10 = GUARDIANS[10]; - const floor20 = GUARDIANS[20]; + const floor10 = getGuardianForFloor(10)!; + const floor20 = getGuardianForFloor(20)!; const result = computePactInsightMultiplier({ signedPacts: [10, 20] }); const expected = floor10.insightMultiplier * floor20.insightMultiplier * 0.5; expect(result).toBe(expected); }); it('should apply interference mitigation to reduce penalty', () => { - const floor10 = GUARDIANS[10]; - const floor20 = GUARDIANS[20]; + const floor10 = getGuardianForFloor(10)!; + const floor20 = getGuardianForFloor(20)!; const baseMult = floor10.insightMultiplier * floor20.insightMultiplier; const noMitigation = computePactInsightMultiplier({ @@ -213,8 +213,8 @@ describe('computePactInsightMultiplier', () => { }); it('should apply synergy bonus when mitigation exceeds 5', () => { - const floor10 = GUARDIANS[10]; - const floor20 = GUARDIANS[20]; + const floor10 = getGuardianForFloor(10)!; + const floor20 = getGuardianForFloor(20)!; const baseMult = floor10.insightMultiplier * floor20.insightMultiplier; const result = computePactInsightMultiplier({ @@ -226,9 +226,9 @@ describe('computePactInsightMultiplier', () => { }); it('should handle three pacts with full penalty', () => { - const floor10 = GUARDIANS[10]; - const floor20 = GUARDIANS[20]; - const floor30 = GUARDIANS[30]; + const floor10 = getGuardianForFloor(10)!; + const floor20 = getGuardianForFloor(20)!; + const floor30 = getGuardianForFloor(30)!; const baseMult = floor10.insightMultiplier * floor20.insightMultiplier * floor30.insightMultiplier; const result = computePactInsightMultiplier({ diff --git a/src/lib/game/__tests__/room-utils-floor-state.test.ts b/src/lib/game/__tests__/room-utils-floor-state.test.ts new file mode 100644 index 0000000..b021b0a --- /dev/null +++ b/src/lib/game/__tests__/room-utils-floor-state.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest'; +import { generateFloorState } from '../utils/room-utils'; +import { PUZZLE_ROOMS, SWARM_CONFIG, SPEED_ROOM_CONFIG } from '../constants'; +import { getGuardianForFloor } from '../data/guardian-encounters'; +import { getFloorMaxHP, getDodgeChance } from '../utils/floor-utils'; + +// ─── generateFloorState ─────────────────────────────────────────────────────── + +describe('generateFloorState', () => { + it('should return a FloorState object', () => { + const state = generateFloorState(1); + expect(typeof state).toBe('object'); + expect(state).toHaveProperty('roomType'); + expect(state).toHaveProperty('enemies'); + }); + + it('should generate guardian state for guardian floor', () => { + const state = generateFloorState(10); + expect(state.roomType).toBe('guardian'); + expect(state.enemies.length).toBe(1); + const g10 = getGuardianForFloor(10)!; + expect(state.enemies[0].name).toBe(g10.name); + expect(state.enemies[0].hp).toBe(g10.hp); + expect(state.enemies[0].element).toBe(g10.element); + }); + + it('should generate combat state for non-guardian floor with combat', () => { + const originalRandom = Math.random; + Math.random = () => 0.5; // Won't trigger special rooms + const state = generateFloorState(5); + expect(state.roomType).toBe('combat'); + expect(state.enemies.length).toBe(1); + expect(state.enemies[0].hp).toBe(getFloorMaxHP(5)); + Math.random = originalRandom; + }); + + it('should generate swarm state for swarm room', () => { + const originalRandom = Math.random; + Math.random = () => 0.14; // < SWARM_ROOM_CHANCE (0.15) + const state = generateFloorState(5); + expect(state.roomType).toBe('swarm'); + expect(Array.isArray(state.enemies)).toBe(true); + expect(state.enemies.length).toBeGreaterThanOrEqual(SWARM_CONFIG.minEnemies); + Math.random = originalRandom; + }); + + it('should generate speed state for speed room', () => { + const originalRandom = Math.random; + Math.random = () => 0.09; // < SPEED_ROOM_CHANCE (0.10) + const state = generateFloorState(5); + expect(state.roomType).toBe('speed'); + expect(state.enemies.length).toBe(1); + Math.random = originalRandom; + }); + + it('should generate puzzle state for puzzle room', () => { + const originalRandom = Math.random; + Math.random = () => 0.19; // < PUZZLE_ROOM_CHANCE (0.20) + const state = generateFloorState(7); + expect(state.roomType).toBe('puzzle'); + expect(Array.isArray(state.enemies)).toBe(true); + expect(state.enemies).toEqual([]); + expect(state.puzzleProgress).toBe(0); + expect(typeof state.puzzleRequired).toBe('number'); + expect(typeof state.puzzleId).toBe('string'); + expect(typeof state.puzzleAttunements).toBe('object'); + Math.random = originalRandom; + }); + + it('should fill puzzle attunements from PUZZLE_ROOMS', () => { + const originalRandom = Math.random; + Math.random = () => 0.19; + const state = generateFloorState(7); + expect(state.roomType).toBe('puzzle'); + expect(state.puzzleAttunements.length).toBeGreaterThan(0); + expect(typeof state.puzzleAttunements[0]).toBe('string'); + Math.random = originalRandom; + }); + + it('should use correct element for floor', () => { + const state = generateFloorState(1); + expect(state.enemies[0].element).toBe('fire'); + const state2 = generateFloorState(2); + expect(state2.enemies[0].element).toBe('water'); + }); + + it('combat enemy HP should match floor max HP', () => { + const state = generateFloorState(50); + expect(state.enemies[0].hp).toBe(getFloorMaxHP(50)); + }); + + it('speed room should have correct dodge chance', () => { + const originalRandom = Math.random; + Math.random = () => 0.09; // Speed room + const speedState = generateFloorState(50); + expect(speedState.roomType).toBe('speed'); + expect(speedState.enemies[0].dodgeChance).toBe(getDodgeChance(50)); + Math.random = originalRandom; + }); + + it('should handle very high floor number', () => { + const state = generateFloorState(1000); + expect(state.roomType).toBe('guardian'); + }); + + it('should handle floor 0', () => { + const state = generateFloorState(0); + expect(state.roomType).toBe('combat'); + }); +}); diff --git a/src/lib/game/__tests__/room-utils.test.ts b/src/lib/game/__tests__/room-utils.test.ts index fbb7890..9594884 100644 --- a/src/lib/game/__tests__/room-utils.test.ts +++ b/src/lib/game/__tests__/room-utils.test.ts @@ -4,12 +4,10 @@ import { getFloorArmor, getDodgeChance, getEnemyBarrier, - generateFloorState, getPuzzleProgressSpeed, } from '../utils/room-utils'; -import { GUARDIANS, FLOOR_ELEM_CYCLE, PUZZLE_ROOMS, SWARM_CONFIG, SPEED_ROOM_CONFIG, FLOOR_ARMOR_CONFIG } from '../constants'; -import type { EnemyState, FloorState } from '../types'; -import { getFloorMaxHP, getFloorElement, getEnemyName } from '../utils/floor-utils'; +import { FLOOR_ELEM_CYCLE, PUZZLE_ROOMS, SWARM_CONFIG, SPEED_ROOM_CONFIG, FLOOR_ARMOR_CONFIG } from '../constants'; +import { getAllGuardianFloors } from '../data/guardian-encounters'; // ─── generateRoomType ───────────────────────────────────────────────────────── @@ -21,8 +19,8 @@ describe('generateRoomType', () => { }); it('should return "guardian" for guardian floors', () => { - // Get all guardian floors from GUARDIANS object - for (const floor of Object.keys(GUARDIANS).map(Number)) { + // Get all guardian floors from the unified system + for (const floor of getAllGuardianFloors().filter(f => f <= 100)) { // Override and restore Math.random to ensure consistent result const originalRandom = Math.random; Math.random = () => 0.1; // Anything < PUZZLE_ROOM_CHANCE ensures non-puzzle @@ -163,7 +161,7 @@ describe('getFloorArmor', () => { }); it('should return 0 for guardian floors', () => { - expect(getFloorArmor(10)).toBe(0); // Ignis Prime has armor 0.10 in GUARDIANS + expect(getFloorArmor(10)).toBe(0); // Floor 10 is a guardian floor }); it('should return armor between min and max for non-guardian floor with armor', () => { @@ -310,114 +308,6 @@ describe('getEnemyBarrier', () => { }); }); -// ─── generateFloorState ─────────────────────────────────────────────────────── - -describe('generateFloorState', () => { - it('should return a FloorState object', () => { - const state = generateFloorState(1); - expect(typeof state).toBe('object'); - expect(state).toHaveProperty('roomType'); - expect(state).toHaveProperty('enemies'); - }); - - it('should generate guardian state for guardian floor', () => { - const state = generateFloorState(10); - expect(state.roomType).toBe('guardian'); - expect(state.enemies.length).toBe(1); - expect(state.enemies[0].name).toBe(GUARDIANS[10].name); - expect(state.enemies[0].hp).toBe(GUARDIANS[10].hp); - expect(state.enemies[0].element).toBe(GUARDIANS[10].element); - }); - - it('should generate combat state for non-guardian floor with combat', () => { - const originalRandom = Math.random; - Math.random = () => 0.5; // Won't trigger special rooms - const state = generateFloorState(5); - expect(state.roomType).toBe('combat'); - expect(state.enemies.length).toBe(1); - expect(state.enemies[0].hp).toBe(getFloorMaxHP(5)); - Math.random = originalRandom; - }); - - it('should generate swarm state for swarm room', () => { - const originalRandom = Math.random; - Math.random = () => 0.14; // < SWARM_ROOM_CHANCE (0.15) - const state = generateFloorState(5); - expect(state.roomType).toBe('swarm'); - expect(Array.isArray(state.enemies)).toBe(true); - expect(state.enemies.length).toBeGreaterThanOrEqual(SWARM_CONFIG.minEnemies); - Math.random = originalRandom; - }); - - it('should generate speed state for speed room', () => { - const originalRandom = Math.random; - Math.random = () => 0.09; // < SPEED_ROOM_CHANCE (0.10) - const state = generateFloorState(5); - expect(state.roomType).toBe('speed'); - expect(state.enemies.length).toBe(1); - Math.random = originalRandom; - }); - - it('should generate puzzle state for puzzle room', () => { - const originalRandom = Math.random; - Math.random = () => 0.19; // < PUZZLE_ROOM_CHANCE (0.20) - const state = generateFloorState(7); - expect(state.roomType).toBe('puzzle'); - expect(Array.isArray(state.enemies)).toBe(true); // Array, even if empty - expect(state.enemies).toEqual([]); - expect(state.puzzleProgress).toBe(0); // Empty array is truthy, empty is falsy - expect(typeof state.puzzleRequired).toBe('number'); - expect(typeof state.puzzleId).toBe('string'); - expect(typeof state.puzzleAttunements).toBe('object'); - Math.random = originalRandom; - }); - - it('should fill puzzle attunements from PUZZLE_ROOMS', () => { - const originalRandom = Math.random; - Math.random = () => 0.19; - const state = generateFloorState(7); - expect(state.roomType).toBe('puzzle'); - expect(state.puzzleAttunements.length).toBeGreaterThan(0); - expect(typeof state.puzzleAttunements[0]).toBe('string'); - Math.random = originalRandom; - }); - - it('should use correct element for floor', () => { - // Test multiple floors to verify element cycle - const state = generateFloorState(1); - expect(state.enemies[0].element).toBe('fire'); - - const state2 = generateFloorState(2); - expect(state2.enemies[0].element).toBe('water'); - }); - - it('combat enemy HP should match floor max HP', () => { - const state = generateFloorState(50); - // Non-guardian floor 50 returns combat - expect(state.enemies[0].hp).toBe(getFloorMaxHP(50)); - }); - - it('speed room should have correct dodge chance', () => { - const state = generateFloorState(50); - const originalRandom = Math.random; - Math.random = () => 0.09; // Speed room - const speedState = generateFloorState(50); - expect(speedState.roomType).toBe('speed'); - expect(speedState.enemies[0].dodgeChance).toBe(getDodgeChance(50)); - Math.random = originalRandom; - }); - - it('should handle very high floor number', () => { - const state = generateFloorState(1000); - expect(state.roomType).toBe('guardian'); - }); - - it('should handle floor 0', () => { - const state = generateFloorState(0); - expect(state.roomType).toBe('combat'); // Guardian? No. Special room? No. Default combat. - }); -}); - // ─── getPuzzleProgressSpeed ─────────────────────────────────────────────────── describe('getPuzzleProgressSpeed', () => { diff --git a/src/lib/game/constants/index.ts b/src/lib/game/constants/index.ts index 6037fd4..3ad942e 100644 --- a/src/lib/game/constants/index.ts +++ b/src/lib/game/constants/index.ts @@ -10,9 +10,6 @@ export { getStudySpeedMultiplier, getStudyCostMultiplier } from './core'; export { MANA_PER_ELEMENT, rawCost, elemCost, ELEMENTS, FLOOR_ELEM_CYCLE } from './elements'; export { ELEMENT_OPPOSITES, ELEMENT_ICON_NAMES, BASE_UNLOCKED_ELEMENTS } from './elements'; -// Guardian constants -export { GUARDIANS } from './guardians'; - // Spell constants export { SPELLS_DEF } from './spells'; diff --git a/src/lib/game/constants/guardians.ts b/src/lib/game/data/guardian-data.ts similarity index 94% rename from src/lib/game/constants/guardians.ts rename to src/lib/game/data/guardian-data.ts index 4fa846b..497b3cd 100644 --- a/src/lib/game/constants/guardians.ts +++ b/src/lib/game/data/guardian-data.ts @@ -1,8 +1,9 @@ -// ─── Guardians ──────────────────────────────────────────────────────────────── +// ─── Base Guardian Definitions (floors 10–100) ──────────────────────────────── +// Static canonical guardian data for the first 100 floors. + import type { GuardianDef } from '../types'; -// All guardians have armor - damage reduction percentage -export const GUARDIANS: Record = { +export const BASE_GUARDIANS: Record = { 10: { name: "Ignis Prime", element: "fire", hp: 5000, pact: 1.5, color: "#FF6B35", armor: 0.10, @@ -159,4 +160,4 @@ export const GUARDIANS: Record = { damageMultiplier: 2.5, insightMultiplier: 2.0, }, -}; \ No newline at end of file +}; diff --git a/src/lib/game/data/guardian-encounters.ts b/src/lib/game/data/guardian-encounters.ts index b8bc826..d096ebb 100644 --- a/src/lib/game/data/guardian-encounters.ts +++ b/src/lib/game/data/guardian-encounters.ts @@ -265,27 +265,24 @@ export function getExtendedGuardian(floor: number): GuardianDef | null { } // ─── Unified Guardian System ───────────────────────────────────────────────── -// Merges the static GUARDIANS constant (floors 10–100) with the extended -// procedural system (compound 110, exotic 120–140, combo 150+). -// For floors 90–100 the static definitions take precedence (canonical names). +// Merges the base guardians (floors 10–100) with the extended procedural system +// (compound 110, exotic 120–140, combo 150+). -import { GUARDIANS } from '../constants/guardians'; +import { BASE_GUARDIANS } from './guardian-data'; /** Get the guardian for any floor, merging static and extended systems. */ export function getGuardianForFloor(floor: number): GuardianDef | null { - // Static GUARDIANS take precedence for floors 10–100 (canonical definitions) - if (GUARDIANS[floor]) { - return { ...GUARDIANS[floor] }; + if (BASE_GUARDIANS[floor]) { + return { ...BASE_GUARDIANS[floor] }; } - // Extended system for floors beyond 100 (and compound floors not in GUARDIANS) return getExtendedGuardian(floor); } -/** All guardian floors — merged from static + extended. */ +/** All guardian floors — merged from base + extended. */ export function getAllGuardianFloors(): number[] { - const staticFloors = Object.keys(GUARDIANS).map(Number); + const baseFloors = Object.keys(BASE_GUARDIANS).map(Number); const extendedFloors = [110, 120, 130, 140, ...Array.from({ length: 10 }, (_, i) => 150 + i * 10)]; - const all = new Set([...staticFloors, ...extendedFloors]); + const all = new Set([...baseFloors, ...extendedFloors]); return Array.from(all).sort((a, b) => a - b); } diff --git a/src/lib/game/hooks/useGameDerived.ts b/src/lib/game/hooks/useGameDerived.ts index ecb64d8..748dfa2 100755 --- a/src/lib/game/hooks/useGameDerived.ts +++ b/src/lib/game/hooks/useGameDerived.ts @@ -18,7 +18,8 @@ import { getElementalBonus, } from '../utils'; import { computePactMultiplier, computePactInsightMultiplier } from '../utils/pact-utils'; -import { ELEMENTS, GUARDIANS, SPELLS_DEF, getStudySpeedMultiplier, getStudyCostMultiplier, HOURS_PER_TICK, TICK_MS } from '../constants'; +import { ELEMENTS, SPELLS_DEF, getStudySpeedMultiplier, getStudyCostMultiplier, HOURS_PER_TICK, TICK_MS } from '../constants'; +import { getGuardianForFloor } from '../data/guardian-encounters'; import { hasSpecial, SPECIAL_EFFECTS } from '../effects/special-effects'; /** @@ -123,12 +124,12 @@ export function useCombatStats() { ); const isGuardianFloor = useMemo( - () => !!GUARDIANS[currentFloor], + () => !!getGuardianForFloor(currentFloor), [currentFloor] ); const currentGuardian = useMemo( - () => GUARDIANS[currentFloor], + () => getGuardianForFloor(currentFloor), [currentFloor] ); diff --git a/src/lib/game/stores/combat-actions.ts b/src/lib/game/stores/combat-actions.ts index 1a8e96d..ecadadf 100644 --- a/src/lib/game/stores/combat-actions.ts +++ b/src/lib/game/stores/combat-actions.ts @@ -2,7 +2,8 @@ // Pure combat logic — no cross-store getState() calls. // All external data (signedPacts, etc.) is passed in as parameters. -import { SPELLS_DEF, GUARDIANS, HOURS_PER_TICK } from '../constants'; +import { SPELLS_DEF, HOURS_PER_TICK } from '../constants'; +import { getGuardianForFloor } from '../data/guardian-encounters'; import type { CombatStore, CombatState } from './combat-state.types'; import type { SpellState } from '../types'; import { getFloorMaxHP, getFloorElement, calcDamage, canAffordSpellCost, deductSpellCost } from '../utils'; @@ -114,16 +115,16 @@ export function processCombatTick( // Check if floor is cleared if (floorHP <= 0) { - const wasGuardian = GUARDIANS[currentFloor]; - onFloorCleared(currentFloor, !!wasGuardian); + const guardian = getGuardianForFloor(currentFloor); + onFloorCleared(currentFloor, !!guardian); currentFloor = Math.min(currentFloor + 1, 100); floorMaxHP = getFloorMaxHP(currentFloor); floorHP = floorMaxHP; castProgress = 0; - if (wasGuardian) { - logMessages.push(`⚔️ ${wasGuardian.name} defeated!`); + if (guardian) { + logMessages.push(`\u2694\ufe0f ${guardian.name} defeated!`); } else if (currentFloor % 5 === 0) { logMessages.push(`🏰 Floor ${currentFloor - 1} cleared!`); } diff --git a/src/lib/game/stores/gameStore.ts b/src/lib/game/stores/gameStore.ts index 8ab3c93..2dfb225 100755 --- a/src/lib/game/stores/gameStore.ts +++ b/src/lib/game/stores/gameStore.ts @@ -4,7 +4,8 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import { TICK_MS, HOURS_PER_TICK, MAX_DAY, GUARDIANS, getStudySpeedMultiplier } from '../constants'; +import { TICK_MS, HOURS_PER_TICK, MAX_DAY, getStudySpeedMultiplier } 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'; @@ -234,7 +235,7 @@ export const useGameStore = create()( // Pact ritual progress if (ctx.prestige.pactRitualFloor !== null) { - const guardian = GUARDIANS[ctx.prestige.pactRitualFloor]; + const guardian = getGuardianForFloor(ctx.prestige.pactRitualFloor); if (guardian) { const pactAffinityBonus = 1 - (ctx.prestige.prestigeUpgrades.pactAffinity || 0) * 0.1; const requiredTime = guardian.pactTime * pactAffinityBonus; @@ -275,7 +276,8 @@ export const useGameStore = create()( 1, (floor, wasGuardian) => { if (wasGuardian) { - addLog(`⚔️ ${GUARDIANS[floor]?.name || 'Guardian'} defeated! Visit the Grimoire to sign a pact.`); + const defeatedGuardian = getGuardianForFloor(floor); + addLog(`\u2694\ufe0f ${defeatedGuardian?.name || 'Guardian'} defeated! Visit the Grimoire to sign a pact.`); } else if (floor % 5 === 0) { addLog(`🏰 Floor ${floor} cleared!`); } diff --git a/src/lib/game/stores/prestigeStore.ts b/src/lib/game/stores/prestigeStore.ts index d251dbf..1d63651 100755 --- a/src/lib/game/stores/prestigeStore.ts +++ b/src/lib/game/stores/prestigeStore.ts @@ -5,7 +5,8 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { createSafeStorage } from '../utils/safe-persist'; import type { Memory } from '../types'; -import { GUARDIANS, PRESTIGE_DEF } from '../constants'; +import { PRESTIGE_DEF } from '../constants'; +import { getGuardianForFloor } from '../data/guardian-encounters'; import { ok, okVoid, fail, ErrorCode } from '../utils/result'; import type { Result } from '../utils/result'; @@ -151,7 +152,7 @@ export const usePrestigeStore = create()( startPactRitual: (floor: number, rawMana: number) => { const state = get(); - const guardian = GUARDIANS[floor]; + const guardian = getGuardianForFloor(floor); if (!guardian) return fail(ErrorCode.INVALID_INPUT, `No guardian at floor ${floor}`); if (!state.defeatedGuardians.includes(floor)) return fail(ErrorCode.GUARDIAN_NOT_DEFEATED, `Guardian at floor ${floor} has not been defeated`); @@ -178,7 +179,7 @@ export const usePrestigeStore = create()( const state = get(); if (state.pactRitualFloor === null) return; - const guardian = GUARDIANS[state.pactRitualFloor]; + const guardian = getGuardianForFloor(state.pactRitualFloor); if (!guardian) return; set({ diff --git a/src/lib/game/utils/combat-utils.ts b/src/lib/game/utils/combat-utils.ts index d3a6371..16c77cd 100644 --- a/src/lib/game/utils/combat-utils.ts +++ b/src/lib/game/utils/combat-utils.ts @@ -2,8 +2,10 @@ import type { SpellCost, EquipmentInstance } from '../types'; import type { DisciplineBonuses } from './mana-utils'; -import { GUARDIANS, SPELLS_DEF, ELEMENT_OPPOSITES, INCURSION_START_DAY, MAX_DAY } from '../constants'; +import { SPELLS_DEF, ELEMENT_OPPOSITES, INCURSION_START_DAY, MAX_DAY } from '../constants'; import { ENCHANTMENT_EFFECTS } from '../data/enchantment-effects'; +import { getGuardianForFloor } from '../data/guardian-encounters'; +import { BASE_GUARDIANS } from '../data/guardian-data'; // ─── Damage Calculation Params ────────────────────────────────────────────── @@ -86,7 +88,7 @@ export function getBoonBonuses(signedPacts: number[]): BoonBonuses { }; for (const floor of signedPacts) { - const guardian = GUARDIANS[floor]; + const guardian = getGuardianForFloor(floor); if (!guardian) continue; for (const boon of guardian.boons) { @@ -158,7 +160,7 @@ export function calcDamage( const elemMasteryBonus = 1 + (skills.elementalMastery || 0) * 0.15; // Guardian bane bonus - const isGuardianFloor = floorElem && Object.values(GUARDIANS).some(g => g.element === floorElem); + const isGuardianFloor = floorElem && Object.values(BASE_GUARDIANS).some(g => g.element === floorElem); const guardianBonus = isGuardianFloor ? 1 + (skills.guardianBane || 0) * 0.2 : 1; diff --git a/src/lib/game/utils/floor-utils.ts b/src/lib/game/utils/floor-utils.ts index 509be85..25b29fe 100644 --- a/src/lib/game/utils/floor-utils.ts +++ b/src/lib/game/utils/floor-utils.ts @@ -1,9 +1,11 @@ // ─── Floor Helpers ──────────────────────────────────────────────────────────── -import { GUARDIANS, FLOOR_ELEM_CYCLE } from '../constants'; +import { FLOOR_ELEM_CYCLE } from '../constants'; +import { getGuardianForFloor } from '../data/guardian-encounters'; export function getFloorMaxHP(floor: number): number { - if (GUARDIANS[floor]) return GUARDIANS[floor].hp; + const guardian = getGuardianForFloor(floor); + if (guardian) return guardian.hp; // Improved scaling: slower early game, faster late game const baseHP = 100; const floorScaling = floor * 50; diff --git a/src/lib/game/utils/room-utils.ts b/src/lib/game/utils/room-utils.ts index 9038d1e..eb0fb5f 100644 --- a/src/lib/game/utils/room-utils.ts +++ b/src/lib/game/utils/room-utils.ts @@ -2,7 +2,8 @@ // Moved from store-modules/room-utils.ts to eliminate legacy dependencies import type { RoomType, FloorState, EnemyState } from '../types'; -import { GUARDIANS, FLOOR_ELEM_CYCLE, PUZZLE_ROOMS, PUZZLE_ROOM_INTERVAL, PUZZLE_ROOM_CHANCE, SWARM_ROOM_CHANCE, SPEED_ROOM_CHANCE, FLOOR_ARMOR_CONFIG, SWARM_CONFIG, SPEED_ROOM_CONFIG } from '../constants'; +import { FLOOR_ELEM_CYCLE, PUZZLE_ROOMS, PUZZLE_ROOM_INTERVAL, PUZZLE_ROOM_CHANCE, SWARM_ROOM_CHANCE, SPEED_ROOM_CHANCE, FLOOR_ARMOR_CONFIG, SWARM_CONFIG, SPEED_ROOM_CONFIG } from '../constants'; +import { getGuardianForFloor } from '../data/guardian-encounters'; import { getFloorMaxHP } from './floor-utils'; import { getFloorElement } from './floor-utils'; import { getEnemyName, generateSwarmEnemies } from './enemy-utils'; @@ -10,7 +11,7 @@ import { getEnemyName, generateSwarmEnemies } from './enemy-utils'; // Generate room type for a floor export function generateRoomType(floor: number): RoomType { // Guardian floors are always guardian type - if (GUARDIANS[floor]) { + if (getGuardianForFloor(floor)) { return 'guardian'; } @@ -35,8 +36,9 @@ export function generateRoomType(floor: number): RoomType { // Get armor for a non-guardian floor export function getFloorArmor(floor: number): number { - if (GUARDIANS[floor]) { - return GUARDIANS[floor].armor || 0; + const guardian = getGuardianForFloor(floor); + if (guardian) { + return guardian.armor || 0; } // Armor becomes more common on higher floors @@ -84,10 +86,11 @@ export function generateFloorState(floor: number): FloorState { const roomType = generateRoomType(floor); const element = getFloorElement(floor); const baseHP = getFloorMaxHP(floor); - const guardian = GUARDIANS[floor]; + const guardian = getGuardianForFloor(floor); switch (roomType) { case 'guardian': + if (!guardian) return { roomType: 'combat', enemies: [] }; return { roomType: 'guardian', enemies: [{ diff --git a/src/lib/game/utils/spire-utils.ts b/src/lib/game/utils/spire-utils.ts index 321a5d1..d6cd15d 100644 --- a/src/lib/game/utils/spire-utils.ts +++ b/src/lib/game/utils/spire-utils.ts @@ -2,10 +2,10 @@ // Spire-specific utility functions for room generation, enemy stat scaling, etc. import type { RoomType, FloorState, EnemyState } from '../types'; -import { GUARDIANS, FLOOR_ELEM_CYCLE, PUZZLE_ROOMS } from '../constants'; +import { FLOOR_ELEM_CYCLE, PUZZLE_ROOMS } from '../constants'; import { getFloorMaxHP, getFloorElement } from './floor-utils'; import { getEnemyName } from './enemy-utils'; -import { isGuardianFloor, getExtendedGuardian } from '../data/guardian-encounters'; +import { getGuardianForFloor, isGuardianFloor } from '../data/guardian-encounters'; // ─── Spire Room Configuration ───────────────────────────────────────────────── @@ -85,7 +85,7 @@ export function generateSpireFloorState(floor: number, roomIndex: number, totalR switch (roomType) { case 'guardian': { - const guardian = GUARDIANS[floor] || getExtendedGuardian(floor); + const guardian = getGuardianForFloor(floor); if (guardian) { return { roomType: 'guardian',