# 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<string, boolean>   // key = "floor:roomIndex"
clearedRooms: Record<string, boolean>     // key = "floor:roomIndex"
isDescentComplete: boolean

// Non-combat room tracking (climbing spec §4.8–§4.12)
// Note: libraryStayed and recoveryStayed live on the currentRoom object, not as
// top-level state fields. This keeps per-room transient state co-located.
libraryStayed: boolean          // on currentRoom; true if player already used "Stay 1 Hour More" in current library room
recoveryStayed: boolean         // on currentRoom; 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. |