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
+1 -1
View File
@@ -1,4 +1,4 @@
# Circular Dependencies # Circular Dependencies
Generated: 2026-06-03T13:40:52.900Z Generated: 2026-06-04T09:16:19.999Z
No circular dependencies found. ✅ No circular dependencies found. ✅
+15 -1
View File
@@ -1,6 +1,6 @@
{ {
"_meta": { "_meta": {
"generated": "2026-06-03T13:40:50.953Z", "generated": "2026-06-04T09:16:18.073Z",
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.", "description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry." "usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
}, },
@@ -18,6 +18,7 @@
"constants/prestige.ts", "constants/prestige.ts",
"constants/rooms.ts", "constants/rooms.ts",
"constants/spells.ts", "constants/spells.ts",
"data/equipment/equipment-types-data.ts",
"types/game.ts" "types/game.ts"
], ],
"constants/prestige.ts": [ "constants/prestige.ts": [
@@ -532,11 +533,17 @@
"constants.ts", "constants.ts",
"data/guardian-encounters.ts", "data/guardian-encounters.ts",
"effects/discipline-effects.ts", "effects/discipline-effects.ts",
"stores/combat-damage.ts",
"stores/combat-state.types.ts", "stores/combat-state.types.ts",
"stores/dot-runtime.ts",
"stores/golem-combat-actions.ts", "stores/golem-combat-actions.ts",
"types.ts", "types.ts",
"utils/index.ts" "utils/index.ts"
], ],
"stores/combat-damage.ts": [
"stores/combat-state.types.ts",
"types.ts"
],
"stores/combat-descent-actions.ts": [ "stores/combat-descent-actions.ts": [
"data/guardian-encounters.ts", "data/guardian-encounters.ts",
"stores/combat-state.types.ts", "stores/combat-state.types.ts",
@@ -623,6 +630,12 @@
"utils/discipline-math.ts", "utils/discipline-math.ts",
"utils/safe-persist.ts" "utils/safe-persist.ts"
], ],
"stores/dot-runtime.ts": [
"constants.ts",
"stores/combat-state.types.ts",
"types.ts",
"types/spells.ts"
],
"stores/gameActions.ts": [ "stores/gameActions.ts": [
"effects/discipline-effects.ts", "effects/discipline-effects.ts",
"stores/attunementStore.ts", "stores/attunementStore.ts",
@@ -737,6 +750,7 @@
"stores/uiStore.ts" "stores/uiStore.ts"
], ],
"stores/pipelines/golem-combat.ts": [ "stores/pipelines/golem-combat.ts": [
"stores/combat-damage.ts",
"stores/combatStore.ts", "stores/combatStore.ts",
"stores/golem-combat-actions.ts", "stores/golem-combat-actions.ts",
"stores/manaStore.ts", "stores/manaStore.ts",
+1
View File
@@ -354,6 +354,7 @@ Mana-Loop/
│ │ │ │ │ └── pact-ritual.ts │ │ │ │ │ └── pact-ritual.ts
│ │ │ │ ├── attunementStore.ts │ │ │ │ ├── attunementStore.ts
│ │ │ │ ├── combat-actions.ts │ │ │ │ ├── combat-actions.ts
│ │ │ │ ├── combat-damage.ts
│ │ │ │ ├── combat-descent-actions.ts │ │ │ │ ├── combat-descent-actions.ts
│ │ │ │ ├── combat-state.types.ts │ │ │ │ ├── combat-state.types.ts
│ │ │ │ ├── combatStore.ts │ │ │ │ ├── combatStore.ts
@@ -24,7 +24,7 @@ function resetStores() {
currentAction: 'climb', currentAction: 'climb',
castProgress: 0, castProgress: 0,
spireMode: false, spireMode: false,
currentRoom: { roomType: 'combat', enemies: [] }, currentRoom: { roomType: 'combat', enemies: [{ id: 'enemy', name: 'Test Enemy', hp: getFloorMaxHP(1), maxHP: getFloorMaxHP(1), armor: 0, dodgeChance: 0, barrier: 0, element: 'fire', activeEffects: [], effectiveArmor: 0 }] },
clearedFloors: {}, clearedFloors: {},
climbDirection: 'up', climbDirection: 'up',
isDescending: false, isDescending: false,
@@ -143,7 +143,7 @@ describe('processCombatTick', () => {
it('should advance room when HP reaches 0 (not last room)', () => { it('should advance room when HP reaches 0 (not last room)', () => {
// Set floor HP very low so it's cleared in one cast // Set floor HP very low so it's cleared in one cast
// currentRoomIndex=0, roomsPerFloor=5 → clears room 0, advances to room 1 // currentRoomIndex=0, roomsPerFloor=5 → clears room 0, advances to room 1
useCombatStore.setState({ floorHP: 1, castProgress: 0.99, climbDirection: 'up', currentRoomIndex: 0, roomsPerFloor: 5 }); useCombatStore.setState({ castProgress: 0.99, climbDirection: 'up', currentRoomIndex: 0, roomsPerFloor: 5, currentRoom: { roomType: 'combat', enemies: [{ id: 'enemy', name: 'Test Enemy', hp: 1, maxHP: 100, armor: 0, dodgeChance: 0, barrier: 0, element: 'fire', activeEffects: [], effectiveArmor: 0 }] } });
const elements = makeInitialElements(500, {}); const elements = makeInitialElements(500, {});
const result = runCombatTick(1000, elements); const result = runCombatTick(1000, elements);
// Room advanced, floor stays the same // Room advanced, floor stays the same
@@ -154,7 +154,7 @@ describe('processCombatTick', () => {
it('should advance floor when last room on floor is cleared', () => { it('should advance floor when last room on floor is cleared', () => {
// Set currentRoomIndex to last room so clearing it advances the floor // Set currentRoomIndex to last room so clearing it advances the floor
useCombatStore.setState({ floorHP: 1, castProgress: 0.99, climbDirection: 'up', currentRoomIndex: 4, roomsPerFloor: 5 }); useCombatStore.setState({ castProgress: 0.99, climbDirection: 'up', currentRoomIndex: 4, roomsPerFloor: 5, currentRoom: { roomType: 'combat', enemies: [{ id: 'enemy', name: 'Test Enemy', hp: 1, maxHP: 100, armor: 0, dodgeChance: 0, barrier: 0, element: 'fire', activeEffects: [], effectiveArmor: 0 }] } });
const elements = makeInitialElements(500, {}); const elements = makeInitialElements(500, {});
const result = runCombatTick(1000, elements); const result = runCombatTick(1000, elements);
expect(result.currentFloor).toBe(2); expect(result.currentFloor).toBe(2);
@@ -189,7 +189,7 @@ describe('melee auto-attack in processCombatTick', () => {
const elements = makeInitialElements(500, {}); const elements = makeInitialElements(500, {});
const sword = makeSwordInstance('ironBlade'); const sword = makeSwordInstance('ironBlade');
const equippedSwords = { [sword.instanceId]: sword }; const equippedSwords = { [sword.instanceId]: sword };
let result: CombatTickResult = { rawMana: 1000, elements, logMessages: [], totalManaGathered: 0, currentFloor: 1, floorHP: 100, floorMaxHP: 100, maxFloorReached: 1, castProgress: 0, equipmentSpellStates: [], activeGolems: [], meleeSwordProgress: {} }; let result: CombatTickResult = { rawMana: 1000, elements, logMessages: [], totalManaGathered: 0, currentFloor: 1, floorHP: 100, floorMaxHP: 100, maxFloorReached: 1, castProgress: 0, equipmentSpellStates: [], activeGolems: [], meleeSwordProgress: {}, currentRoom: { roomType: 'combat', enemies: [] } };
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
result = runCombatTickWithSwords(1000, elements, equippedSwords); result = runCombatTickWithSwords(1000, elements, equippedSwords);
} }
@@ -221,7 +221,7 @@ describe('melee auto-attack in processCombatTick', () => {
const sword = makeSwordInstance('ironBlade'); const sword = makeSwordInstance('ironBlade');
sword.instanceId = 'test-sword'; sword.instanceId = 'test-sword';
const equippedSwords = { [sword.instanceId]: sword }; const equippedSwords = { [sword.instanceId]: sword };
let result: CombatTickResult = { rawMana: 1000, elements, logMessages: [], totalManaGathered: 0, currentFloor: 1, floorHP: 100000, floorMaxHP: 100000, maxFloorReached: 1, castProgress: 0, equipmentSpellStates: [], activeGolems: [], meleeSwordProgress: {} }; let result: CombatTickResult = { rawMana: 1000, elements, logMessages: [], totalManaGathered: 0, currentFloor: 1, floorHP: 100000, floorMaxHP: 100000, maxFloorReached: 1, castProgress: 0, equipmentSpellStates: [], activeGolems: [], meleeSwordProgress: {}, currentRoom: { roomType: 'combat', enemies: [] } };
// Run 25 ticks: each tick adds 0.048 progress, so 25 * 0.048 = 1.2, triggering a hit // Run 25 ticks: each tick adds 0.048 progress, so 25 * 0.048 = 1.2, triggering a hit
for (let i = 0; i < 25; i++) { for (let i = 0; i < 25; i++) {
result = runCombatTickWithSwords(1000, elements, equippedSwords); result = runCombatTickWithSwords(1000, elements, equippedSwords);
+56 -70
View File
@@ -5,12 +5,15 @@
import { SPELLS_DEF, HOURS_PER_TICK, EQUIPMENT_TYPES } from '../constants'; import { SPELLS_DEF, HOURS_PER_TICK, EQUIPMENT_TYPES } from '../constants';
import { getGuardianForFloor } from '../data/guardian-encounters'; import { getGuardianForFloor } from '../data/guardian-encounters';
import type { CombatStore, CombatState } from './combat-state.types'; 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 { applyOnHitEffect, processDoTPhase } from './dot-runtime';
import type { ActiveGolem } from '../types'; import type { ActiveGolem } from '../types';
import { getFloorMaxHP, getFloorElement, getMultiElementBonus, calcDamage, calcMeleeDamage, canAffordSpellCost, deductSpellCost } from '../utils'; import { getFloorMaxHP, getFloorElement, getMultiElementBonus, calcDamage, calcMeleeDamage, canAffordSpellCost, deductSpellCost } from '../utils';
import { computeDisciplineEffects } from '../effects/discipline-effects'; import { computeDisciplineEffects } from '../effects/discipline-effects';
import { processGolemMaintenance, processGolemAttacks } from './golem-combat-actions'; import { processGolemMaintenance, processGolemAttacks } from './golem-combat-actions';
import { applyDamageToRoom } from './combat-damage';
// ─── Result Type ───────────────────────────────────────────────────────────────
/** /**
* Create a default CombatTickResult for safe fallback on error. * Create a default CombatTickResult for safe fallback on error.
@@ -34,6 +37,7 @@ function makeDefaultCombatTickResult(
equipmentSpellStates: state.equipmentSpellStates, equipmentSpellStates: state.equipmentSpellStates,
activeGolems, activeGolems,
meleeSwordProgress: state.meleeSwordProgress, meleeSwordProgress: state.meleeSwordProgress,
currentRoom: state.currentRoom,
}; };
} }
@@ -50,8 +54,11 @@ export interface CombatTickResult {
equipmentSpellStates: CombatState['equipmentSpellStates']; equipmentSpellStates: CombatState['equipmentSpellStates'];
activeGolems: ActiveGolem[]; activeGolems: ActiveGolem[];
meleeSwordProgress: Record<string, number>; meleeSwordProgress: Record<string, number>;
currentRoom: FloorState;
} }
// ─── Main Combat Tick ──────────────────────────────────────────────────────────
export function processCombatTick( export function processCombatTick(
get: () => CombatStore, get: () => CombatStore,
set: (state: Partial<CombatState>) => void, set: (state: Partial<CombatState>) => void,
@@ -108,81 +115,65 @@ export function processCombatTick(
let currentFloor = state.currentFloor; let currentFloor = state.currentFloor;
let floorMaxHP = state.floorMaxHP; let floorMaxHP = state.floorMaxHP;
let castProgress = state.castProgress; let castProgress = state.castProgress;
let currentRoom = state.currentRoom;
const updatedEquipmentSpellStates = [...state.equipmentSpellStates]; const updatedEquipmentSpellStates = [...state.equipmentSpellStates];
// ─── Spell casting (only when a valid spell is configured) ──────────────── // ─── Spell casting (only when a valid spell is configured) ────────────────
if (spellDef) { if (spellDef) {
// Compute discipline bonuses once per tick
const disciplineEffects = computeDisciplineEffects(); const disciplineEffects = computeDisciplineEffects();
// Calculate cast speed (no skill bonus)
const totalAttackSpeed = attackSpeedMult; const totalAttackSpeed = attackSpeedMult;
const spellCastSpeed = spellDef.castSpeed || 1; const spellCastSpeed = spellDef.castSpeed || 1;
const progressPerTick = HOURS_PER_TICK * spellCastSpeed * totalAttackSpeed; const progressPerTick = HOURS_PER_TICK * spellCastSpeed * totalAttackSpeed;
const isSpellAoe = !!spellDef.isAoe;
castProgress = (castProgress || 0) + progressPerTick; castProgress = (castProgress || 0) + progressPerTick;
// Process complete casts for active spell (safety counter prevents infinite loop) // Process complete casts for active spell
let safetyCounter = 0; let safetyCounter = 0;
const MAX_CASTS_PER_TICK = 100; const MAX_CASTS_PER_TICK = 100;
while (castProgress >= 1 && canAffordSpellCost(spellDef.cost, rawMana, elements) && safetyCounter < MAX_CASTS_PER_TICK) { while (castProgress >= 1 && canAffordSpellCost(spellDef.cost, rawMana, elements) && safetyCounter < MAX_CASTS_PER_TICK) {
// Deduct spell cost
const afterCost = deductSpellCost(spellDef.cost, rawMana, elements); const afterCost = deductSpellCost(spellDef.cost, rawMana, elements);
rawMana = afterCost.rawMana; rawMana = afterCost.rawMana;
elements = afterCost.elements; elements = afterCost.elements;
// Calculate base damage (without elemental bonus first)
const floorElement = getFloorElement(currentFloor); const floorElement = getFloorElement(currentFloor);
const baseDamage = calcDamage( const baseDamage = calcDamage({ signedPacts }, spellId, undefined, disciplineEffects);
{ signedPacts },
spellId,
undefined,
disciplineEffects,
);
// Apply elemental bonus — for multi-element guardians, use all elements
const guardian = getGuardianForFloor(currentFloor); const guardian = getGuardianForFloor(currentFloor);
const floorElems = guardian && guardian.element.length > 0 const floorElems = guardian && guardian.element.length > 0 ? guardian.element : [floorElement];
? guardian.element
: [floorElement];
const multiElemBonus = getMultiElementBonus(spellDef.elem, floorElems); const multiElemBonus = getMultiElementBonus(spellDef.elem, floorElems);
const damage = baseDamage * multiElemBonus; const damage = baseDamage * multiElemBonus;
// Let gameStore apply damage modifiers (executioner, berserker)
const result = onDamageDealt(damage); const result = onDamageDealt(damage);
rawMana = result.rawMana; rawMana = result.rawMana;
elements = result.elements; elements = result.elements;
const finalDamage = result.modifiedDamage || damage; const finalDamage = result.modifiedDamage || damage;
// Guard against NaN damage — if damage is not finite, stop processing
if (!Number.isFinite(finalDamage)) { if (!Number.isFinite(finalDamage)) {
logMessages.push('⚠️ Combat stopped: invalid damage value'); logMessages.push('⚠️ Combat stopped: invalid damage value');
break; break;
} }
// Apply damage // Apply damage per-enemy (spec §3.2)
floorHP = Math.max(0, floorHP - finalDamage); const roomResult = applyDamageToRoom(get, set, finalDamage, isSpellAoe);
floorHP = roomResult.floorHP;
floorMaxHP = roomResult.floorMaxHP;
currentRoom = get().currentRoom;
castProgress -= 1; castProgress -= 1;
safetyCounter++; safetyCounter++;
// Apply on-hit effect (DoT/debuff) to enemy (spec §6.2)
applyOnHitEffect(get, set, spellId, logMessages); applyOnHitEffect(get, set, spellId, logMessages);
currentRoom = get().currentRoom;
// Check if room/floor is cleared if (roomResult.roomCleared) {
if (floorHP <= 0) {
const guardian = getGuardianForFloor(currentFloor); const guardian = getGuardianForFloor(currentFloor);
onFloorCleared(currentFloor, !!guardian); 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(); get().advanceRoomOrFloor();
// Re-read state after advancement
const newState = get(); const newState = get();
currentFloor = newState.currentFloor; currentFloor = newState.currentFloor;
floorMaxHP = newState.floorMaxHP; floorMaxHP = newState.floorMaxHP;
floorHP = newState.floorHP; floorHP = newState.floorHP;
currentRoom = newState.currentRoom;
castProgress = 0; castProgress = 0;
if (guardian) { if (guardian) {
logMessages.push(`⚔️ ${guardian.name} defeated!`); logMessages.push(`⚔️ ${guardian.name} defeated!`);
} else if (currentFloor % 5 === 0) { } 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++) { for (let i = 0; i < updatedEquipmentSpellStates.length; i++) {
const eSpell = updatedEquipmentSpellStates[i]; const eSpell = updatedEquipmentSpellStates[i];
const eSpellDef = SPELLS_DEF[eSpell.spellId]; const eSpellDef = SPELLS_DEF[eSpell.spellId];
if (!eSpellDef) continue; if (!eSpellDef) continue;
// Calculate progress for this equipment spell const isESpellAoe = !!eSpellDef.isAoe;
const eSpellCastSpeed = eSpellDef.castSpeed || 1; const eSpellCastSpeed = eSpellDef.castSpeed || 1;
const eProgressPerTick = HOURS_PER_TICK * eSpellCastSpeed * attackSpeedMult; const eProgressPerTick = HOURS_PER_TICK * eSpellCastSpeed * attackSpeedMult;
let eCastProgress = (eSpell.castProgress || 0) + eProgressPerTick; let eCastProgress = (eSpell.castProgress || 0) + eProgressPerTick;
// Process complete casts for equipment spells (safety counter prevents infinite loop)
let eSafetyCounter = 0; let eSafetyCounter = 0;
const MAX_E_CASTS_PER_TICK = 100; const MAX_E_CASTS_PER_TICK = 100;
while (eCastProgress >= 1 && canAffordSpellCost(eSpellDef.cost, rawMana, elements) && eSafetyCounter < MAX_E_CASTS_PER_TICK) { while (eCastProgress >= 1 && canAffordSpellCost(eSpellDef.cost, rawMana, elements) && eSafetyCounter < MAX_E_CASTS_PER_TICK) {
// Deduct cost
const eAfterCost = deductSpellCost(eSpellDef.cost, rawMana, elements); const eAfterCost = deductSpellCost(eSpellDef.cost, rawMana, elements);
rawMana = eAfterCost.rawMana; rawMana = eAfterCost.rawMana;
elements = eAfterCost.elements; elements = eAfterCost.elements;
// Calculate damage — for multi-element guardians, use all elements
const eFloorElement = getFloorElement(currentFloor); const eFloorElement = getFloorElement(currentFloor);
const eBaseDamage = calcDamage( const eBaseDamage = calcDamage({ signedPacts }, eSpell.spellId, undefined, disciplineEffects);
{ signedPacts },
eSpell.spellId,
undefined,
disciplineEffects,
);
const eGuardian = getGuardianForFloor(currentFloor); const eGuardian = getGuardianForFloor(currentFloor);
const eFloorElems = eGuardian && eGuardian.element.length > 0 const eFloorElems = eGuardian && eGuardian.element.length > 0 ? eGuardian.element : [eFloorElement];
? eGuardian.element
: [eFloorElement];
const eMultiElemBonus = getMultiElementBonus(eSpellDef.elem, eFloorElems); const eMultiElemBonus = getMultiElementBonus(eSpellDef.elem, eFloorElems);
const eDamage = eBaseDamage * eMultiElemBonus; const eDamage = eBaseDamage * eMultiElemBonus;
@@ -230,26 +212,25 @@ export function processCombatTick(
elements = eResult.elements; elements = eResult.elements;
const eFinalDamage = eResult.modifiedDamage || eDamage; 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; eCastProgress -= 1;
eSafetyCounter++; eSafetyCounter++;
if (floorHP <= 0) { if (eRoomResult.roomCleared) {
const eGuardian = getGuardianForFloor(currentFloor); const eGuardian = getGuardianForFloor(currentFloor);
onFloorCleared(currentFloor, !!eGuardian); onFloorCleared(currentFloor, !!eGuardian);
// ── Spec: room-aware advancement ─────────────────────────────────
get().advanceRoomOrFloor(); get().advanceRoomOrFloor();
const newState = get(); const newState = get();
currentFloor = newState.currentFloor; currentFloor = newState.currentFloor;
floorMaxHP = newState.floorMaxHP; floorMaxHP = newState.floorMaxHP;
floorHP = newState.floorHP; floorHP = newState.floorHP;
currentRoom = newState.currentRoom;
eCastProgress = 0; eCastProgress = 0;
if (eGuardian) { if (eGuardian) {
logMessages.push(`⚔️ ${eGuardian.name} defeated!`); logMessages.push(`⚔️ ${eGuardian.name} defeated!`);
@@ -260,19 +241,16 @@ export function processCombatTick(
} }
} }
// Update equipment spell state
updatedEquipmentSpellStates[i] = { ...eSpell, castProgress: eCastProgress % 1 }; updatedEquipmentSpellStates[i] = { ...eSpell, castProgress: eCastProgress % 1 };
} }
} }
// ─── Melee sword attacks (spec §3.1, §4.3) ──────────────────────────── // ─── Melee sword attacks (spec §3.1, §4.3) ────────────────────────────
// Melee: no mana cost, no Executioner/Berserker, elemental matchup applies
const updatedMeleeSwordProgress = { ...state.meleeSwordProgress }; const updatedMeleeSwordProgress = { ...state.meleeSwordProgress };
const floorElement = getFloorElement(currentFloor); const floorElement = getFloorElement(currentFloor);
const guardian = getGuardianForFloor(currentFloor); const guardian = getGuardianForFloor(currentFloor);
const floorElems = guardian && guardian.element.length > 0 ? guardian.element : [floorElement]; const floorElems = guardian && guardian.element.length > 0 ? guardian.element : [floorElement];
if (equippedSwords && Object.keys(equippedSwords).length > 0 && floorHP > 0) { if (equippedSwords && Object.keys(equippedSwords).length > 0 && floorHP > 0) {
for (const [instanceId, swordInstance] of Object.entries(equippedSwords)) { for (const [instanceId, swordInstance] of Object.entries(equippedSwords)) {
const swordType = EQUIPMENT_TYPES[swordInstance.typeId]; const swordType = EQUIPMENT_TYPES[swordInstance.typeId];
@@ -283,12 +261,18 @@ export function processCombatTick(
let meleeSafetyCounter = 0; let meleeSafetyCounter = 0;
while (meleeProgress >= 1 && meleeSafetyCounter < 100) { while (meleeProgress >= 1 && meleeSafetyCounter < 100) {
const meleeDamage = calcMeleeDamage(swordInstance as EquipmentInstance, swordType, floorElems[0]); 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; 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; meleeProgress -= 1;
meleeSafetyCounter++; meleeSafetyCounter++;
if (floorHP <= 0) {
if (meleeRoomResult.roomCleared) {
const g = getGuardianForFloor(currentFloor); const g = getGuardianForFloor(currentFloor);
onFloorCleared(currentFloor, !!g); onFloorCleared(currentFloor, !!g);
get().advanceRoomOrFloor(); get().advanceRoomOrFloor();
@@ -296,6 +280,7 @@ export function processCombatTick(
currentFloor = ns.currentFloor; currentFloor = ns.currentFloor;
floorMaxHP = ns.floorMaxHP; floorMaxHP = ns.floorMaxHP;
floorHP = ns.floorHP; floorHP = ns.floorHP;
currentRoom = ns.currentRoom;
meleeProgress = 0; meleeProgress = 0;
break; break;
} }
@@ -305,8 +290,6 @@ export function processCombatTick(
} }
// ─── Golem attacks (spec §9.4) ─────────────────────────────────────────── // ─── 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) { if (activeGolems.length > 0 && floorHP > 0) {
const golemResult = processGolemAttacks( const golemResult = processGolemAttacks(
activeGolems, activeGolems,
@@ -323,20 +306,23 @@ export function processCombatTick(
activeGolems = golemResult.activeGolems; activeGolems = golemResult.activeGolems;
logMessages.push(...golemResult.logMessages); logMessages.push(...golemResult.logMessages);
// Read back floor state after golem damage
const postGolemState = get(); const postGolemState = get();
floorHP = postGolemState.floorHP; floorHP = postGolemState.floorHP;
floorMaxHP = postGolemState.floorMaxHP; floorMaxHP = postGolemState.floorMaxHP;
currentFloor = postGolemState.currentFloor; currentFloor = postGolemState.currentFloor;
currentRoom = postGolemState.currentRoom;
} }
// ─── DoT/Debuff tick processing (spec §6.3) ───────────────────────────── // ─── DoT/Debuff tick processing (spec §6.3) ─────────────────────────────
// Process after all weapon/golem attacks
if (floorHP > 0) { if (floorHP > 0) {
const doTDamage = processDoTPhase(get, set, applyEnemyDefenses, logMessages); processDoTPhase(get, set, applyEnemyDefenses, logMessages);
floorHP = Math.max(0, floorHP - doTDamage); // 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) { if (floorHP <= 0) {
const guardian = getGuardianForFloor(currentFloor); const guardian = getGuardianForFloor(currentFloor);
onFloorCleared(currentFloor, !!guardian); onFloorCleared(currentFloor, !!guardian);
@@ -345,6 +331,7 @@ export function processCombatTick(
currentFloor = newState.currentFloor; currentFloor = newState.currentFloor;
floorMaxHP = newState.floorMaxHP; floorMaxHP = newState.floorMaxHP;
floorHP = newState.floorHP; floorHP = newState.floorHP;
currentRoom = newState.currentRoom;
} }
} }
@@ -357,15 +344,15 @@ export function processCombatTick(
totalManaGathered, totalManaGathered,
currentFloor, currentFloor,
floorHP, floorHP,
floorMaxHP: getFloorMaxHP(currentFloor), floorMaxHP,
maxFloorReached: newMaxFloorReached, maxFloorReached: newMaxFloorReached,
castProgress, castProgress,
equipmentSpellStates: updatedEquipmentSpellStates, equipmentSpellStates: updatedEquipmentSpellStates,
activeGolems, activeGolems,
meleeSwordProgress: updatedMeleeSwordProgress, meleeSwordProgress: updatedMeleeSwordProgress,
currentRoom,
}; };
} catch (error) { } catch (error) {
// Return safe defaults on error — combat tick should never crash the game
const errorMsg = error instanceof Error ? error.message : String(error); const errorMsg = error instanceof Error ? error.message : String(error);
logMessages.push(`⚠️ Combat error: ${errorMsg}`); logMessages.push(`⚠️ Combat error: ${errorMsg}`);
return makeDefaultCombatTickResult(rawMana, elements, state, golemancyState.activeGolems); return makeDefaultCombatTickResult(rawMana, elements, state, golemancyState.activeGolems);
@@ -378,7 +365,6 @@ export function makeInitialSpells(spellsToKeep: string[] = []): Record<string, S
manaBolt: { learned: true, level: 1, studyProgress: 0 }, manaBolt: { learned: true, level: 1, studyProgress: 0 },
}; };
// Add kept spells
for (const spellId of spellsToKeep) { for (const spellId of spellsToKeep) {
startSpells[spellId] = { learned: true, level: 1, studyProgress: 0 }; startSpells[spellId] = { learned: true, level: 1, studyProgress: 0 };
} }
+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[]; equipmentSpellStates: EquipmentSpellState[];
activeGolems: ActiveGolem[]; activeGolems: ActiveGolem[];
meleeSwordProgress: Record<string, number>; meleeSwordProgress: Record<string, number>;
currentRoom: FloorState;
}; };
// Reset // Reset
+1 -1
View File
@@ -332,7 +332,7 @@ export const useGameStore = create<GameCoordinatorStore>()(
elements = combatResult.elements; elements = combatResult.elements;
totalManaGathered += combatResult.totalManaGathered || 0; totalManaGathered += combatResult.totalManaGathered || 0;
if (combatResult.logMessages) combatResult.logMessages.forEach(msg => addLog(msg)); 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') { if (ctx.combat.currentAction === 'craft') {
+28 -6
View File
@@ -5,7 +5,8 @@
import { useCombatStore } from '../combatStore'; import { useCombatStore } from '../combatStore';
import { useManaStore } from '../manaStore'; import { useManaStore } from '../manaStore';
import { processGolemRoomDuration } from '../golem-combat-actions'; 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 { export interface GolemCombatContext {
addLog: (msg: string) => void; addLog: (msg: string) => void;
@@ -38,12 +39,33 @@ export function buildGolemCombatPipeline(_addLog: (msg: string) => void): {
const golemApplyDamageToRoom = (dmg: number) => { const golemApplyDamageToRoom = (dmg: number) => {
const cs = useCombatStore.getState(); const cs = useCombatStore.getState();
if (dmg > 0) { const room = cs.currentRoom;
const newFloorHP = Math.max(0, cs.floorHP - dmg); if (!room || room.enemies.length === 0 || dmg <= 0) {
useCombatStore.setState({ floorHP: newFloorHP }); 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 }; return { activeGolems, golemApplyDamageToRoom };