Files
Mana-Loop/src/lib/game/stores/combat-actions.ts
T
n8n-gitea 9d4b3f3c69
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s
fix: complete golemancy component-based redesign cleanup
- Fix summonGolemsForRoom to use golemLoadout instead of removed enabledGolems
- Fix discBonus hardcoded to 0 in golem-combat.ts pipeline (now computed from discipline effects)
- Remove deprecated types: SummonedGolem, ActiveGolem, GolemDef
- Remove legacy GolemancyState fields: enabledGolems, summonedGolems, legacyActiveGolems
- Remove orphaned store actions: toggleGolem, setEnabledGolems
- Delete orphaned legacy data files: base-golems.ts, elemental-golems.ts, hybrid-golems.ts
- Update GolemDebugSection to use new golemLoadout system
- Update golemancy-spec.md §17 to reflect all features as complete
- Update all test files to match new type shapes
- All 958 tests passing
2026-06-06 18:37:09 +02:00

385 lines
16 KiB
TypeScript

// ─── Combat Actions ─────────────────────────────────────────────────────────────
// Pure combat logic — no cross-store getState() calls.
// All external data (signedPacts, etc.) is passed in as parameters.
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, FloorState } from '../types';
import { applyOnHitEffect, processDoTPhase } from './dot-runtime';
import type { RuntimeActiveGolem } from '../types';
import { getFloorMaxHP, getFloorElement, getMultiElementBonus, calcDamage, calcMeleeDamage, canAffordSpellCost, deductSpellCost } from '../utils';
import { computeDisciplineEffects } from '../effects/discipline-effects';
import {
processGolemMaintenance,
processGolemAttacks,
processGolemManaRegen,
} from './golem-combat-actions';
import { applyDamageToRoom } from './combat-damage';
// ─── Result Type ───────────────────────────────────────────────────────────────
/**
* Create a default CombatTickResult for safe fallback on error.
*/
function makeDefaultCombatTickResult(
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
state: CombatState,
activeGolems: RuntimeActiveGolem[],
): CombatTickResult {
return {
rawMana,
elements,
logMessages: [],
totalManaGathered: 0,
currentFloor: state.currentFloor,
floorHP: state.floorHP,
floorMaxHP: state.floorMaxHP,
maxFloorReached: state.maxFloorReached,
castProgress: state.castProgress,
equipmentSpellStates: state.equipmentSpellStates,
activeGolems,
meleeSwordProgress: state.meleeSwordProgress,
currentRoom: state.currentRoom,
};
}
export interface CombatTickResult {
rawMana: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
logMessages: string[];
totalManaGathered: number;
currentFloor: number;
floorHP: number;
floorMaxHP: number;
maxFloorReached: number;
castProgress: number;
equipmentSpellStates: CombatState['equipmentSpellStates'];
activeGolems: RuntimeActiveGolem[];
meleeSwordProgress: Record<string, number>;
currentRoom: FloorState;
}
// ─── Main Combat Tick ──────────────────────────────────────────────────────────
export function processCombatTick(
get: () => CombatStore,
set: (state: Partial<CombatState>) => void,
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
_maxMana: number,
attackSpeedMult: number,
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
onDamageDealt: (damage: number) => {
rawMana: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
modifiedDamage?: number;
},
signedPacts: number[],
golemancyState: { activeGolems: RuntimeActiveGolem[] },
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
applyEnemyDefenses: (
dmg: number,
enemy: EnemyState | null,
roomType: string,
addLog: (msg: string) => void,
bypassArmor?: boolean,
bypassBarrier?: boolean,
) => number,
equippedSwords?: Record<string, EquipmentInstance>,
): CombatTickResult {
const state = get();
const logMessages: string[] = [];
let totalManaGathered = 0;
if (state.currentAction !== 'climb') {
return makeDefaultCombatTickResult(rawMana, elements, state, golemancyState.activeGolems);
}
try {
// ─── Golem maintenance (spec §13) ──────────────────────────────────────
const golemDesigns = state.golemancy.golemDesigns || {};
const maintenanceResult = processGolemMaintenance(
golemancyState.activeGolems,
golemDesigns,
rawMana,
elements,
);
let activeGolems = maintenanceResult.maintainedGolems;
rawMana = maintenanceResult.rawMana;
elements = maintenanceResult.elements;
logMessages.push(...maintenanceResult.logMessages);
// ─── Golem mana regen (spec §12) ───────────────────────────────────────
activeGolems = processGolemManaRegen(activeGolems, golemDesigns);
// 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 floorHP = state.floorHP;
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) {
const disciplineEffects = computeDisciplineEffects();
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
let safetyCounter = 0;
const MAX_CASTS_PER_TICK = 100;
while (castProgress >= 1 && canAffordSpellCost(spellDef.cost, rawMana, elements) && safetyCounter < MAX_CASTS_PER_TICK) {
const afterCost = deductSpellCost(spellDef.cost, rawMana, elements);
rawMana = afterCost.rawMana;
elements = afterCost.elements;
const floorElement = getFloorElement(currentFloor);
const baseDamage = calcDamage({ signedPacts }, spellId, undefined, disciplineEffects);
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 result = onDamageDealt(damage);
rawMana = result.rawMana;
elements = result.elements;
const finalDamage = result.modifiedDamage || damage;
if (!Number.isFinite(finalDamage)) {
logMessages.push('⚠️ Combat stopped: invalid damage value');
break;
}
// 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++;
applyOnHitEffect(get, set, spellId, logMessages);
currentRoom = get().currentRoom;
if (roomResult.roomCleared) {
const guardian = getGuardianForFloor(currentFloor);
onFloorCleared(currentFloor, !!guardian);
get().advanceRoomOrFloor();
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) {
logMessages.push(`🗺️ Floor ${currentFloor - 1} cleared!`);
}
}
}
// 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;
const isESpellAoe = !!eSpellDef.isAoe;
const eSpellCastSpeed = eSpellDef.castSpeed || 1;
const eProgressPerTick = HOURS_PER_TICK * eSpellCastSpeed * attackSpeedMult;
let eCastProgress = (eSpell.castProgress || 0) + eProgressPerTick;
let eSafetyCounter = 0;
const MAX_E_CASTS_PER_TICK = 100;
while (eCastProgress >= 1 && canAffordSpellCost(eSpellDef.cost, rawMana, elements) && eSafetyCounter < MAX_E_CASTS_PER_TICK) {
const eAfterCost = deductSpellCost(eSpellDef.cost, rawMana, elements);
rawMana = eAfterCost.rawMana;
elements = eAfterCost.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;
if (!Number.isFinite(eFinalDamage)) break;
// 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 (eRoomResult.roomCleared) {
const eGuardian = getGuardianForFloor(currentFloor);
onFloorCleared(currentFloor, !!eGuardian);
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!`);
} else if (currentFloor % 5 === 0) {
logMessages.push(`🗺️ Floor ${currentFloor - 1} cleared!`);
}
break;
}
}
updatedEquipmentSpellStates[i] = { ...eSpell, castProgress: eCastProgress % 1 };
}
}
// ─── Melee sword attacks (spec §3.1, §4.3) ────────────────────────────
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]);
// Get the current target enemy (lowest HP, matching focus-fire targeting in applyDamageToRoom)
const currentRoomState = get().currentRoom;
const livingEnemies = currentRoomState.enemies.filter(e => e.hp > 0);
const targetEnemy = livingEnemies.length > 0
? livingEnemies.reduce((lowest, e) => e.hp < lowest.hp ? e : lowest)
: null;
const finalMeleeDamage = applyEnemyDefenses(meleeDamage, targetEnemy, currentRoomState.roomType, (msg) => logMessages.push(msg));
if (!Number.isFinite(finalMeleeDamage)) break;
// 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 (meleeRoomResult.roomCleared) {
const g = getGuardianForFloor(currentFloor);
onFloorCleared(currentFloor, !!g);
get().advanceRoomOrFloor();
const ns = get();
currentFloor = ns.currentFloor;
floorMaxHP = ns.floorMaxHP;
floorHP = ns.floorHP;
currentRoom = ns.currentRoom;
meleeProgress = 0;
break;
}
}
updatedMeleeSwordProgress[instanceId] = meleeProgress % 1;
}
}
// ─── Golem attacks (spec §11) ───────────────────────────────────────────
if (activeGolems.length > 0 && floorHP > 0) {
const golemResult = processGolemAttacks(
activeGolems,
golemDesigns,
onDamageDealt,
golemApplyDamageToRoom,
);
rawMana = golemResult.rawMana;
elements = golemResult.elements;
activeGolems = golemResult.activeGolems;
logMessages.push(...golemResult.logMessages);
const postGolemState = get();
floorHP = postGolemState.floorHP;
floorMaxHP = postGolemState.floorMaxHP;
currentFloor = postGolemState.currentFloor;
currentRoom = postGolemState.currentRoom;
}
// ─── DoT/Debuff tick processing (spec §6.3) ─────────────────────────────
if (floorHP > 0) {
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 });
if (floorHP <= 0) {
const guardian = getGuardianForFloor(currentFloor);
onFloorCleared(currentFloor, !!guardian);
get().advanceRoomOrFloor();
const newState = get();
currentFloor = newState.currentFloor;
floorMaxHP = newState.floorMaxHP;
floorHP = newState.floorHP;
currentRoom = newState.currentRoom;
}
}
const newMaxFloorReached = Math.max(state.maxFloorReached, currentFloor);
return {
rawMana,
elements,
logMessages,
totalManaGathered,
currentFloor,
floorHP,
floorMaxHP,
maxFloorReached: newMaxFloorReached,
castProgress,
equipmentSpellStates: updatedEquipmentSpellStates,
activeGolems,
meleeSwordProgress: updatedMeleeSwordProgress,
currentRoom,
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logMessages.push(`⚠️ Combat error: ${errorMsg}`);
return makeDefaultCombatTickResult(rawMana, elements, state, golemancyState.activeGolems);
}
}
// Helper function to create initial spells
export function makeInitialSpells(spellsToKeep: string[] = []): Record<string, SpellState> {
const startSpells: Record<string, SpellState> = {
manaBolt: { learned: true, level: 1, studyProgress: 0 },
};
for (const spellId of spellsToKeep) {
startSpells[spellId] = { learned: true, level: 1, studyProgress: 0 };
}
return startSpells;
}