Files
Mana-Loop/docs/specs/spire-climbing-spec.md
T
n8n-gitea 64c1d2f51e
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s
fix: spire climbing spec discrepancies (DISC-1,14,15,18,19,21,22,23,29,34)
- DISC-1: Fix ascent seed missing +runId in combat-descent-actions.ts
- DISC-14: Recovery room 10x regen/conversion already in gameStore.ts
- DISC-15/18/21: Add missing completion logs for recovery, library, treasure rooms
- DISC-19: Filter library discipline selection to non-paused disciplines
- DISC-22: Fix puzzle room log format to match spec
- DISC-23: Replace hardcoded MAX_LEVEL with MAX_ATTUNEMENT_LEVEL
- DISC-29: Update spec to document libraryStayed/recoveryStayed on currentRoom
- DISC-34: Add 'Exited the Spire' activity log in exitSpireMode
2026-06-08 20:36:42 +02:00

682 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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: 517
```
- Guardian floors (every 10th): exactly **1 room**.
- All other floors: **517 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% | 37 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 215 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 110: 23 items
// - Floors 1050: 47 items
// - Floors 50+: 815 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 215 items scaling with floor, loot logged, skip button works.
9. Puzzle room — base time scales with floor (424h), 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 215 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. |