refactor: complete error handling standardization (issue #101)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m26s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m26s
Prestige Store: - Convert doPrestige() to return Result<void> with specific error codes (INVALID_PRESTIGE_ID, PRESTIGE_MAX_LEVEL, INSUFFICIENT_INSIGHT) - Convert startPactRitual() to return Result<void> with specific error codes (GUARDIAN_NOT_DEFEATED, PACT_ALREADY_SIGNED, PACT_SLOTS_FULL, INSUFFICIENT_MANA, RITUAL_IN_PROGRESS) Combat Actions: - Add try/catch wrapper inside processCombatTick with safe fallback defaults - Add makeDefaultCombatTickResult helper for error recovery LocalStorage Error Handling: - Create safe-persist.ts utility wrapping localStorage with error handling (corrupted JSON, quota exceeded, unexpected failures) - Update all 8 Zustand stores to use createSafeStorage() in persist middleware UI Updates: - Update GuardianPactsTab to use Result pattern for ritual error messages Tests: - Update store-actions-combat-prestige.test.ts for Result return types - Update store-actions.test.ts ManaStore tests for Result pattern - Remove duplicate Prestige/Discipline sections from store-actions.test.ts - All files under 400 line limit 601 tests pass (3 pre-existing failures in spire-utils.test.ts)
This commit is contained in:
@@ -7,6 +7,29 @@ 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;
|
||||
@@ -41,158 +64,143 @@ export function processCombatTick(
|
||||
let totalManaGathered = 0;
|
||||
|
||||
if (state.currentAction !== 'climb') {
|
||||
return {
|
||||
rawMana,
|
||||
elements,
|
||||
logMessages,
|
||||
totalManaGathered,
|
||||
currentFloor: state.currentFloor,
|
||||
floorHP: state.floorHP,
|
||||
floorMaxHP: state.floorMaxHP,
|
||||
maxFloorReached: state.maxFloorReached,
|
||||
castProgress: state.castProgress,
|
||||
equipmentSpellStates: state.equipmentSpellStates,
|
||||
};
|
||||
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 wasGuardian = GUARDIANS[currentFloor];
|
||||
onFloorCleared(currentFloor, !!wasGuardian);
|
||||
|
||||
currentFloor = Math.min(currentFloor + 1, 100);
|
||||
floorMaxHP = getFloorMaxHP(currentFloor);
|
||||
floorHP = floorMaxHP;
|
||||
castProgress = 0;
|
||||
|
||||
if (wasGuardian) {
|
||||
logMessages.push(`⚔️ ${wasGuardian.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: state.currentFloor,
|
||||
floorHP: state.floorHP,
|
||||
floorMaxHP: state.floorMaxHP,
|
||||
maxFloorReached: state.maxFloorReached,
|
||||
castProgress: state.castProgress,
|
||||
equipmentSpellStates: state.equipmentSpellStates,
|
||||
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);
|
||||
}
|
||||
|
||||
// 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 wasGuardian = GUARDIANS[currentFloor];
|
||||
onFloorCleared(currentFloor, !!wasGuardian);
|
||||
|
||||
currentFloor = Math.min(currentFloor + 1, 100);
|
||||
floorMaxHP = getFloorMaxHP(currentFloor);
|
||||
floorHP = floorMaxHP;
|
||||
castProgress = 0;
|
||||
|
||||
if (wasGuardian) {
|
||||
logMessages.push(`⚔️ ${wasGuardian.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,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to create initial spells
|
||||
|
||||
Reference in New Issue
Block a user