feat: implement spire descent system with room-aware navigation
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s

Implements the spec-driven spire multi-room climbing and descent system:
- Room navigation: currentRoomIndex, roomsPerFloor, startFloor, exitFloor
- Descent tracking: descentPeak, roomResetState, clearedRooms, isDescentComplete
- enterDescentMode: snapshots peak, sets climbDirection='down'
- advanceRoomOrFloor: room-by-room ascending/descending with floor transitions
- onEnterRoomDescend: per-room 50% reset check with auto-skip
- onEnterLibraryRoom: discipline XP scaled by floor
- Seeded PRNG for deterministic room counts and types
- UI: Descend button during ascent, Exit Spire only when isDescentComplete
- UI: Room X/Y display, room type badge, in-game time in RoomDisplay
- Extracted descent actions to combat-descent-actions.ts (file size limit)
- Updated tests for room-aware combat behavior

Spec: docs/specs/spire-climbing-spec.md §4.1-§4.9, §6
This commit is contained in:
2026-06-03 12:40:42 +02:00
parent feae6b468d
commit 1b4e5cf5ac
13 changed files with 638 additions and 126 deletions
@@ -12,6 +12,13 @@ import { DebugName } from '@/components/game/debug/debug-context';
interface RoomDisplayProps {
floorState: FloorState;
floor: number;
/** 0-indexed current room (spec §3) */
roomIndex?: number;
/** Total rooms on this floor (spec §3) */
totalRooms?: number;
/** Current in-game time for display (combat spec §10) */
day?: number;
hour?: number;
}
function EnemyRow({ enemy }: { enemy: EnemyState }) {
@@ -72,7 +79,7 @@ function EnemyRow({ enemy }: { enemy: EnemyState }) {
);
}
export function RoomDisplay({ floorState }: RoomDisplayProps) {
export function RoomDisplay({ floorState, floor, roomIndex, totalRooms, day, hour }: RoomDisplayProps) {
// Guard against null/undefined/stale floorState
if (!floorState || !floorState.roomType) {
return (
@@ -86,6 +93,17 @@ export function RoomDisplay({ floorState }: RoomDisplayProps) {
const roomDisplay = getSpireRoomTypeDisplay(floorState.roomType as RoomType);
// ─── Spec §3: Show "Room X / Y" ─────────────────────────────────────────
const showRoomIndex = roomIndex !== undefined && totalRooms !== undefined;
const roomLabel = showRoomIndex
? `Room ${roomIndex + 1} / ${totalRooms}`
: null;
// ─── Combat spec §10: In-game time display ───────────────────────────────
const timeLabel = day !== undefined && hour !== undefined
? `D${day} H${Math.floor(hour)}`
: null;
// Handle special room types (cast to string for extended types)
const rt = floorState.roomType as string;
@@ -97,6 +115,8 @@ export function RoomDisplay({ floorState }: RoomDisplayProps) {
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2" style={{ color: '#10B981' }}>
💚 Recovery Room
{roomLabel && <Badge variant="outline" className="text-[10px] border-green-600 text-green-400">{roomLabel}</Badge>}
{timeLabel && <span className="text-[10px] text-gray-500 ml-auto">{timeLabel}</span>}
</CardTitle>
</CardHeader>
<CardContent>
@@ -119,6 +139,8 @@ export function RoomDisplay({ floorState }: RoomDisplayProps) {
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2" style={{ color: '#6366F1' }}>
📚 Ancient Library
{roomLabel && <Badge variant="outline" className="text-[10px] border-indigo-600 text-indigo-400">{roomLabel}</Badge>}
{timeLabel && <span className="text-[10px] text-gray-500 ml-auto">{timeLabel}</span>}
</CardTitle>
</CardHeader>
<CardContent>
@@ -136,6 +158,8 @@ export function RoomDisplay({ floorState }: RoomDisplayProps) {
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2" style={{ color: '#F59E0B' }}>
💎 Treasure Room
{roomLabel && <Badge variant="outline" className="text-[10px] border-amber-600 text-amber-400">{roomLabel}</Badge>}
{timeLabel && <span className="text-[10px] text-gray-500 ml-auto">{timeLabel}</span>}
</CardTitle>
</CardHeader>
<CardContent>
@@ -156,6 +180,8 @@ export function RoomDisplay({ floorState }: RoomDisplayProps) {
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2" style={{ color: '#8B5CF6' }}>
🧩 Puzzle Room {puzzleId.replace(/_/g, ' ')}
{roomLabel && <Badge variant="outline" className="text-[10px] border-purple-600 text-purple-400">{roomLabel}</Badge>}
{timeLabel && <span className="text-[10px] text-gray-500 ml-auto">{timeLabel}</span>}
</CardTitle>
</CardHeader>
<CardContent>
@@ -186,6 +212,16 @@ export function RoomDisplay({ floorState }: RoomDisplayProps) {
<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>}
{/* ── Spec §3: Room type badge + room index ── */}
{roomLabel && (
<Badge variant="outline" className="text-[10px] border-gray-600 text-gray-400">
{roomLabel}
</Badge>
)}
{/* ── Combat spec §10: In-game time ── */}
{timeLabel && (
<span className="text-[10px] text-gray-500 ml-auto">{timeLabel}</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
@@ -2,7 +2,7 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useCombatStore, useManaStore, usePrestigeStore, fmt, computeMaxMana, computeRegen } from '@/lib/game/stores';
import { useCombatStore, useManaStore, usePrestigeStore, useGameStore, fmt, computeMaxMana, computeRegen } from '@/lib/game/stores';
import { computeDisciplineEffects } from '@/lib/game/effects/discipline-effects';
import { getUnifiedEffects } from '@/lib/game/effects';
import { useCraftingStore } from '@/lib/game/stores/craftingStore';
@@ -56,6 +56,7 @@ function useSpireStats(prestigeUpgrades: Record<string, number>, equippedInstanc
export function SpireCombatPage() {
const [roomsCleared, setRoomsCleared] = useState(0);
// ─── Spec: read room-aware state from combat store ───────────────────────
const {
currentFloor,
floorHP,
@@ -64,6 +65,11 @@ export function SpireCombatPage() {
isDescending,
currentRoom,
activityLog,
climbDirection,
currentRoomIndex,
roomsPerFloor,
isDescentComplete,
startFloor,
setCurrentRoom,
setFloorHP,
setClearedFloor,
@@ -81,6 +87,11 @@ export function SpireCombatPage() {
isDescending: s.isDescending,
currentRoom: s.currentRoom,
activityLog: s.activityLog,
climbDirection: s.climbDirection,
currentRoomIndex: s.currentRoomIndex,
roomsPerFloor: s.roomsPerFloor,
isDescentComplete: s.isDescentComplete,
startFloor: s.startFloor,
setCurrentRoom: s.setCurrentRoom,
setFloorHP: s.setFloorHP,
setClearedFloor: s.setClearedFloor,
@@ -107,6 +118,10 @@ export function SpireCombatPage() {
equipmentInstances: s.equipmentInstances,
})));
// ─── Combat spec §10: read current in-game time ──────────────────────────
const day = useGameStore((s) => s.day);
const hour = useGameStore((s) => s.hour);
const { maxMana, baseRegen } = useSpireStats(prestigeUpgrades, equippedInstances, equipmentInstances);
// Use a deterministic seed based on floor to avoid Math.random() causing
@@ -129,8 +144,6 @@ export function SpireCombatPage() {
// Generate initial room when floor or room count changes.
// Uses a ref guard to prevent infinite re-render loops.
// Generate room on mount and when floor changes.
// Uses a ref guard to prevent infinite re-render loops.
const lastGeneratedRef = useRef<string | null>(null);
useEffect(() => {
const key = `${currentFloor}:${totalRooms}`;
@@ -236,7 +249,14 @@ export function SpireCombatPage() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="lg:col-span-2">
<RoomDisplay floorState={currentRoom} floor={currentFloor} />
<RoomDisplay
floorState={currentRoom}
floor={currentFloor}
roomIndex={currentRoomIndex}
totalRooms={roomsPerFloor}
day={day}
hour={hour}
/>
</div>
<div>
<SpireCombatControls castProgress={castProgress} />
@@ -5,7 +5,7 @@ import { useShallow } from 'zustand/react/shallow';
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 { Mountain, ArrowUp, ArrowDown, LogOut, ChevronDown } from 'lucide-react';
import { getGuardianForFloor, isGuardianFloor } from '@/lib/game/data/guardian-encounters';
import { DebugName } from '@/components/game/debug/debug-context';
@@ -34,11 +34,16 @@ export function SpireHeader({
}: SpireHeaderProps) {
const maxFloorReached = useCombatStore((s) => s.maxFloorReached);
const insight = usePrestigeStore((s) => s.insight);
// ─── Spec: read climbDirection and isDescentComplete ─────────────────────
const climbDirection = useCombatStore((s) => s.climbDirection);
const isDescentComplete = useCombatStore((s) => s.isDescentComplete);
const enterDescentMode = useCombatStore((s) => s.enterDescentMode);
const guardian = getGuardianForFloor(currentFloor);
const isGuardian = isGuardianFloor(currentFloor);
const hpPercent = floorMaxHP > 0 ? (floorHP / floorMaxHP) * 100 : 100;
const roomProgress = totalRooms > 0 ? ((roomsCleared) / totalRooms) * 100 : 0;
const isAscending = climbDirection === 'up';
return (
<DebugName name="SpireHeader">
@@ -59,27 +64,47 @@ export function SpireHeader({
</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 && (
{/* ── Spec §4.5: "Descend" button available at any point during ascent ── */}
{isAscending && (
<Button
size="sm"
variant="outline"
onClick={enterDescentMode}
className="border-amber-600 text-amber-400 hover:bg-amber-900/30"
>
<ChevronDown className="w-4 h-4 mr-1" />
Descend
</Button>
)}
{/* Legacy climb buttons (kept for non-spire-mode compatibility) */}
{!climbDirection && (
<>
<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>
</>
)}
{/* ── Spec §4.9: "Exit Spire" only visible when isDescentComplete ── */}
{isDescentComplete && (
<Button
size="sm"
variant="outline"