From 7d56fc368f6e0ad11ed49cde78f4300037c85c0d Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Wed, 20 May 2026 09:28:05 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Recreate=20Spire=20Combat=20Page=20?= =?UTF-8?q?=E2=80=94=20full=20spire=20climbing=20experience?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add guardian-encounters.ts: Extended guardian definitions for all mana types (compound, exotic, combo) with dynamic name generation - Add spire-utils.ts: Spire-specific utilities (room generation, enemy stat scaling, insight calculation) - Add enemy-generator.ts: Enemy generation with combinable modifiers (mage, shield, armored, swarm, agile) - Add SpireCombatPage/ directory with modular sub-components: - SpireHeader.tsx: Floor info, climb controls, exit button, HP/room progress bars - RoomDisplay.tsx: Current room info with enemies, barriers, armor, dodge stats - SpireCombatControls.tsx: Spell selection panel, golem status panel - SpireActivityLog.tsx: Combat activity log - SpireManaDisplay.tsx: Compact mana display with elemental pools - Modify page.tsx: Conditionally render SpireCombatPage when spireMode is true - Add comprehensive tests (49 tests) for spire utilities, guardian encounters, and enemy generation --- .gitignore | 1 + docs/circular-deps.txt | 4 +- docs/dependency-graph.json | 5 +- docs/project-structure.txt | 17 +- src/app/page.tsx | 12 + .../game/tabs/SpireCombatPage/RoomDisplay.tsx | 190 ++++++++++++ .../tabs/SpireCombatPage/SpireActivityLog.tsx | 43 +++ .../SpireCombatPage/SpireCombatControls.tsx | 133 +++++++++ .../tabs/SpireCombatPage/SpireCombatPage.tsx | 237 +++++++++++++++ .../game/tabs/SpireCombatPage/SpireHeader.tsx | 128 ++++++++ .../tabs/SpireCombatPage/SpireManaDisplay.tsx | 72 +++++ .../game/tabs/SpireCombatPage/index.ts | 8 + .../game/__tests__/enemy-generator.test.ts | 131 +++++++++ src/lib/game/__tests__/spire-utils.test.ts | 270 +++++++++++++++++ src/lib/game/data/guardian-encounters.ts | 278 ++++++++++++++++++ src/lib/game/utils/enemy-generator.ts | 223 ++++++++++++++ src/lib/game/utils/spire-utils.ts | 257 ++++++++++++++++ 17 files changed, 2004 insertions(+), 5 deletions(-) create mode 100644 src/components/game/tabs/SpireCombatPage/RoomDisplay.tsx create mode 100644 src/components/game/tabs/SpireCombatPage/SpireActivityLog.tsx create mode 100644 src/components/game/tabs/SpireCombatPage/SpireCombatControls.tsx create mode 100644 src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx create mode 100644 src/components/game/tabs/SpireCombatPage/SpireHeader.tsx create mode 100644 src/components/game/tabs/SpireCombatPage/SpireManaDisplay.tsx create mode 100644 src/components/game/tabs/SpireCombatPage/index.ts create mode 100644 src/lib/game/__tests__/enemy-generator.test.ts create mode 100644 src/lib/game/__tests__/spire-utils.test.ts create mode 100644 src/lib/game/data/guardian-encounters.ts create mode 100644 src/lib/game/utils/enemy-generator.ts create mode 100644 src/lib/game/utils/spire-utils.ts 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 */} +
+
+

🏔️ SPIRE

+
+ Floor {currentFloor} · Insight: {fmt(insight)} +
+
+
+ + {/* 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' }; +}