feat: implement sword/melee auto-attack system (spec §3.1, §4.3)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m1s

- Add calcMeleeDamage() with elemental matchup for enchanted swords
- Add meleeSwordProgress per-instance accumulator to combat state
- Add melee branch in processCombatTick (no mana cost, no Executioner/Berserker)
- Add baseDamage/attackSpeed stats to all 5 sword types
- Wire equippedSwords through gameStore to combat tick pipeline
- 16 new regression tests, all 937 tests pass
This commit is contained in:
2026-06-03 21:59:30 +02:00
parent b506f0bcc3
commit 8dde423526
10 changed files with 564 additions and 183 deletions
+195 -179
View File
@@ -2,14 +2,13 @@
// Pure combat logic — no cross-store getState() calls.
// All external data (signedPacts, etc.) is passed in as parameters.
import { SPELLS_DEF, HOURS_PER_TICK } from '../constants';
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 } from '../types';
import type { SpellState, EnemyState, EquipmentInstance } from '../types';
import { applyOnHitEffect, processDoTPhase } from './dot-runtime';
import type { ActiveGolem } from '../types';
import type { SpellOnHitEffect } from '../types/spells';
import { getFloorMaxHP, getFloorElement, getMultiElementBonus, calcDamage, canAffordSpellCost, deductSpellCost } from '../utils';
import { getFloorMaxHP, getFloorElement, getMultiElementBonus, calcDamage, calcMeleeDamage, canAffordSpellCost, deductSpellCost } from '../utils';
import { computeDisciplineEffects } from '../effects/discipline-effects';
import { processGolemMaintenance, processGolemAttacks } from './golem-combat-actions';
@@ -34,6 +33,7 @@ function makeDefaultCombatTickResult(
castProgress: state.castProgress,
equipmentSpellStates: state.equipmentSpellStates,
activeGolems,
meleeSwordProgress: state.meleeSwordProgress,
};
}
@@ -49,6 +49,7 @@ export interface CombatTickResult {
castProgress: number;
equipmentSpellStates: CombatState['equipmentSpellStates'];
activeGolems: ActiveGolem[];
meleeSwordProgress: Record<string, number>;
}
export function processCombatTick(
@@ -75,6 +76,7 @@ export function processCombatTick(
bypassArmor?: boolean,
bypassBarrier?: boolean,
) => number,
equippedSwords?: Record<string, EquipmentInstance>,
): CombatTickResult {
const state = get();
const logMessages: string[] = [];
@@ -84,212 +86,225 @@ export function processCombatTick(
return makeDefaultCombatTickResult(rawMana, elements, state, golemancyState.activeGolems);
}
// ─── Golem maintenance (spec §9.5) ──────────────────────────────────────
const maintenanceResult = processGolemMaintenance(
golemancyState.activeGolems,
rawMana,
elements,
);
let activeGolems = maintenanceResult.maintainedGolems;
rawMana = maintenanceResult.rawMana;
elements = maintenanceResult.elements;
logMessages.push(...maintenanceResult.logMessages);
// Write maintained golems back immediately so tick state stays consistent
set({ golemancy: { ...state.golemancy, activeGolems } });
const spellId = state.activeSpell;
const spellDef = SPELLS_DEF[spellId];
if (!spellDef) {
// Even if no spell is configured, golems can still fight
// Process golem attacks even without a valid spell
if (activeGolems.length > 0) {
const golemAttackResult = processGolemAttacks(
activeGolems,
rawMana,
elements,
state.floorHP,
state.floorMaxHP,
state.currentFloor,
onDamageDealt,
golemApplyDamageToRoom,
);
rawMana = golemAttackResult.rawMana;
elements = golemAttackResult.elements;
activeGolems = golemAttackResult.activeGolems;
logMessages.push(...golemAttackResult.logMessages);
// Check if golems cleared the room
const newFloorHP = golemApplyDamageToRoom(0);
if (newFloorHP.roomCleared || golemAttackResult.totalDamageDealt > 0) {
// Re-check floor state after golem attacks
const newState = get();
if (newState.floorHP <= 0) {
return makeDefaultCombatTickResult(rawMana, elements, state, activeGolems);
}
}
}
return makeDefaultCombatTickResult(rawMana, elements, state, activeGolems);
}
try {
// Compute discipline bonuses once per tick
const disciplineEffects = computeDisciplineEffects();
// ─── Golem maintenance (spec §9.5) ──────────────────────────────────────
const maintenanceResult = processGolemMaintenance(
golemancyState.activeGolems,
rawMana,
elements,
);
let activeGolems = maintenanceResult.maintainedGolems;
rawMana = maintenanceResult.rawMana;
elements = maintenanceResult.elements;
logMessages.push(...maintenanceResult.logMessages);
// Calculate cast speed (no skill bonus)
const totalAttackSpeed = attackSpeedMult;
const spellCastSpeed = spellDef.castSpeed || 1;
const progressPerTick = HOURS_PER_TICK * spellCastSpeed * totalAttackSpeed;
// Write maintained golems back immediately so tick state stays consistent
set({ golemancy: { ...state.golemancy, activeGolems } });
const spellId = state.activeSpell;
const spellDef = SPELLS_DEF[spellId];
let castProgress = (state.castProgress || 0) + progressPerTick;
let floorHP = state.floorHP;
let currentFloor = state.currentFloor;
let floorMaxHP = state.floorMaxHP;
// Process complete casts for active spell (safety counter prevents infinite loop)
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 guardian = getGuardianForFloor(currentFloor);
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);
castProgress -= 1;
safetyCounter++;
// Apply on-hit effect (DoT/debuff) to enemy (spec §6.2)
applyOnHitEffect(get, set, spellId, logMessages);
// Check if room/floor is cleared
if (floorHP <= 0) {
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;
castProgress = 0;
if (guardian) {
logMessages.push(`\u2694\ufe0f ${guardian.name} defeated!`);
} else if (currentFloor % 5 === 0) {
logMessages.push(`\ud83c\udff0 Floor ${currentFloor - 1} cleared!`);
}
}
}
// Process equipment spell states (for progress bars in UI)
let castProgress = state.castProgress;
const updatedEquipmentSpellStates = [...state.equipmentSpellStates];
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 eSpellCastSpeed = eSpellDef.castSpeed || 1;
const eProgressPerTick = HOURS_PER_TICK * eSpellCastSpeed * totalAttackSpeed;
let eCastProgress = (eSpell.castProgress || 0) + eProgressPerTick;
// ─── Spell casting (only when a valid spell is configured) ────────────────
if (spellDef) {
// Compute discipline bonuses once per tick
const disciplineEffects = computeDisciplineEffects();
// 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(
// Calculate cast speed (no skill bonus)
const totalAttackSpeed = attackSpeedMult;
const spellCastSpeed = spellDef.castSpeed || 1;
const progressPerTick = HOURS_PER_TICK * spellCastSpeed * totalAttackSpeed;
castProgress = (castProgress || 0) + progressPerTick;
// Process complete casts for active spell (safety counter prevents infinite loop)
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 },
eSpell.spellId,
spellId,
undefined,
disciplineEffects,
);
const eGuardian = getGuardianForFloor(currentFloor);
const eFloorElems = eGuardian && eGuardian.element.length > 0
? eGuardian.element
: [eFloorElement];
const eMultiElemBonus = getMultiElementBonus(eSpellDef.elem, eFloorElems);
const eDamage = eBaseDamage * eMultiElemBonus;
// Apply elemental bonus — for multi-element guardians, use all elements
const guardian = getGuardianForFloor(currentFloor);
const floorElems = guardian && guardian.element.length > 0
? guardian.element
: [floorElement];
const multiElemBonus = getMultiElementBonus(spellDef.elem, floorElems);
const damage = baseDamage * multiElemBonus;
const eResult = onDamageDealt(eDamage);
rawMana = eResult.rawMana;
elements = eResult.elements;
const eFinalDamage = eResult.modifiedDamage || eDamage;
// 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 (!Number.isFinite(eFinalDamage)) {
// Guard against NaN damage — if damage is not finite, stop processing
if (!Number.isFinite(finalDamage)) {
logMessages.push('⚠️ Combat stopped: invalid damage value');
break;
}
floorHP = Math.max(0, floorHP - eFinalDamage);
eCastProgress -= 1;
eSafetyCounter++;
// Apply damage
floorHP = Math.max(0, floorHP - finalDamage);
castProgress -= 1;
safetyCounter++;
// Apply on-hit effect (DoT/debuff) to enemy (spec §6.2)
applyOnHitEffect(get, set, spellId, logMessages);
// Check if room/floor is cleared
if (floorHP <= 0) {
const eGuardian = getGuardianForFloor(currentFloor);
onFloorCleared(currentFloor, !!eGuardian);
const guardian = getGuardianForFloor(currentFloor);
onFloorCleared(currentFloor, !!guardian);
// ── Spec: room-aware advancement ─────────────────────────────────
// ── 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;
eCastProgress = 0;
if (eGuardian) {
logMessages.push(`\u2694\ufe0f ${eGuardian.name} defeated!`);
castProgress = 0;
if (guardian) {
logMessages.push(`⚔️ ${guardian.name} defeated!`);
} else if (currentFloor % 5 === 0) {
logMessages.push(`\ud83c\udff0 Floor ${currentFloor - 1} cleared!`);
logMessages.push(`🗺️ Floor ${currentFloor - 1} cleared!`);
}
break;
}
}
// Update equipment spell state
updatedEquipmentSpellStates[i] = { ...eSpell, castProgress: eCastProgress % 1 };
// Process equipment spell states (for progress bars in UI)
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 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 eGuardian = getGuardianForFloor(currentFloor);
const eFloorElems = eGuardian && eGuardian.element.length > 0
? eGuardian.element
: [eFloorElement];
const eMultiElemBonus = getMultiElementBonus(eSpellDef.elem, eFloorElems);
const eDamage = eBaseDamage * eMultiElemBonus;
const eResult = onDamageDealt(eDamage);
rawMana = eResult.rawMana;
elements = eResult.elements;
const eFinalDamage = eResult.modifiedDamage || eDamage;
// Guard against NaN damage
if (!Number.isFinite(eFinalDamage)) {
break;
}
floorHP = Math.max(0, floorHP - eFinalDamage);
eCastProgress -= 1;
eSafetyCounter++;
if (floorHP <= 0) {
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;
eCastProgress = 0;
if (eGuardian) {
logMessages.push(`⚔️ ${eGuardian.name} defeated!`);
} else if (currentFloor % 5 === 0) {
logMessages.push(`🗺️ Floor ${currentFloor - 1} cleared!`);
}
break;
}
}
// Update equipment spell state
updatedEquipmentSpellStates[i] = { ...eSpell, castProgress: eCastProgress % 1 };
}
}
// ─── Golem attacks (spec §9.4) ─────────────────────────────────────────
// ─── 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];
if (!swordType || !swordType.stats?.attackSpeed) continue;
const swordAttackSpeed = swordType.stats.attackSpeed;
const meleeProgressPerTick = HOURS_PER_TICK * swordAttackSpeed * attackSpeedMult;
let meleeProgress = (updatedMeleeSwordProgress[instanceId] || 0) + meleeProgressPerTick;
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));
if (!Number.isFinite(finalMeleeDamage)) break;
floorHP = Math.max(0, floorHP - finalMeleeDamage);
meleeProgress -= 1;
meleeSafetyCounter++;
if (floorHP <= 0) {
const g = getGuardianForFloor(currentFloor);
onFloorCleared(currentFloor, !!g);
get().advanceRoomOrFloor();
const ns = get();
currentFloor = ns.currentFloor;
floorMaxHP = ns.floorMaxHP;
floorHP = ns.floorHP;
meleeProgress = 0;
break;
}
}
updatedMeleeSwordProgress[instanceId] = meleeProgress % 1;
}
}
// ─── 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) {
@@ -315,7 +330,7 @@ export function processCombatTick(
currentFloor = postGolemState.currentFloor;
}
// ─── DoT/Debuff tick processing (spec §6.3) ──────────────────────────
// ─── DoT/Debuff tick processing (spec §6.3) ─────────────────────────────
// Process after all weapon/golem attacks
if (floorHP > 0) {
const doTDamage = processDoTPhase(get, set, applyEnemyDefenses, logMessages);
@@ -347,12 +362,13 @@ export function processCombatTick(
castProgress,
equipmentSpellStates: updatedEquipmentSpellStates,
activeGolems,
meleeSwordProgress: updatedMeleeSwordProgress,
};
} 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, activeGolems);
return makeDefaultCombatTickResult(rawMana, elements, state, golemancyState.activeGolems);
}
}
@@ -368,4 +384,4 @@ export function makeInitialSpells(spellsToKeep: string[] = []): Record<string, S
}
return startSpells;
}
}
+6 -1
View File
@@ -1,7 +1,7 @@
// ─── Combat State Types ────────────────────────────────────────────────────────
// Shared types for combat store and combat actions to avoid circular dependency
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem, EnemyState } from '../types';
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem, EnemyState, EquipmentInstance } from '../types';
// ─── Combat State (data only) ─────────────────────────────────────────────────
@@ -58,6 +58,9 @@ export interface CombatState {
comboHitCount: number;
floorHitCount: number;
// Melee sword progress accumulators (spec §3.1)
meleeSwordProgress: Record<string, number>;
// Guardian defensive state (shield, barrier, regen)
guardianShield: number;
guardianShieldMax: number;
@@ -153,6 +156,7 @@ export interface CombatActions {
bypassArmor?: boolean,
bypassBarrier?: boolean,
) => number,
equippedSwords?: Record<string, EquipmentInstance>,
) => {
rawMana: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
@@ -165,6 +169,7 @@ export interface CombatActions {
castProgress: number;
equipmentSpellStates: EquipmentSpellState[];
activeGolems: ActiveGolem[];
meleeSwordProgress: Record<string, number>;
};
// Reset
+7 -1
View File
@@ -4,7 +4,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { createSafeStorage } from '../utils/safe-persist';
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem, EnemyState } from '../types';
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem, EnemyState, EquipmentInstance } from '../types';
import { getFloorMaxHP } from '../utils';
import { generateFloorState } from '../utils/room-utils';
import { addActivityLogEntry } from '../utils/activity-log';
@@ -66,6 +66,9 @@ export const useCombatStore = create<CombatStore>()(
comboHitCount: 0,
floorHitCount: 0,
// Melee sword progress accumulators (spec §3.1)
meleeSwordProgress: {},
// Guardian defensive state
guardianShield: 0,
guardianShieldMax: 0,
@@ -310,6 +313,7 @@ export const useCombatStore = create<CombatStore>()(
bypassArmor?: boolean,
bypassBarrier?: boolean,
) => number,
equippedSwords?: Record<string, EquipmentInstance>,
) => {
return processCombatTick(
get,
@@ -324,6 +328,7 @@ export const useCombatStore = create<CombatStore>()(
golemancyState,
golemApplyDamageToRoom,
applyEnemyDefenses,
equippedSwords,
);
},
@@ -372,6 +377,7 @@ export const useCombatStore = create<CombatStore>()(
guardianShieldMax: state.guardianShieldMax,
guardianBarrier: state.guardianBarrier,
guardianBarrierMax: state.guardianBarrierMax,
meleeSwordProgress: state.meleeSwordProgress,
}),
}
)
+16 -2
View File
@@ -1,7 +1,7 @@
// Game Store — coordinator, tick pipeline, time/incursion
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { HOURS_PER_TICK, MAX_DAY } from '../constants';
import { HOURS_PER_TICK, MAX_DAY, EQUIPMENT_TYPES } from '../constants';
import { computeEquipmentEffects } from '../effects';
import type { ComputedEffects } from '../effects/upgrade-effects.types';
@@ -303,6 +303,19 @@ export const useGameStore = create<GameCoordinatorStore>()(
const activeEnemy = combatCbs.applyMageBarrierRecharge(primaryEnemy) ?? primaryEnemy;
const defCtx = { roomType: ctx.combat.currentRoom?.roomType ?? 'combat', enemy: activeEnemy };
const golemPipeline = buildGolemCombatPipeline(addLog);
// Build equipped swords map for melee auto-attack (spec §3.1)
const equippedSwords: Record<string, import('../types').EquipmentInstance> = {};
for (const [slot, instanceId] of Object.entries(ctx.crafting.equippedInstances || {})) {
if (!instanceId) continue;
const inst = ctx.crafting.equipmentInstances?.[instanceId];
if (!inst) continue;
const eqType = EQUIPMENT_TYPES[inst.typeId];
if (eqType?.category === 'sword') {
equippedSwords[instanceId] = inst;
}
}
const combatResult = useCombatStore.getState().processCombatTick(
rawMana, elements, maxMana, 1,
combatCbs.onFloorCleared,
@@ -313,12 +326,13 @@ export const useGameStore = create<GameCoordinatorStore>()(
(dmg, enemy, roomType, addLogFn, bypassArmor, bypassBarrier) => applyEnemyDefensesFromPipeline(
dmg, enemy, roomType, addLogFn, bypassArmor, bypassBarrier,
),
equippedSwords,
);
rawMana = combatResult.rawMana;
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 } };
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 };
}
if (ctx.combat.currentAction === 'craft') {