# Spire Climbing System — Design Spec > Describes the full lifecycle of a spire run: entering, climbing room-by-room, > clearing floors, descending, and exiting. --- ## 1. Objective The Spire is the core progression loop of Mana Loop. The player enters at a starting floor determined by their `spireKey` prestige level, clears rooms by casting spells at enemies, advances floor by floor to ever-higher challenges, and must fully descend back to the exit floor before they can leave. **Design goals:** - Each floor is a multi-room dungeon with variable room counts. - The descent is a meaningful mini-game: the player re-traverses every room they climbed in reverse, with each individual room having a 50% independent chance to have reset its enemies. - Climbing rewards (insight, pacts, loot, discipline XP) are gated behind reaching high floors and signing pacts with guardians. --- ## 2. Controls / API ### 2.1 Player Actions | Action | Trigger | Effect | |---|---|---| | Enter Spire | UI button on Spire Summary tab | `enterSpireMode()` — init spire state | | Climb Up | automatic after room is cleared (ascending) | `advanceRoomOrFloor()` | | Start Descent | "Descend" button on the climb page | `enterDescentMode()` — snapshots peak, begins reverse traversal | | Exit Spire | "Exit" button (only at exit floor R0 during descent) | `exitSpireMode()` — reset to outside-spire state | ### 2.2 Game Commands (Store Actions) The following are the **necessary** new store actions. Actions already implemented that need modification are noted separately. | Command | Store | Description | |---|---|---| | `enterSpireMode()` | combatStore | Reset to starting floor R0, generate first room, enter spire mode | | `exitSpireMode()` | combatStore | Leave spire, reset all run state | | `enterDescentMode()` | combatStore | **NEW** — snapshot peak floor/room, set `climbDirection = 'down'` | | `advanceRoomOrFloor()` | combatStore | **NEW** — move to next room/floor (ascending) or previous room/floor (descending) | | `processCombatTick(...)` | combatStore | **MODIFY** — must become room-aware (see §4.4) | | `tickNonCombatRoom(hours)` | combatStore | **NEW** — tick non-combat room progress (library, recovery, treasure, puzzle) | | `skipNonCombatRoom()` | combatStore | **NEW** — skip to next room (library, recovery, treasure only) | | `stayLongerInRoom()` | combatStore | **NEW** — extend current room by 1 hour (library, recovery only, once per room) | > **Removed vs. original draft:** `skipClearedRoom`, `markFloorReset`, `setCurrentRoom`, > `setClearedFloor`, and `initGuardianDefensiveState` are **not needed as separate public > actions** — this logic lives inside `advanceRoomOrFloor()` and `processCombatTick()` > as private helpers. `addActivityLog` already exists. ### 2.3 State Transitions ``` outside-spire │ enterSpireMode() ▼ climbing-up (startFloor R0) │ room cleared → advanceRoomOrFloor() → next room │ last room on floor cleared → next floor, R0 │ player presses "Descend" ▼ descending (peak floor, peak room) │ room cleared or skipped → advanceRoomOrFloor() → prev room │ R0 of floor → prev floor, last room │ reach exit floor R0 ▼ descent complete — "Exit Spire" button shown │ exitSpireMode() ▼ outside-spire ``` --- ## 3. Project Layout Files to create or modify: ``` docs/specs/ spire-climbing-spec.md ← this file spire-combat-spec.md ← companion: spell damage, weapons, golems src/lib/game/stores/ combat-state.types.ts — add currentRoomIndex, roomsPerFloor, descentPeak, roomResetState, exitFloor fields combatStore.ts — add enterDescentMode(), advanceRoomOrFloor() combat-actions.ts — make processCombatTick room-aware combat-descent-actions.ts — add non-combat room handlers (recovery, treasure, library, puzzle) src/lib/game/utils/ spire-utils.ts — ensure getRoomsForFloor accepts a seed; add generateTreasureLoot() room-utils.ts — add generateSpireRoomType() src/components/game/tabs/ SpireCombatPage/ SpireCombatPage.tsx — wire room-cleared; add descent UI SpireHeader.tsx — "Descend" button on ascent; "Exit" button at exit floor R0 RoomDisplay.tsx — show "Room X / Y", room type badge, current game time SpireActivityLog.tsx — log all room/floor events ``` --- ## 4. Detailed Mechanics ### 4.1 Entering the Spire 1. Player presses "Enter Spire" on the Spire Summary tab. 2. `enterSpireMode()` runs: - `spireMode = true` - `currentAction = 'climb'` - `startFloor = 1 + (spireKey × 2)` — prestige upgrade; spireKey 0 → F1, spireKey 1 → F3, etc. - `exitFloor = startFloor` — the floor the player must reach on descent to be allowed to exit - `currentFloor = startFloor` - `currentRoomIndex = 0` - `roomsPerFloor = getRoomsForFloor(currentFloor, seed)` - `currentRoom = generateSpireFloorState(currentFloor, 0, roomsPerFloor)` - `clearedRooms = {}` — tracks which `floor:roomIndex` pairs have been cleared - `climbDirection = 'up'` - `descentPeak = null` - `roomResetState = {}` — per-room reset rolls, lazily populated on descent - activity log: `"Entered the Spire at Floor ${startFloor}"` ### 4.2 Room Count Per Floor ``` getRoomsForFloor(floor, seed): if isGuardianFloor(floor): return 1 base = 5 floorBonus = min(10, floor / 20) // slow scaling, max +10 randomVariation = floor(seededRandom(seed) * 3) // 0, 1, or 2 return base + floorBonus + randomVariation // range: 5–17 ``` - Guardian floors (every 10th): exactly **1 room**. - All other floors: **5–17 rooms**, scaling slowly with floor level. - Room count is **deterministic** per floor via seed so the same count is reproduced on descent. Seed = `floor × 12345 + runId`. ### 4.3 Room Types Generated by `generateSpireRoomType(floor, roomIndex, totalRooms)`. **Base roll (every room):** ``` roll = seededRandom(floor, roomIndex) if roll < 0.10: → rare roll (see below) elif roll < 0.22: → 'swarm' elif roll < 0.32: → 'speed' else: → 'combat' (~68% of rooms) ``` **Rare roll (~10% of rooms)** — secondary roll determines sub-type: ``` rareRoll = seededRandom(floor, roomIndex, 'rare') if rareRoll < 0.40: → 'recovery' elif rareRoll < 0.70: → 'treasure' else: → 'library' ``` So across all rooms: ~40% of 10% = **~4% recovery**, ~30% of 10% = **~3% treasure**, ~30% of 10% = **~3% library**. **Override rules (applied after base roll):** - Last room on a guardian floor → always `'guardian'` - Every 7th floor, one room (chosen by seed) → always `'puzzle'` **Room type summary:** | Type | Approx. Frequency | Description | |---|---|---| | `combat` | ~68% | Single enemy, normal stats | | `swarm` | ~12% | 3–7 weak enemies | | `speed` | ~10% | Single enemy with elevated dodge chance | | `guardian` | Every 10th floor, 1 room | Boss — high HP, shield, barrier, health regen | | `recovery` | ~4% | No enemies; 1 hour; grants 10× mana regen & conversion rates for all unlocked mana types (see §4.8) | | `treasure` | ~3% | No enemies; 1 hour; grants 2–15 random items (mostly fabricator materials, rarely pre-crafted gear), scaling with floor (see §4.9) | | `library` | ~3% | No enemies; 1 hour; grants discipline XP at 25× normal rate to a random unlocked discipline (see §4.10) | | `puzzle` | ~1 per 7 floors | Attunement-based challenge; up to 24 hours base time, reduced by attunement levels (see §4.11) | **Speed room interaction:** A `speed` room combined with an enemy that also has the `agile` modifier results in an **additive dodge bonus** on top of the agile modifier value. See combat spec §2.3 for modifier details. ### 4.4 Ascending — Room and Floor Advancement Rooms advance **automatically** when all enemies in the current room reach 0 HP. Non-combat rooms advance when their timed progression completes (or when the player presses "Skip"). The player does not press a button for combat rooms. ``` advanceRoomOrFloor() [direction = 'up']: markRoomCleared(currentFloor, currentRoomIndex) activityLog("Room ${currentRoomIndex + 1}/${roomsPerFloor} cleared") if currentRoomIndex + 1 >= roomsPerFloor: // Last room on this floor activityLog("Floor ${currentFloor} cleared — ascending") newFloor = min(currentFloor + 1, FLOOR_CAP) currentFloor = newFloor currentRoomIndex = 0 roomsPerFloor = getRoomsForFloor(newFloor, seed) currentRoom = generateSpireFloorState(newFloor, 0, roomsPerFloor) resetCastProgress() else: currentRoomIndex += 1 currentRoom = generateSpireFloorState(currentFloor, currentRoomIndex, roomsPerFloor) resetCastProgress() ``` Non-combat rooms (recovery, treasure, library, puzzle) initialize timed progression on entry. When progress reaches the required amount, `advanceRoomOrFloor()` is called automatically. The player can press "Skip" to advance immediately (library, recovery, treasure) or press "Stay 1 Hour More" (library, recovery only) to extend the time. Puzzle rooms are mandatory — no skip or stay buttons. ### 4.5 Descent Initiation The "Descend" button is available at any point during ascent. Pressing it: ``` enterDescentMode(): descentPeak = { floor: currentFloor, roomIndex: currentRoomIndex } climbDirection = 'down' activityLog("Beginning descent from Floor ${currentFloor}, Room ${currentRoomIndex + 1}") // Start descending from the current room (player re-fights or skips it) onEnterRoomDescend() ``` ### 4.6 Descending — Reverse Traversal On descent, rooms are visited in **strict reverse order**: within a floor, rooms count down from the highest index back to 0. When room 0 is cleared or skipped, the player moves down to the previous floor at its **highest** room index. ``` advanceRoomOrFloor() [direction = 'down']: activityLog("Room ${currentRoomIndex + 1} passed") if currentFloor <= exitFloor && currentRoomIndex <= 0: // Reached the exit point isDescentComplete = true activityLog("Descent complete — Exit Spire is now available") return if currentRoomIndex <= 0: // Move down to previous floor, enter at its last room currentFloor -= 1 roomsPerFloor = getRoomsForFloor(currentFloor, seed) currentRoomIndex = roomsPerFloor - 1 activityLog("Descended to Floor ${currentFloor}") else: currentRoomIndex -= 1 currentRoom = generateSpireFloorState(currentFloor, currentRoomIndex, roomsPerFloor) resetCastProgress() onEnterRoomDescend() ``` ### 4.7 Per-Room Reset on Descent Each room is checked **independently** when the player enters it during descent. Floors do not share a single reset roll — every room rolls on its own. ``` onEnterRoomDescend(): key = `${currentFloor}:${currentRoomIndex}` if roomResetState[key] is undefined: roomResetState[key] = (Math.random() < 0.5) if !wasRoomCleared(currentFloor, currentRoomIndex): // Room was never cleared on the way up — must fight it now activityLog("Room ${currentRoomIndex + 1} on Floor ${currentFloor} was not cleared — enemies present") // enemies already in currentRoom from generation, no change needed return if roomResetState[key] === true: // Room reset — re-generate enemies currentRoom = generateSpireFloorState(currentFloor, currentRoomIndex, roomsPerFloor) activityLog("Room ${currentRoomIndex + 1} on Floor ${currentFloor} has reset — enemies respawned") else: // Room did not reset — auto-skip activityLog("Room ${currentRoomIndex + 1} on Floor ${currentFloor} is clear — moving on") advanceRoomOrFloor() // immediately continue ``` Guardian rooms that reset on descent re-initialize the full guardian defensive state (shield pool, barrier %, health regen) as if the player is fighting the guardian for the first time. ### 4.8 Recovery Rooms — Boosted Mana Regen & Conversion When a `recovery` room is entered: ``` onEnterRecoveryRoom(floor): recoveryProgress = 0 recoveryRequired = 1 // 1 hour recoveryStayed = false activityLog("Entered recovery room on Floor ${floor}") // Do NOT call advanceRoomOrFloor() — wait for progress to complete ``` **Effect:** While in the recovery room, the player receives a **10× multiplier** to: - **Mana regeneration rate** for all unlocked mana types (e.g., 1 raw/hour → 10 raw/hour) - **Mana conversion efficiency** for all unlocked mana types (e.g., 10 raw → 1 transference/hour becomes 10 raw → 10 transference/hour) The multiplier is applied through the mana store for the duration of the room. **UI:** - Progress bar showing time elapsed / 1 hour - Thematic text: *"Resting and recovering in a mana-rich chamber"* - **"Stay 1 Hour More" button** (once only) — adds 1 more hour to `recoveryRequired`, disabled after use - **"Skip" button** — calls `advanceRoomOrFloor()` immediately **Activity log events:** - `"Entered recovery room on Floor {N}"` - `"Recovery complete — mana regen and conversion boosted"` ### 4.9 Treasure Rooms — Loot When a `treasure` room is entered: ``` onEnterTreasureRoom(floor): treasureProgress = 0 treasureRequired = 1 // 1 hour treasureLoot = generateTreasureLoot(floor) treasureLootClaimed = [] activityLog("Entered treasure room on Floor ${floor}") // Do NOT call advanceRoomOrFloor() — wait for progress to complete ``` **Loot generation** (`generateTreasureLoot`): ``` generateTreasureLoot(floor): // 1. Determine item count based on floor: // - Floors 1–10: 2–3 items // - Floors 10–50: 4–7 items // - Floors 50+: 8–15 items // 2. For each item slot: // - 85%+ chance: fabricator material (from LOOT_DROPS, filtered by minFloor) // - ~15% chance: pre-crafted equipment (rare, higher floors only) // 3. Weight by dropChance; higher floors get access to better items // 4. Return array of LootDrop with amounts ``` **Loot delivery:** Items are granted progressively as the hour elapses: - At **10%** progress: first item(s) granted - At **50%** progress: mid-tier items granted - At **95%** progress: more items granted - At **100%** progress: final and best item(s) granted Each item is added to the player's loot inventory and logged in the activity log. **UI:** - Progress bar showing time elapsed / 1 hour - Thematic text: *"Rummaging through ancient chests and caches"* - **"Skip" button** — calls `advanceRoomOrFloor()` immediately (forfeits remaining loot) **Activity log events:** - `"Entered treasure room on Floor {N}"` - `"Found {itemName} x{amount}"` (for each item as it's granted) - `"Treasure room looted — {count} items recovered"` ### 4.10 Library Rooms — Discipline XP When a `library` room is entered: ``` onEnterLibraryRoom(floor): discipline = pickRandom(allUnlockedDisciplines) libraryProgress = 0 libraryRequired = 1 // 1 hour libraryStayed = false activityLog("Entered library room on Floor ${floor}") // Do NOT call advanceRoomOrFloor() — wait for progress to complete ``` **Effect:** While in the library room, the selected discipline gains XP at **25× the normal rate**. XP is granted continuously over the hour (not a lump sum). No mana cost. - Target discipline is chosen randomly from all **unlocked** disciplines (not just active ones). - If no disciplines are unlocked, nothing happens (edge case — player should always have at least one). **UI:** - Progress bar showing time elapsed / 1 hour - Thematic text: *"Studying Mana Circulation from ancient tomes"* - **"Stay 1 Hour More" button** (once only) — adds 1 more hour to `libraryRequired`, disabled after use - **"Skip" button** — calls `advanceRoomOrFloor()` immediately **Activity log events:** - `"Entered library room on Floor {N}"` - `"{Discipline} gained {XP} XP from ancient tomes"` (continuous, logged periodically) - `"Library study complete"` ### 4.11 Puzzle Rooms — Attunement Challenge When a `puzzle` room is entered: ``` onEnterPuzzleRoom(floor, puzzleId): puzzleProgress = 0 puzzleRequired = calcPuzzleTime(floor, puzzleId) activityLog("Entered puzzle room on Floor ${floor}") // Do NOT call advanceRoomOrFloor() — wait for progress to complete ``` **Base time calculation** (scales with floor): ``` calcPuzzleBaseTime(floor): if floor <= 20: return 4 // 4 hours if floor <= 50: return 8 // 8 hours if floor <= 100: return 16 // 16 hours return 24 // 24 hours max ``` **Attunement-based time reduction:** Each puzzle is associated with 1 or more attunements (defined in `PUZZLE_ROOMS`). The player's attunement levels reduce the required time: ``` calcPuzzleTime(floor, puzzleId): base = calcPuzzleBaseTime(floor) puzzle = PUZZLE_ROOMS[puzzleId] attunements = puzzle.attunements // e.g., ['enchanter'] or ['enchanter', 'invoker'] totalReduction = 0 for each attunementId in attunements: attLevel = getAttunementLevel(attunementId) maxLevel = getMaxAttunementLevel() // Each attunement contributes up to (1 / attunements.length) * 0.90 reduction share = 1 / attunements.length reduction = share * 0.90 * (attLevel / maxLevel) totalReduction += reduction return base * (1 - totalReduction) ``` **Examples:** - Single-attunement puzzle (enchanter trial), max enchanter level: `base × (1 - 0.90) = base × 0.10` (90% reduction) - Dual-attunement puzzle (enchanter + invoker), max both levels: `base × (1 - 0.45 - 0.45) = base × 0.10` (90% reduction) - Dual-attunement puzzle, max enchanter only: `base × (1 - 0.45) = base × 0.55` (45% reduction from enchanter, 0% from invoker) **UI:** - Progress bar showing time elapsed / total time - Thematic text based on puzzle type: - Enchanter puzzle: *"Deciphering an enchanted lock"* - Fabricator puzzle: *"Disassembling a mana-powered mechanism"* - Invoker puzzle: *"Communing with residual guardian spirits"* - Hybrid puzzle: *"Working through a complex attunement challenge"* - **No "Skip" or "Stay" buttons** — puzzle rooms are mandatory **Activity log events:** - `"Entered puzzle room on Floor {N} — {puzzleName}"` - `"Puzzle solved!"` ### 4.12 Non-Combat Room Tick Processing Every game tick, if the current room is non-combat: ``` tickNonCombatRoom(hours): room = currentRoom if room.roomType === 'library': room.libraryProgress += hours xpThisTick = calcDisciplineXPRate(discipline) × 25 × hours discipline.addXP(xpThisTick) if room.libraryProgress >= room.libraryRequired: advanceRoomOrFloor() else if room.roomType === 'recovery': room.recoveryProgress += hours // 10× regen/conversion is applied passively via mana store flags if room.recoveryProgress >= room.recoveryRequired: advanceRoomOrFloor() else if room.roomType === 'treasure': room.treasureProgress += hours // Check loot thresholds and grant items progressPct = room.treasureProgress / room.treasureRequired for each lootItem in room.treasureLoot: if not claimed and progressPct >= lootItem.threshold: grantLoot(lootItem) activityLog("Found ${lootItem.name}") if room.treasureProgress >= room.treasureRequired: advanceRoomOrFloor() else if room.roomType === 'puzzle': room.puzzleProgress += hours if room.puzzleProgress >= room.puzzleRequired: activityLog("Puzzle solved!") advanceRoomOrFloor() ``` **Player actions during non-combat rooms:** ``` skipNonCombatRoom(): // Only for library, recovery, treasure if currentRoom.roomType in ['library', 'recovery', 'treasure']: advanceRoomOrFloor() stayLongerInRoom(): // Only for library and recovery, once per room if currentRoom.roomType === 'library' and not libraryStayed: libraryRequired += 1 libraryStayed = true else if currentRoom.roomType === 'recovery' and not recoveryStayed: recoveryRequired += 1 recoveryStayed = true ``` ### 4.13 Exiting the Spire The "Exit Spire" button is visible **only** when: - `isDescentComplete === true` (Internally this means `currentFloor === exitFloor && currentRoomIndex === 0 && climbDirection === 'down'`.) ``` exitSpireMode(): spireMode = false currentAction = 'meditate' climbDirection = null descentPeak = null roomResetState = {} clearedRooms = {} currentFloor = exitFloor currentRoomIndex = 0 isDescentComplete = false activityLog("Exited the Spire") ``` --- ## 5. Activity Log Events Every meaningful state change appends an entry to the spire activity log. Required events: | Event | Message | |---|---| | Enter spire | `"Entered the Spire at Floor {N}"` | | Room cleared (combat) | `"Floor {N} Room {R}/{total} cleared"` | | Room skipped (no reset) | `"Floor {N} Room {R} is clear — moving on"` | | Room reset on descent | `"Floor {N} Room {R} has reset — enemies respawned"` | | Room not cleared on ascent | `"Floor {N} Room {R} was not cleared — enemies present"` | | Floor ascended | `"Ascending to Floor {N}"` | | Floor descended | `"Descended to Floor {N}"` | | Non-combat room entered | `"Entered {roomType} room on Floor {N}"` | | Library XP granted | `"{Discipline} gained {XP} XP from ancient tomes"` (continuous, logged periodically) | | Library study complete | `"Library study complete"` | | Recovery entered | `"Entered recovery room on Floor {N}"` | | Recovery complete | `"Recovery complete — mana regen and conversion boosted"` | | Treasure entered | `"Entered treasure room on Floor {N}"` | | Treasure item found | `"Found {itemName} x{amount}"` (per item as granted) | | Treasure room complete | `"Treasure room looted — {count} items recovered"` | | Puzzle entered | `"Entered puzzle room on Floor {N} — {puzzleName}"` | | Puzzle solved | `"Puzzle solved!"` | | Stay longer activated | `"Decided to stay longer in {roomType} room"` | | Descent initiated | `"Beginning descent from Floor {N} Room {R}"` | | Descent complete | `"Descent complete — Exit Spire is now available"` | | Exit spire | `"Exited the Spire"` | --- ## 6. State Fields Summary New and modified fields in `combat-state.types.ts`: ```typescript // Run identity startFloor: number // floor entered at (= 1 + spireKey × 2) exitFloor: number // floor player must reach to exit (= startFloor) // Room navigation currentRoomIndex: number // 0-indexed room within currentFloor roomsPerFloor: number // total rooms on currentFloor (deterministic) // Descent tracking climbDirection: 'up' | 'down' | null descentPeak: { floor: number; roomIndex: number } | null roomResetState: Record // key = "floor:roomIndex" clearedRooms: Record // key = "floor:roomIndex" isDescentComplete: boolean // Non-combat room tracking (climbing spec §4.8–§4.12) libraryStayed: boolean // true if player already used "Stay 1 Hour More" in current library room recoveryStayed: boolean // true if player already used "Stay 1 Hour More" in current recovery room ``` > `isDescending: boolean` (legacy alias) can be removed in favour of `climbDirection === 'down'`. --- ## 7. Code Style Notes - Room count uses the same deterministic seed on descent as ascent: `seed = floor × 12345 + runId`. - `roomResetState` and `clearedRooms` use composite string keys (`"floor:roomIndex"`) to avoid nested object complexity. - Descent-related state is **not persisted** — a page reload mid-descent forfeits the run. - All activity log calls go through the existing `addActivityLog(type, msg, details)` action. --- ## 8. Testing ### Unit Tests 1. `getRoomsForFloor` — same output for same (floor, seed); returns 1 for guardian floors. 2. `generateSpireRoomType` — rare roll produces recovery/treasure/library at correct ratios; guardian floor override works; puzzle floor override works. 3. `advanceRoomOrFloor` ascending — increments roomIndex; on last room, increments floor and resets roomIndex to 0. 4. `advanceRoomOrFloor` descending — decrements roomIndex; at roomIndex 0, moves to previous floor at `roomsPerFloor - 1`; at exitFloor R0, sets `isDescentComplete`. 5. Per-room reset — each room rolls independently; two rooms on the same floor can have different outcomes. 6. Library room — takes 1 hour, grants 25× XP to random unlocked discipline, stay button works once, skip button works. 7. Recovery room — takes 1 hour, grants 10× regen/conversion, stay button works once, skip button works. 8. Treasure room — takes 1 hour, grants 2–15 items scaling with floor, loot logged, skip button works. 9. Puzzle room — base time scales with floor (4–24h), attunement reduction up to 90%, mandatory (no skip/stay). 10. `spireKey` — `startFloor` and `exitFloor` correctly reflect `1 + spireKey × 2`. ### Integration Tests 1. Full ascent then descent — player reaches F3 R4, starts descent, verifies F3 R4→R3→R2→R1→R0, then F2 last_room→...→R0, then F1 last_room→...→R0 (if startFloor = F1). 2. Per-room reset independence — mock random so room 0 resets and room 1 does not on the same floor. 3. Exit gating — "Exit Spire" not visible until `isDescentComplete` is true. --- ## 9. Boundaries / Out of Scope - Visual animations for loot drops or room transitions. - Sound effects. - New loot drop definitions (use existing `LOOT_DROPS` data). - New puzzle definitions (use existing `PUZZLE_ROOMS` data). - Golem summoning lifecycle (see combat spec §6). - DoT / debuff runtime processing (see combat spec §5). - Incursion's effect on mana regen during spire (handled in manaStore, not here). - Auto-climb / auto-descend automation. - Per-floor rewards (insight, mana drops) — handled by `onFloorCleared` in combat-tick. --- ## 10. Acceptance Criteria | # | Criterion | |---|---| | AC-1 | `spireKey 0` starts at F1; `spireKey 1` starts at F3; `spireKey 2` starts at F5. | | AC-2 | Entering spire starts at `startFloor` R0; rooms advance automatically on clear. | | AC-3 | Each room shows "Room X / Y" and the room type in the UI. | | AC-4 | After clearing last room on floor N, player moves to F(N+1) R0 with new room count. | | AC-5 | "Descend" button is available at any point during ascent. | | AC-6 | Descent traverses rooms in exact reverse (R_max → R0 per floor, then floor-1). | | AC-7 | Each room on descent rolls its reset independently (50%); two rooms on the same floor can differ. | | AC-8 | Skipped rooms (no reset) log an activity entry and auto-advance immediately. | | AC-9 | Library room takes 1 hour, grants 25× XP to a random unlocked discipline, has skip + stay buttons. | | AC-10 | Recovery room takes 1 hour, grants 10× mana regen and conversion rates for all unlocked types, has skip + stay buttons. | | AC-11 | Treasure room takes 1 hour, grants 2–15 items scaling with floor (mostly materials, rare equipment), loot listed in activity log, has skip button. | | AC-12 | Puzzle room takes up to 24 hours (floor-scaled), reduced by attunement levels (up to 90% reduction), no skip/stay buttons, mandatory completion. | | AC-13 | All non-combat rooms show a progress bar with thematic description text. | | AC-14 | "Stay 1 Hour More" button works once per library/recovery room, then disables. | | AC-15 | "Skip" button on library/recovery/treasure advances immediately. | | AC-16 | "Exit Spire" is only visible when `isDescentComplete === true`. | | AC-17 | Guardian rooms that reset on descent re-initialize full guardian defensive state. | | AC-18 | Activity log contains an entry for every room skip, reset, clear, floor transition, non-combat room event, and spire entry/exit. |