fix: issues #221 #217 #225 #227 #224 #226 - crafting refunds, mana tracking, cancel slot, multi-element guardians, spell kill advance
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m59s

This commit is contained in:
2026-05-31 01:18:01 +02:00
parent e4f4b297e8
commit 6793461a9f
16 changed files with 263 additions and 63 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# Circular Dependencies
Generated: 2026-05-30T20:28:32.289Z
Generated: 2026-05-30T20:28:51.293Z
No circular dependencies found. ✅
+1 -1
View File
@@ -1,6 +1,6 @@
{
"_meta": {
"generated": "2026-05-30T20:28:30.386Z",
"generated": "2026-05-30T20:28:49.474Z",
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
},
+2
View File
@@ -30,6 +30,7 @@ export function LeftPanel() {
const currentAction = useCombatStore((s) => s.currentAction);
const designProgress = useCraftingStore((s) => s.designProgress);
const designProgress2 = useCraftingStore((s) => s.designProgress2);
const cancelDesign = useCraftingStore((s) => s.cancelDesign);
const preparationProgress = useCraftingStore((s) => s.preparationProgress);
const applicationProgress = useCraftingStore((s) => s.applicationProgress);
const equipmentCraftingProgress = useCraftingStore((s) => s.equipmentCraftingProgress);
@@ -101,6 +102,7 @@ export function LeftPanel() {
preparationProgress={preparationProgress}
applicationProgress={applicationProgress}
equipmentCraftingProgress={equipmentCraftingProgress}
cancelDesign={cancelDesign}
/>
</CardContent>
</Card>
+17 -6
View File
@@ -12,7 +12,7 @@ interface ActionButtonsProps {
preparationProgress: { progress: number; required: number } | null;
applicationProgress: { progress: number; required: number } | null;
equipmentCraftingProgress: { progress: number; required: number } | null;
}
cancelDesign?: (slot: 1 | 2) => void;
// Map action IDs to labels and icons
const ACTION_CONFIG: Record<string, { label: string; icon: typeof Sparkles; color: string }> = {
@@ -50,6 +50,7 @@ export function ActionButtons({
preparationProgress,
applicationProgress,
equipmentCraftingProgress,
cancelDesign,
}: ActionButtonsProps) {
const config = ACTION_CONFIG[currentAction] || { label: currentAction, icon: Sparkles, color: 'text-gray-400' };
const Icon = config.icon;
@@ -135,12 +136,22 @@ export function ActionButtons({
{/* Show second design slot if active */}
{designProgress2 && (
<div className="mt-2 pt-2 border-t border-gray-700">
<div className="flex items-center gap-2">
<Target className="w-3 h-3 text-purple-400" />
<span className="text-xs text-gray-400">Second Design Slot</span>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Target className="w-3 h-3 text-purple-400" />
<span className="text-xs text-gray-400">Second Design Slot</span>
</div>
{cancelDesign && (
<button
onClick={() => cancelDesign(2)}
className="text-xs text-red-400 hover:text-red-300 cursor-pointer"
>
Cancel
</button>
)}
</div>
<ProgressBar
progress={designProgress2.progress}
<ProgressBar
progress={designProgress2.progress}
required={designProgress2.required}
label="Design progress"
/>
@@ -31,7 +31,7 @@ export function EquipmentTypeSelector({
/>
<div className="flex justify-between text-xs text-[var(--text-muted)]">
<span>{designProgress.progress.toFixed(1)}h / {designProgress.required.toFixed(1)}h</span>
<ActionButton size="sm" variant="ghost" onClick={cancelDesign}>Cancel</ActionButton>
<ActionButton size="sm" variant="ghost" onClick={() => cancelDesign(1)}>Cancel</ActionButton>
</div>
</div>
) : (
@@ -143,14 +143,13 @@ describe('Issue 80 — Combat store partialize', () => {
});
});
// ─── Issue 78: cancelDesign logic ─────────────────────────────────────────────
// ─── Issue 78 / #225: cancelDesign slot targeting ─────────────────────────────
describe('Issue 78 — cancelDesign logic', () => {
it('should cancel designProgress first when both slots are active', async () => {
describe('Issue 78 / #225 — cancelDesign slot targeting', () => {
it('should cancel slot 1 when slot=1 is specified', async () => {
const { useCraftingStore } = await import('../stores/craftingStore');
const store = useCraftingStore.getState();
// Set both slots active
store.setDesignProgress({
designId: 'test-1',
progress: 0,
@@ -160,15 +159,70 @@ describe('Issue 78 — cancelDesign logic', () => {
effects: [],
});
store.setDesignProgress2({
design: 'test-2',
designId: 'test-2',
progress: 0,
required: 100,
name: 'Design 2',
equipmentType: 'basicStaff',
effects: [],
} as any);
});
store.cancelDesign(1);
const state = useCraftingStore.getState();
expect(state.designProgress).toBeNull();
expect(state.designProgress2).not.toBeNull();
});
it('should cancel slot 2 when slot=2 is specified, leaving slot 1 alone', async () => {
const { useCraftingStore } = await import('../stores/craftingStore');
const store = useCraftingStore.getState();
store.setDesignProgress({
designId: 'test-1',
progress: 0,
required: 100,
name: 'Design 1',
equipmentType: 'basicStaff',
effects: [],
});
store.setDesignProgress2({
designId: 'test-2',
progress: 0,
required: 100,
name: 'Design 2',
equipmentType: 'basicStaff',
effects: [],
});
store.cancelDesign(2);
const state = useCraftingStore.getState();
expect(state.designProgress).not.toBeNull();
expect(state.designProgress2).toBeNull();
});
it('should cancel slot 1 by default when no slot specified and slot 1 is active', async () => {
const { useCraftingStore } = await import('../stores/craftingStore');
const store = useCraftingStore.getState();
store.setDesignProgress({
designId: 'test-1',
progress: 0,
required: 100,
name: 'Design 1',
equipmentType: 'basicStaff',
effects: [],
});
store.setDesignProgress2({
designId: 'test-2',
progress: 0,
required: 100,
name: 'Design 2',
equipmentType: 'basicStaff',
effects: [],
});
// Cancel should remove designProgress (slot 1), not designProgress2
store.cancelDesign();
const state = useCraftingStore.getState();
@@ -176,11 +230,10 @@ describe('Issue 78 — cancelDesign logic', () => {
expect(state.designProgress2).not.toBeNull();
});
it('should cancel designProgress2 when designProgress is null', async () => {
it('should cancel slot 2 by default when slot 1 is null', async () => {
const { useCraftingStore } = await import('../stores/craftingStore');
const store = useCraftingStore.getState();
// Only slot 2 active
store.setDesignProgress(null);
store.setDesignProgress2({
designId: 'test-2',
@@ -126,13 +126,20 @@ describe('Tick Integration', () => {
expect(useManaStore.getState().rawMana).toBeLessThanOrEqual(100);
});
it('should not decrease totalManaGathered on tick', () => {
// Note: passive regen in tick() updates rawMana directly, not via addRawMana,
// so totalManaGathered only increases from gatherMana or combat loot.
// This is expected behavior — totalManaGathered tracks active gathering.
it('should increase totalManaGathered from meditation regen', () => {
// Passive meditation regen should count toward totalManaGathered
useManaStore.setState({ rawMana: 50, totalManaGathered: 5 });
useGameStore.getState().tick();
expect(useManaStore.getState().totalManaGathered).toBeGreaterThanOrEqual(5);
expect(useManaStore.getState().totalManaGathered).toBeGreaterThan(5);
});
it('should not count regen toward totalManaGathered when at cap', () => {
// Regen that would exceed max mana should not count toward totalManaGathered
useManaStore.setState({ rawMana: 100, totalManaGathered: 50 });
useGameStore.getState().tick();
// When rawMana equals or exceeds maxMana, no regen is added to totalManaGathered
// Use toBeCloseTo with tolerance for floating point drift from base regen calculations
expect(useManaStore.getState().totalManaGathered).toBeLessThanOrEqual(50.05);
});
});
@@ -304,13 +311,13 @@ describe('Tick Integration', () => {
expect(useGameStore.getState().hour).toBeCloseTo(1, 5);
});
it('should not lose totalManaGathered over ticks', () => {
it('should accumulate totalManaGathered from meditation over ticks', () => {
useManaStore.setState({ rawMana: 10, totalManaGathered: 42 });
for (let i = 0; i < 10; i++) {
useGameStore.getState().tick();
}
// totalManaGathered should stay at 42 (passive regen doesn't change it)
expect(useManaStore.getState().totalManaGathered).toBe(42);
// Passive meditation regen should now count toward totalManaGathered
expect(useManaStore.getState().totalManaGathered).toBeGreaterThan(42);
});
});
@@ -3,6 +3,8 @@
import type { CraftingState } from '../stores/craftingStore.types';
import type { GameAction } from '../types';
import * as CraftingApply from '../crafting-apply';
import { useManaStore } from '../stores/manaStore';
import { useUIStore } from '../stores/uiStore';
/**
* Start applying an enchantment design to an equipment instance.
@@ -66,8 +68,25 @@ export function resumeApplication(
}
export function cancelApplication(
get: () => CraftingState,
set: (partial: Partial<CraftingState>) => void
) {
const state = get();
const progress = state.applicationProgress;
if (!progress) return;
// Refund mana proportionally to remaining progress
const remainingFraction = progress.required > 0
? Math.max(0, (progress.required - progress.progress) / progress.required)
: 1;
// Full refund for unspent progress, 50% for spent progress
const refundRate = remainingFraction + (1 - remainingFraction) * 0.5;
const manaRefund = Math.floor(progress.manaSpent * refundRate);
if (manaRefund > 0) {
useManaStore.setState((s) => ({ rawMana: s.rawMana + manaRefund }));
}
useUIStore.getState().addLog(`🚫 Enchantment application cancelled. Refunded ${manaRefund} mana.`);
set({
applicationProgress: null,
});
@@ -2,6 +2,8 @@
import type { CraftingState } from '../stores/craftingStore.types';
import * as CraftingPrep from '../crafting-prep';
import { useManaStore } from '../stores/manaStore';
import { useUIStore } from '../stores/uiStore';
export function startPreparing(
equipmentInstanceId: string,
@@ -35,8 +37,25 @@ export function startPreparing(
}
export function cancelPreparation(
get: () => CraftingState,
set: (partial: Partial<CraftingState>) => void
) {
const state = get();
const progress = state.preparationProgress;
if (!progress) return;
// Refund mana proportionally to remaining progress
const remainingFraction = progress.required > 0
? Math.max(0, (progress.required - progress.progress) / progress.required)
: 1;
// Full refund for unspent progress, 50% for spent progress
const refundRate = remainingFraction + (1 - remainingFraction) * 0.5;
const manaRefund = Math.floor(progress.manaCostPaid * refundRate);
if (manaRefund > 0) {
useManaStore.setState((s) => ({ rawMana: s.rawMana + manaRefund }));
}
useUIStore.getState().addLog(`🚫 Preparation cancelled. Refunded ${manaRefund} mana.`);
set({
preparationProgress: null,
});
+34 -9
View File
@@ -6,7 +6,7 @@ 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 { getFloorMaxHP, getFloorElement, getMultiElementBonus, calcDamage, canAffordSpellCost, deductSpellCost } from '../utils';
import { computeDisciplineEffects } from '../effects/discipline-effects';
/**
@@ -95,14 +95,21 @@ export function processCombatTick(
const afterCost = deductSpellCost(spellDef.cost, rawMana, elements);
rawMana = afterCost.rawMana;
elements = afterCost.elements;
// Calculate base damage
// Calculate base damage (without elemental bonus first)
const floorElement = getFloorElement(currentFloor);
const damage = calcDamage(
const baseDamage = calcDamage(
{ signedPacts },
spellId,
floorElement,
undefined,
disciplineEffects,
);
// Apply elemental bonus — for multi-element guardians, use all elements
const guardian = getGuardianForFloor(currentFloor);
const floorElems = guardian && guardian.element.length > 0
? guardian.element
: [floorElement];
const multiElemBonus = getMultiElementBonus(spellDef.elem, floorElems);
const damage = baseDamage * multiElemBonus;
// Let gameStore apply damage modifiers (executioner, berserker)
const result = onDamageDealt(damage);
@@ -125,7 +132,6 @@ export function processCombatTick(
if (floorHP <= 0) {
const guardian = getGuardianForFloor(currentFloor);
onFloorCleared(currentFloor, !!guardian);
currentFloor = Math.min(currentFloor + 1, 100);
floorMaxHP = getFloorMaxHP(currentFloor);
floorHP = floorMaxHP;
@@ -159,14 +165,20 @@ export function processCombatTick(
const eAfterCost = deductSpellCost(eSpellDef.cost, rawMana, elements);
rawMana = eAfterCost.rawMana;
elements = eAfterCost.elements;
// Calculate damage
// Calculate damage — for multi-element guardians, use all elements
const eFloorElement = getFloorElement(currentFloor);
const eDamage = calcDamage(
const eBaseDamage = calcDamage(
{ signedPacts },
eSpell.spellId,
eFloorElement,
undefined,
disciplineEffects,
);
const eGuardian = getGuardianForFloor(currentFloor);
const eFloorElems = eGuardian && eGuardian.element.length > 0
? eGuardian.element
: [eFloorElement];
const eMultiElemBonus = getMultiElementBonus(eSpellDef.elem, eFloorElems);
const eDamage = eBaseDamage * eMultiElemBonus;
const eResult = onDamageDealt(eDamage);
rawMana = eResult.rawMana;
@@ -182,7 +194,20 @@ export function processCombatTick(
eCastProgress -= 1;
eSafetyCounter++;
if (floorHP <= 0) break; // Floor cleared, stop processing
if (floorHP <= 0) {
const eGuardian = getGuardianForFloor(currentFloor);
onFloorCleared(currentFloor, !!eGuardian);
currentFloor = Math.min(currentFloor + 1, 100);
floorMaxHP = getFloorMaxHP(currentFloor);
floorHP = floorMaxHP;
eCastProgress = 0;
if (eGuardian) {
logMessages.push(`\u2694\ufe0f ${eGuardian.name} defeated!`);
} else if (currentFloor % 5 === 0) {
logMessages.push(`🏰 Floor ${currentFloor - 1} cleared!`);
}
break;
}
}
// Update equipment spell state
+13 -4
View File
@@ -82,9 +82,18 @@ export const useCraftingStore = create<CraftingStore>()(
return true;
},
cancelDesign: () => {
cancelDesign: (slot?: 1 | 2) => {
const state = get();
if (state.designProgress) {
if (slot === 2) {
if (state.designProgress2) {
set({ designProgress2: null });
}
} else if (slot === 1) {
if (state.designProgress) {
set({ designProgress: null });
useCombatStore.setState({ currentAction: 'meditate' });
}
} else if (state.designProgress) {
set({ designProgress: null });
useCombatStore.setState({ currentAction: 'meditate' });
} else if (state.designProgress2) {
@@ -136,7 +145,7 @@ export const useCraftingStore = create<CraftingStore>()(
},
cancelApplication: () => {
ApplicationActions.cancelApplication(set as unknown as (partial: Partial<CraftingState>) => void);
ApplicationActions.cancelApplication(get, set as unknown as (partial: Partial<CraftingState>) => void);
useCombatStore.setState({ currentAction: 'meditate' });
},
@@ -169,7 +178,7 @@ export const useCraftingStore = create<CraftingStore>()(
},
cancelPreparation: () => {
PreparationActions.cancelPreparation(set);
PreparationActions.cancelPreparation(get, set);
useCombatStore.setState({ currentAction: 'meditate' });
},
+1 -1
View File
@@ -48,7 +48,7 @@ export interface CraftingActions {
setApplicationProgress: (progress: ApplicationProgress | null) => void;
setEquipmentCraftingProgress: (progress: EquipmentCraftingProgress | null) => void;
startDesigningEnchantment: (name: string, equipmentTypeId: string, effects: DesignEffect[]) => boolean;
cancelDesign: () => void;
cancelDesign: (slot?: 1 | 2) => void;
saveDesign: (design: EnchantmentDesign) => void;
deleteDesign: (designId: string) => void;
startApplying: (equipmentInstanceId: string, designId: string) => boolean;
+5 -1
View File
@@ -182,8 +182,12 @@ export const useGameStore = create<GameCoordinatorStore>()(
const effectiveRegen = Math.max(0, baseRegen * (1 - incursionStrength) * meditationMultiplier - totalConversionPerTick);
const rawAfterConversion = ctx.mana.rawMana + rawManaDelta;
const regenFromMeditation = Math.max(0, effectiveRegen * HOURS_PER_TICK);
const roomLeft = Math.max(0, maxMana - Math.max(0, rawAfterConversion));
// Only count regen that actually fits below the cap (fix #224)
const actualRegenAdded = Math.floor(Math.min(regenFromMeditation, roomLeft) * 1000) / 1000;
let rawMana = Math.max(0, Math.min(rawAfterConversion + effectiveRegen * HOURS_PER_TICK, maxMana));
let totalManaGathered = ctx.mana.totalManaGathered;
let totalManaGathered = ctx.mana.totalManaGathered + Math.max(0, actualRegenAdded);
if (ctx.combat.currentAction === 'convert') {
const convertResult = useManaStore.getState().processConvertAction(rawMana);
@@ -9,6 +9,7 @@ import {
deductFabricatorMana,
deductMaterials,
makeFabricatorProgress,
refundFabricatorMana,
} from '../../crafting-fabricator';
import { useManaStore } from '../manaStore';
import { useCombatStore } from '../combatStore';
@@ -50,33 +51,71 @@ export function startCraftingEquipment(
export function cancelEquipmentCrafting(get: GetFn, set: SetFn): void {
const progress = get().equipmentCraftingProgress;
if (!progress) return;
const cancelResult = CraftingEquipment.cancelEquipmentCrafting(
progress.blueprintId,
progress.manaSpent,
progress.progress,
progress.required,
);
// Refund materials proportionally to remaining progress
const recipe = CraftingEquipment.getRecipe(progress.blueprintId);
if (recipe) {
const remainingFraction = progress.required > 0
? Math.max(0, (progress.required - progress.progress) / progress.required)
: 1;
const currentMaterials = get().lootInventory.materials;
const refundedMaterials = { ...currentMaterials };
for (const [matId, amount] of Object.entries(recipe.materials)) {
const refundAmount = Math.floor(amount * remainingFraction);
if (refundAmount > 0) {
refundedMaterials[matId] = (refundedMaterials[matId] || 0) + refundAmount;
const isFabricator = progress.blueprintId.startsWith('fabricator-');
if (isFabricator) {
// Fabricator recipe cancel: refund elemental/raw mana and materials
const recipeId = progress.blueprintId.replace('fabricator-', '');
const recipe = getFabricatorRecipe(recipeId);
if (recipe) {
const remainingFraction = progress.required > 0
? Math.max(0, (progress.required - progress.progress) / progress.required)
: 1;
// Full refund for unspent progress, 50% for spent progress
const refundRate = remainingFraction + (1 - remainingFraction) * 0.5;
const manaRefund = Math.floor(progress.manaSpent * refundRate);
// Refund the correct mana type
const rawMana = useManaStore.getState().rawMana;
const elements = useManaStore.getState().elements;
const refunded = refundFabricatorMana(recipe, manaRefund, rawMana, elements);
useManaStore.setState({ rawMana: refunded.rawMana, elements: refunded.elements });
// Refund materials
const currentMaterials = get().lootInventory.materials;
const refundedMaterials = { ...currentMaterials };
for (const [matId, amount] of Object.entries(recipe.materials)) {
const refundAmount = Math.floor(amount * remainingFraction);
if (refundAmount > 0) {
refundedMaterials[matId] = (refundedMaterials[matId] || 0) + refundAmount;
}
}
set({ equipmentCraftingProgress: null, lootInventory: { ...get().lootInventory, materials: refundedMaterials } });
useUIStore.getState().addLog(`🚫 Fabricator crafting cancelled. Refunded ${manaRefund} ${recipe.manaType} mana.`);
} else {
set({ equipmentCraftingProgress: null });
}
set({ equipmentCraftingProgress: null, lootInventory: { ...get().lootInventory, materials: refundedMaterials } });
} else {
set({ equipmentCraftingProgress: null });
// Standard equipment crafting cancel
const cancelResult = CraftingEquipment.cancelEquipmentCrafting(
progress.blueprintId,
progress.manaSpent,
progress.progress,
progress.required,
);
// Refund materials proportionally to remaining progress
const recipe = CraftingEquipment.getRecipe(progress.blueprintId);
if (recipe) {
const remainingFraction = progress.required > 0
? Math.max(0, (progress.required - progress.progress) / progress.required)
: 1;
const currentMaterials = get().lootInventory.materials;
const refundedMaterials = { ...currentMaterials };
for (const [matId, amount] of Object.entries(recipe.materials)) {
const refundAmount = Math.floor(amount * remainingFraction);
if (refundAmount > 0) {
refundedMaterials[matId] = (refundedMaterials[matId] || 0) + refundAmount;
}
}
set({ equipmentCraftingProgress: null, lootInventory: { ...get().lootInventory, materials: refundedMaterials } });
} else {
set({ equipmentCraftingProgress: null });
}
useManaStore.setState((s) => ({ rawMana: s.rawMana + cancelResult.manaRefund }));
useUIStore.getState().addLog(cancelResult.logMessage);
}
useManaStore.setState((s) => ({ rawMana: s.rawMana + cancelResult.manaRefund }));
useCombatStore.setState({ currentAction: 'meditate' });
useUIStore.getState().addLog(cancelResult.logMessage);
}
export function startFabricatorCrafting(recipeId: string, get: GetFn, set: SetFn): boolean {
+11
View File
@@ -50,6 +50,17 @@ export function getElementalBonus(spellElem: string, floorElem: string): number
return 1.0; // Neutral
}
/**
* Get the elemental bonus against a multi-element floor/guardian.
* Uses the minimum bonus across all elements so multi-element guardians
* are not trivially countered (each element can resist different spells).
*/
export function getMultiElementBonus(spellElem: string, floorElems: string[]): number {
if (floorElems.length === 0) return 1.0;
if (floorElems.length === 1) return getElementalBonus(spellElem, floorElems[0]);
return Math.min(...floorElems.map(e => getElementalBonus(spellElem, e)));
}
// ─── Boon Bonuses ─────────────────────────────────────────────────────────────
export interface BoonBonuses {
+1
View File
@@ -18,6 +18,7 @@ export {
// computeElementMax is now in ../store.ts with support for unlockedManaTypeUpgrades
export {
getElementalBonus,
getMultiElementBonus,
getBoonBonuses,
calcDamage,
calcInsight,