feat: Recreate Spire Combat Page — full spire climbing experience
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s
- 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
This commit is contained in:
@@ -48,3 +48,4 @@ prompt
|
||||
|
||||
server.log
|
||||
# Skills directory
|
||||
.desloppify/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = () => <div className="p-4 text-center text-gray-400">Loading...</div>;
|
||||
|
||||
@@ -217,6 +218,17 @@ export default function ManaLoopGame() {
|
||||
|
||||
if (!mounted) return <div className="p-4 text-center text-gray-400">Loading...</div>;
|
||||
|
||||
// Spire mode: full-page replacement view
|
||||
if (spireMode) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<div className="p-4 text-center text-gray-400">Loading spire...</div>}>
|
||||
<SpireCombatPage />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<TooltipProvider>
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-1 p-2 bg-gray-800/50 rounded border border-gray-700/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{elemDef && (
|
||||
<span style={{ color: elemDef.color }}>{elemDef.sym}</span>
|
||||
)}
|
||||
<span className="text-sm font-medium text-gray-200">{enemy.name}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
{fmt(enemy.hp)} / {fmt(enemy.maxHP)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* HP bar */}
|
||||
<div className="relative">
|
||||
<Progress
|
||||
value={hpPercent}
|
||||
className="h-2 bg-gray-700"
|
||||
style={{ '--progress-bg': elemDef?.color || '#EF4444' } as React.CSSProperties}
|
||||
/>
|
||||
{hasBarrier && (
|
||||
<div
|
||||
className="absolute top-0 left-0 h-2 rounded-full bg-blue-400/40"
|
||||
style={{ width: `${barrierPercent}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Enemy stats */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{enemy.armor > 0 && (
|
||||
<Badge variant="outline" className="text-[10px] border-amber-600 text-amber-400">
|
||||
⛰️ {Math.round(enemy.armor * 100)}% armor
|
||||
</Badge>
|
||||
)}
|
||||
{enemy.dodgeChance > 0 && (
|
||||
<Badge variant="outline" className="text-[10px] border-green-600 text-green-400">
|
||||
💨 {Math.round(enemy.dodgeChance * 100)}% dodge
|
||||
</Badge>
|
||||
)}
|
||||
{hasBarrier && (
|
||||
<Badge variant="outline" className="text-[10px] border-blue-600 text-blue-400">
|
||||
🛡️ Barrier
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card className="bg-gray-900/80 border-green-800/40">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2" style={{ color: '#10B981' }}>
|
||||
💚 Recovery Room
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-gray-400 mb-2">
|
||||
Rest and recover. Spend 1 hour to gain 5x mana regen & conversion rates.
|
||||
</p>
|
||||
<Progress
|
||||
value={(progress / required) * 100}
|
||||
className="h-2 bg-gray-800"
|
||||
style={{ '--progress-bg': '#10B981' } as React.CSSProperties}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (rt === 'library') {
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-indigo-800/40">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2" style={{ color: '#6366F1' }}>
|
||||
📚 Ancient Library
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-gray-400">
|
||||
Study a random discipline at 10x XP speed (no mana cost). Spend 1 hour to gain knowledge.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (rt === 'treasure') {
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-amber-800/40">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2" style={{ color: '#F59E0B' }}>
|
||||
💎 Treasure Room
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-gray-400">
|
||||
A hidden cache of resources awaits. Claim your reward!
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (floorState.roomType === 'puzzle') {
|
||||
const puzzleId = floorState.puzzleId || 'unknown';
|
||||
const progress = floorState.puzzleProgress || 0;
|
||||
const required = floorState.puzzleRequired || 1;
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-purple-800/40">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2" style={{ color: '#8B5CF6' }}>
|
||||
🧩 Puzzle Room — {puzzleId.replace(/_/g, ' ')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-gray-400 mb-2">
|
||||
Solve the puzzle. Higher attunement levels speed up progress.
|
||||
</p>
|
||||
<Progress
|
||||
value={(progress / required) * 100}
|
||||
className="h-2 bg-gray-800"
|
||||
style={{ '--progress-bg': '#8B5CF6' } as React.CSSProperties}
|
||||
/>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Progress: {Math.round(progress * 100)} / {Math.round(required * 100)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Combat rooms (combat, swarm, speed, guardian)
|
||||
const enemies = floorState.enemies || [];
|
||||
const isGuardian = floorState.roomType === 'guardian';
|
||||
|
||||
return (
|
||||
<Card className={`bg-gray-900/80 ${isGuardian ? 'border-red-800/40' : 'border-gray-700'}`}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2" style={{ color: roomDisplay.color }}>
|
||||
{roomDisplay.icon} {roomDisplay.label}
|
||||
{isGuardian && <Badge className="bg-red-900/50 text-red-300 text-xs">BOSS</Badge>}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{enemies.length === 0 ? (
|
||||
<div className="text-xs text-gray-500 italic">Room cleared!</div>
|
||||
) : (
|
||||
enemies.map((enemy) => (
|
||||
<EnemyRow key={enemy.id} enemy={enemy} floor={floor} />
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-gray-400">📜 Activity Log</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-48">
|
||||
{entries.length === 0 ? (
|
||||
<div className="text-xs text-gray-500 italic">No activity yet.</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="text-xs text-gray-300 border-b border-gray-800 pb-1 last:border-0"
|
||||
>
|
||||
<span className="text-gray-600 mr-1">
|
||||
[{entry.eventType}]
|
||||
</span>
|
||||
{entry.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
{/* Active Spell Panel */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-amber-400">🔮 Active Spells</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{/* Cast progress */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="text-gray-400">Cast Progress</span>
|
||||
<span className="text-gray-500">{Math.round(castProgress * 100)}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={castProgress * 100}
|
||||
className="h-2 bg-gray-800"
|
||||
style={{ '--progress-bg': '#F59E0B' } as React.CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Spell selection */}
|
||||
<div className="grid grid-cols-1 gap-1.5 max-h-48 overflow-y-auto">
|
||||
{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 (
|
||||
<button
|
||||
key={spellId}
|
||||
onClick={() => setSpell(spellId)}
|
||||
className={`flex items-center justify-between p-2 rounded text-xs transition-colors ${
|
||||
isActive
|
||||
? 'bg-amber-900/40 border border-amber-600'
|
||||
: canCast
|
||||
? 'bg-gray-800/50 border border-gray-700 hover:border-gray-500'
|
||||
: 'bg-gray-800/30 border border-gray-800 opacity-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{elemDef && <span>{elemDef.sym}</span>}
|
||||
<span className={isActive ? 'text-amber-300 font-medium' : 'text-gray-300'}>
|
||||
{def.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-gray-500">
|
||||
{formatSpellCost(def.cost)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{learnedSpells.length === 0 && (
|
||||
<div className="text-xs text-gray-500 italic">No spells learned yet.</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Golem Status Panel */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-blue-400">🗿 Golems</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{summonedGolems.length === 0 ? (
|
||||
<div className="text-xs text-gray-500 italic">No golems summoned.</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{summonedGolems.map((sg) => {
|
||||
const golemDef = GOLEMS_DEF[sg.golemId];
|
||||
if (!golemDef) return null;
|
||||
const elemColor = ELEMENTS[golemDef.baseManaType]?.color || '#888';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={sg.golemId}
|
||||
className="flex items-center justify-between p-2 bg-gray-800/50 rounded border border-gray-700/50"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span style={{ color: elemColor }}>●</span>
|
||||
<span className="text-xs text-gray-200">{golemDef.name}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{golemDef.damage} dmg · {golemDef.attackSpeed}/h
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-center min-h-screen text-gray-500">
|
||||
Loading spire...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 flex flex-col">
|
||||
{/* Compact header */}
|
||||
<header className="sticky top-0 z-50 bg-gray-900/95 border-b border-gray-800 px-4 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-bold text-amber-400 tracking-wider">🏔️ SPIRE</h1>
|
||||
<div className="text-xs text-gray-500">
|
||||
Floor {currentFloor} · Insight: {fmt(insight)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 p-4 space-y-4 max-w-7xl mx-auto w-full">
|
||||
{/* Top section: Header + Mana */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div className="lg:col-span-2">
|
||||
<SpireHeader
|
||||
currentFloor={currentFloor}
|
||||
floorHP={floorHP}
|
||||
floorMaxHP={floorMaxHP}
|
||||
roomsCleared={roomsCleared}
|
||||
totalRooms={totalRooms}
|
||||
onClimbUp={handleClimbUp}
|
||||
onClimbDown={handleClimbDown}
|
||||
onExitSpire={handleExitSpire}
|
||||
isDescending={isDescending}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<SpireManaDisplay maxMana={maxMana} effectiveRegen={baseRegen} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle section: Room + Controls */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div className="lg:col-span-2">
|
||||
<RoomDisplay floorState={currentRoom} floor={currentFloor} />
|
||||
</div>
|
||||
<div>
|
||||
<SpireCombatControls castProgress={castProgress} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom: Activity Log */}
|
||||
<SpireActivityLog activityLog={activityLog} maxEntries={20} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardContent className="py-3 space-y-3">
|
||||
{/* Top row: Floor info + controls */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Mountain className="w-6 h-6 text-amber-400" />
|
||||
<div>
|
||||
<div className="text-xl font-bold text-amber-400">
|
||||
Floor {currentFloor}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
Max: {maxFloorReached} · Insight: {fmt(insight)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onClimbUp}
|
||||
disabled={isDescending}
|
||||
className="border-gray-600 hover:border-amber-500"
|
||||
>
|
||||
<ArrowUp className="w-4 h-4 mr-1" />
|
||||
Climb Up
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onClimbDown}
|
||||
disabled={currentFloor <= 1 || isDescending}
|
||||
className="border-gray-600 hover:border-amber-500"
|
||||
>
|
||||
<ArrowDown className="w-4 h-4 mr-1" />
|
||||
Climb Down
|
||||
</Button>
|
||||
{currentFloor === 1 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onExitSpire}
|
||||
className="border-green-600 text-green-400 hover:bg-green-900/30"
|
||||
>
|
||||
<LogOut className="w-4 h-4 mr-1" />
|
||||
Exit Spire
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floor HP bar */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className={isGuardian ? 'text-red-400 font-semibold' : 'text-gray-400'}>
|
||||
{isGuardian && guardian ? `🛡️ ${guardian.name}` : 'Floor HP'}
|
||||
</span>
|
||||
<span className="text-gray-500">
|
||||
{fmt(floorHP)} / {fmt(floorMaxHP)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={hpPercent}
|
||||
className="h-3 bg-gray-800"
|
||||
style={{
|
||||
'--progress-bg': isGuardian ? '#EF4444' : '#F59E0B',
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Room progress */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="text-gray-400">Rooms Cleared</span>
|
||||
<span className="text-gray-500">{roomsCleared} / {totalRooms}</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={roomProgress}
|
||||
className="h-2 bg-gray-800"
|
||||
style={{ '--progress-bg': '#6366F1' } as React.CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardContent className="py-3 space-y-2">
|
||||
{/* Raw Mana */}
|
||||
<div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-xl font-bold game-mono" style={{ color: '#60A5FA' }}>
|
||||
{fmt(rawMana)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">/ {fmt(maxMana)}</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-500">
|
||||
+{fmtDec(effectiveRegen)}/hr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Progress
|
||||
value={manaPercent}
|
||||
className="h-1.5 bg-gray-800"
|
||||
style={{ '--progress-bg': '#60A5FA' } as React.CSSProperties}
|
||||
/>
|
||||
|
||||
{/* Elemental pools (compact) */}
|
||||
{unlockedElements.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{unlockedElements.map(([id, state]) => {
|
||||
const elem = ELEMENTS[id];
|
||||
if (!elem) return null;
|
||||
const pct = state.max > 0 ? (state.current / state.max) * 100 : 0;
|
||||
return (
|
||||
<div key={id} className="text-center">
|
||||
<div className="text-[10px]" style={{ color: elem.color }}>
|
||||
{elem.sym} {fmt(state.current)}
|
||||
</div>
|
||||
<div className="h-1 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${pct}%`, backgroundColor: elem.color }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string, string[]> = {
|
||||
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<number, GuardianDef> = {
|
||||
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<number, GuardianDef> = {
|
||||
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;
|
||||
}
|
||||
@@ -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<EnemyModifier, { label: string; icon: string; color: string; desc: string }> = {
|
||||
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(', ');
|
||||
}
|
||||
@@ -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<string, { label: string; icon: string; color: string }> = {
|
||||
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' };
|
||||
}
|
||||
Reference in New Issue
Block a user