Files
Mana-Loop/docs/specs/spire-climbing-spec.md
T
n8n-gitea feae6b468d
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m22s
fix: update AGENTS.md and specs for incursion day, elemental matchups, golem count, and descent mechanics
2026-06-03 11:54:40 +02:00

17 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)

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

src/lib/game/utils/
  spire-utils.ts              — ensure getRoomsForFloor accepts a seed
  room-utils.ts               — add generateSpireRoomType(), library XP helper

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; restores a portion of current mana pool
treasure ~3% No enemies; grants loot / equipment drop
library ~3% No enemies; grants discipline XP scaled to current floor (see §4.8)
puzzle ~1 per 7 floors Attunement-based challenge; no combat

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 (or immediately for non-combat rooms). The player does not press a button.

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) trigger advanceRoomOrFloor() immediately on entry after applying their effect.

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 Library Rooms — Discipline XP

When a library room is entered:

onEnterLibraryRoom(floor):
  discipline = pickRandom(allActiveDisciplines)
  xpGrant = BASE_LIBRARY_XP × (1 + floor / 10)
  discipline.addXP(xpGrant)
  activityLog("Ancient tome studied — ${discipline.name} gained ${xpGrant} XP")
  advanceRoomOrFloor()
  • BASE_LIBRARY_XP is a tunable constant (suggested starting value: 50).
  • XP is granted to a random active discipline (one of the player's currently running disciplines).
  • If no disciplines are active, XP is granted to a random unlocked discipline.
  • The grant scales linearly with floor: floor 10 = 2×, floor 20 = 3×, etc.

4.9 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 tome"
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

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 XP — grant scales with floor; targets an active discipline.
  7. 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

  • Loot generation inside treasure rooms (drop tables are a separate system).
  • 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 rooms grant discipline XP scaled by floor to a random active discipline and log the event.
AC-10 "Exit Spire" is only visible when isDescentComplete === true.
AC-11 Guardian rooms that reset on descent re-initialize full guardian defensive state.
AC-12 Activity log contains an entry for every room skip, reset, clear, floor transition, and spire entry/exit.