diff --git a/.gitignore b/.gitignore
index 583f01b..cd38ab2 100755
--- a/.gitignore
+++ b/.gitignore
@@ -48,3 +48,4 @@ prompt
server.log
# Skills directory
+.desloppify/
diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt
index d7dde38..72a78db 100644
--- a/docs/circular-deps.txt
+++ b/docs/circular-deps.txt
@@ -1,8 +1,8 @@
# Circular Dependencies
-Generated: 2026-05-19T20:59:58.496Z
+Generated: 2026-05-20T00:32:46.898Z
Found: 3 circular chain(s) — these MUST be fixed before modifying involved files.
-1. Processed 121 files (1.2s) (4 warnings)
+1. Processed 122 files (2.7s) (4 warnings)
2. 1) data/equipment/index.ts > data/equipment/utils.ts
3. 2) data/golems/index.ts > data/golems/utils.ts
diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json
index a7d1ba5..66c3699 100644
--- a/docs/dependency-graph.json
+++ b/docs/dependency-graph.json
@@ -1,6 +1,6 @@
{
"_meta": {
- "generated": "2026-05-19T20:59:57.136Z",
+ "generated": "2026-05-20T00:32:43.891Z",
"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."
},
@@ -334,6 +334,9 @@
"data/equipment/index.ts",
"data/equipment/types.ts"
],
+ "data/fabricator-recipes.ts": [
+ "data/equipment/types.ts"
+ ],
"data/golems/base-golems.ts": [
"data/golems/types.ts"
],
diff --git a/docs/project-structure.txt b/docs/project-structure.txt
index 878bdc7..58d7106 100644
--- a/docs/project-structure.txt
+++ b/docs/project-structure.txt
@@ -104,6 +104,14 @@ Mana-Loop/
│ │ │ │ │ ├── EquipmentEffectsSummary.tsx
│ │ │ │ │ ├── EquipmentSlotGrid.tsx
│ │ │ │ │ └── InventoryList.tsx
+│ │ │ │ ├── SpireCombatPage/
+│ │ │ │ │ ├── RoomDisplay.tsx
+│ │ │ │ │ ├── SpireActivityLog.tsx
+│ │ │ │ │ ├── SpireCombatControls.tsx
+│ │ │ │ │ ├── SpireCombatPage.tsx
+│ │ │ │ │ ├── SpireHeader.tsx
+│ │ │ │ │ ├── SpireManaDisplay.tsx
+│ │ │ │ │ └── index.ts
│ │ │ │ ├── StatsTab/
│ │ │ │ │ ├── CombatStatsSection.tsx
│ │ │ │ │ ├── ElementStatsSection.tsx
@@ -183,7 +191,9 @@ Mana-Loop/
│ │ │ ├── achievements.test.ts
│ │ │ ├── bug-fixes.test.ts
│ │ │ ├── computed-stats.test.ts
-│ │ │ └── regression-fixes.test.ts
+│ │ │ ├── enemy-generator.test.ts
+│ │ │ ├── regression-fixes.test.ts
+│ │ │ └── spire-utils.test.ts
│ │ ├── constants/
│ │ │ ├── spells-modules/
│ │ │ │ ├── advanced-spells.ts
@@ -263,6 +273,7 @@ Mana-Loop/
│ │ │ ├── enchantment-effects.ts
│ │ │ ├── enchantment-types.ts
│ │ │ ├── fabricator-recipes.ts
+│ │ │ ├── guardian-encounters.ts
│ │ │ └── loot-drops.ts
│ │ ├── effects/
│ │ │ ├── discipline-effects.ts
@@ -301,13 +312,15 @@ Mana-Loop/
│ │ │ ├── activity-log.ts
│ │ │ ├── combat-utils.ts
│ │ │ ├── discipline-math.ts
+│ │ │ ├── enemy-generator.ts
│ │ │ ├── enemy-utils.ts
│ │ │ ├── floor-utils.ts
│ │ │ ├── formatting.ts
│ │ │ ├── index.ts
│ │ │ ├── mana-utils.ts
│ │ │ ├── pact-utils.ts
-│ │ │ └── room-utils.ts
+│ │ │ ├── room-utils.ts
+│ │ │ └── spire-utils.ts
│ │ ├── constants.ts
│ │ ├── crafting-apply.ts
│ │ ├── crafting-attunements.ts
diff --git a/src/app/page.tsx b/src/app/page.tsx
index eec169e..031ecc4 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -53,6 +53,7 @@ const GolemancyTab = lazy(() => import('@/components/game/tabs').then(module =>
const GuardianPactsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.GuardianPactsTab })));
const SpireSummaryTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.SpireSummaryTab })));
const CraftingTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.CraftingTab })));
+const SpireCombatPage = lazy(() => import('@/components/game/tabs/SpireCombatPage').then(module => ({ default: module.SpireCombatPage })));
const TabLoadingFallback = () =>
Loading...
;
@@ -217,6 +218,17 @@ export default function ManaLoopGame() {
if (!mounted) return Loading...
;
+ // Spire mode: full-page replacement view
+ if (spireMode) {
+ return (
+
+ Loading spire...}>
+
+
+
+ );
+ }
+
return (
diff --git a/src/components/game/tabs/SpireCombatPage/RoomDisplay.tsx b/src/components/game/tabs/SpireCombatPage/RoomDisplay.tsx
new file mode 100644
index 0000000..54957f2
--- /dev/null
+++ b/src/components/game/tabs/SpireCombatPage/RoomDisplay.tsx
@@ -0,0 +1,190 @@
+'use client';
+
+import type { FloorState, EnemyState } from '@/lib/game/types';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { Progress } from '@/components/ui/progress';
+import { getSpireRoomTypeDisplay } from '@/lib/game/utils/spire-utils';
+import { getModifierDisplay, getModifierDescription } from '@/lib/game/utils/enemy-generator';
+import { ELEMENTS } from '@/lib/game/constants';
+import { fmt } from '@/lib/game/stores';
+
+interface RoomDisplayProps {
+ floorState: FloorState;
+ floor: number;
+}
+
+function EnemyRow({ enemy, floor }: { enemy: EnemyState; floor: number }) {
+ const elemDef = ELEMENTS[enemy.element];
+ const hpPercent = enemy.maxHP > 0 ? (enemy.hp / enemy.maxHP) * 100 : 0;
+ const barrierVal = enemy.barrier ?? 0;
+ const hasBarrier = barrierVal > 0;
+ const barrierPercent = hasBarrier ? barrierVal * 100 : 0;
+
+ return (
+
+
+
+ {elemDef && (
+ {elemDef.sym}
+ )}
+ {enemy.name}
+
+
+ {fmt(enemy.hp)} / {fmt(enemy.maxHP)}
+
+
+
+ {/* HP bar */}
+
+
+ {hasBarrier && (
+
+ )}
+
+
+ {/* Enemy stats */}
+
+ {enemy.armor > 0 && (
+
+ ⛰️ {Math.round(enemy.armor * 100)}% armor
+
+ )}
+ {enemy.dodgeChance > 0 && (
+
+ 💨 {Math.round(enemy.dodgeChance * 100)}% dodge
+
+ )}
+ {hasBarrier && (
+
+ 🛡️ Barrier
+
+ )}
+
+
+ );
+}
+
+export function RoomDisplay({ floorState, floor }: RoomDisplayProps) {
+ const roomDisplay = getSpireRoomTypeDisplay(floorState.roomType as any);
+
+ // Handle special room types (cast to string for extended types)
+ const rt = floorState.roomType as string;
+
+ if (rt === 'recovery') {
+ const progress = (floorState as any).puzzleProgress || 0;
+ const required = (floorState as any).puzzleRequired || 1;
+ return (
+
+
+
+ 💚 Recovery Room
+
+
+
+
+ Rest and recover. Spend 1 hour to gain 5x mana regen & conversion rates.
+
+
+
+
+ );
+ }
+
+ if (rt === 'library') {
+ return (
+
+
+
+ 📚 Ancient Library
+
+
+
+
+ Study a random discipline at 10x XP speed (no mana cost). Spend 1 hour to gain knowledge.
+
+
+
+ );
+ }
+
+ if (rt === 'treasure') {
+ return (
+
+
+
+ 💎 Treasure Room
+
+
+
+
+ A hidden cache of resources awaits. Claim your reward!
+
+
+
+ );
+ }
+
+ if (floorState.roomType === 'puzzle') {
+ const puzzleId = floorState.puzzleId || 'unknown';
+ const progress = floorState.puzzleProgress || 0;
+ const required = floorState.puzzleRequired || 1;
+ return (
+
+
+
+ 🧩 Puzzle Room — {puzzleId.replace(/_/g, ' ')}
+
+
+
+
+ Solve the puzzle. Higher attunement levels speed up progress.
+
+
+
+ Progress: {Math.round(progress * 100)} / {Math.round(required * 100)}
+
+
+
+ );
+ }
+
+ // Combat rooms (combat, swarm, speed, guardian)
+ const enemies = floorState.enemies || [];
+ const isGuardian = floorState.roomType === 'guardian';
+
+ return (
+
+
+
+ {roomDisplay.icon} {roomDisplay.label}
+ {isGuardian && BOSS}
+
+
+
+ {enemies.length === 0 ? (
+ Room cleared!
+ ) : (
+ enemies.map((enemy) => (
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/src/components/game/tabs/SpireCombatPage/SpireActivityLog.tsx b/src/components/game/tabs/SpireCombatPage/SpireActivityLog.tsx
new file mode 100644
index 0000000..be5d73e
--- /dev/null
+++ b/src/components/game/tabs/SpireCombatPage/SpireActivityLog.tsx
@@ -0,0 +1,43 @@
+'use client';
+
+import type { ActivityLogEntry } from '@/lib/game/types';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { ScrollArea } from '@/components/ui/scroll-area';
+
+interface SpireActivityLogProps {
+ activityLog: ActivityLogEntry[];
+ maxEntries?: number;
+}
+
+export function SpireActivityLog({ activityLog, maxEntries = 30 }: SpireActivityLogProps) {
+ const entries = activityLog.slice(0, maxEntries);
+
+ return (
+
+
+ 📜 Activity Log
+
+
+
+ {entries.length === 0 ? (
+ No activity yet.
+ ) : (
+
+ {entries.map((entry) => (
+
+
+ [{entry.eventType}]
+
+ {entry.message}
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/game/tabs/SpireCombatPage/SpireCombatControls.tsx b/src/components/game/tabs/SpireCombatPage/SpireCombatControls.tsx
new file mode 100644
index 0000000..76780b7
--- /dev/null
+++ b/src/components/game/tabs/SpireCombatPage/SpireCombatControls.tsx
@@ -0,0 +1,133 @@
+'use client';
+
+import { useCombatStore, useManaStore, canAffordSpellCost, fmt } from '@/lib/game/stores';
+import { SPELLS_DEF, ELEMENTS } from '@/lib/game/constants';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Progress } from '@/components/ui/progress';
+import { GOLEMS_DEF } from '@/lib/game/data/golems';
+
+interface SpireCombatControlsProps {
+ castProgress: number;
+}
+
+function formatSpellCost(cost: { type: 'raw' | 'element'; element?: string; amount: number }): string {
+ if (cost.type === 'raw') return `${cost.amount} raw`;
+ const elemDef = ELEMENTS[cost.element || ''];
+ return `${cost.amount} ${elemDef?.sym || '?'}`;
+}
+
+export function SpireCombatControls({ castProgress }: SpireCombatControlsProps) {
+ const spells = useCombatStore((s) => s.spells);
+ const activeSpell = useCombatStore((s) => s.activeSpell);
+ const setSpell = useCombatStore((s) => s.setSpell);
+ const golemancy = useCombatStore((s) => s.golemancy);
+ const rawMana = useManaStore((s) => s.rawMana);
+ const elements = useManaStore((s) => s.elements);
+
+ const learnedSpells = Object.entries(spells)
+ .filter(([, state]) => state?.learned)
+ .map(([id]) => id);
+
+ const summonedGolems = golemancy.summonedGolems || [];
+
+ return (
+
+ {/* Active Spell Panel */}
+
+
+ 🔮 Active Spells
+
+
+ {/* Cast progress */}
+
+
+ Cast Progress
+ {Math.round(castProgress * 100)}%
+
+
+
+
+ {/* Spell selection */}
+
+ {learnedSpells.map((spellId) => {
+ const def = SPELLS_DEF[spellId];
+ if (!def) return null;
+ const isActive = activeSpell === spellId;
+ const canCast = canAffordSpellCost(def.cost, rawMana, elements);
+ const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
+
+ return (
+
+ );
+ })}
+
+
+ {learnedSpells.length === 0 && (
+ No spells learned yet.
+ )}
+
+
+
+ {/* Golem Status Panel */}
+
+
+ 🗿 Golems
+
+
+ {summonedGolems.length === 0 ? (
+ No golems summoned.
+ ) : (
+
+ {summonedGolems.map((sg) => {
+ const golemDef = GOLEMS_DEF[sg.golemId];
+ if (!golemDef) return null;
+ const elemColor = ELEMENTS[golemDef.baseManaType]?.color || '#888';
+
+ return (
+
+
+ ●
+ {golemDef.name}
+
+
+ {golemDef.damage} dmg · {golemDef.attackSpeed}/h
+
+
+ );
+ })}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx b/src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx
new file mode 100644
index 0000000..fa344ef
--- /dev/null
+++ b/src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx
@@ -0,0 +1,237 @@
+'use client';
+
+import { useState, useEffect, useMemo } from 'react';
+import { useShallow } from 'zustand/react/shallow';
+import { useCombatStore, useManaStore, usePrestigeStore, fmt, computeMaxMana, computeRegen } from '@/lib/game/stores';
+import { computeDisciplineEffects } from '@/lib/game/effects/discipline-effects';
+import { getUnifiedEffects } from '@/lib/game/effects';
+import { useDisciplineStore } from '@/lib/game/stores/discipline-slice';
+import { useCraftingStore } from '@/lib/game/stores/craftingStore';
+import { GUARDIANS } from '@/lib/game/constants';
+import { getExtendedGuardian, isGuardianFloor } from '@/lib/game/data/guardian-encounters';
+import { getRoomsForFloor, generateSpireFloorState, calcInsight } from '@/lib/game/utils/spire-utils';
+import { SpireHeader } from './SpireHeader';
+import { RoomDisplay } from './RoomDisplay';
+import { SpireCombatControls } from './SpireCombatControls';
+import { SpireActivityLog } from './SpireActivityLog';
+import { SpireManaDisplay } from './SpireManaDisplay';
+
+export function SpireCombatPage() {
+ const [mounted, setMounted] = useState(false);
+ const [roomsCleared, setRoomsCleared] = useState(0);
+
+ // Combat store
+ const {
+ currentFloor,
+ floorHP,
+ floorMaxHP,
+ castProgress,
+ clearedFloors,
+ isDescending,
+ currentRoom,
+ activityLog,
+ setCurrentRoom,
+ setFloorHP,
+ setClearedFloor,
+ climbDownFloor,
+ exitSpireMode,
+ startClimbUp,
+ startClimbDown,
+ addActivityLog,
+ processCombatTick,
+ setAction,
+ } = useCombatStore(useShallow((s) => ({
+ currentFloor: s.currentFloor,
+ floorHP: s.floorHP,
+ floorMaxHP: s.floorMaxHP,
+ castProgress: s.castProgress,
+ clearedFloors: s.clearedFloors,
+ isDescending: s.isDescending,
+ currentRoom: s.currentRoom,
+ activityLog: s.activityLog,
+ setCurrentRoom: s.setCurrentRoom,
+ setFloorHP: s.setFloorHP,
+ setClearedFloor: s.setClearedFloor,
+ climbDownFloor: s.climbDownFloor,
+ exitSpireMode: s.exitSpireMode,
+ startClimbUp: s.startClimbUp,
+ startClimbDown: s.startClimbDown,
+ addActivityLog: s.addActivityLog,
+ processCombatTick: s.processCombatTick,
+ setAction: s.setAction,
+ })));
+
+ // Mana store
+ const { rawMana, elements } = useManaStore(useShallow((s) => ({
+ rawMana: s.rawMana,
+ elements: s.elements,
+ })));
+
+ // Prestige store
+ const { prestigeUpgrades, insight } = usePrestigeStore(useShallow((s) => ({
+ prestigeUpgrades: s.prestigeUpgrades,
+ insight: s.insight,
+ })));
+
+ // Crafting store for equipment effects
+ const equippedInstances = useCraftingStore((s) => s.equippedInstances);
+ const equipmentInstances = useCraftingStore((s) => s.equipmentInstances);
+
+ // Discipline effects
+ const disciplineStoreState = useDisciplineStore();
+ const disciplineEffects = computeDisciplineEffects(disciplineStoreState as any);
+
+ // Compute derived stats
+ const upgradeEffects = getUnifiedEffects({
+ skillUpgrades: {},
+ skillTiers: {},
+ equippedInstances,
+ equipmentInstances,
+ });
+
+ const maxMana = computeMaxMana({
+ skills: {},
+ prestigeUpgrades,
+ skillUpgrades: {},
+ skillTiers: {},
+ }, upgradeEffects as any, disciplineEffects);
+
+ const baseRegen = computeRegen({
+ skills: {},
+ prestigeUpgrades,
+ skillUpgrades: {},
+ skillTiers: {},
+ attunements: {},
+ }, upgradeEffects as any, disciplineEffects);
+
+ // Total rooms for current floor
+ const totalRooms = useMemo(() => getRoomsForFloor(currentFloor), [currentFloor]);
+
+ // Initialize room on floor change
+ useEffect(() => {
+ setMounted(true);
+ setRoomsCleared(0);
+ const newRoom = generateSpireFloorState(currentFloor, 0, totalRooms);
+ setCurrentRoom(newRoom);
+ setAction('climb');
+ }, [currentFloor, totalRooms, setCurrentRoom, setAction]);
+
+ // Handle room/floor transitions
+ const handleRoomCleared = () => {
+ const nextRoomIndex = roomsCleared + 1;
+
+ if (nextRoomIndex >= totalRooms) {
+ // Floor cleared
+ const wasGuardian = isGuardianFloor(currentFloor);
+ setClearedFloor(currentFloor, true);
+
+ if (wasGuardian) {
+ const guardian = GUARDIANS[currentFloor] || getExtendedGuardian(currentFloor);
+ if (guardian) {
+ addActivityLog('enemy_defeated', `⚔️ ${guardian.name} defeated!`, {
+ enemyName: guardian.name,
+ floor: currentFloor,
+ });
+ }
+ }
+
+ addActivityLog('floor_cleared', `🏰 Floor ${currentFloor} cleared!`, {
+ floor: currentFloor,
+ });
+
+ // Auto-advance to next floor
+ const newFloor = currentFloor + 1;
+ const newTotalRooms = getRoomsForFloor(newFloor);
+ const newRoom = generateSpireFloorState(newFloor, 0, newTotalRooms);
+
+ setCurrentRoom(newRoom);
+ setFloorHP(floorMaxHP); // Reset HP for new floor
+ setClearedFloor(currentFloor, true);
+ setRoomsCleared(0);
+ } else {
+ // Next room on same floor
+ const newRoom = generateSpireFloorState(currentFloor, nextRoomIndex, totalRooms);
+ setCurrentRoom(newRoom);
+ setRoomsCleared(nextRoomIndex);
+ }
+ };
+
+ // Handle climb up
+ const handleClimbUp = () => {
+ startClimbUp();
+ addActivityLog('floor_transition', `⬆️ Climbing to floor ${currentFloor + 1}...`);
+ };
+
+ // Handle climb down
+ const handleClimbDown = () => {
+ if (currentFloor <= 1) return;
+ startClimbDown();
+ climbDownFloor();
+ setRoomsCleared(0);
+ addActivityLog('floor_transition', `⬇️ Descending to floor ${currentFloor - 1}...`);
+ };
+
+ // Handle exit spire
+ const handleExitSpire = () => {
+ exitSpireMode();
+ addActivityLog('floor_transition', '🚪 Exited the Spire.');
+ };
+
+ if (!mounted) {
+ return (
+
+ Loading spire...
+
+ );
+ }
+
+ return (
+
+ {/* Compact header */}
+
+
+ {/* Main content */}
+
+ {/* Top section: Header + Mana */}
+
+
+ {/* Middle section: Room + Controls */}
+
+
+ {/* Bottom: Activity Log */}
+
+
+
+ );
+}
diff --git a/src/components/game/tabs/SpireCombatPage/SpireHeader.tsx b/src/components/game/tabs/SpireCombatPage/SpireHeader.tsx
new file mode 100644
index 0000000..b1b5d66
--- /dev/null
+++ b/src/components/game/tabs/SpireCombatPage/SpireHeader.tsx
@@ -0,0 +1,128 @@
+'use client';
+
+import { useCombatStore, usePrestigeStore, fmt } from '@/lib/game/stores';
+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';
+
+interface SpireHeaderProps {
+ currentFloor: number;
+ floorHP: number;
+ floorMaxHP: number;
+ roomsCleared: number;
+ totalRooms: number;
+ onClimbUp: () => void;
+ onClimbDown: () => void;
+ onExitSpire: () => void;
+ isDescending: boolean;
+}
+
+export function SpireHeader({
+ currentFloor,
+ floorHP,
+ floorMaxHP,
+ roomsCleared,
+ totalRooms,
+ onClimbUp,
+ onClimbDown,
+ onExitSpire,
+ isDescending,
+}: SpireHeaderProps) {
+ const maxFloorReached = useCombatStore((s) => s.maxFloorReached);
+ const { insight } = usePrestigeStore((s) => ({ insight: s.insight }));
+
+ const guardian = GUARDIANS[currentFloor] || getExtendedGuardian(currentFloor);
+ const isGuardian = isGuardianFloor(currentFloor);
+ const hpPercent = floorMaxHP > 0 ? (floorHP / floorMaxHP) * 100 : 100;
+ const roomProgress = totalRooms > 0 ? ((roomsCleared) / totalRooms) * 100 : 0;
+
+ return (
+
+
+ {/* Top row: Floor info + controls */}
+
+
+
+
+
+ Floor {currentFloor}
+
+
+ Max: {maxFloorReached} · Insight: {fmt(insight)}
+
+
+
+
+
+
+
+ {currentFloor === 1 && (
+
+ )}
+
+
+
+ {/* Floor HP bar */}
+
+
+
+ {isGuardian && guardian ? `🛡️ ${guardian.name}` : 'Floor HP'}
+
+
+ {fmt(floorHP)} / {fmt(floorMaxHP)}
+
+
+
+
+
+ {/* Room progress */}
+
+
+ Rooms Cleared
+ {roomsCleared} / {totalRooms}
+
+
+
+
+
+ );
+}
diff --git a/src/components/game/tabs/SpireCombatPage/SpireManaDisplay.tsx b/src/components/game/tabs/SpireCombatPage/SpireManaDisplay.tsx
new file mode 100644
index 0000000..ae8e102
--- /dev/null
+++ b/src/components/game/tabs/SpireCombatPage/SpireManaDisplay.tsx
@@ -0,0 +1,72 @@
+'use client';
+
+import { useManaStore, fmt, fmtDec } from '@/lib/game/stores';
+import { Card, CardContent } from '@/components/ui/card';
+import { Progress } from '@/components/ui/progress';
+import { ELEMENTS } from '@/lib/game/constants';
+
+interface SpireManaDisplayProps {
+ maxMana: number;
+ effectiveRegen: number;
+}
+
+export function SpireManaDisplay({ maxMana, effectiveRegen }: SpireManaDisplayProps) {
+ const rawMana = useManaStore((s) => s.rawMana);
+ const elements = useManaStore((s) => s.elements);
+
+ const unlockedElements = Object.entries(elements)
+ .filter(([, state]) => state.unlocked && state.current > 0)
+ .sort((a, b) => b[1].current - a[1].current)
+ .slice(0, 6); // Show max 6 in compact view
+
+ const manaPercent = maxMana > 0 ? (rawMana / maxMana) * 100 : 0;
+
+ return (
+
+
+ {/* Raw Mana */}
+
+
+
+ {fmt(rawMana)}
+
+ / {fmt(maxMana)}
+
+
+ +{fmtDec(effectiveRegen)}/hr
+
+
+
+
+
+ {/* Elemental pools (compact) */}
+ {unlockedElements.length > 0 && (
+
+ {unlockedElements.map(([id, state]) => {
+ const elem = ELEMENTS[id];
+ if (!elem) return null;
+ const pct = state.max > 0 ? (state.current / state.max) * 100 : 0;
+ return (
+
+
+ {elem.sym} {fmt(state.current)}
+
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/game/tabs/SpireCombatPage/index.ts b/src/components/game/tabs/SpireCombatPage/index.ts
new file mode 100644
index 0000000..d970912
--- /dev/null
+++ b/src/components/game/tabs/SpireCombatPage/index.ts
@@ -0,0 +1,8 @@
+// ─── SpireCombatPage Barrel ────────────────────────────────────────────────────
+
+export { SpireCombatPage } from './SpireCombatPage';
+export { SpireHeader } from './SpireHeader';
+export { RoomDisplay } from './RoomDisplay';
+export { SpireCombatControls } from './SpireCombatControls';
+export { SpireActivityLog } from './SpireActivityLog';
+export { SpireManaDisplay } from './SpireManaDisplay';
diff --git a/src/lib/game/__tests__/enemy-generator.test.ts b/src/lib/game/__tests__/enemy-generator.test.ts
new file mode 100644
index 0000000..5328b6a
--- /dev/null
+++ b/src/lib/game/__tests__/enemy-generator.test.ts
@@ -0,0 +1,131 @@
+import { describe, it, expect } from 'vitest';
+import {
+ selectModifiers,
+ generateEnemy,
+ generateSwarm,
+ getModifierDisplay,
+ getModifierDescription,
+} from '../utils/enemy-generator';
+
+const SWARM_CFG = { minEnemies: 3, maxEnemies: 7 };
+const SHIELD_AMOUNT = 0.15;
+
+describe('selectModifiers', () => {
+ it('should return an array', () => {
+ const mods = selectModifiers(20);
+ expect(Array.isArray(mods)).toBe(true);
+ });
+
+ it('should return at most 2 modifiers', () => {
+ for (let i = 0; i < 50; i++) {
+ const mods = selectModifiers(50);
+ expect(mods.length).toBeLessThanOrEqual(2);
+ }
+ });
+
+ it('should return empty array for low floors', () => {
+ const mods = selectModifiers(1);
+ expect(mods.length).toBe(0);
+ });
+
+ it('should only return valid modifier types', () => {
+ const validMods = ['mage', 'shield', 'armored', 'swarm', 'agile'];
+ for (let floor = 1; floor <= 100; floor++) {
+ const mods = selectModifiers(floor);
+ for (const mod of mods) {
+ expect(validMods).toContain(mod);
+ }
+ }
+ });
+});
+
+describe('generateEnemy', () => {
+ it('should generate enemy with positive HP', () => {
+ const enemy = generateEnemy(10);
+ expect(enemy.hp).toBeGreaterThan(0);
+ expect(enemy.maxHP).toBeGreaterThan(0);
+ });
+
+ it('should include modifiers array', () => {
+ const enemy = generateEnemy(20);
+ expect(Array.isArray(enemy.modifiers)).toBe(true);
+ });
+
+ it('should apply armored modifier', () => {
+ const enemy = generateEnemy(30, ['armored']);
+ expect(enemy.modifiers).toContain('armored');
+ expect(enemy.armor).toBeGreaterThan(0);
+ });
+
+ it('should apply agile modifier', () => {
+ const enemy = generateEnemy(30, ['agile']);
+ expect(enemy.modifiers).toContain('agile');
+ expect(enemy.dodgeChance).toBeGreaterThan(0);
+ });
+
+ it('should apply mage modifier', () => {
+ const enemy = generateEnemy(30, ['mage']);
+ expect(enemy.modifiers).toContain('mage');
+ expect(enemy.barrier).toBeGreaterThan(0);
+ });
+
+ it('should apply shield modifier', () => {
+ const enemy = generateEnemy(30, ['shield']);
+ expect(enemy.modifiers).toContain('shield');
+ expect(enemy.barrier).toBeGreaterThanOrEqual(SHIELD_AMOUNT);
+ });
+
+ it('should have valid element', () => {
+ const enemy = generateEnemy(10);
+ expect(enemy.element).toBeTruthy();
+ expect(typeof enemy.element).toBe('string');
+ });
+});
+
+describe('generateSwarm', () => {
+ it('should generate multiple enemies', () => {
+ const enemies = generateSwarm(20);
+ expect(enemies.length).toBeGreaterThanOrEqual(SWARM_CFG.minEnemies);
+ expect(enemies.length).toBeLessThanOrEqual(SWARM_CFG.maxEnemies);
+ });
+
+ it('each enemy should have reduced HP', () => {
+ const enemies = generateSwarm(20);
+ for (const enemy of enemies) {
+ expect(enemy.hp).toBeGreaterThan(0);
+ expect(enemy.maxHP).toBeGreaterThan(0);
+ }
+ });
+
+ it('should include modifiers', () => {
+ const enemies = generateSwarm(20, ['armored']);
+ for (const enemy of enemies) {
+ expect(enemy.modifiers).toContain('armored');
+ }
+ });
+});
+
+describe('getModifierDisplay', () => {
+ it('should return display info for all modifiers', () => {
+ const modifiers = ['mage', 'shield', 'armored', 'swarm', 'agile'] as const;
+ for (const mod of modifiers) {
+ const display = getModifierDisplay(mod);
+ expect(display.label).toBeTruthy();
+ expect(display.icon).toBeTruthy();
+ expect(display.color).toBeTruthy();
+ expect(display.desc).toBeTruthy();
+ }
+ });
+});
+
+describe('getModifierDescription', () => {
+ it('should return standard for no modifiers', () => {
+ expect(getModifierDescription([])).toBe('Standard enemy');
+ });
+
+ it('should return modifier labels', () => {
+ const desc = getModifierDescription(['armored', 'agile']);
+ expect(desc).toContain('Armored');
+ expect(desc).toContain('Agile');
+ });
+});
diff --git a/src/lib/game/__tests__/spire-utils.test.ts b/src/lib/game/__tests__/spire-utils.test.ts
new file mode 100644
index 0000000..71200aa
--- /dev/null
+++ b/src/lib/game/__tests__/spire-utils.test.ts
@@ -0,0 +1,270 @@
+import { describe, it, expect } from 'vitest';
+import {
+ getRoomsForFloor,
+ generateSpireRoomType,
+ generateSpireFloorState,
+ getSpireEnemyArmor,
+ getSpireEnemyBarrier,
+ calcInsight,
+ getSpireRoomTypeDisplay,
+ SPIRE_CONFIG,
+} from '../utils/spire-utils';
+import { isGuardianFloor, getExtendedGuardian, getGuardianHP, generateGuardianName, generateComboGuardianName, ALL_GUARDIAN_FLOORS } from '../data/guardian-encounters';
+
+// ─── Spire Utils ─────────────────────────────────────────────────────────────
+
+describe('getRoomsForFloor', () => {
+ it('should return at least minRoomsPerFloor for non-guardian floors', () => {
+ for (let floor = 1; floor <= 50; floor++) {
+ if (floor % 10 === 0) continue; // Skip guardian floors
+ const rooms = getRoomsForFloor(floor);
+ expect(rooms).toBeGreaterThanOrEqual(SPIRE_CONFIG.minRoomsPerFloor);
+ expect(rooms).toBeLessThanOrEqual(SPIRE_CONFIG.maxRoomsPerFloor + 5);
+ }
+ });
+
+ it('should return 1 room for guardian floors', () => {
+ expect(getRoomsForFloor(10)).toBe(1);
+ expect(getRoomsForFloor(20)).toBe(1);
+ expect(getRoomsForFloor(100)).toBe(1);
+ });
+
+ it('should return more rooms for higher non-guardian floors', () => {
+ const lowFloor = getRoomsForFloor(3);
+ const highFloor = getRoomsForFloor(79);
+ expect(highFloor).toBeGreaterThanOrEqual(lowFloor);
+ });
+});
+
+describe('generateSpireRoomType', () => {
+ it('should return guardian for last room on guardian floors', () => {
+ const totalRooms = getRoomsForFloor(10);
+ const roomType = generateSpireRoomType(10, totalRooms - 1, totalRooms);
+ expect(roomType).toBe('guardian');
+ });
+
+ it('should return combat for first room on non-guardian floors', () => {
+ for (const floor of [1, 5, 15, 25]) {
+ const roomType = generateSpireRoomType(floor, 0, 10);
+ expect(roomType).toBe('combat');
+ }
+ });
+
+ it('should return combat for first room on guardian floors (not last room)', () => {
+ // Floor 50 is a guardian floor, but first room should still be combat
+ const roomType = generateSpireRoomType(50, 0, 10);
+ expect(roomType).toBe('combat');
+ });
+
+ it('should return valid room types', () => {
+ const validTypes = ['combat', 'swarm', 'speed', 'guardian', 'puzzle', 'recovery', 'library', 'treasure'];
+ for (let i = 0; i < 100; i++) {
+ const roomType = generateSpireRoomType(25, 3, 10);
+ expect(validTypes).toContain(roomType);
+ }
+ });
+});
+
+describe('generateSpireFloorState', () => {
+ it('should generate guardian floor for floor 10', () => {
+ const state = generateSpireFloorState(10, 0, 1);
+ expect(state.roomType).toBe('guardian');
+ expect(state.enemies.length).toBe(1);
+ expect(state.enemies[0].name).toBeTruthy();
+ });
+
+ it('should generate combat floor with enemies', () => {
+ const state = generateSpireFloorState(5, 0, 8);
+ expect(state.enemies.length).toBeGreaterThan(0);
+ expect(state.enemies[0].hp).toBeGreaterThan(0);
+ expect(state.enemies[0].maxHP).toBeGreaterThan(0);
+ });
+
+ it('should generate swarm floor with multiple enemies', () => {
+ // Force swarm by using a non-special room index
+ const state = generateSpireFloorState(20, 1, 10);
+ // Room type depends on random, but enemies should be valid
+ if (state.roomType === 'swarm') {
+ expect(state.enemies.length).toBeGreaterThanOrEqual(3);
+ }
+ });
+});
+
+describe('getSpireEnemyArmor', () => {
+ it('should return 0 for floors below 10', () => {
+ for (let i = 0; i < 20; i++) {
+ const armor = getSpireEnemyArmor(5);
+ expect(armor).toBeGreaterThanOrEqual(0);
+ expect(armor).toBeLessThanOrEqual(0.3);
+ }
+ });
+
+ it('should return values between 0 and 0.3', () => {
+ for (let floor = 1; floor <= 100; floor++) {
+ const armor = getSpireEnemyArmor(floor);
+ expect(armor).toBeGreaterThanOrEqual(0);
+ expect(armor).toBeLessThanOrEqual(0.3);
+ }
+ });
+});
+
+describe('getSpireEnemyBarrier', () => {
+ it('should return 0 for floors below 15', () => {
+ for (let i = 0; i < 10; i++) {
+ const barrier = getSpireEnemyBarrier(10, 'fire');
+ expect(barrier).toBeGreaterThanOrEqual(0);
+ }
+ });
+
+ it('should return values between 0 and 0.3', () => {
+ for (let floor = 15; floor <= 100; floor++) {
+ const barrier = getSpireEnemyBarrier(floor, 'fire');
+ expect(barrier).toBeGreaterThanOrEqual(0);
+ expect(barrier).toBeLessThanOrEqual(0.3);
+ }
+ });
+});
+
+describe('calcInsight', () => {
+ it('should return positive insight for any floor', () => {
+ expect(calcInsight(1, false)).toBeGreaterThan(0);
+ expect(calcInsight(10, true)).toBeGreaterThan(0);
+ expect(calcInsight(50, false)).toBeGreaterThan(0);
+ });
+
+ it('should give more insight for guardian floors', () => {
+ const normal = calcInsight(10, false);
+ const guardian = calcInsight(10, true);
+ expect(guardian).toBeGreaterThan(normal);
+ });
+
+ it('should scale with floor number', () => {
+ const low = calcInsight(5, false);
+ const high = calcInsight(50, false);
+ expect(high).toBeGreaterThan(low);
+ });
+});
+
+describe('getSpireRoomTypeDisplay', () => {
+ it('should return display info for all room types', () => {
+ const types = ['combat', 'swarm', 'speed', 'guardian', 'puzzle', 'recovery', 'library', 'treasure'];
+ for (const type of types) {
+ const display = getSpireRoomTypeDisplay(type as any);
+ expect(display.label).toBeTruthy();
+ expect(display.icon).toBeTruthy();
+ expect(display.color).toBeTruthy();
+ }
+ });
+
+ it('should return unknown for invalid room type', () => {
+ const display = getSpireRoomTypeDisplay('invalid' as any);
+ expect(display.label).toBe('Unknown');
+ });
+});
+
+// ─── Guardian Encounters ─────────────────────────────────────────────────────
+
+describe('isGuardianFloor', () => {
+ it('should return true for every 10th floor', () => {
+ expect(isGuardianFloor(10)).toBe(true);
+ expect(isGuardianFloor(20)).toBe(true);
+ expect(isGuardianFloor(100)).toBe(true);
+ expect(isGuardianFloor(150)).toBe(true);
+ });
+
+ it('should return false for non-10th floors', () => {
+ expect(isGuardianFloor(1)).toBe(false);
+ expect(isGuardianFloor(15)).toBe(false);
+ expect(isGuardianFloor(99)).toBe(false);
+ });
+});
+
+describe('getExtendedGuardian', () => {
+ it('should return compound guardians for floors 90, 110', () => {
+ const g90 = getExtendedGuardian(90);
+ expect(g90).not.toBeNull();
+ expect(g90!.element).toBe('metal');
+ expect(g90!.name).toBeTruthy();
+
+ const g110 = getExtendedGuardian(110);
+ expect(g110).not.toBeNull();
+ expect(g110!.element).toBe('lightning');
+ });
+
+ it('should return exotic guardians for floors 120, 130, 140', () => {
+ const g120 = getExtendedGuardian(120);
+ expect(g120).not.toBeNull();
+ expect(g120!.element).toBe('crystal');
+
+ const g130 = getExtendedGuardian(130);
+ expect(g130).not.toBeNull();
+ expect(g130!.element).toBe('stellar');
+
+ const g140 = getExtendedGuardian(140);
+ expect(g140).not.toBeNull();
+ expect(g140!.element).toBe('void');
+ });
+
+ it('should return combo guardians for floors 150+', () => {
+ const g150 = getExtendedGuardian(150);
+ expect(g150).not.toBeNull();
+ expect(g150!.element).toContain('+');
+ });
+
+ it('should return null for non-guardian floors', () => {
+ expect(getExtendedGuardian(1)).toBeNull();
+ expect(getExtendedGuardian(15)).toBeNull();
+ expect(getExtendedGuardian(95)).toBeNull();
+ });
+});
+
+describe('getGuardianHP', () => {
+ it('should return positive HP', () => {
+ expect(getGuardianHP(10)).toBeGreaterThan(0);
+ expect(getGuardianHP(100)).toBeGreaterThan(0);
+ expect(getGuardianHP(200)).toBeGreaterThan(0);
+ });
+
+ it('should scale with floor', () => {
+ const low = getGuardianHP(10);
+ const high = getGuardianHP(100);
+ expect(high).toBeGreaterThan(low);
+ });
+});
+
+describe('generateGuardianName', () => {
+ it('should generate non-empty names', () => {
+ for (const element of ['fire', 'water', 'air', 'earth', 'light', 'dark', 'death']) {
+ const name = generateGuardianName(element);
+ expect(name).toBeTruthy();
+ expect(name.length).toBeGreaterThan(0);
+ }
+ });
+
+ it('should include a title', () => {
+ const name = generateGuardianName('fire');
+ expect(name).toContain(' the ');
+ });
+});
+
+describe('generateComboGuardianName', () => {
+ it('should combine two element prefixes', () => {
+ const name = generateComboGuardianName(['fire', 'water']);
+ expect(name).toContain(' the ');
+ expect(name.length).toBeGreaterThan(0);
+ });
+});
+
+describe('ALL_GUARDIAN_FLOORS', () => {
+ it('should include base guardian floors', () => {
+ expect(ALL_GUARDIAN_FLOORS).toContain(10);
+ expect(ALL_GUARDIAN_FLOORS).toContain(20);
+ expect(ALL_GUARDIAN_FLOORS).toContain(100);
+ });
+
+ it('should be sorted', () => {
+ for (let i = 1; i < ALL_GUARDIAN_FLOORS.length; i++) {
+ expect(ALL_GUARDIAN_FLOORS[i]).toBeGreaterThan(ALL_GUARDIAN_FLOORS[i - 1]);
+ }
+ });
+});
diff --git a/src/lib/game/data/guardian-encounters.ts b/src/lib/game/data/guardian-encounters.ts
new file mode 100644
index 0000000..6405708
--- /dev/null
+++ b/src/lib/game/data/guardian-encounters.ts
@@ -0,0 +1,278 @@
+// ─── Extended Guardian Encounters ─────────────────────────────────────────────
+// Full guardian definitions for all mana types across all spire floors.
+// Guardians at floors 10-80: base types, 90-110: compound, 120+: exotic/combination.
+
+import type { GuardianDef } from '../types';
+
+// ─── Name Generation ──────────────────────────────────────────────────────────
+
+const GUARDIAN_PREFIXES: Record = {
+ fire: ['Ignis', 'Pyra', 'Sol', 'Vulcan', 'Ember'],
+ water: ['Aqua', 'Marina', 'Thal', 'Pelag', 'Coral'],
+ air: ['Ventus', 'Zephyr', 'Aero', 'Nimbus', 'Gale'],
+ earth: ['Terra', 'Petra', 'Mont', 'Gaia', 'Ore'],
+ light: ['Lux', 'Solaris', 'Radi', 'Lumin', 'Aur'],
+ dark: ['Umbra', 'Noct', 'Teneb', 'Ereb', 'Nyx'],
+ death: ['Mors', 'Necro', 'Than', 'Mort', 'Skull'],
+ transference: ['Link', 'Arcana', 'Vinc', 'Bind', 'Chain'],
+ metal: ['Ferr', 'Chroma', 'Steel', 'Arg', 'Ore'],
+ sand: ['Arena', 'Dune', 'Siroc', 'Erg', 'Sah'],
+ lightning: ['Volt', 'Fulg', 'Electr', 'Spark', 'Storm'],
+ crystal: ['Prism', 'Gemma', 'Crystal', 'Shard', 'Facet'],
+ stellar: ['Astro', 'Stella', 'Nova', 'Cosmo', 'Lumin'],
+ void: ['Void', 'Abyss', 'Null', 'Nihil', 'Obliv'],
+};
+
+const GUARDIAN_TITLES: string[] = [
+ 'Warden', 'Keeper', 'Lord', 'Titan', 'Sovereign',
+ 'Guardian', 'Sentinel', 'Champion', 'Overlord', 'Archon',
+];
+
+export function generateGuardianName(element: string): string {
+ const prefixes = GUARDIAN_PREFIXES[element] || ['Unknown'];
+ const prefix = prefixes[Math.floor(Math.random() * prefixes.length)];
+ const title = GUARDIAN_TITLES[Math.floor(Math.random() * GUARDIAN_TITLES.length)];
+ return `${prefix} the ${title}`;
+}
+
+export function generateComboGuardianName(elements: string[]): string {
+ const parts = elements.map((el) => {
+ const prefixes = GUARDIAN_PREFIXES[el] || ['Unknown'];
+ return prefixes[Math.floor(Math.random() * prefixes.length)];
+ });
+ const title = GUARDIAN_TITLES[Math.floor(Math.random() * GUARDIAN_TITLES.length)];
+ return `${parts.join('-')} the ${title}`;
+}
+
+// ─── Guardian HP Scaling ──────────────────────────────────────────────────────
+
+export function getGuardianHP(floor: number): number {
+ // Base scaling: exponential growth per floor
+ const base = 5000;
+ const exponent = 1.1 + (floor / 200);
+ return Math.floor(base * Math.pow(floor / 10, exponent));
+}
+
+// ─── Extended Guardian Definitions ────────────────────────────────────────────
+
+// Floors 10-80: Base mana type guardians (already in constants/guardians.ts)
+// Floors 90-110: Compound mana type guardians
+// Floors 120-140: Exotic mana type guardians
+// Floors 150+: Combination guardians
+
+const COMPOUND_GUARDIANS: Record = {
+ 90: {
+ name: '', // Generated dynamically
+ element: 'metal',
+ hp: getGuardianHP(90),
+ pact: 3.5,
+ color: '#BDC3C7',
+ armor: 0.30,
+ boons: [
+ { type: 'elementalDamage', value: 15, desc: '+15% Metal damage' },
+ { type: 'maxMana', value: 150, desc: '+150 max mana' },
+ ],
+ pactCost: 60000,
+ pactTime: 18,
+ uniquePerk: 'Metal spells pierce 20% armor',
+ power: 6000,
+ effects: [{ type: 'armor_pierce', value: 0.2 }],
+ signingCost: { mana: 60000, time: 18 },
+ unlocksMana: ['metal'],
+ damageMultiplier: 1.9,
+ insightMultiplier: 1.6,
+ },
+ 100: {
+ name: '',
+ element: 'sand',
+ hp: getGuardianHP(100),
+ pact: 3.75,
+ color: '#D4AC0D',
+ armor: 0.25,
+ boons: [
+ { type: 'elementalDamage', value: 15, desc: '+15% Sand damage' },
+ { type: 'manaRegen', value: 1.5, desc: '+1.5 mana regen' },
+ ],
+ pactCost: 80000,
+ pactTime: 20,
+ uniquePerk: 'Sand spells slow enemies by 25%',
+ power: 8000,
+ effects: [{ type: 'slow', value: 0.25 }],
+ signingCost: { mana: 80000, time: 20 },
+ unlocksMana: ['sand'],
+ damageMultiplier: 2.0,
+ insightMultiplier: 1.7,
+ },
+ 110: {
+ name: '',
+ element: 'lightning',
+ hp: getGuardianHP(110),
+ pact: 4.0,
+ color: '#FFEB3B',
+ armor: 0.22,
+ boons: [
+ { type: 'elementalDamage', value: 15, desc: '+15% Lightning damage' },
+ { type: 'castingSpeed', value: 15, desc: '+15% casting speed' },
+ ],
+ pactCost: 100000,
+ pactTime: 22,
+ uniquePerk: 'Lightning spells chain to 2 additional targets',
+ power: 10000,
+ effects: [{ type: 'chain', value: 2 }],
+ signingCost: { mana: 100000, time: 22 },
+ unlocksMana: ['lightning'],
+ damageMultiplier: 2.1,
+ insightMultiplier: 1.8,
+ },
+};
+
+const EXOTIC_GUARDIANS: Record = {
+ 120: {
+ name: '',
+ element: 'crystal',
+ hp: getGuardianHP(120),
+ pact: 4.5,
+ color: '#85C1E9',
+ armor: 0.35,
+ boons: [
+ { type: 'elementalDamage', value: 20, desc: '+20% Crystal damage' },
+ { type: 'maxMana', value: 300, desc: '+300 max mana' },
+ { type: 'manaRegen', value: 2, desc: '+2 mana regen' },
+ ],
+ pactCost: 150000,
+ pactTime: 26,
+ uniquePerk: 'Crystal spells reflect 15% damage back to attackers',
+ power: 15000,
+ effects: [{ type: 'reflect', value: 0.15 }],
+ signingCost: { mana: 150000, time: 26 },
+ unlocksMana: ['crystal'],
+ damageMultiplier: 2.3,
+ insightMultiplier: 1.9,
+ },
+ 130: {
+ name: '',
+ element: 'stellar',
+ hp: getGuardianHP(130),
+ pact: 5.0,
+ color: '#F0E68C',
+ armor: 0.30,
+ boons: [
+ { type: 'elementalDamage', value: 25, desc: '+25% Stellar damage' },
+ { type: 'insightGain', value: 20, desc: '+20% insight gain' },
+ ],
+ pactCost: 200000,
+ pactTime: 30,
+ uniquePerk: 'Stellar spells deal +30% damage at night',
+ power: 20000,
+ effects: [{ type: 'night_bonus', value: 0.3 }],
+ signingCost: { mana: 200000, time: 30 },
+ unlocksMana: ['stellar'],
+ damageMultiplier: 2.5,
+ insightMultiplier: 2.0,
+ },
+ 140: {
+ name: '',
+ element: 'void',
+ hp: getGuardianHP(140),
+ pact: 5.5,
+ color: '#4A235A',
+ armor: 0.35,
+ boons: [
+ { type: 'elementalDamage', value: 25, desc: '+25% Void damage' },
+ { type: 'rawDamage', value: 15, desc: '+15% raw damage' },
+ { type: 'maxMana', value: 400, desc: '+400 max mana' },
+ ],
+ pactCost: 300000,
+ pactTime: 34,
+ uniquePerk: 'Void spells ignore 40% of all resistances',
+ power: 30000,
+ effects: [{ type: 'resist_ignore', value: 0.4 }],
+ signingCost: { mana: 300000, time: 34 },
+ unlocksMana: ['void'],
+ damageMultiplier: 2.8,
+ insightMultiplier: 2.2,
+ },
+};
+
+// ─── Combination Guardians (Floor 150+) ───────────────────────────────────────
+
+const COMBO_PAIRS: [string, string][] = [
+ ['fire', 'water'], // Steam
+ ['fire', 'air'], // Already lightning but different flavor
+ ['water', 'earth'], // Already sand but different flavor
+ ['light', 'dark'], // Twilight
+ ['death', 'light'], // Undeath
+ ['fire', 'death'], // Hellfire
+ ['water', 'dark'], // Abyssal
+ ['air', 'light'], // Radiant wind
+ ['earth', 'death'], // Fossil
+];
+
+export function getComboGuardian(floor: number): GuardianDef {
+ const comboIndex = Math.floor((floor - 150) / 10) % COMBO_PAIRS.length;
+ const [el1, el2] = COMBO_PAIRS[comboIndex];
+ const hp = getGuardianHP(floor);
+ const armor = Math.min(0.5, 0.25 + (floor - 150) * 0.002);
+
+ return {
+ name: '',
+ element: `${el1}+${el2}`,
+ hp,
+ pact: 6.0 + (floor - 150) * 0.05,
+ color: '#E8D5F5',
+ armor,
+ boons: [
+ { type: 'elementalDamage', value: 10, desc: `+10% ${el1} damage` },
+ { type: 'elementalDamage', value: 10, desc: `+10% ${el2} damage` },
+ ],
+ pactCost: Math.floor(hp * 0.5),
+ pactTime: 20 + Math.floor((floor - 150) / 10),
+ uniquePerk: `Dual-aspect: ${el1} and ${el2} spells gain +20% effectiveness`,
+ power: Math.floor(hp * 0.5),
+ effects: [
+ { type: `${el1}_boost`, value: 0.2 },
+ { type: `${el2}_boost`, value: 0.2 },
+ ],
+ signingCost: { mana: Math.floor(hp * 0.5), time: 20 + Math.floor((floor - 150) / 10) },
+ unlocksMana: [el1, el2],
+ damageMultiplier: 3.0 + (floor - 150) * 0.02,
+ insightMultiplier: 2.5 + (floor - 150) * 0.01,
+ };
+}
+
+// ─── Guardian Lookup ──────────────────────────────────────────────────────────
+
+export function getExtendedGuardian(floor: number): GuardianDef | null {
+ if (COMPOUND_GUARDIANS[floor]) {
+ const g = { ...COMPOUND_GUARDIANS[floor] };
+ if (!g.name) g.name = generateGuardianName(g.element);
+ return g;
+ }
+ if (EXOTIC_GUARDIANS[floor]) {
+ const g = { ...EXOTIC_GUARDIANS[floor] };
+ if (!g.name) g.name = generateGuardianName(g.element);
+ return g;
+ }
+ if (floor >= 150 && floor % 10 === 0) {
+ const g = getComboGuardian(floor);
+ if (!g.name) {
+ const elements = g.element.split('+');
+ g.name = generateComboGuardianName(elements);
+ }
+ return g;
+ }
+ return null;
+}
+
+// All guardian floors (extended)
+export const ALL_GUARDIAN_FLOORS: number[] = [
+ 10, 20, 30, 40, 50, 60, 80, 100, // Original
+ 90, 110, // Compound
+ 120, 130, 140, // Exotic
+ ...Array.from({ length: 10 }, (_, i) => 150 + i * 10), // Combo
+].sort((a, b) => a - b);
+
+// Check if a floor is a guardian floor (every 10th floor)
+export function isGuardianFloor(floor: number): boolean {
+ return floor % 10 === 0;
+}
diff --git a/src/lib/game/utils/enemy-generator.ts b/src/lib/game/utils/enemy-generator.ts
new file mode 100644
index 0000000..2006050
--- /dev/null
+++ b/src/lib/game/utils/enemy-generator.ts
@@ -0,0 +1,223 @@
+// ─── Enemy Generator ───────────────────────────────────────────────────────────
+// Enemy generation with modifiers: mage, shield, armored, swarm, agile
+// Modifiers are combinable (e.g., armored + swarm)
+
+import type { EnemyState } from '../types';
+import { getFloorMaxHP, getFloorElement } from './floor-utils';
+import { getEnemyName } from './enemy-utils';
+
+// ─── Enemy Modifier Types ─────────────────────────────────────────────────────
+
+export type EnemyModifier = 'mage' | 'shield' | 'armored' | 'swarm' | 'agile';
+
+export interface GeneratedEnemy extends EnemyState {
+ modifiers: EnemyModifier[];
+}
+
+// ─── Modifier Configuration ───────────────────────────────────────────────────
+
+const MODIFIER_CONFIG = {
+ mage: {
+ barrierPerFloor: 0.003,
+ maxBarrier: 0.4,
+ barrierRechargeRate: 0.05, // Recharges 5% of max HP per tick
+ },
+ shield: {
+ shieldAmount: 0.15, // 15% of max HP as one-time shield
+ },
+ armored: {
+ armorPerFloor: 0.003,
+ maxArmor: 0.45,
+ minArmor: 0.1,
+ },
+ swarm: {
+ minEnemies: 3,
+ maxEnemies: 7,
+ hpMultiplier: 0.35,
+ armorPerFloor: 0.002,
+ },
+ agile: {
+ baseDodge: 0.20,
+ dodgePerFloor: 0.004,
+ maxDodge: 0.55,
+ },
+};
+
+// ─── Modifier Selection ───────────────────────────────────────────────────────
+
+export function selectModifiers(floor: number): EnemyModifier[] {
+ const modifiers: EnemyModifier[] = [];
+
+ // Mage: appears floor 15+, more common at higher floors
+ if (floor >= 15 && Math.random() < Math.min(0.3, (floor - 15) * 0.01)) {
+ modifiers.push('mage');
+ }
+
+ // Shield: appears floor 10+, moderate chance
+ if (floor >= 10 && Math.random() < Math.min(0.25, (floor - 10) * 0.008)) {
+ modifiers.push('shield');
+ }
+
+ // Armored: appears floor 5+, common
+ if (floor >= 5 && Math.random() < Math.min(0.4, (floor - 5) * 0.012)) {
+ modifiers.push('armored');
+ }
+
+ // Swarm: appears floor 8+, moderate chance
+ if (floor >= 8 && Math.random() < 0.15) {
+ modifiers.push('swarm');
+ }
+
+ // Agile: appears floor 12+, moderate chance
+ if (floor >= 12 && Math.random() < Math.min(0.25, (floor - 12) * 0.008)) {
+ modifiers.push('agile');
+ }
+
+ // Limit to 2 modifiers max for balance
+ if (modifiers.length > 2) {
+ // Shuffle and take first 2
+ for (let i = modifiers.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [modifiers[i], modifiers[j]] = [modifiers[j], modifiers[i]];
+ }
+ return modifiers.slice(0, 2);
+ }
+
+ return modifiers;
+}
+
+// ─── Enemy Generation ─────────────────────────────────────────────────────────
+
+export function generateEnemy(floor: number, modifiers?: EnemyModifier[]): GeneratedEnemy {
+ const element = getFloorElement(floor);
+ const baseHP = getFloorMaxHP(floor);
+ const activeModifiers = modifiers || selectModifiers(floor);
+
+ let hp = baseHP;
+ let armor = 0;
+ let dodgeChance = 0;
+ let barrier = 0;
+ let name = getEnemyName(element, floor);
+
+ // Apply modifier effects
+ if (activeModifiers.includes('armored')) {
+ const progress = Math.min(1, floor / 100);
+ armor = Math.min(
+ MODIFIER_CONFIG.armored.maxArmor,
+ MODIFIER_CONFIG.armored.minArmor + (MODIFIER_CONFIG.armored.maxArmor - MODIFIER_CONFIG.armored.minArmor) * progress
+ );
+ name = `Armored ${name}`;
+ }
+
+ if (activeModifiers.includes('agile')) {
+ dodgeChance = Math.min(
+ MODIFIER_CONFIG.agile.maxDodge,
+ MODIFIER_CONFIG.agile.baseDodge + floor * MODIFIER_CONFIG.agile.dodgePerFloor
+ );
+ name = `Agile ${name}`;
+ }
+
+ if (activeModifiers.includes('mage')) {
+ barrier = Math.min(
+ MODIFIER_CONFIG.mage.maxBarrier,
+ floor * MODIFIER_CONFIG.mage.barrierPerFloor
+ );
+ name = `Mage ${name}`;
+ }
+
+ if (activeModifiers.includes('shield')) {
+ barrier = Math.max(barrier, MODIFIER_CONFIG.shield.shieldAmount);
+ name = `${name} (Shielded)`;
+ }
+
+ return {
+ id: 'enemy',
+ name,
+ hp,
+ maxHP: hp,
+ armor,
+ dodgeChance,
+ barrier,
+ element,
+ modifiers: activeModifiers,
+ };
+}
+
+// ─── Swarm Generation ─────────────────────────────────────────────────────────
+
+export function generateSwarm(floor: number, modifiers?: EnemyModifier[]): GeneratedEnemy[] {
+ const element = getFloorElement(floor);
+ const baseHP = getFloorMaxHP(floor);
+ const activeModifiers = modifiers || [];
+ const numEnemies = MODIFIER_CONFIG.swarm.minEnemies +
+ Math.floor(Math.random() * (MODIFIER_CONFIG.swarm.maxEnemies - MODIFIER_CONFIG.swarm.minEnemies + 1));
+
+ const enemies: GeneratedEnemy[] = [];
+
+ for (let i = 0; i < numEnemies; i++) {
+ const enemyName = getEnemyName(element, floor);
+ const hp = Math.floor(baseHP * MODIFIER_CONFIG.swarm.hpMultiplier);
+ const armor = activeModifiers.includes('armored')
+ ? Math.min(0.3, floor * MODIFIER_CONFIG.swarm.armorPerFloor)
+ : 0;
+
+ enemies.push({
+ id: `swarm_${i}`,
+ name: `${enemyName} ${i + 1}`,
+ hp,
+ maxHP: hp,
+ armor,
+ dodgeChance: activeModifiers.includes('agile')
+ ? Math.min(0.35, 0.15 + floor * 0.003)
+ : 0,
+ barrier: 0,
+ element,
+ modifiers: activeModifiers,
+ });
+ }
+
+ return enemies;
+}
+
+// ─── Modifier Display ─────────────────────────────────────────────────────────
+
+export function getModifierDisplay(modifier: EnemyModifier): { label: string; icon: string; color: string; desc: string } {
+ const displays: Record = {
+ mage: {
+ label: 'Mage',
+ icon: '🔮',
+ color: '#8B5CF6',
+ desc: 'Casts barriers that re-apply occasionally',
+ },
+ shield: {
+ label: 'Shielded',
+ icon: '🛡️',
+ color: '#3B82F6',
+ desc: 'Has a one-time shield that must be broken',
+ },
+ armored: {
+ label: 'Armored',
+ icon: '⛰️',
+ color: '#F59E0B',
+ desc: 'Reduces incoming damage',
+ },
+ swarm: {
+ label: 'Swarm',
+ icon: '🐝',
+ color: '#EF4444',
+ desc: 'Multiple weaker enemies',
+ },
+ agile: {
+ label: 'Agile',
+ icon: '💨',
+ color: '#10B981',
+ desc: 'Can dodge attacks',
+ },
+ };
+ return displays[modifier];
+}
+
+export function getModifierDescription(modifiers: EnemyModifier[]): string {
+ if (modifiers.length === 0) return 'Standard enemy';
+ return modifiers.map((m) => getModifierDisplay(m).label).join(', ');
+}
diff --git a/src/lib/game/utils/spire-utils.ts b/src/lib/game/utils/spire-utils.ts
new file mode 100644
index 0000000..b95c5ba
--- /dev/null
+++ b/src/lib/game/utils/spire-utils.ts
@@ -0,0 +1,257 @@
+// ─── Spire Utility Functions ───────────────────────────────────────────────────
+// 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 { getFloorMaxHP, getFloorElement } from './floor-utils';
+import { getEnemyName } from './enemy-utils';
+import { isGuardianFloor, getExtendedGuardian } from '../data/guardian-encounters';
+
+// ─── Spire Room Configuration ─────────────────────────────────────────────────
+
+export const SPIRE_CONFIG = {
+ minRoomsPerFloor: 5,
+ maxRoomsPerFloor: 15,
+ guardianRooms: 1,
+ puzzleRoomChance: 0.12,
+ rareRoomChance: 0.05,
+ recoveryRoomChance: 0.4,
+ libraryRoomChance: 0.3,
+ treasureRoomChance: 0.3,
+};
+
+// ─── Room Count ───────────────────────────────────────────────────────────────
+
+export function getRoomsForFloor(floor: number): number {
+ if (isGuardianFloor(floor)) return SPIRE_CONFIG.guardianRooms;
+ const base = SPIRE_CONFIG.minRoomsPerFloor;
+ const range = SPIRE_CONFIG.maxRoomsPerFloor - SPIRE_CONFIG.minRoomsPerFloor;
+ // Slight increase in rooms at higher floors
+ const floorBonus = Math.min(range, Math.floor(floor / 20));
+ const randomVariation = Math.floor(Math.random() * 3);
+ return base + floorBonus + randomVariation;
+}
+
+// ─── Spire Room Types ─────────────────────────────────────────────────────────
+
+export type SpireRoomType = RoomType | 'recovery' | 'library' | 'treasure';
+
+// ─── Room Generation ──────────────────────────────────────────────────────────
+
+export function generateSpireRoomType(floor: number, roomIndex: number, totalRooms: number): SpireRoomType {
+ // Last room on guardian floors is always guardian
+ if (isGuardianFloor(floor) && roomIndex === totalRooms - 1) {
+ return 'guardian';
+ }
+
+ // First room on a floor is never a special room (always combat)
+ if (roomIndex === 0) {
+ return generateCombatRoomType(floor);
+ }
+
+ // Rare rooms (mid-floor)
+ if (roomIndex === Math.floor(totalRooms / 2) && Math.random() < SPIRE_CONFIG.rareRoomChance) {
+ return generateRareRoomType();
+ }
+
+ // Puzzle rooms
+ if (floor % 7 === 0 && Math.random() < SPIRE_CONFIG.puzzleRoomChance) {
+ return 'puzzle';
+ }
+
+ return generateCombatRoomType(floor);
+}
+
+function generateCombatRoomType(floor: number): RoomType {
+ const roll = Math.random();
+ if (roll < 0.12) return 'swarm';
+ if (roll < 0.22) return 'speed';
+ return 'combat';
+}
+
+function generateRareRoomType(): SpireRoomType {
+ const roll = Math.random();
+ if (roll < SPIRE_CONFIG.recoveryRoomChance) return 'recovery';
+ if (roll < SPIRE_CONFIG.recoveryRoomChance + SPIRE_CONFIG.libraryRoomChance) return 'library';
+ return 'treasure';
+}
+
+// ─── Floor State Generation ───────────────────────────────────────────────────
+
+export function generateSpireFloorState(floor: number, roomIndex: number, totalRooms: number): FloorState {
+ const roomType = generateSpireRoomType(floor, roomIndex, totalRooms);
+ const element = getFloorElement(floor);
+ const baseHP = getFloorMaxHP(floor);
+
+ switch (roomType) {
+ case 'guardian': {
+ const guardian = GUARDIANS[floor] || getExtendedGuardian(floor);
+ if (guardian) {
+ return {
+ roomType: 'guardian',
+ enemies: [{
+ id: 'guardian',
+ name: guardian.name,
+ hp: guardian.hp,
+ maxHP: guardian.hp,
+ armor: guardian.armor || 0,
+ dodgeChance: 0,
+ barrier: 0,
+ element: guardian.element,
+ }],
+ };
+ }
+ // Fallback if no guardian defined for this floor
+ return generateCombatRoom(floor, element, baseHP);
+ }
+
+ case 'swarm':
+ return generateSwarmRoom(floor, element, baseHP);
+
+ case 'speed':
+ return generateSpeedRoom(floor, element, baseHP);
+
+ case 'puzzle': {
+ const puzzleKeys = Object.keys(PUZZLE_ROOMS);
+ const selectedPuzzle = puzzleKeys[Math.floor(Math.random() * puzzleKeys.length)];
+ const puzzle = PUZZLE_ROOMS[selectedPuzzle];
+ return {
+ roomType: 'puzzle',
+ enemies: [],
+ puzzleProgress: 0,
+ puzzleRequired: 1,
+ puzzleId: selectedPuzzle,
+ puzzleAttunements: puzzle.attunements,
+ };
+ }
+
+ case 'recovery':
+ return {
+ roomType: 'recovery',
+ enemies: [],
+ recoveryProgress: 0,
+ recoveryRequired: 1,
+ } as unknown as FloorState;
+
+ case 'library':
+ return {
+ roomType: 'library',
+ enemies: [],
+ libraryProgress: 0,
+ libraryRequired: 1,
+ } as unknown as FloorState;
+
+ case 'treasure':
+ return {
+ roomType: 'treasure',
+ enemies: [],
+ } as unknown as FloorState;
+
+ default:
+ return generateCombatRoom(floor, element, baseHP);
+ }
+}
+
+function generateCombatRoom(floor: number, element: string, baseHP: number): FloorState {
+ const armor = getSpireEnemyArmor(floor);
+ const barrier = getSpireEnemyBarrier(floor, element);
+ const enemyName = getEnemyName(element, floor);
+
+ return {
+ roomType: 'combat',
+ enemies: [{
+ id: 'enemy',
+ name: enemyName,
+ hp: baseHP,
+ maxHP: baseHP,
+ armor,
+ dodgeChance: 0,
+ barrier,
+ element,
+ }],
+ };
+}
+
+function generateSwarmRoom(floor: number, element: string, baseHP: number): FloorState {
+ const numEnemies = 3 + Math.floor(Math.random() * 5); // 3-7 enemies
+ const enemies: EnemyState[] = [];
+
+ for (let i = 0; i < numEnemies; i++) {
+ enemies.push({
+ id: `swarm_${i}`,
+ name: `${getEnemyName(element, floor)} ${i + 1}`,
+ hp: Math.floor(baseHP * 0.35),
+ maxHP: Math.floor(baseHP * 0.35),
+ armor: Math.floor(floor / 15) * 0.02,
+ dodgeChance: 0,
+ barrier: 0,
+ element,
+ });
+ }
+
+ return { roomType: 'swarm', enemies };
+}
+
+function generateSpeedRoom(floor: number, element: string, baseHP: number): FloorState {
+ const dodgeChance = Math.min(0.55, 0.20 + floor * 0.005);
+ const armor = getSpireEnemyArmor(floor);
+
+ return {
+ roomType: 'speed',
+ enemies: [{
+ id: 'agile_enemy',
+ name: `Agile ${getEnemyName(element, floor)}`,
+ hp: baseHP,
+ maxHP: baseHP,
+ armor,
+ dodgeChance,
+ barrier: getSpireEnemyBarrier(floor, element),
+ element,
+ }],
+ };
+}
+
+// ─── Enemy Stat Scaling ───────────────────────────────────────────────────────
+
+export function getSpireEnemyArmor(floor: number): number {
+ if (floor < 10) return 0;
+ const baseChance = Math.min(0.5, (floor - 10) * 0.01);
+ if (Math.random() > baseChance) return 0;
+ const minArmor = 0.05;
+ const maxArmor = 0.30;
+ const progress = Math.min(1, (floor - 10) / 90);
+ return minArmor + (maxArmor - minArmor) * progress * Math.random();
+}
+
+export function getSpireEnemyBarrier(floor: number, element: string): number {
+ if (floor < 15) return 0;
+ const barrierElements = ['light', 'water', 'earth'];
+ const baseChance = barrierElements.includes(element) ? 0.12 : 0.06;
+ const floorBonus = Math.min(0.2, (floor - 15) * 0.003);
+ if (Math.random() > Math.min(0.35, baseChance + floorBonus)) return 0;
+ const progress = Math.min(1, (floor - 15) / 85);
+ return 0.1 + progress * 0.2;
+}
+
+// ─── Insight Calculation ──────────────────────────────────────────────────────
+
+export function calcInsight(floor: number, isGuardian: boolean): number {
+ const base = Math.floor(Math.pow(floor, 1.2));
+ return isGuardian ? Math.floor(base * 2.5) : base;
+}
+
+// ─── Room Type Display ────────────────────────────────────────────────────────
+
+export function getSpireRoomTypeDisplay(roomType: SpireRoomType): { label: string; icon: string; color: string } {
+ const displays: Record = {
+ combat: { label: 'Combat', icon: '⚔️', color: '#EF4444' },
+ swarm: { label: 'Swarm', icon: '🐝', color: '#F59E0B' },
+ speed: { label: 'Speed', icon: '💨', color: '#3B82F6' },
+ guardian: { label: 'Guardian', icon: '🛡️', color: '#EF4444' },
+ puzzle: { label: 'Puzzle', icon: '🧩', color: '#8B5CF6' },
+ recovery: { label: 'Recovery', icon: '💚', color: '#10B981' },
+ library: { label: 'Ancient Library', icon: '📚', color: '#6366F1' },
+ treasure: { label: 'Treasure', icon: '💎', color: '#F59E0B' },
+ };
+ return displays[roomType] || { label: 'Unknown', icon: '❓', color: '#6B7280' };
+}