645 lines
25 KiB
Markdown
645 lines
25 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. Players design
|
||
custom golems from components (Core + Frame + Mind Circuit + Enchantments), then
|
||
configure a loadout. 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 golem designs 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, `summonGolemsOnRoomEntry()` iterates the
|
||
loadout in priority order:
|
||
|
||
```
|
||
summonGolemsOnRoomEntry(loadout, rawMana, elements, currentFloor, existingActiveGolems, disciplineSlotsBonus, fabricatorLevel):
|
||
for each entry in loadout:
|
||
if !entry.enabled → skip
|
||
if activeGolems.length >= totalSlots → break // max 7
|
||
if already active → skip
|
||
resolve components (Core, Frame, Mind Circuit) from design
|
||
stats = computeGolemStats(componentDesign)
|
||
if player can afford stats.totalSummonCost:
|
||
deduct summon cost from player mana
|
||
activeGolems.push({
|
||
designId: entry.designId,
|
||
summonedFloor: currentFloor,
|
||
attackProgress: 0,
|
||
roomsRemaining: stats.maxRoomDuration,
|
||
currentMana: stats.manaCapacity, // starts full
|
||
spellCastIndex: 0,
|
||
})
|
||
else:
|
||
log "Not enough mana — skipped"
|
||
```
|
||
|
||
Total slots = `min(7, floor(fabricatorLevel / 2) + disciplineBonus)`.
|
||
|
||
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:
|
||
|
||
```
|
||
attackProgress += HOURS_PER_TICK × frame.attackSpeed
|
||
while attackProgress >= 1:
|
||
if mindCircuit has spells && golem.currentMana >= spellCost:
|
||
cast spell: damage = baseSpellDamage × frame.magicAffinity
|
||
golem.currentMana -= spellCost
|
||
spellCastIndex = (spellCastIndex + 1) % selectedSpells.length
|
||
else:
|
||
dmg = frame.baseDamage × (1 + frame.armorPierce)
|
||
apply enchantment effects (burn, slow, etc.)
|
||
applyDamageToRoom(dmg)
|
||
attackProgress -= 1
|
||
```
|
||
|
||
Golems ignore Executioner and Berserker discipline specials.
|
||
|
||
### 9.5 Maintenance Cost
|
||
|
||
Each tick, `processGolemMaintenance()` checks upkeep for each active golem:
|
||
|
||
```
|
||
upkeepPerTick = core.manaRegen × 2 × HOURS_PER_TICK
|
||
if player has enough of core.primaryManaType:
|
||
deduct upkeepPerTick from player element mana
|
||
else:
|
||
dismiss(golem)
|
||
log "${name} dismissed — insufficient mana for upkeep"
|
||
```
|
||
|
||
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
|
||
|
||
`countdownGolemRoomDuration()` runs on room clear:
|
||
|
||
```
|
||
for each activeGolem:
|
||
golem.roomsRemaining -= 1
|
||
if golem.roomsRemaining <= 0:
|
||
dismiss(golem)
|
||
log "${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
|
||
|
||
The runtime active golem type (`RuntimeActiveGolem` in `types/game.ts`):
|
||
|
||
```typescript
|
||
interface RuntimeActiveGolem {
|
||
designId: string; // Reference to the player's GolemDesign
|
||
summonedFloor: number; // Floor when golem was summoned
|
||
attackProgress: number; // Progress toward next attack (accumulated)
|
||
roomsRemaining: number; // Rooms before golem fades
|
||
currentMana: number; // Current mana in golem's own pool
|
||
spellCastIndex: number; // For alternating/cycling spell circuits
|
||
}
|
||
```
|
||
|
||
The serialized design type (`SerializedGolemDesign` in `types/game.ts`):
|
||
|
||
```typescript
|
||
interface SerializedGolemDesign {
|
||
id: string;
|
||
name: string;
|
||
coreId: string;
|
||
frameId: string;
|
||
mindCircuitId: string;
|
||
enchantmentIds: string[];
|
||
selectedManaTypes: string[];
|
||
selectedSpells: string[];
|
||
}
|
||
```
|
||
|
||
Golem stats are computed from components via `computeGolemStats()` in
|
||
`data/golems/utils.ts`, which sums summon costs from all components and derives
|
||
upkeep from `core.manaRegen × 2`.
|
||
|
||
---
|
||
|
||
## 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 + runtime | **Implemented** — component-based system complete | Verified working |
|
||
| 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 component definitions (4 cores, 7 frames, 4 mind circuits, 8 enchantments) |
|
||
| `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 | |