bd15df85ff
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
The curse debuff was stored on enemies via dot-runtime.ts but the amplification was never applied to incoming damage. Added curse magnitude check in applyEnemyDefenses (combat-tick.ts) that multiplies incoming damage by (1 + magnitude) for each active curse effect. - Curse amplification applied BEFORE dodge/barrier/armse defenses - Multiple curse effects stack multiplicatively - Non-curse effects (burn, freeze, etc.) are ignored for amplification Also updated spire-combat-spec.md Known Gaps table to reflect: - Melee defense bypass fixed (issue #285) - Curse amplification now implemented (issue #286) Added 9 regression tests in curse-amplification.test.ts. All 957 tests pass (50 test files).
618 lines
24 KiB
Markdown
618 lines
24 KiB
Markdown
# 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` | **Implemented** — fixed in issue #285 (was bypassed for melee) | Implement in `onDamageDealt` §5.2 |
|
||
| Enemy barrier absorption | `EnemyState.barrier`, `MODIFIER_CONFIG.mage/shield` | **Implemented** — fixed in issue #285 (was bypassed for melee) | Implement in `onDamageDealt` §5.2 |
|
||
| Enemy dodge roll | `EnemyState.dodgeChance`, `MODIFIER_CONFIG.agile` | **Implemented** — fixed in issue #285 (was bypassed for melee) | 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 | **Implemented** — `dot-runtime.ts` complete and wired into combat tick; curse amplification added (issue #286) | Verified working |
|
||
| Golemancy combat | Full golem data exists | Disconnected | Implement per §9 |
|
||
| Sword melee attacks | Weapon type exists | **Implemented** — meleeProgress with enemy defense application (issue #285) | 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 (10 golems, tiers 1–4; undergoing redesign — see issue #268) |
|
||
| `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 | |