refactor: complete error handling standardization (issue #101)
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:
2026-05-22 09:19:20 +02:00
parent 8a7ddaae27
commit 49f8de01ca
21 changed files with 542 additions and 547 deletions
+148 -140
View File
@@ -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