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
+22 -9
View File
@@ -128,19 +128,27 @@ export function processCombatTick(
castProgress -= 1;
safetyCounter++;
// Check if floor is cleared
// Check if room/floor is cleared
if (floorHP <= 0) {
const guardian = getGuardianForFloor(currentFloor);
onFloorCleared(currentFloor, !!guardian);
currentFloor = Math.min(currentFloor + 1, 100);
floorMaxHP = getFloorMaxHP(currentFloor);
floorHP = floorMaxHP;
// ── Spec: room-aware advancement (climbing spec §4.4) ──────────────
// Instead of directly incrementing the floor, delegate to the store's
// advanceRoomOrFloor which handles room-by-room and floor transitions.
get().advanceRoomOrFloor();
// Re-read state after advancement
const newState = get();
currentFloor = newState.currentFloor;
floorMaxHP = newState.floorMaxHP;
floorHP = newState.floorHP;
castProgress = 0;
if (guardian) {
logMessages.push(`\u2694\ufe0f ${guardian.name} defeated!`);
} else if (currentFloor % 5 === 0) {
logMessages.push(`🏰 Floor ${currentFloor - 1} cleared!`);
logMessages.push(`\ud83c\udff0 Floor ${currentFloor - 1} cleared!`);
}
}
}
@@ -197,14 +205,19 @@ export function processCombatTick(
if (floorHP <= 0) {
const eGuardian = getGuardianForFloor(currentFloor);
onFloorCleared(currentFloor, !!eGuardian);
currentFloor = Math.min(currentFloor + 1, 100);
floorMaxHP = getFloorMaxHP(currentFloor);
floorHP = floorMaxHP;
// ── Spec: room-aware advancement ─────────────────────────────────
get().advanceRoomOrFloor();
const newState = get();
currentFloor = newState.currentFloor;
floorMaxHP = newState.floorMaxHP;
floorHP = newState.floorHP;
eCastProgress = 0;
if (eGuardian) {
logMessages.push(`\u2694\ufe0f ${eGuardian.name} defeated!`);
} else if (currentFloor % 5 === 0) {
logMessages.push(`🏰 Floor ${currentFloor - 1} cleared!`);
logMessages.push(`\ud83c\udff0 Floor ${currentFloor - 1} cleared!`);
}
break;
}