Files
Mana-Loop/docs/specs/spire-climbing-spec.md
T
n8n-gitea 94a2b671b9
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m3s
docs: update spire-climbing-spec with non-combat room mechanics (recovery, treasure, library, puzzle)
2026-06-04 13:37:38 +02:00

27 KiB
Raw Blame History

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:

// 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)
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 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. spireKeystartFloor 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.