fix: update AGENTS.md and specs for incursion day, elemental matchups, golem count, and descent mechanics
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m22s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m22s
This commit is contained in:
@@ -114,7 +114,7 @@ ManaDrainPerTick = drainBase × (1 + (XP / difficultyFactor)^0.4)
|
||||
- Stacking cost: each additional stack costs 20% more
|
||||
|
||||
### Golemancy
|
||||
- 10 golems total: 1 base (Earth) + 3 elemental (Steel, Crystal, Sand) + 6 hybrid (Lava, Galvanic, Obsidian, Prism, Quicksilver, Voidstone)
|
||||
- 12 golems total: 1 base (Earth) + 3 elemental (Steel, Crystal, Sand) + 6 hybrid (Lava, Galvanic, Obsidian, Prism, Quicksilver, Voidstone) + 2 advanced
|
||||
- Golems slots: `floor(fabricatorLevel / 2)`, max 5 at level 10
|
||||
- Hybrid golems require Enchanter 5 + Fabricator 5
|
||||
|
||||
@@ -130,14 +130,17 @@ ManaDrainPerTick = drainBase × (1 + (XP / difficultyFactor)^0.4)
|
||||
### Combat
|
||||
- Cast-speed based: `castProgress += HOURS_PER_TICK × spellCastSpeed × attackSpeedMult`
|
||||
- Elemental bonuses: super effective (1.5×), same element (1.25×), weak (0.75×), neutral (1.0×)
|
||||
- Element opposites: fire↔water, air↔earth, light↔dark, lightning→earth
|
||||
- Element opposites (bidirectional): fire↔water, air↔earth, light↔dark, frost↔fire
|
||||
- Element counters (directional): lightning→water (lightning counters water), earth→lightning (earth counters lightning)
|
||||
- Composite element counters: blackflame counters frost/water/light (and they counter blackflame); radiantflames counters frost/water/dark (and they counter radiantflames)
|
||||
- All mana types double as spell elements
|
||||
- Enemy modifiers (max 2 per enemy): Armored, Agile, Mage, Shield, Swarm
|
||||
- Room types: Combat (default), Guardian (every 10th), Swarm (15%), Speed (10%), Puzzle (20% on every 7th floor)
|
||||
- Floor HP: `100 + floor × 50 + floor^1.7` for non-guardian floors
|
||||
|
||||
### Time & Incursion
|
||||
- `TICK_MS`: 200ms, `HOURS_PER_TICK`: 0.04, `MAX_DAY`: 30
|
||||
- Incursion starts day 5 (not day 20)
|
||||
- Incursion starts day 20
|
||||
- Incursion strength: `min(0.95, (totalHours / maxHours) × 0.95)`
|
||||
|
||||
### Prestige (Insight)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Circular Dependencies
|
||||
Generated: 2026-06-02T14:00:41.812Z
|
||||
Generated: 2026-06-02T14:39:46.904Z
|
||||
|
||||
No circular dependencies found. ✅
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"_meta": {
|
||||
"generated": "2026-06-02T14:00:40.018Z",
|
||||
"generated": "2026-06-02T14:39:44.895Z",
|
||||
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
|
||||
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
|
||||
},
|
||||
|
||||
@@ -11,6 +11,9 @@ Mana-Loop/
|
||||
│ ├── post-merge
|
||||
│ └── pre-commit
|
||||
├── docs/
|
||||
│ ├── specs/
|
||||
│ │ ├── spire-climbing-spec.md
|
||||
│ │ └── spire-combat-spec.md
|
||||
│ ├── GAME_BRIEFING.md
|
||||
│ ├── circular-deps.txt
|
||||
│ ├── dependency-graph.json
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
# 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. |
|
||||
@@ -0,0 +1,618 @@
|
||||
# Spire Combat System — Design Spec
|
||||
|
||||
> Describes how individual spire rooms are fought: weapons, spell autocasting,
|
||||
> mana costs, damage calculation, elemental matchups, armor, shields, barriers,
|
||||
> enemy modifiers, debuffs/DoT, golems, and the combat tick pipeline.
|
||||
|
||||
---
|
||||
|
||||
## 1. Objective
|
||||
|
||||
Spire combat is the micro-game fought in every combat room. The player does **not**
|
||||
manually trigger attacks — all weapons and golems fight automatically on their own
|
||||
timers. Early game this means one staff autocasting one spell; late game it can mean
|
||||
multiple weapons each on their own cast timer, plus golems attacking in parallel.
|
||||
|
||||
**Design goals:**
|
||||
- Combat is fully automatic once a room is entered. No input required.
|
||||
- Damage math is transparent and multiplicative: base × discipline × boon × element × crit.
|
||||
- Enemies have meaningful defensive variety via modifiers (armored, mage, shield, agile, swarm).
|
||||
- Guardian bosses have an additional layer of defense (shield pool, percentage barrier, health regen).
|
||||
- The player is **immortal** — no player HP, no armor, no healing, no lifesteal.
|
||||
- Room clearing is determined by total enemy HP reaching 0, which triggers advancement.
|
||||
|
||||
---
|
||||
|
||||
## 2. Combat Sources
|
||||
|
||||
There are three independent sources of damage, each running on its own timer:
|
||||
|
||||
| Source | Mana Cost | Attack Speed | Damage | Notes |
|
||||
|---|---|---|---|---|
|
||||
| **Staff / spells** | Yes — per cast | Determined by spell's `castSpeed` | Moderate–high; scales with enchantments | Can apply debuffs/DoT/special effects |
|
||||
| **Sword / melee** | None | Determined by weapon's `attackSpeed` stat | Lower than spells; fast | Elemental damage via enchantment; no mana drain |
|
||||
| **Golems** | Maintenance cost per tick (not per attack) | Per-golem `attackSpeed` | Variable by golem tier | See §6 |
|
||||
|
||||
### 2.1 Player Does Not Choose Spells
|
||||
|
||||
The player **does not select which spell to cast**. All spells granted by equipped
|
||||
weapons are autocast simultaneously, each on its own independent cast timer.
|
||||
|
||||
- **Early game:** One staff with one spell → one autocast timer.
|
||||
- **Late game:** Multiple weapons with multiple spells → multiple independent timers,
|
||||
all firing in parallel.
|
||||
- The late-game ability to manually prioritise or pin specific spells is a prestige/
|
||||
discipline unlock and is **out of scope for the initial implementation**.
|
||||
|
||||
### 2.2 Staves (Spell Weapons)
|
||||
|
||||
- Grant spells via `effect.type === 'spell'` enchantments.
|
||||
- Each equipped staff can carry one or more spell enchantments.
|
||||
- Each spell on a staff runs its own `castProgress` accumulator.
|
||||
- Casting a spell costs mana (raw or elemental, per the spell's `cost` definition).
|
||||
- If the player cannot afford a spell's cost, that spell's cast is held (progress
|
||||
does not reset) until mana is available.
|
||||
|
||||
### 2.3 Swords (Melee Weapons)
|
||||
|
||||
- Deal physical + optional elemental damage via `effect.type === 'bonus'` enchantments
|
||||
(e.g. `fireAttack`, `waterAttack` enchant types).
|
||||
- Cost **no mana** per swing.
|
||||
- Faster attack speed than spells but lower damage per hit.
|
||||
- Use the **same elemental matchup table** as spells (1.25× resonance, 1.5× super effective,
|
||||
0.75× weak — see §4.2).
|
||||
- Sword auto-attacks run on their own `meleeProgress` accumulator, independent of spells.
|
||||
|
||||
---
|
||||
|
||||
## 3. Combat Tick Pipeline
|
||||
|
||||
### 3.1 Tick Overview (every 200ms / `HOURS_PER_TICK = 0.04`)
|
||||
|
||||
```
|
||||
gameStore.tick()
|
||||
└─ if currentAction === 'climb':
|
||||
└─ processCombatTick(combatStore, ...)
|
||||
├─ for each equipped spell (each on own castProgress):
|
||||
│ ├─ castProgress += HOURS_PER_TICK × spell.castSpeed × attackSpeedMult
|
||||
│ └─ while castProgress >= 1 AND canAffordCost:
|
||||
│ ├─ deductSpellCost()
|
||||
│ ├─ calcDamage() → apply elemental + crit
|
||||
│ ├─ onDamageDealt(dmg) → specials + enemy defenses
|
||||
│ ├─ applySpellEffects() → debuffs / DoT (§5)
|
||||
│ └─ applyDamageToRoom(finalDmg)
|
||||
│
|
||||
├─ for each equipped sword (each on own meleeProgress):
|
||||
│ ├─ meleeProgress += HOURS_PER_TICK × sword.attackSpeed
|
||||
│ └─ while meleeProgress >= 1:
|
||||
│ ├─ calcMeleeDamage() → elemental matchup applied
|
||||
│ ├─ onDamageDealt(dmg) → enemy defenses (no specials for melee)
|
||||
│ └─ applyDamageToRoom(finalDmg)
|
||||
│
|
||||
├─ for each active golem (§6):
|
||||
│ ├─ golemProgress += HOURS_PER_TICK × golem.attackSpeed
|
||||
│ ├─ check maintenance cost (deduct or dismiss golem)
|
||||
│ └─ while golemProgress >= 1:
|
||||
│ ├─ calcGolemDamage()
|
||||
│ ├─ applyGolemEffects() → per-golem special effects
|
||||
│ └─ applyDamageToRoom(finalDmg)
|
||||
│
|
||||
├─ tick active DoT/debuff effects on enemies (§5.3)
|
||||
│
|
||||
└─ if allEnemyHP <= 0:
|
||||
onRoomCleared() → advanceRoomOrFloor()
|
||||
```
|
||||
|
||||
### 3.2 `applyDamageToRoom`
|
||||
|
||||
```
|
||||
applyDamageToRoom(dmg, targetEnemy?):
|
||||
if spell is AoE and targetEnemy is null:
|
||||
// distribute damage across all enemies
|
||||
for each enemy in room:
|
||||
enemy.hp = max(0, enemy.hp - dmg)
|
||||
else:
|
||||
target = targetEnemy ?? lowestHPEnemy()
|
||||
target.hp = max(0, target.hp - dmg)
|
||||
|
||||
if all enemies.hp === 0:
|
||||
onRoomCleared()
|
||||
```
|
||||
|
||||
> **Targeting:** Non-AoE attacks target the enemy with the lowest current HP by
|
||||
> default (focus-fire to clear rooms faster). This is implicit — no UI selection.
|
||||
|
||||
---
|
||||
|
||||
## 4. Damage Calculation
|
||||
|
||||
### 4.1 Spell Damage (`calcDamage` in `combat-utils.ts`)
|
||||
|
||||
```
|
||||
baseDmg = spell.baseDamage + disciplineEffects.baseDamageBonus
|
||||
pct = 1 + disciplineEffects.baseDamageMultiplier
|
||||
rawMult = 1 + boons.rawDamage / 100
|
||||
elemMult = 1 + boons.elementalDamage / 100
|
||||
critChance = boons.critChance / 100
|
||||
critMult = 1.5 + boons.critDamage / 100
|
||||
|
||||
damage = baseDmg × pct × rawMult × elemMult
|
||||
|
||||
if spell.elem !== 'raw':
|
||||
damage ×= getElementalBonus(spell.elem, enemy.element)
|
||||
|
||||
if Math.random() < critChance:
|
||||
damage ×= critMult
|
||||
```
|
||||
|
||||
### 4.2 Elemental Matchup (`getElementalBonus`)
|
||||
|
||||
Used by both spells and swords.
|
||||
|
||||
| Relationship | Multiplier |
|
||||
|---|---|
|
||||
| Spell/sword element === enemy element | 1.25× (resonance) |
|
||||
| Spell/sword element is the **counter** of enemy element | 1.5× (super effective) |
|
||||
| Enemy element is the **counter** of spell/sword element | 0.75× (weak) |
|
||||
| Raw element (no element) | 1.0× (neutral) |
|
||||
| All other combinations | 1.0× (neutral) |
|
||||
|
||||
Elemental counters (partial list):
|
||||
```
|
||||
fire ↔ water air ↔ earth light ↔ dark
|
||||
frost ↔ fire lightning → water earth → lightning
|
||||
```
|
||||
|
||||
Composite element counters:
|
||||
```
|
||||
blackflame counters: frost, water, light (frost/water/light also counter blackflame)
|
||||
radiantflames counters: frost, water, dark (frost/water/dark also counter radiantflames)
|
||||
```
|
||||
|
||||
> All 22 mana types (base, utility, composite, exotic) are valid spell elements.
|
||||
> Composite/exotic elements use the same matchup table; multi-element spells use
|
||||
> `getMultiElementBonus()` which applies `Math.min()` across all enemy element matchups,
|
||||
> making it harder to exploit a single counter-element.
|
||||
|
||||
**Multi-element guardians:** `getMultiElementBonus()` uses `Math.min()` across all
|
||||
guardian elements, making it harder to exploit a single counter-element.
|
||||
|
||||
### 4.3 Melee Damage (`calcMeleeDamage`)
|
||||
|
||||
```
|
||||
baseDmg = sword.baseDamage + sword.elementalEnchantDamage
|
||||
damage = baseDmg × getElementalBonus(sword.enchantElement, enemy.element)
|
||||
// No critChance, no discipline damage bonus for melee in v1
|
||||
// attackSpeedMult from equipment does apply to meleeProgress accumulation
|
||||
```
|
||||
|
||||
### 4.4 Discipline Combat Specials
|
||||
|
||||
Applied inside `onDamageDealt` before enemy defenses:
|
||||
|
||||
| Special | Condition | Effect |
|
||||
|---|---|---|
|
||||
| **Executioner** | Enemy HP < 25% of maxHP | `dmg × = 2` |
|
||||
| **Berserker** | Player rawMana < 50% of maxMana | `dmg × = 1.5` |
|
||||
|
||||
Both can apply simultaneously (stack multiplicatively). Melee attacks do **not**
|
||||
trigger Executioner or Berserker in v1.
|
||||
|
||||
### 4.5 Speed Room + Agile Modifier Interaction
|
||||
|
||||
When a room is of type `speed` **and** the enemy also has the `agile` modifier,
|
||||
the effective dodge chance is computed additively:
|
||||
|
||||
```
|
||||
effectiveDodge = speedRoomBonus + agileDodgeChance
|
||||
// e.g. speedRoom adds +0.20, agile adds up to 0.55 → cap at 0.75
|
||||
effectiveDodge = min(0.75, speedRoomBonus + agileDodgeChance)
|
||||
```
|
||||
|
||||
`speedRoomBonus` is a constant (suggested: `0.20`). This ensures speed rooms remain
|
||||
meaningfully harder than plain combat rooms even without an agile modifier.
|
||||
|
||||
---
|
||||
|
||||
## 5. Enemy Defenses
|
||||
|
||||
### 5.1 Enemy Modifiers
|
||||
|
||||
Each enemy can have up to **2 modifiers** (randomly selected, floored-gated):
|
||||
|
||||
| Modifier | Min Floor | Max Chance | Stat Effect |
|
||||
|---|---|---|---|
|
||||
| `armored` | 5 | 40% | `armor = min(0.45, floor × 0.003)` — % damage reduction |
|
||||
| `shield` | 10 | 25% | One-time barrier pool = 15% of maxHP |
|
||||
| `agile` | 12 | 25% | `dodgeChance = min(0.55, floor × 0.003)` |
|
||||
| `mage` | 15 | 30% | `barrier = min(0.4, floor × 0.003)`; recharges 5%/tick |
|
||||
| `swarm` | 8 | 15% | Spawns 3–7 enemies at 35% HP each |
|
||||
|
||||
### 5.2 Damage Reduction Order (Regular Enemies)
|
||||
|
||||
```
|
||||
onDamageDealt(dmg, enemy):
|
||||
// 1. Dodge check
|
||||
if enemy.dodgeChance > 0 && Math.random() < enemy.dodgeChance:
|
||||
activityLog("Attack dodged!")
|
||||
return 0
|
||||
|
||||
// 2. Barrier absorption (percentage)
|
||||
if enemy.barrier > 0:
|
||||
dmg ×= (1 - enemy.barrier)
|
||||
// Mage barrier recharges: enemy.barrier = min(barrierMax, enemy.barrier + rechargeRate)
|
||||
|
||||
// 3. Armor reduction (flat percentage)
|
||||
if enemy.armor > 0:
|
||||
dmg ×= (1 - enemy.armor)
|
||||
|
||||
return dmg
|
||||
```
|
||||
|
||||
> **Note:** In the current codebase, armor, barrier, and dodge for regular enemies
|
||||
> are stored on `EnemyState` but **not yet applied** in the pipeline. This spec defines
|
||||
> the intended implementation. See §9 for full gap list.
|
||||
|
||||
### 5.3 Guardian Defensive Pipeline
|
||||
|
||||
Applied inside `makeOnDamageDealt` in `combat-tick.ts` (already partially implemented):
|
||||
|
||||
```
|
||||
onDamageDealt(dmg) [guardian room]:
|
||||
// Specials first (Executioner, Berserker)
|
||||
dmg = applyDisciplineSpecials(dmg)
|
||||
|
||||
// Regen ticks
|
||||
guardianShield = min(shieldMax, guardianShield + shieldRegen × HOURS_PER_TICK)
|
||||
guardianBarrier = min(barrierMax, guardianBarrier + barrierRegen × HOURS_PER_TICK)
|
||||
|
||||
// Shield absorption (flat pool first)
|
||||
absorb = min(guardianShield, dmg)
|
||||
guardianShield -= absorb
|
||||
dmg -= absorb
|
||||
|
||||
// Barrier reduction (percentage)
|
||||
if guardianBarrier > 0:
|
||||
dmg ×= (1 - guardianBarrier)
|
||||
|
||||
// Health regen (reduces net damage)
|
||||
healAmount = healthRegenIsPercent
|
||||
? floor(floorMaxHP × healthRegen / 100 × HOURS_PER_TICK)
|
||||
: floor(healthRegen × HOURS_PER_TICK)
|
||||
dmg -= healAmount // can go negative, effectively healing floorHP
|
||||
|
||||
return dmg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Debuffs and Damage-Over-Time
|
||||
|
||||
### 6.1 Overview
|
||||
|
||||
Some spells and golem attacks apply effects that persist on enemies between ticks.
|
||||
These are tracked in `EnemyState.activeEffects: ActiveEffect[]`.
|
||||
|
||||
```typescript
|
||||
interface ActiveEffect {
|
||||
type: EffectType;
|
||||
remainingDuration: number; // in ticks
|
||||
magnitude: number; // effect strength (damage per tick, % reduction, etc.)
|
||||
source: 'spell' | 'golem';
|
||||
bypassArmor?: boolean;
|
||||
bypassBarrier?: boolean;
|
||||
}
|
||||
|
||||
type EffectType =
|
||||
| 'burn' // fire DoT per tick
|
||||
| 'poison' // nature DoT per tick, stacks
|
||||
| 'bleed' // physical DoT per tick
|
||||
| 'freeze' // slows enemy (future: reduces attack speed of enemy, if relevant)
|
||||
| 'slow' // reduces enemy barrier/dodge temporarily
|
||||
| 'curse' // amplifies incoming damage by %
|
||||
| 'armor_corrode' // reduces armor value by % for duration
|
||||
| 'blind' // increases dodge miss rate on enemy attacks (N/A — player immortal; repurpose as accuracy debuff)
|
||||
```
|
||||
|
||||
### 6.2 Applying Effects
|
||||
|
||||
Spells that apply effects include the effect definition in their `SpellDefinition`:
|
||||
|
||||
```typescript
|
||||
interface SpellDefinition {
|
||||
// ...existing fields...
|
||||
onHitEffect?: {
|
||||
type: EffectType;
|
||||
duration: number; // ticks
|
||||
magnitude: number;
|
||||
bypassArmor?: boolean;
|
||||
bypassBarrier?: boolean;
|
||||
applyChance?: number; // 0-1, defaults to 1.0
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
On a successful hit:
|
||||
```
|
||||
if spell.onHitEffect && Math.random() < (spell.onHitEffect.applyChance ?? 1.0):
|
||||
enemy.activeEffects.push({ ...spell.onHitEffect, remainingDuration: spell.onHitEffect.duration })
|
||||
activityLog("${enemy.name} afflicted with ${effectType}")
|
||||
```
|
||||
|
||||
### 6.3 Effect Tick Processing
|
||||
|
||||
Each combat tick, after all weapon attacks, active effects are processed:
|
||||
|
||||
```
|
||||
tickActiveEffects(enemy):
|
||||
for each effect in enemy.activeEffects:
|
||||
if effect is DoT (burn/poison/bleed):
|
||||
dmg = effect.magnitude
|
||||
if effect.bypassArmor: // skip armor reduction step
|
||||
dmg applied directly to enemy.hp
|
||||
elif effect.bypassBarrier:
|
||||
dmg applied after armor, before barrier
|
||||
else:
|
||||
dmg = applyEnemyDefenses(dmg, enemy)
|
||||
enemy.hp = max(0, enemy.hp - dmg)
|
||||
|
||||
elif effect is 'curse':
|
||||
// Tracked on enemy; checked in calcDamage to amplify incoming damage
|
||||
incomingDamageMult × = (1 + effect.magnitude)
|
||||
|
||||
elif effect is 'armor_corrode':
|
||||
// Temporarily reduce armor
|
||||
enemy.effectiveArmor = max(0, enemy.armor - effect.magnitude)
|
||||
|
||||
effect.remainingDuration -= 1
|
||||
if effect.remainingDuration <= 0:
|
||||
remove effect from enemy.activeEffects
|
||||
```
|
||||
|
||||
### 6.4 Spell Effect Examples
|
||||
|
||||
| Spell type | Effect | Notes |
|
||||
|---|---|---|
|
||||
| Fire spells | `burn` — fire DoT, 3–5 ticks | Standard DoT |
|
||||
| Death spells | `curse` — +20% incoming damage for 4 ticks | Amplifier (no "nature" element) |
|
||||
| Lightning spells | `armor_corrode` — -15% armor for 3 ticks | Bypass synergy |
|
||||
| Frost spells | `freeze` / `slow` — reduces effective dodge | Soft CC (note: "frost", not "ice") |
|
||||
| Void/shadow spells | `bypassArmor: true` | Direct to HP |
|
||||
| Certain advanced spells | `bypassBarrier: true` | Ignores shield/barrier |
|
||||
|
||||
---
|
||||
|
||||
## 7. Spell Autocasting — Late Game Manual Override
|
||||
|
||||
The initial implementation autocasts all equipped spells simultaneously. The
|
||||
late-game unlock (via prestige/discipline) that allows manual spell selection is
|
||||
**out of scope for v1**. When implemented it will:
|
||||
|
||||
- Allow the player to pin one spell per weapon as the "priority" cast.
|
||||
- Other spells on the same weapon continue autocasting normally.
|
||||
- UI: a toggle or pin icon next to each spell in the equipment panel.
|
||||
|
||||
---
|
||||
|
||||
## 8. Incursion Effects on Combat
|
||||
|
||||
Incursion (days 20–30) affects **mana regeneration only** — it does not modify
|
||||
enemy stats, spell damage, or golem behaviour directly.
|
||||
|
||||
```
|
||||
effectiveRegen = max(0, baseRegen × (1 - incursionStrength) × meditationMult - conversionCost)
|
||||
```
|
||||
|
||||
At peak incursion (day 30), regen falls to 5% of base. Practical effects:
|
||||
- Spells that cannot be afforded are held (cast timer pauses at 100%).
|
||||
- Golems with unsatisfied maintenance costs are dismissed (see §9.3).
|
||||
- Sword attacks are unaffected (no mana cost).
|
||||
|
||||
---
|
||||
|
||||
## 9. Golemancy System
|
||||
|
||||
### 9.1 Overview
|
||||
|
||||
Golemancy is the **Fabricator attunement's** combat contribution. Golems are
|
||||
summoned automatically at room entry, fight alongside the player, and disappear
|
||||
after a fixed number of rooms or if their maintenance cost cannot be met.
|
||||
|
||||
### 9.2 Golem Loadout (Outside Spire)
|
||||
|
||||
The player configures a **golem loadout** from the Golemancy tab before entering
|
||||
the spire. The loadout defines which golems to attempt to summon and in what order.
|
||||
This configuration persists across rooms but not across spire runs.
|
||||
|
||||
### 9.3 Summoning on Room Entry
|
||||
|
||||
When the player enters a new combat room:
|
||||
|
||||
```
|
||||
onRoomEntry():
|
||||
for each golem in golemLoadout:
|
||||
if player has enough mana of golem.summonCostType >= golem.summonCost:
|
||||
deductMana(golem.summonCost, golem.summonCostType)
|
||||
activeGolems.push({
|
||||
...golemDef,
|
||||
roomsRemaining: golemDef.maxRoomDuration,
|
||||
attackProgress: 0,
|
||||
})
|
||||
activityLog("${golem.name} summoned")
|
||||
else:
|
||||
activityLog("Not enough mana to summon ${golem.name} — skipped")
|
||||
```
|
||||
|
||||
Golems that could not be summoned (insufficient mana) are **not re-attempted**
|
||||
within the same room. They will be attempted again on the next room entry.
|
||||
|
||||
### 9.4 Golem Combat
|
||||
|
||||
Each active golem attacks on its own `attackProgress` timer, identical to swords:
|
||||
|
||||
```
|
||||
golemProgress += HOURS_PER_TICK × golem.attackSpeed
|
||||
while golemProgress >= 1:
|
||||
dmg = golem.baseDamage
|
||||
// Apply golem's own elemental type if it has one
|
||||
if golem.element:
|
||||
dmg ×= getElementalBonus(golem.element, enemy.element)
|
||||
// Apply golem special effects (DoT, armor pierce, AoE, etc.)
|
||||
applyGolemEffects(golem, dmg, enemy)
|
||||
applyDamageToRoom(dmg)
|
||||
golemProgress -= 1
|
||||
```
|
||||
|
||||
Golems ignore Executioner and Berserker discipline specials.
|
||||
|
||||
### 9.5 Maintenance Cost
|
||||
|
||||
Each tick, each active golem checks its maintenance cost:
|
||||
|
||||
```
|
||||
tickGolemMaintenance(golem):
|
||||
if player mana[golem.maintenanceCostType] >= golem.maintenanceCost × HOURS_PER_TICK:
|
||||
deductMana(golem.maintenanceCost × HOURS_PER_TICK, golem.maintenanceCostType)
|
||||
else:
|
||||
dismiss(golem)
|
||||
activityLog("${golem.name} dismissed — insufficient ${golem.maintenanceCostType} mana")
|
||||
```
|
||||
|
||||
A dismissed golem is **not re-summoned mid-room**. It will be re-attempted on the
|
||||
next room entry if mana has recovered.
|
||||
|
||||
### 9.6 Room Duration Limit
|
||||
|
||||
```
|
||||
onRoomCleared():
|
||||
for each activeGolem:
|
||||
activeGolem.roomsRemaining -= 1
|
||||
if activeGolem.roomsRemaining <= 0:
|
||||
dismiss(golem)
|
||||
activityLog("${golem.name} has faded after ${maxRoomDuration} rooms")
|
||||
```
|
||||
|
||||
Room duration ticks down on room clear, not on room entry — golems persist through
|
||||
the full room they were summoned in.
|
||||
|
||||
### 9.7 Golem Data Shape
|
||||
|
||||
```typescript
|
||||
interface GolemDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
tier: number; // 1–4 (determines general power)
|
||||
baseDamage: number;
|
||||
attackSpeed: number; // attacks per in-game hour
|
||||
element?: ElementType; // optional elemental type for matchup
|
||||
maxRoomDuration: number; // rooms before disappearing
|
||||
summonCost: number;
|
||||
summonCostType: ElementType | 'raw';
|
||||
maintenanceCost: number; // per in-game hour
|
||||
maintenanceCostType: ElementType | 'raw';
|
||||
onHitEffect?: GolemHitEffect; // DoT, AoE, etc.
|
||||
armorPierce?: number; // 0-1, bypasses this fraction of enemy armor
|
||||
aoe?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. In-Game Time Display
|
||||
|
||||
The current in-game time (day and hour) should be visible during spire combat.
|
||||
Display location: **SpireHeader** or **RoomDisplay** component, shown as a small
|
||||
badge or subtitle, e.g. `"Day 4, Hour 12"` or `"D4 H12"`.
|
||||
|
||||
The value is read from `gameStore.day` and `gameStore.hour` (already tracked). No
|
||||
new state is needed — only a UI read.
|
||||
|
||||
This is especially relevant as incursion begins at Day 20, so the player needs to
|
||||
be able to gauge how much time they have left without leaving the spire view.
|
||||
|
||||
---
|
||||
|
||||
## 11. Known Gaps / Incomplete Features
|
||||
|
||||
The following are defined in data but not yet wired into the runtime pipeline.
|
||||
They are **in scope for the implementation this spec describes**:
|
||||
|
||||
| Feature | Where Defined | Status | This Spec's Requirement |
|
||||
|---|---|---|---|
|
||||
| Enemy armor reduction | `EnemyState.armor`, `MODIFIER_CONFIG.armored` | Data-only | Implement in `onDamageDealt` §5.2 |
|
||||
| Enemy barrier absorption | `EnemyState.barrier`, `MODIFIER_CONFIG.mage/shield` | Data-only | Implement in `onDamageDealt` §5.2 |
|
||||
| Enemy dodge roll | `EnemyState.dodgeChance`, `MODIFIER_CONFIG.agile` | Data-only | Implement in `onDamageDealt` §5.2 |
|
||||
| Mage barrier recharge | `MODIFIER_CONFIG.mage.barrierRechargeRate` | Data-only | Tick in `onDamageDealt` §5.2 |
|
||||
| Guardian armor | `GuardianDef.armor` | Data-only | Add check to guardian pipeline §5.3 |
|
||||
| DoT / debuff system | Spell/enchantment type defs | No runtime | Implement per §6 |
|
||||
| Golemancy combat | Full golem data exists | Disconnected | Implement per §9 |
|
||||
| Sword melee attacks | Weapon type exists | Not in combat tick | Add `meleeProgress` per §3.1 |
|
||||
| AoE target distribution | `SpellDefinition.aoe` flag | Partial | Implement per §3.2 |
|
||||
| `elemMasteryBonus` | Stub in `calcDamage` | Hardcoded `1` | Future — leave as `1` for now |
|
||||
| `guardianBonus` | Stub in `calcDamage` | Hardcoded `1` | Future — leave as `1` for now |
|
||||
|
||||
---
|
||||
|
||||
## 12. State Fields (Combat-Relevant)
|
||||
|
||||
```typescript
|
||||
// Per-weapon cast timers (replace single castProgress)
|
||||
weaponCastProgress: Record<instanceId, number> // one entry per equipped weapon
|
||||
|
||||
// Per-sword melee timers
|
||||
meleeSwordProgress: Record<instanceId, number>
|
||||
|
||||
// Active golems
|
||||
activeGolems: ActiveGolem[] // summoned this run
|
||||
|
||||
// Enemy state extension
|
||||
interface EnemyState {
|
||||
// ...existing fields...
|
||||
activeEffects: ActiveEffect[] // NEW — live debuffs/DoTs
|
||||
effectiveArmor: number // NEW — armor after corrode effects
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Acceptance Criteria
|
||||
|
||||
| # | Criterion |
|
||||
|---|---|
|
||||
| AC-1 | All equipped spells autocast simultaneously on independent timers — no manual input needed. |
|
||||
| AC-2 | Swords auto-attack on their own timer with no mana cost; elemental matchup applies. |
|
||||
| AC-3 | A player with no equipped weapons still enters the spire (golems-only or empty run). |
|
||||
| AC-4 | Damage formula: base × discipline × boon × elemental × crit produces correct results. |
|
||||
| AC-5 | Elemental matchup applies correctly for both spells and swords. |
|
||||
| AC-6 | Executioner doubles damage when enemy HP < 25%; Berserker grants 1.5× when low on mana. |
|
||||
| AC-7 | Armored enemies reduce damage by their armor percentage. |
|
||||
| AC-8 | Barrier enemies absorb a percentage of each hit before HP is reduced. |
|
||||
| AC-9 | Agile enemies dodge attacks at their dodge chance rate. |
|
||||
| AC-10 | Speed room + agile modifier combines additively for dodge chance (capped at 0.75). |
|
||||
| AC-11 | Guardian shield absorbs flat damage before barrier reduces percentage damage. |
|
||||
| AC-12 | DoT effects (burn, poison, etc.) tick each combat tick and expire after their duration. |
|
||||
| AC-13 | `bypassArmor` effects skip the armor reduction step entirely. |
|
||||
| AC-14 | Golems are summoned on room entry if mana allows; not re-summoned mid-room if dismissed. |
|
||||
| AC-15 | Golem maintenance cost is deducted each tick; golems dismiss if cost cannot be met. |
|
||||
| AC-16 | Golems disappear after `maxRoomDuration` rooms. |
|
||||
| AC-17 | Current in-game time (day + hour) is visible in the spire combat UI. |
|
||||
| AC-18 | Player has no HP, no armor, no healing — combat ends only when all enemies die. |
|
||||
|
||||
---
|
||||
|
||||
## 14. Files Reference
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `src/lib/game/stores/combat-actions.ts` | `processCombatTick` — main weapon/golem/DoT loop |
|
||||
| `src/lib/game/stores/pipelines/combat-tick.ts` | `makeOnDamageDealt` — specials + guardian defenses |
|
||||
| `src/lib/game/utils/combat-utils.ts` | `calcDamage`, `calcMeleeDamage`, `getElementalBonus` |
|
||||
| `src/lib/game/utils/enemy-generator.ts` | `selectModifiers`, `applyModifiers`, `MODIFIER_CONFIG` |
|
||||
| `src/lib/game/constants/spells.ts` | Spell registry (all tiers) |
|
||||
| `src/lib/game/constants/elements.ts` | Element list, opposition cycle |
|
||||
| `src/lib/game/constants/core.ts` | `HOURS_PER_TICK`, `INCURSION_START_DAY` |
|
||||
| `src/lib/game/data/guardian-encounters.ts` | Guardian definitions |
|
||||
| `src/lib/game/data/golems/` | Golem definitions (12 golems, tiers 1–4) |
|
||||
| `src/lib/game/effects.ts` | `getUnifiedEffects` — merges all combat bonuses |
|
||||
| `src/components/game/tabs/SpireCombatPage/SpireHeader.tsx` | In-game time display |
|
||||
| `src/components/game/tabs/SpireCombatPage/RoomDisplay.tsx` | Room type, enemy state, active effects |
|
||||
Reference in New Issue
Block a user