feat: Recreate Spire Combat Page — full spire climbing experience
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:
2026-05-20 09:28:05 +02:00
parent 1c7fc8c551
commit 7d56fc368f
17 changed files with 2004 additions and 5 deletions
@@ -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>
);
}