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

This commit is contained in:
2026-06-03 11:54:40 +02:00
parent 3383aedd2f
commit feae6b468d
6 changed files with 1068 additions and 5 deletions
+6 -3
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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."
},
+3
View File
@@ -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
+439
View File
@@ -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: 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`:
```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. |
+618
View File
@@ -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` | Moderatehigh; 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 37 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, 35 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 2030) 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; // 14 (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 14) |
| `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 |