Files
Mana-Loop/docs/specs/spire-combat-spec.md
T
n8n-gitea 6aed5c8d2b
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m20s
docs: reconcile spec inconsistencies across all documentation
- Fix incursion start day: 5→20 in GAME_BRIEFING.md (matches code constant)
- Fix fabricator discipline count: 2→5 in AGENTS.md
- Fix discipline counts: elemental.ts 22→21, advanced-regen.ts 14→15
- Fix equipment count: 50→43, remove shields, fix catalysts count
- Fix prestige upgrade count: add missing manaWell, manaFlow, pactBinding, pactInterferenceMitigation
- Remove x3 victory multiplier (no victory condition defined yet)
- Update pact persistence: pacts do NOT persist through prestige
- Update elemental matchup tables to match ultimate truth
- Update room type frequencies to match spire-climbing-spec
- Update guardian data tables to use formulas
- Update Tier 3 guardian elements to match guardian-data.ts code
- Add pactBinding + pactInterferenceMitigation to PRESTIGE_DEF constants
- Wire pactInterferenceMitigation into useGameDerived.ts
- Update spire-combat-spec.md Known Gaps table (DoT implemented, melee bypass bug)
- Update invoker-spec.md known issues (all resolved)
- Update golemancy-spec.md status (undergoing redesign)
- Update PrestigeTab test to expect 15 upgrades
- Create Gitea issues #285 (melee defense bypass), #286 (DoT verified), #287 (mana conversion gap)
2026-06-05 14:07:22 +02:00

24 KiB
Raw Blame History

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[].

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:

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

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 Implemented (spells/DoTs only; melee bypasses — see issue #285) Implement in onDamageDealt §5.2
Enemy barrier absorption EnemyState.barrier, MODIFIER_CONFIG.mage/shield Implemented (spells/DoTs only; melee bypasses — see issue #285) Implement in onDamageDealt §5.2
Enemy dodge roll EnemyState.dodgeChance, MODIFIER_CONFIG.agile Implemented (spells/DoTs only; melee bypasses — see issue #285) 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 Implementeddot-runtime.ts complete and wired into combat tick Verified working; curse amplification needs investigation (see issue #286)
Golemancy combat Full golem data exists Disconnected Implement per §9
Sword melee attacks Weapon type exists Partial — meleeProgress exists but bypasses enemy defenses (see 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)

// 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 14; 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