diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index ce9c977..e802214 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,4 +1,4 @@ # Circular Dependencies -Generated: 2026-06-03T13:40:52.900Z +Generated: 2026-06-04T09:16:19.999Z No circular dependencies found. ✅ diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index dc07048..6354d02 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_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.", "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/rooms.ts", "constants/spells.ts", + "data/equipment/equipment-types-data.ts", "types/game.ts" ], "constants/prestige.ts": [ @@ -532,11 +533,17 @@ "constants.ts", "data/guardian-encounters.ts", "effects/discipline-effects.ts", + "stores/combat-damage.ts", "stores/combat-state.types.ts", + "stores/dot-runtime.ts", "stores/golem-combat-actions.ts", "types.ts", "utils/index.ts" ], + "stores/combat-damage.ts": [ + "stores/combat-state.types.ts", + "types.ts" + ], "stores/combat-descent-actions.ts": [ "data/guardian-encounters.ts", "stores/combat-state.types.ts", @@ -623,6 +630,12 @@ "utils/discipline-math.ts", "utils/safe-persist.ts" ], + "stores/dot-runtime.ts": [ + "constants.ts", + "stores/combat-state.types.ts", + "types.ts", + "types/spells.ts" + ], "stores/gameActions.ts": [ "effects/discipline-effects.ts", "stores/attunementStore.ts", @@ -737,6 +750,7 @@ "stores/uiStore.ts" ], "stores/pipelines/golem-combat.ts": [ + "stores/combat-damage.ts", "stores/combatStore.ts", "stores/golem-combat-actions.ts", "stores/manaStore.ts", diff --git a/docs/project-structure.txt b/docs/project-structure.txt index 938fcaa..bd9fdb1 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -354,6 +354,7 @@ Mana-Loop/ │ │ │ │ │ └── pact-ritual.ts │ │ │ │ ├── attunementStore.ts │ │ │ │ ├── combat-actions.ts +│ │ │ │ ├── combat-damage.ts │ │ │ │ ├── combat-descent-actions.ts │ │ │ │ ├── combat-state.types.ts │ │ │ │ ├── combatStore.ts diff --git a/src/lib/game/__tests__/combat-actions.test.ts b/src/lib/game/__tests__/combat-actions.test.ts index 11d4e59..7a86b14 100644 --- a/src/lib/game/__tests__/combat-actions.test.ts +++ b/src/lib/game/__tests__/combat-actions.test.ts @@ -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); diff --git a/src/lib/game/__tests__/melee-auto-attack.test.ts b/src/lib/game/__tests__/melee-auto-attack.test.ts index dba9b23..e7b29d0 100644 --- a/src/lib/game/__tests__/melee-auto-attack.test.ts +++ b/src/lib/game/__tests__/melee-auto-attack.test.ts @@ -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); diff --git a/src/lib/game/stores/combat-actions.ts b/src/lib/game/stores/combat-actions.ts index e6f7d09..ba8ddfe 100644 --- a/src/lib/game/stores/combat-actions.ts +++ b/src/lib/game/stores/combat-actions.ts @@ -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; + currentRoom: FloorState; } +// ─── Main Combat Tick ────────────────────────────────────────────────────────── + export function processCombatTick( get: () => CombatStore, set: (state: Partial) => 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 CombatStore, + set: (state: Partial) => 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 }; +} diff --git a/src/lib/game/stores/combat-state.types.ts b/src/lib/game/stores/combat-state.types.ts index 94b653b..e8edfad 100644 --- a/src/lib/game/stores/combat-state.types.ts +++ b/src/lib/game/stores/combat-state.types.ts @@ -170,6 +170,7 @@ export interface CombatActions { equipmentSpellStates: EquipmentSpellState[]; activeGolems: ActiveGolem[]; meleeSwordProgress: Record; + currentRoom: FloorState; }; // Reset diff --git a/src/lib/game/stores/gameStore.ts b/src/lib/game/stores/gameStore.ts index 2c416bb..ba9229f 100644 --- a/src/lib/game/stores/gameStore.ts +++ b/src/lib/game/stores/gameStore.ts @@ -332,7 +332,7 @@ export const useGameStore = create()( 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') { diff --git a/src/lib/game/stores/pipelines/golem-combat.ts b/src/lib/game/stores/pipelines/golem-combat.ts index 2f78bc6..8c9a7c7 100644 --- a/src/lib/game/stores/pipelines/golem-combat.ts +++ b/src/lib/game/stores/pipelines/golem-combat.ts @@ -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 };