513cab81a3
- 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
220 lines
7.5 KiB
TypeScript
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;
|
|
}
|