feat: implement spire descent system with room-aware navigation
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s
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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user