refactor: remove GUARDIANS constant, consolidate into guardian-data.ts

- Delete src/lib/game/constants/guardians.ts (the old static GUARDIANS constant)
- Create src/lib/game/data/guardian-data.ts with BASE_GUARDIANS (same data, new home)
- Remove GUARDIANS export from constants/index.ts
- Update all 11 files that imported GUARDIANS to use getGuardianForFloor() or BASE_GUARDIANS:
  - useGameDerived.ts, combat-actions.ts, gameStore.ts, prestigeStore.ts
  - combat-utils.ts, room-utils.ts, floor-utils.ts, spire-utils.ts
  - SpireCombatPage.tsx, SpireHeader.tsx
- Update 4 test files to use getGuardianForFloor() instead of GUARDIANS constant
- guardian-encounters.ts now imports BASE_GUARDIANS from guardian-data.ts
- Split room-utils.test.ts (505 lines) into room-utils.test.ts + room-utils-floor-state.test.ts
This commit is contained in:
2026-05-23 16:09:19 +02:00
parent d7b822d965
commit 513cab81a3
21 changed files with 228 additions and 217 deletions
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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": [
+2 -1
View File
@@ -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/
@@ -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);
@@ -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,
@@ -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;
@@ -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);
});
+27 -27
View File
@@ -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({
@@ -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');
});
});
+5 -115
View File
@@ -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', () => {
-3
View File
@@ -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';
@@ -1,8 +1,9 @@
// ─── Guardians ────────────────────────────────────────────────────────────────
// ─── Base Guardian Definitions (floors 10100) ────────────────────────────────
// 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<number, GuardianDef> = {
export const BASE_GUARDIANS: Record<number, GuardianDef> = {
10: {
name: "Ignis Prime", element: "fire", hp: 5000, pact: 1.5, color: "#FF6B35",
armor: 0.10,
@@ -159,4 +160,4 @@ export const GUARDIANS: Record<number, GuardianDef> = {
damageMultiplier: 2.5,
insightMultiplier: 2.0,
},
};
};
+8 -11
View File
@@ -265,27 +265,24 @@ export function getExtendedGuardian(floor: number): GuardianDef | null {
}
// ─── Unified Guardian System ─────────────────────────────────────────────────
// Merges the static GUARDIANS constant (floors 10100) with the extended
// procedural system (compound 110, exotic 120140, combo 150+).
// For floors 90100 the static definitions take precedence (canonical names).
// Merges the base guardians (floors 10100) with the extended procedural system
// (compound 110, exotic 120140, 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 10100 (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);
}
+4 -3
View File
@@ -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]
);
+6 -5
View File
@@ -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!`);
}
+5 -3
View File
@@ -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<GameCoordinatorStore>()(
// 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<GameCoordinatorStore>()(
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!`);
}
+4 -3
View File
@@ -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<PrestigeStore>()(
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<PrestigeStore>()(
const state = get();
if (state.pactRitualFloor === null) return;
const guardian = GUARDIANS[state.pactRitualFloor];
const guardian = getGuardianForFloor(state.pactRitualFloor);
if (!guardian) return;
set({
+5 -3
View File
@@ -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;
+4 -2
View File
@@ -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;
+8 -5
View File
@@ -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: [{
+3 -3
View File
@@ -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',