From feae6b468d1180c4f88811ca5bdc7fbaf80a5d31 Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Wed, 3 Jun 2026 11:54:40 +0200 Subject: [PATCH] fix: update AGENTS.md and specs for incursion day, elemental matchups, golem count, and descent mechanics --- AGENTS.md | 9 +- docs/circular-deps.txt | 2 +- docs/dependency-graph.json | 2 +- docs/project-structure.txt | 3 + docs/specs/spire-climbing-spec.md | 439 +++++++++++++++++++++ docs/specs/spire-combat-spec.md | 618 ++++++++++++++++++++++++++++++ 6 files changed, 1068 insertions(+), 5 deletions(-) create mode 100644 docs/specs/spire-climbing-spec.md create mode 100644 docs/specs/spire-combat-spec.md diff --git a/AGENTS.md b/AGENTS.md index a0bd3c5..f57093d 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 013c376..ec2049f 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,4 +1,4 @@ # Circular Dependencies -Generated: 2026-06-02T14:00:41.812Z +Generated: 2026-06-02T14:39:46.904Z No circular dependencies found. ✅ diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index a6e5966..9d6e141 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -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." }, diff --git a/docs/project-structure.txt b/docs/project-structure.txt index 590d862..71a62a7 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -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 diff --git a/docs/specs/spire-climbing-spec.md b/docs/specs/spire-climbing-spec.md new file mode 100644 index 0000000..5ecde49 --- /dev/null +++ b/docs/specs/spire-climbing-spec.md @@ -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 // key = "floor:roomIndex" +clearedRooms: Record // 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. | \ No newline at end of file diff --git a/docs/specs/spire-combat-spec.md b/docs/specs/spire-combat-spec.md new file mode 100644 index 0000000..0de450a --- /dev/null +++ b/docs/specs/spire-combat-spec.md @@ -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 // one entry per equipped weapon + +// Per-sword melee timers +meleeSwordProgress: Record + +// 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 | \ No newline at end of file