fix: resolve 7 medium-priority bugs from audit #372
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m1s

- #371: Replace Math.random() with seeded PRNG in getSpireEnemyArmor/Barrier
- #370: Add mana refund when cancelling pact ritual in cancelPactRitual
- #367: Add ENCHANT_MASTERY check for design slot 2 in crafting store
- #364: Fix useGameDerived to read crafting data from useCraftingStore
- #363: Clamp recovery room regen delta to prevent negative mana loss
- #365: Add shield/barrier/healthRegen fields to all procedural guardians
- #362: Refactor enchanting tick pipeline to return writes instead of direct store calls

Extracted procedural guardian generators into guardian-procedural.ts to stay under 400-line limit.

All 1158 tests pass.
This commit is contained in:
2026-06-11 11:37:06 +02:00
parent 2d9f0042ef
commit 9476e92a4b
11 changed files with 253 additions and 319 deletions
+6
View File
@@ -10,6 +10,7 @@ import { useCombatStore } from './combatStore';
import { useUIStore } from './uiStore';
import { getEnchantingEfficiencyBonus } from '../effects/discipline-effects';
import { hasSpecial, SPECIAL_EFFECTS } from '../effects/special-effects';
import * as ApplicationActions from '../crafting-actions/application-actions';
import * as PreparationActions from '../crafting-actions/preparation-actions';
import { equipItem as equipItemAction, unequipItem as unequipItemAction } from '../crafting-actions/equipment-actions';
@@ -19,6 +20,7 @@ import { createDefaultCraftingState } from './crafting-initial-state';
import { craftMaterial as craftMaterialAction } from '../crafting-actions/crafting-material-actions';
import { processEquipmentCraftingTick } from './crafting-equipment-tick';
import { startCraftingEquipment, cancelEquipmentCrafting, startFabricatorCrafting } from './pipelines/equipment-crafting';
import { computeEffects } from '../effects/upgrade-effects';
export const useCraftingStore = create<CraftingStore>()(
persist(
@@ -65,6 +67,10 @@ export const useCraftingStore = create<CraftingStore>()(
// Update currentAction in combatStore
useCombatStore.setState({ currentAction: 'design' });
} else if (!state.designProgress2) {
// Check for Enchant Mastery before allowing second design slot
const computedEffects = computeEffects();
const hasEnchantMastery = hasSpecial(computedEffects, SPECIAL_EFFECTS.ENCHANT_MASTERY);
if (!hasEnchantMastery) return false;
updates = {
designProgress2: {
designId: CraftingUtils.generateDesignId(),
+4 -1
View File
@@ -317,7 +317,7 @@ export const useGameStore = create<GameCoordinatorStore>()(
// the delta (9× additional) to avoid double-counting.
const boostedRegen = baseRegen * 10;
const netBoostedRegen = Math.max(0, boostedRegen * (1 - incursionStrength) * meditationMultiplier - conversionResult.totalRawDrain);
const regenDelta = netBoostedRegen - netRawRegen;
const regenDelta = Math.max(0, netBoostedRegen - netRawRegen);
rawMana = Math.min(rawMana + regenDelta * HOURS_PER_TICK, maxMana);
for (const [elem, entry] of Object.entries(conversionResult.rates)) {
if (entry.paused || entry.finalRate <= 0 || !elements[elem]) continue;
@@ -351,6 +351,9 @@ export const useGameStore = create<GameCoordinatorStore>()(
if (enchantingResult.writes.attunement) {
writes.attunement = { ...(writes.attunement || {}), ...enchantingResult.writes.attunement };
}
if (enchantingResult.writes.crafting) {
writes.crafting = { ...(writes.crafting || {}), ...enchantingResult.writes.crafting };
}
}
// Phase 3: Write
@@ -1,13 +1,16 @@
// ─── Enchanting Tick Handlers ─────────────────────────────────────────────────
// Design → Prepare → Application tick processing for the enchanting pipeline.
// Extracted from gameStore.ts to keep the coordinator under the 400-line limit.
//
// IMPORTANT: This function reads from the ctx snapshot and returns writes.
// It must NOT call useCraftingStore.setState() directly — that would bypass
// the tick pipeline's snapshot-and-batch-write pattern and cause race conditions.
import { HOURS_PER_TICK } from '../../constants';
import type { ComputedEffects } from '../../effects/upgrade-effects.types';
import { calculateDesignProgress, createCompletedDesignFromProgress } from '../../crafting-design';
import { calculatePreparationTick, completePreparation } from '../../crafting-prep';
import { calculateApplicationTick, applyEnchantments, updateEnchanterAttunement } from '../../crafting-apply';
import { useCraftingStore } from '../craftingStore';
import { getEnchantingEfficiencyBonus } from '../../effects/discipline-effects';
import type { TickContext, TickWrites } from '../tick-pipeline';
@@ -26,6 +29,11 @@ export function processEnchantingTicks(
const writes: Partial<TickWrites> = {};
const currentAction = ctx.combat.currentAction;
// Helper to merge crafting writes
const mergeCrafting = (partial: Partial<typeof ctx.crafting>) => {
writes.crafting = { ...(writes.crafting || {}), ...partial } as typeof writes.crafting;
};
// ── Phase 1: Design ──────────────────────────────────────────────────────
if (currentAction === 'design') {
const designProgress = ctx.crafting.designProgress;
@@ -54,19 +62,29 @@ export function processEnchantingTicks(
},
getEnchantingEfficiencyBonus(),
);
useCraftingStore.getState().saveDesign(completedDesign);
// Return write instead of calling store directly
mergeCrafting({
enchantmentDesigns: [...ctx.crafting.enchantmentDesigns, completedDesign],
designProgress: designProgress ? null : undefined,
designProgress2: designProgress2 ? null : undefined,
});
addLog('Design "' + completedDesign.name + '" completed!');
writes.combat = { ...(writes.combat || {}), currentAction: 'meditate' };
} else {
// Return write instead of calling store directly
if (designProgress) {
useCraftingStore.getState().setDesignProgress({
...designProgress,
progress: designResult.progress,
mergeCrafting({
designProgress: {
...designProgress,
progress: designResult.progress,
},
});
} else if (designProgress2) {
useCraftingStore.getState().setDesignProgress2({
...designProgress2,
progress: designResult.progress,
mergeCrafting({
designProgress2: {
...designProgress2,
progress: designResult.progress,
},
});
}
}
@@ -81,7 +99,7 @@ export function processEnchantingTicks(
} else {
const instance = ctx.crafting.equipmentInstances[prepProgress.equipmentInstanceId];
if (!instance) {
useCraftingStore.getState().setPreparationProgress(null);
mergeCrafting({ preparationProgress: null });
writes.combat = { ...(writes.combat || {}), currentAction: 'meditate' };
addLog('Preparation failed: equipment not found.');
} else {
@@ -97,20 +115,21 @@ export function processEnchantingTicks(
}
if (prepResult.isComplete) {
const completionResult = completePreparation(instance);
useCraftingStore.setState((s) => ({
equipmentInstances: {
...s.equipmentInstances,
[prepProgress.equipmentInstanceId]: completionResult.updatedInstance,
},
const newInstances = { ...ctx.crafting.equipmentInstances };
newInstances[prepProgress.equipmentInstanceId] = completionResult.updatedInstance;
mergeCrafting({
equipmentInstances: newInstances,
preparationProgress: null,
}));
});
writes.combat = { ...(writes.combat || {}), currentAction: 'meditate' };
addLog(completionResult.logMessage);
} else {
useCraftingStore.getState().setPreparationProgress({
...prepProgress,
progress: prepResult.progress,
manaCostPaid: prepResult.manaCostPaid,
mergeCrafting({
preparationProgress: {
...prepProgress,
progress: prepResult.progress,
manaCostPaid: prepResult.manaCostPaid,
},
});
}
}
@@ -128,7 +147,7 @@ export function processEnchantingTicks(
const instance = ctx.crafting.equipmentInstances[appProgress.equipmentInstanceId];
const design = ctx.crafting.enchantmentDesigns.find((d) => d.id === appProgress.designId);
if (!instance || !design) {
useCraftingStore.getState().setApplicationProgress(null);
mergeCrafting({ applicationProgress: null });
writes.combat = { ...(writes.combat || {}), currentAction: 'meditate' };
addLog('Enchantment failed: equipment or design not found.');
} else {
@@ -146,13 +165,12 @@ export function processEnchantingTicks(
}
if (appResult.isComplete) {
const applyResult = applyEnchantments(instance, design, effects);
useCraftingStore.setState((s) => ({
equipmentInstances: {
...s.equipmentInstances,
[appProgress.equipmentInstanceId]: applyResult.updatedInstance,
},
const newInstances = { ...ctx.crafting.equipmentInstances };
newInstances[appProgress.equipmentInstanceId] = applyResult.updatedInstance;
mergeCrafting({
equipmentInstances: newInstances,
applicationProgress: null,
}));
});
const updatedAttunements = updateEnchanterAttunement(
ctx.attunement.attunements,
applyResult.xpGained,
@@ -164,10 +182,12 @@ export function processEnchantingTicks(
addLog('Free enchantment triggered! No mana consumed this tick.');
}
} else {
useCraftingStore.getState().setApplicationProgress({
...appProgress,
progress: appResult.progress,
manaSpent: appResult.manaSpent,
mergeCrafting({
applicationProgress: {
...appProgress,
progress: appResult.progress,
manaSpent: appResult.manaSpent,
},
});
}
}
+9
View File
@@ -144,6 +144,15 @@ export const usePrestigeStore = create<PrestigeStore>()(
},
cancelPactRitual: () => {
const state = get();
if (state.pactRitualFloor !== null) {
const guardian = getGuardianForFloor(state.pactRitualFloor);
if (guardian) {
useManaStore.setState((s) => ({
rawMana: s.rawMana + guardian.pactCost,
}));
}
}
set({
pactRitualFloor: null,
pactRitualProgress: 0,