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
+36 -24
View File
@@ -7,11 +7,17 @@ import { createSafeStorage } from '../utils/safe-persist';
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType } from '../types';
import { getFloorMaxHP } from '../utils';
import { generateFloorState } from '../utils/room-utils';
import { generateSpireFloorState } from '../utils/spire-utils';
import { addActivityLogEntry } from '../utils/activity-log';
import { processCombatTick, makeInitialSpells } from './combat-actions';
import { getGuardianForFloor } from '../data/guardian-encounters';
import type { CombatStore } from './combat-state.types';
import {
enterDescentMode,
advanceRoomOrFloor,
onEnterRoomDescend,
onEnterLibraryRoom,
createEnterSpireMode,
} from './combat-descent-actions';
export const useCombatStore = create<CombatStore>()(
persist(
@@ -33,6 +39,18 @@ export const useCombatStore = create<CombatStore>()(
climbDirection: null,
isDescending: false,
// ─── Spec: Room navigation state ──────────────────────────────────────
startFloor: 1,
exitFloor: 1,
currentRoomIndex: 0,
roomsPerFloor: 1,
// ─── Spec: Descent tracking state ─────────────────────────────────────
descentPeak: null,
roomResetState: {},
clearedRooms: {},
isDescentComplete: false,
// Golemancy
golemancy: {
enabledGolems: [],
@@ -158,13 +176,18 @@ export const useCombatStore = create<CombatStore>()(
currentAction: 'meditate',
climbDirection: null,
isDescending: false,
currentFloor: 1,
floorHP: getFloorMaxHP(1),
floorMaxHP: getFloorMaxHP(1),
currentRoom: generateFloorState(1),
currentFloor: s.exitFloor,
floorHP: getFloorMaxHP(s.exitFloor),
floorMaxHP: getFloorMaxHP(s.exitFloor),
currentRoom: generateFloorState(s.exitFloor),
castProgress: 0,
clearedFloors: {},
// Preserve maxFloorReached — don't reset to 0 on spire exit (fix #238)
clearedRooms: {},
roomResetState: {},
descentPeak: null,
isDescentComplete: false,
currentRoomIndex: 0,
roomsPerFloor: 1,
maxFloorReached: Math.max(s.maxFloorReached, 1),
}));
},
@@ -174,17 +197,21 @@ export const useCombatStore = create<CombatStore>()(
startClimbDown: () => set({ climbDirection: 'down', currentAction: 'climb' }),
startPracticing: () => set((s) => {
// Only override if the current action is 'meditate' — don't clobber climb/study/etc.
if (s.currentAction !== 'meditate') return s;
return { currentAction: 'practicing' };
}),
stopPracticing: () => set((s) => {
// Only restore to meditate if we're currently practicing (don't clobber other actions)
if (s.currentAction !== 'practicing') return s;
return { currentAction: 'meditate' };
}),
// ─── Spec: Descent actions (delegated to combat-descent-actions.ts) ────
enterDescentMode: () => enterDescentMode(get, set),
advanceRoomOrFloor: () => advanceRoomOrFloor(get, set),
onEnterRoomDescend: () => onEnterRoomDescend(get, set),
onEnterLibraryRoom: () => onEnterLibraryRoom(get, set),
// Golemancy
toggleGolem: (golemId: string) => {
set((s) => {
@@ -207,22 +234,7 @@ export const useCombatStore = create<CombatStore>()(
}));
},
enterSpireMode: () => {
const freshRoom = generateSpireFloorState(1, 0, 1);
set((s) => ({
spireMode: true,
currentAction: 'climb',
currentFloor: 1,
floorHP: getFloorMaxHP(1),
floorMaxHP: getFloorMaxHP(1),
currentRoom: freshRoom,
castProgress: 0,
climbDirection: null,
isDescending: false,
clearedFloors: {},
// Don't inflate maxFloorReached — it should reflect actual progress (fix #238)
}));
},
enterSpireMode: createEnterSpireMode(get, set, generateFloorState),
learnSpell: (spellId: string) => {
set((state) => ({