Files
Mana-Loop/src/lib/game/stores/combat-actions.ts
T
n8n-gitea 513cab81a3 refactor: remove GUARDIANS constant, consolidate into guardian-data.ts
- Delete src/lib/game/constants/guardians.ts (the old static GUARDIANS constant)
- Create src/lib/game/data/guardian-data.ts with BASE_GUARDIANS (same data, new home)
- Remove GUARDIANS export from constants/index.ts
- Update all 11 files that imported GUARDIANS to use getGuardianForFloor() or BASE_GUARDIANS:
  - useGameDerived.ts, combat-actions.ts, gameStore.ts, prestigeStore.ts
  - combat-utils.ts, room-utils.ts, floor-utils.ts, spire-utils.ts
  - SpireCombatPage.tsx, SpireHeader.tsx
- Update 4 test files to use getGuardianForFloor() instead of GUARDIANS constant
- guardian-encounters.ts now imports BASE_GUARDIANS from guardian-data.ts
- Split room-utils.test.ts (505 lines) into room-utils.test.ts + room-utils-floor-state.test.ts
2026-05-23 16:09:19 +02:00

220 lines
7.5 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 } from '../constants';
import { getGuardianForFloor } from '../data/guardian-encounters';
import type { CombatStore, CombatState } from './combat-state.types';
import type { SpellState } from '../types';
import { getFloorMaxHP, getFloorElement, calcDamage, canAffordSpellCost, deductSpellCost } from '../utils';
import { computeDisciplineEffects } from '../effects/discipline-effects';
import { ErrorCode } from '../utils/result';
/**
* Create a default CombatTickResult for safe fallback on error.
*/
function makeDefaultCombatTickResult(
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
state: CombatState,
): 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,
};
}
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'];
}
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[],
): CombatTickResult {
const state = get();
const logMessages: string[] = [];
let totalManaGathered = 0;
if (state.currentAction !== 'climb') {
return makeDefaultCombatTickResult(rawMana, elements, state);
}
const spellId = state.activeSpell;
const spellDef = SPELLS_DEF[spellId];
if (!spellDef) {
return makeDefaultCombatTickResult(rawMana, elements, state);
}
try {
// 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;
let castProgress = (state.castProgress || 0) + progressPerTick;
let floorHP = state.floorHP;
let currentFloor = state.currentFloor;
let floorMaxHP = state.floorMaxHP;
// Process complete casts for active spell
while (castProgress >= 1 && canAffordSpellCost(spellDef.cost, rawMana, elements)) {
// Deduct spell cost
const afterCost = deductSpellCost(spellDef.cost, rawMana, elements);
rawMana = afterCost.rawMana;
elements = afterCost.elements;
// Calculate base damage
const floorElement = getFloorElement(currentFloor);
const damage = calcDamage(
{ skills: {}, signedPacts },
spellId,
floorElement,
disciplineEffects,
);
// Let gameStore apply damage modifiers (executioner, berserker)
const result = onDamageDealt(damage);
rawMana = result.rawMana;
elements = result.elements;
const finalDamage = result.modifiedDamage || damage;
// Apply damage
floorHP = Math.max(0, floorHP - finalDamage);
castProgress -= 1;
// Check if floor is cleared
if (floorHP <= 0) {
const guardian = getGuardianForFloor(currentFloor);
onFloorCleared(currentFloor, !!guardian);
currentFloor = Math.min(currentFloor + 1, 100);
floorMaxHP = getFloorMaxHP(currentFloor);
floorHP = floorMaxHP;
castProgress = 0;
if (guardian) {
logMessages.push(`\u2694\ufe0f ${guardian.name} defeated!`);
} else if (currentFloor % 5 === 0) {
logMessages.push(`🏰 Floor ${currentFloor - 1} cleared!`);
}
}
}
// Process equipment spell states (for progress bars in UI)
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;
// Process complete casts for equipment spells
while (eCastProgress >= 1 && canAffordSpellCost(eSpellDef.cost, rawMana, elements)) {
// Deduct cost
const eAfterCost = deductSpellCost(eSpellDef.cost, rawMana, elements);
rawMana = eAfterCost.rawMana;
elements = eAfterCost.elements;
// Calculate damage
const eFloorElement = getFloorElement(currentFloor);
const eDamage = calcDamage(
{ skills: {}, signedPacts },
eSpell.spellId,
eFloorElement,
disciplineEffects,
);
const eResult = onDamageDealt(eDamage);
rawMana = eResult.rawMana;
elements = eResult.elements;
const eFinalDamage = eResult.modifiedDamage || eDamage;
floorHP = Math.max(0, floorHP - eFinalDamage);
eCastProgress -= 1;
if (floorHP <= 0) break; // Floor cleared, stop processing
}
// Update equipment spell state
updatedEquipmentSpellStates[i] = { ...eSpell, castProgress: eCastProgress % 1 };
}
const newMaxFloorReached = Math.max(state.maxFloorReached, currentFloor);
set({
currentFloor,
floorHP,
floorMaxHP: getFloorMaxHP(currentFloor),
maxFloorReached: newMaxFloorReached,
castProgress,
equipmentSpellStates: updatedEquipmentSpellStates,
});
return {
rawMana,
elements,
logMessages,
totalManaGathered,
currentFloor,
floorHP,
floorMaxHP: getFloorMaxHP(currentFloor),
maxFloorReached: newMaxFloorReached,
castProgress,
equipmentSpellStates: updatedEquipmentSpellStates,
};
} 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);
}
}
// 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 },
};
// Add kept spells
for (const spellId of spellsToKeep) {
startSpells[spellId] = { learned: true, level: 1, studyProgress: 0 };
}
return startSpells;
}