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;
}
}
+73
View File
@@ -0,0 +1,73 @@
// ─── Per-Enemy Damage Application (spec §3.2) ─────────────────────────────────
// Extracted from combat-actions.ts to stay under the 400-line file limit.
import type { CombatStore, CombatState } from './combat-state.types';
import type { EnemyState } from '../types';
/**
* Find the enemy with the lowest current HP in the room (focus-fire targeting).
* Returns null if no living enemies exist.
*/
export function lowestHPEnemy(enemies: EnemyState[]): EnemyState | null {
let lowest: EnemyState | null = null;
for (const enemy of enemies) {
if (enemy.hp <= 0) continue;
if (!lowest || enemy.hp < lowest.hp) {
lowest = enemy;
}
}
return lowest;
}
/**
* Apply damage to enemies in the current room.
*
* Spec §3.2:
* - AoE spells: each enemy takes full damage
* - Single-target: target the enemy with lowest HP (focus-fire)
* - Recalculates floorHP as sum of all enemy HP
* - Triggers onRoomCleared when all enemies reach 0 HP
*
* @returns { floorHP, floorMaxHP, roomCleared }
*/
export function applyDamageToRoom(
get: () => CombatStore,
set: (state: Partial<CombatState>) => void,
dmg: number,
isAoe: boolean,
): { floorHP: number; floorMaxHP: number; roomCleared: boolean } {
const state = get();
const room = state.currentRoom;
if (!room || room.enemies.length === 0) {
// No enemies in room — fall back to direct floorHP subtraction
const newFloorHP = Math.max(0, state.floorHP - dmg);
set({ floorHP: newFloorHP });
return { floorHP: newFloorHP, floorMaxHP: state.floorMaxHP, roomCleared: newFloorHP <= 0 };
}
// For single-target, find the lowest HP enemy once (focus-fire)
const singleTarget = isAoe ? null : lowestHPEnemy(room.enemies);
const updatedEnemies = room.enemies.map((enemy) => {
if (enemy.hp <= 0) return enemy;
if (isAoe) {
// AoE: each enemy takes full damage
return { ...enemy, hp: Math.max(0, enemy.hp - dmg) };
}
// Single-target: only damage the lowest HP enemy
if (singleTarget && enemy.id === singleTarget.id) {
return { ...enemy, hp: Math.max(0, enemy.hp - dmg) };
}
return enemy;
});
const newFloorHP = updatedEnemies.reduce((sum, e) => sum + e.hp, 0);
const allDead = updatedEnemies.every((e) => e.hp <= 0);
set({
currentRoom: { ...room, enemies: updatedEnemies },
floorHP: newFloorHP,
});
return { floorHP: newFloorHP, floorMaxHP: state.floorMaxHP, roomCleared: allDead };
}
@@ -170,6 +170,7 @@ export interface CombatActions {
equipmentSpellStates: EquipmentSpellState[];
activeGolems: ActiveGolem[];
meleeSwordProgress: Record<string, number>;
currentRoom: FloorState;
};
// Reset
+1 -1
View File
@@ -332,7 +332,7 @@ export const useGameStore = create<GameCoordinatorStore>()(
elements = combatResult.elements;
totalManaGathered += combatResult.totalManaGathered || 0;
if (combatResult.logMessages) combatResult.logMessages.forEach(msg => addLog(msg));
writes.combat = { ...(writes.combat || {}), currentFloor: combatResult.currentFloor, floorHP: combatResult.floorHP, floorMaxHP: combatResult.floorMaxHP, maxFloorReached: combatResult.maxFloorReached, castProgress: combatResult.castProgress, equipmentSpellStates: combatResult.equipmentSpellStates, golemancy: { ...(writes.combat?.golemancy || ctx.combat.golemancy), activeGolems: combatResult.activeGolems }, meleeSwordProgress: combatResult.meleeSwordProgress };
writes.combat = { ...(writes.combat || {}), currentFloor: combatResult.currentFloor, floorHP: combatResult.floorHP, floorMaxHP: combatResult.floorMaxHP, maxFloorReached: combatResult.maxFloorReached, castProgress: combatResult.castProgress, equipmentSpellStates: combatResult.equipmentSpellStates, golemancy: { ...(writes.combat?.golemancy || ctx.combat.golemancy), activeGolems: combatResult.activeGolems }, meleeSwordProgress: combatResult.meleeSwordProgress, currentRoom: combatResult.currentRoom };
}
if (ctx.combat.currentAction === 'craft') {
+28 -6
View File
@@ -5,7 +5,8 @@
import { useCombatStore } from '../combatStore';
import { useManaStore } from '../manaStore';
import { processGolemRoomDuration } from '../golem-combat-actions';
import type { ActiveGolem } from '../../types';
import { lowestHPEnemy } from '../combat-damage';
import type { ActiveGolem, EnemyState } from '../../types';
export interface GolemCombatContext {
addLog: (msg: string) => void;
@@ -38,12 +39,33 @@ export function buildGolemCombatPipeline(_addLog: (msg: string) => void): {
const golemApplyDamageToRoom = (dmg: number) => {
const cs = useCombatStore.getState();
if (dmg > 0) {
const newFloorHP = Math.max(0, cs.floorHP - dmg);
useCombatStore.setState({ floorHP: newFloorHP });
const room = cs.currentRoom;
if (!room || room.enemies.length === 0 || dmg <= 0) {
return { floorHP: cs.floorHP, floorMaxHP: cs.floorMaxHP, roomCleared: false };
}
const roomCleared = useCombatStore.getState().floorHP <= 0;
return { floorHP: cs.floorHP, floorMaxHP: cs.floorMaxHP, roomCleared };
// Golems use focus-fire targeting (spec §9.4) — target lowest HP enemy
const target = lowestHPEnemy(room.enemies);
if (!target) {
return { floorHP: cs.floorHP, floorMaxHP: cs.floorMaxHP, roomCleared: false };
}
const updatedEnemies = room.enemies.map((enemy) => {
if (enemy.id === target.id && enemy.hp > 0) {
return { ...enemy, hp: Math.max(0, enemy.hp - dmg) };
}
return enemy;
});
const newFloorHP = updatedEnemies.reduce((sum, e) => sum + e.hp, 0);
const allDead = updatedEnemies.every((e) => e.hp <= 0);
useCombatStore.setState({
currentRoom: { ...room, enemies: updatedEnemies },
floorHP: newFloorHP,
});
return { floorHP: newFloorHP, floorMaxHP: cs.floorMaxHP, roomCleared: allDead };
};
return { activeGolems, golemApplyDamageToRoom };