9d4b3f3c69
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s
- 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
385 lines
16 KiB
TypeScript
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;
|
|
}
|