diff --git a/docs/specs/spire-climbing-spec.md b/docs/specs/spire-climbing-spec.md index 5ecde49..a9a371a 100644 --- a/docs/specs/spire-climbing-spec.md +++ b/docs/specs/spire-climbing-spec.md @@ -45,6 +45,9 @@ that need modification are noted separately. | `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 @@ -89,10 +92,11 @@ src/lib/game/stores/ 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 - room-utils.ts — add generateSpireRoomType(), library XP helper + spire-utils.ts — ensure getRoomsForFloor accepts a seed; add generateTreasureLoot() + room-utils.ts — add generateSpireRoomType() src/components/game/tabs/ SpireCombatPage/ @@ -180,10 +184,10 @@ So across all rooms: ~40% of 10% = **~4% recovery**, ~30% of 10% = **~3% treasur | `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 | +| `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 @@ -191,8 +195,9 @@ 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. +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']: @@ -214,8 +219,11 @@ advanceRoomOrFloor() [direction = 'up']: resetCastProgress() ``` -Non-combat rooms (recovery, treasure, library, puzzle) trigger `advanceRoomOrFloor()` -immediately on entry after applying their effect. +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 @@ -292,26 +300,234 @@ Guardian rooms that reset on descent re-initialize the full guardian defensive s (shield pool, barrier %, health regen) as if the player is fighting the guardian for the first time. -### 4.8 Library Rooms — Discipline XP +### 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(allActiveDisciplines) - xpGrant = BASE_LIBRARY_XP × (1 + floor / 10) - discipline.addXP(xpGrant) - activityLog("Ancient tome studied — ${discipline.name} gained ${xpGrant} XP") - advanceRoomOrFloor() + 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 ``` -- `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. +**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. -### 4.9 Exiting the Spire +- 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` @@ -348,7 +564,16 @@ Every meaningful state change appends an entry to the spire activity log. Requir | 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"` | +| 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"` | @@ -374,6 +599,10 @@ descentPeak: { floor: number; roomIndex: number } | null roomResetState: Record // key = "floor:roomIndex" clearedRooms: Record // 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'`. @@ -399,8 +628,11 @@ isDescentComplete: boolean 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`. +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 @@ -412,7 +644,10 @@ isDescentComplete: boolean ## 9. Boundaries / Out of Scope -- Loot generation inside treasure rooms (drop tables are a separate system). +- 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). @@ -433,7 +668,13 @@ isDescentComplete: boolean | 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. | \ No newline at end of file +| 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. | \ No newline at end of file