439 lines
17 KiB
Markdown
439 lines
17 KiB
Markdown
# 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: 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; 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`:
|
||
|
||
```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
|
||
```
|
||
|
||
> `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. `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
|
||
|
||
- 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. | |