feat: implement per-enemy damage application (spec §3.2)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m6s

- Add applyDamageToRoom() function targeting individual enemies
- Add lowestHPEnemy() helper for focus-fire targeting
- AoE spells distribute damage across all enemies
- Single-target attacks hit lowest HP enemy first
- Refactor processCombatTick spell/equipment/melee/DoT damage to use per-enemy system
- Update golemApplyDamageToRoom in golem-combat pipeline for per-enemy targeting
- Add currentRoom to CombatTickResult and sync through gameStore
- Update combat-actions tests with proper enemy setup for per-enemy tests
- Extract combat-damage.ts module to stay under 400-line limit
This commit is contained in:
2026-06-04 11:37:21 +02:00
parent 8dde423526
commit 23e629f37e
10 changed files with 182 additions and 85 deletions
+57 -71
View File
@@ -5,12 +5,15 @@
import { SPELLS_DEF, HOURS_PER_TICK, EQUIPMENT_TYPES } from '../constants';
import { getGuardianForFloor } from '../data/guardian-encounters';
import type { CombatStore, CombatState } from './combat-state.types';
import type { SpellState, EnemyState, EquipmentInstance } from '../types';
import type { SpellState, EnemyState, EquipmentInstance, FloorState } from '../types';
import { applyOnHitEffect, processDoTPhase } from './dot-runtime';
import type { ActiveGolem } from '../types';
import { getFloorMaxHP, getFloorElement, getMultiElementBonus, calcDamage, calcMeleeDamage, canAffordSpellCost, deductSpellCost } from '../utils';
import { computeDisciplineEffects } from '../effects/discipline-effects';
import { processGolemMaintenance, processGolemAttacks } from './golem-combat-actions';
import { applyDamageToRoom } from './combat-damage';
// ─── Result Type ───────────────────────────────────────────────────────────────
/**
* Create a default CombatTickResult for safe fallback on error.
@@ -34,6 +37,7 @@ function makeDefaultCombatTickResult(
equipmentSpellStates: state.equipmentSpellStates,
activeGolems,
meleeSwordProgress: state.meleeSwordProgress,
currentRoom: state.currentRoom,
};
}
@@ -50,8 +54,11 @@ export interface CombatTickResult {
equipmentSpellStates: CombatState['equipmentSpellStates'];
activeGolems: ActiveGolem[];
meleeSwordProgress: Record<string, number>;
currentRoom: FloorState;
}
// ─── Main Combat Tick ──────────────────────────────────────────────────────────
export function processCombatTick(
get: () => CombatStore,
set: (state: Partial<CombatState>) => void,
@@ -108,81 +115,65 @@ export function processCombatTick(
let currentFloor = state.currentFloor;
let floorMaxHP = state.floorMaxHP;
let castProgress = state.castProgress;
let currentRoom = state.currentRoom;
const updatedEquipmentSpellStates = [...state.equipmentSpellStates];
// ─── Spell casting (only when a valid spell is configured) ────────────────
if (spellDef) {
// Compute discipline bonuses once per tick
const disciplineEffects = computeDisciplineEffects();
// Calculate cast speed (no skill bonus)
const totalAttackSpeed = attackSpeedMult;
const spellCastSpeed = spellDef.castSpeed || 1;
const progressPerTick = HOURS_PER_TICK * spellCastSpeed * totalAttackSpeed;
const isSpellAoe = !!spellDef.isAoe;
castProgress = (castProgress || 0) + progressPerTick;
// Process complete casts for active spell (safety counter prevents infinite loop)
// Process complete casts for active spell
let safetyCounter = 0;
const MAX_CASTS_PER_TICK = 100;
while (castProgress >= 1 && canAffordSpellCost(spellDef.cost, rawMana, elements) && safetyCounter < MAX_CASTS_PER_TICK) {
// Deduct spell cost
const afterCost = deductSpellCost(spellDef.cost, rawMana, elements);
rawMana = afterCost.rawMana;
elements = afterCost.elements;
// Calculate base damage (without elemental bonus first)
const floorElement = getFloorElement(currentFloor);
const baseDamage = calcDamage(
{ signedPacts },
spellId,
undefined,
disciplineEffects,
);
// Apply elemental bonus — for multi-element guardians, use all elements
const baseDamage = calcDamage({ signedPacts }, spellId, undefined, disciplineEffects);
const guardian = getGuardianForFloor(currentFloor);
const floorElems = guardian && guardian.element.length > 0
? guardian.element
: [floorElement];
const floorElems = guardian && guardian.element.length > 0 ? guardian.element : [floorElement];
const multiElemBonus = getMultiElementBonus(spellDef.elem, floorElems);
const damage = baseDamage * multiElemBonus;
// Let gameStore apply damage modifiers (executioner, berserker)
const result = onDamageDealt(damage);
rawMana = result.rawMana;
elements = result.elements;
const finalDamage = result.modifiedDamage || damage;
// Guard against NaN damage — if damage is not finite, stop processing
if (!Number.isFinite(finalDamage)) {
logMessages.push('⚠️ Combat stopped: invalid damage value');
break;
}
// Apply damage
floorHP = Math.max(0, floorHP - finalDamage);
// Apply damage per-enemy (spec §3.2)
const roomResult = applyDamageToRoom(get, set, finalDamage, isSpellAoe);
floorHP = roomResult.floorHP;
floorMaxHP = roomResult.floorMaxHP;
currentRoom = get().currentRoom;
castProgress -= 1;
safetyCounter++;
// Apply on-hit effect (DoT/debuff) to enemy (spec §6.2)
applyOnHitEffect(get, set, spellId, logMessages);
currentRoom = get().currentRoom;
// Check if room/floor is cleared
if (floorHP <= 0) {
if (roomResult.roomCleared) {
const guardian = getGuardianForFloor(currentFloor);
onFloorCleared(currentFloor, !!guardian);
// ── Spec: room-aware advancement (climbing spec §4.4) ──────────────
// Instead of directly incrementing the floor, delegate to the store's
// advanceRoomOrFloor which handles room-by-room and floor transitions.
get().advanceRoomOrFloor();
// Re-read state after advancement
const newState = get();
currentFloor = newState.currentFloor;
floorMaxHP = newState.floorMaxHP;
floorHP = newState.floorHP;
currentRoom = newState.currentRoom;
castProgress = 0;
if (guardian) {
logMessages.push(`⚔️ ${guardian.name} defeated!`);
} else if (currentFloor % 5 === 0) {
@@ -191,37 +182,28 @@ export function processCombatTick(
}
}
// Process equipment spell states (for progress bars in UI)
// Process equipment spell states
for (let i = 0; i < updatedEquipmentSpellStates.length; i++) {
const eSpell = updatedEquipmentSpellStates[i];
const eSpellDef = SPELLS_DEF[eSpell.spellId];
if (!eSpellDef) continue;
// Calculate progress for this equipment spell
const isESpellAoe = !!eSpellDef.isAoe;
const eSpellCastSpeed = eSpellDef.castSpeed || 1;
const eProgressPerTick = HOURS_PER_TICK * eSpellCastSpeed * attackSpeedMult;
let eCastProgress = (eSpell.castProgress || 0) + eProgressPerTick;
// Process complete casts for equipment spells (safety counter prevents infinite loop)
let eSafetyCounter = 0;
const MAX_E_CASTS_PER_TICK = 100;
while (eCastProgress >= 1 && canAffordSpellCost(eSpellDef.cost, rawMana, elements) && eSafetyCounter < MAX_E_CASTS_PER_TICK) {
// Deduct cost
const eAfterCost = deductSpellCost(eSpellDef.cost, rawMana, elements);
rawMana = eAfterCost.rawMana;
elements = eAfterCost.elements;
// Calculate damage — for multi-element guardians, use all elements
const eFloorElement = getFloorElement(currentFloor);
const eBaseDamage = calcDamage(
{ signedPacts },
eSpell.spellId,
undefined,
disciplineEffects,
);
const eBaseDamage = calcDamage({ signedPacts }, eSpell.spellId, undefined, disciplineEffects);
const eGuardian = getGuardianForFloor(currentFloor);
const eFloorElems = eGuardian && eGuardian.element.length > 0
? eGuardian.element
: [eFloorElement];
const eFloorElems = eGuardian && eGuardian.element.length > 0 ? eGuardian.element : [eFloorElement];
const eMultiElemBonus = getMultiElementBonus(eSpellDef.elem, eFloorElems);
const eDamage = eBaseDamage * eMultiElemBonus;
@@ -230,26 +212,25 @@ export function processCombatTick(
elements = eResult.elements;
const eFinalDamage = eResult.modifiedDamage || eDamage;
// Guard against NaN damage
if (!Number.isFinite(eFinalDamage)) {
break;
}
if (!Number.isFinite(eFinalDamage)) break;
floorHP = Math.max(0, floorHP - eFinalDamage);
// Apply damage per-enemy (spec §3.2)
const eRoomResult = applyDamageToRoom(get, set, eFinalDamage, isESpellAoe);
floorHP = eRoomResult.floorHP;
floorMaxHP = eRoomResult.floorMaxHP;
currentRoom = get().currentRoom;
eCastProgress -= 1;
eSafetyCounter++;
if (floorHP <= 0) {
if (eRoomResult.roomCleared) {
const eGuardian = getGuardianForFloor(currentFloor);
onFloorCleared(currentFloor, !!eGuardian);
// ── Spec: room-aware advancement ─────────────────────────────────
get().advanceRoomOrFloor();
const newState = get();
currentFloor = newState.currentFloor;
floorMaxHP = newState.floorMaxHP;
floorHP = newState.floorHP;
currentRoom = newState.currentRoom;
eCastProgress = 0;
if (eGuardian) {
logMessages.push(`⚔️ ${eGuardian.name} defeated!`);
@@ -260,19 +241,16 @@ export function processCombatTick(
}
}
// Update equipment spell state
updatedEquipmentSpellStates[i] = { ...eSpell, castProgress: eCastProgress % 1 };
}
}
// ─── Melee sword attacks (spec §3.1, §4.3) ────────────────────────────
// Melee: no mana cost, no Executioner/Berserker, elemental matchup applies
const updatedMeleeSwordProgress = { ...state.meleeSwordProgress };
const floorElement = getFloorElement(currentFloor);
const guardian = getGuardianForFloor(currentFloor);
const floorElems = guardian && guardian.element.length > 0 ? guardian.element : [floorElement];
if (equippedSwords && Object.keys(equippedSwords).length > 0 && floorHP > 0) {
for (const [instanceId, swordInstance] of Object.entries(equippedSwords)) {
const swordType = EQUIPMENT_TYPES[swordInstance.typeId];
@@ -283,12 +261,18 @@ export function processCombatTick(
let meleeSafetyCounter = 0;
while (meleeProgress >= 1 && meleeSafetyCounter < 100) {
const meleeDamage = calcMeleeDamage(swordInstance as EquipmentInstance, swordType, floorElems[0]);
const finalMeleeDamage = applyEnemyDefenses(meleDamage, null, 'combat', (msg) => logMessages.push(msg));
const finalMeleeDamage = applyEnemyDefenses(meleeDamage, null, 'combat', (msg) => logMessages.push(msg));
if (!Number.isFinite(finalMeleeDamage)) break;
floorHP = Math.max(0, floorHP - finalMeleeDamage);
// Apply melee damage per-enemy (spec §3.2, focus-fire)
const meleeRoomResult = applyDamageToRoom(get, set, finalMeleeDamage, false);
floorHP = meleeRoomResult.floorHP;
floorMaxHP = meleeRoomResult.floorMaxHP;
currentRoom = get().currentRoom;
meleeProgress -= 1;
meleeSafetyCounter++;
if (floorHP <= 0) {
if (meleeRoomResult.roomCleared) {
const g = getGuardianForFloor(currentFloor);
onFloorCleared(currentFloor, !!g);
get().advanceRoomOrFloor();
@@ -296,6 +280,7 @@ export function processCombatTick(
currentFloor = ns.currentFloor;
floorMaxHP = ns.floorMaxHP;
floorHP = ns.floorHP;
currentRoom = ns.currentRoom;
meleeProgress = 0;
break;
}
@@ -305,8 +290,6 @@ export function processCombatTick(
}
// ─── Golem attacks (spec §9.4) ───────────────────────────────────────────
// Golems attack after spells, using the same damage pipeline.
// They ignore Executioner/Berserker (handled internally by processGolemAttacks).
if (activeGolems.length > 0 && floorHP > 0) {
const golemResult = processGolemAttacks(
activeGolems,
@@ -323,20 +306,23 @@ export function processCombatTick(
activeGolems = golemResult.activeGolems;
logMessages.push(...golemResult.logMessages);
// Read back floor state after golem damage
const postGolemState = get();
floorHP = postGolemState.floorHP;
floorMaxHP = postGolemState.floorMaxHP;
currentFloor = postGolemState.currentFloor;
currentRoom = postGolemState.currentRoom;
}
// ─── DoT/Debuff tick processing (spec §6.3) ─────────────────────────────
// Process after all weapon/golem attacks
if (floorHP > 0) {
const doTDamage = processDoTPhase(get, set, applyEnemyDefenses, logMessages);
floorHP = Math.max(0, floorHP - doTDamage);
processDoTPhase(get, set, applyEnemyDefenses, logMessages);
// processDoTPhase writes per-enemy HP to currentRoom.
// Recalculate floorHP from updated room state.
const postDoTState = get();
currentRoom = postDoTState.currentRoom;
floorHP = currentRoom.enemies.reduce((sum, e) => sum + e.hp, 0);
set({ floorHP });
// Check if DoT cleared the room
if (floorHP <= 0) {
const guardian = getGuardianForFloor(currentFloor);
onFloorCleared(currentFloor, !!guardian);
@@ -345,6 +331,7 @@ export function processCombatTick(
currentFloor = newState.currentFloor;
floorMaxHP = newState.floorMaxHP;
floorHP = newState.floorHP;
currentRoom = newState.currentRoom;
}
}
@@ -357,15 +344,15 @@ export function processCombatTick(
totalManaGathered,
currentFloor,
floorHP,
floorMaxHP: getFloorMaxHP(currentFloor),
floorMaxHP,
maxFloorReached: newMaxFloorReached,
castProgress,
equipmentSpellStates: updatedEquipmentSpellStates,
activeGolems,
meleeSwordProgress: updatedMeleeSwordProgress,
currentRoom,
};
} catch (error) {
// Return safe defaults on error — combat tick should never crash the game
const errorMsg = error instanceof Error ? error.message : String(error);
logMessages.push(`⚠️ Combat error: ${errorMsg}`);
return makeDefaultCombatTickResult(rawMana, elements, state, golemancyState.activeGolems);
@@ -378,10 +365,9 @@ export function makeInitialSpells(spellsToKeep: string[] = []): Record<string, S
manaBolt: { learned: true, level: 1, studyProgress: 0 },
};
// Add kept spells
for (const spellId of spellsToKeep) {
startSpells[spellId] = { learned: true, level: 1, studyProgress: 0 };
}
return startSpells;
}
}