feat: implement per-enemy damage application (spec §3.2)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m6s
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:
@@ -24,7 +24,7 @@ function resetStores() {
|
||||
currentAction: 'climb',
|
||||
castProgress: 0,
|
||||
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: {},
|
||||
climbDirection: 'up',
|
||||
isDescending: false,
|
||||
@@ -143,7 +143,7 @@ describe('processCombatTick', () => {
|
||||
it('should advance room when HP reaches 0 (not last room)', () => {
|
||||
// Set floor HP very low so it's cleared in one cast
|
||||
// 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 result = runCombatTick(1000, elements);
|
||||
// Room advanced, floor stays the same
|
||||
@@ -154,7 +154,7 @@ describe('processCombatTick', () => {
|
||||
|
||||
it('should advance floor when last room on floor is cleared', () => {
|
||||
// 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 result = runCombatTick(1000, elements);
|
||||
expect(result.currentFloor).toBe(2);
|
||||
|
||||
@@ -189,7 +189,7 @@ describe('melee auto-attack in processCombatTick', () => {
|
||||
const elements = makeInitialElements(500, {});
|
||||
const sword = makeSwordInstance('ironBlade');
|
||||
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++) {
|
||||
result = runCombatTickWithSwords(1000, elements, equippedSwords);
|
||||
}
|
||||
@@ -221,7 +221,7 @@ describe('melee auto-attack in processCombatTick', () => {
|
||||
const sword = makeSwordInstance('ironBlade');
|
||||
sword.instanceId = 'test-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
|
||||
for (let i = 0; i < 25; i++) {
|
||||
result = runCombatTickWithSwords(1000, elements, equippedSwords);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user