From 71fbc7c9646719b6f974ed2292c411b26dfc0680 Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Fri, 8 May 2026 11:45:31 +0200 Subject: [PATCH] fix: SpireTab store props, mana regen display, skill cost deduction, grimoire cost format, unequip store, add test suite --- AGENTS.md | 13 +++ docs/project-structure.txt | 5 + src/app/components/LeftPanel.tsx | 26 ++--- src/app/page.tsx | 6 +- src/components/game/tabs/CombatStatsPanel.tsx | 20 ++-- src/components/game/tabs/EquipmentTab.tsx | 4 +- .../game/stores/__tests__/equipment.test.ts | 97 +++++++++++++++++++ src/lib/game/stores/__tests__/mana.test.ts | 30 ++++++ src/lib/game/stores/__tests__/regen.test.ts | 50 ++++++++++ src/lib/game/stores/__tests__/skill.test.ts | 55 +++++++++++ .../game/stores/__tests__/spell-cost.test.ts | 64 ++++++++++++ vitest.config.ts | 6 ++ 12 files changed, 354 insertions(+), 22 deletions(-) create mode 100644 src/lib/game/stores/__tests__/equipment.test.ts create mode 100644 src/lib/game/stores/__tests__/mana.test.ts create mode 100644 src/lib/game/stores/__tests__/regen.test.ts create mode 100644 src/lib/game/stores/__tests__/skill.test.ts create mode 100644 src/lib/game/stores/__tests__/spell-cost.test.ts diff --git a/AGENTS.md b/AGENTS.md index e67b03b..adea696 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -471,6 +471,19 @@ Runs after merging branches: 5. **Not updating modular stores**: Check all stores in `stores/` directory for related state 6. **Bypassing crafting-actions**: Use the modular actions in `crafting-actions/` for new crafting features +## Testing + +Run `npm run test` before every commit. Tests must pass. + +When fixing a bug, write a test that would have caught it first. +Test files live in src/lib/game/stores/__tests__/. + +Critical paths that must always have tests: +- Any store action that modifies rawMana +- Any store action that modifies equippedInstances +- computeRegen, computeMaxMana, calcDamage +- canAffordSpellCost, spendRawMana, deductSpellCost + ## Testing Guidelines - Run `npm run lint` after changes diff --git a/docs/project-structure.txt b/docs/project-structure.txt index 91acfb7..586a851 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -383,6 +383,11 @@ Mana-Loop/ │ │ │ │ │ ├── spell-definitions.test.ts │ │ │ │ │ └── study-speed.test.ts │ │ │ │ ├── ui-store-tests/ +│ │ │ │ ├── equipment.test.ts +│ │ │ │ ├── mana.test.ts +│ │ │ │ ├── regen.test.ts +│ │ │ │ ├── skill.test.ts +│ │ │ │ ├── spell-cost.test.ts │ │ │ │ ├── store-methods.test.ts │ │ │ │ └── stores.test.ts │ │ │ ├── attunementStore.ts diff --git a/src/app/components/LeftPanel.tsx b/src/app/components/LeftPanel.tsx index 0e5873b..b47e0c1 100644 --- a/src/app/components/LeftPanel.tsx +++ b/src/app/components/LeftPanel.tsx @@ -7,9 +7,10 @@ import { ManaDisplay } from '@/components/game'; import { ActionButtons } from '@/components/game'; import { CalendarDisplay } from '@/components/game'; import { DebugName } from '@/lib/game/debug-context'; -import { useGameStore, useManaStore, useSkillStore, useCombatStore, useCraftingStore } from '@/lib/game/stores'; +import { useGameStore, useManaStore, useSkillStore, useCombatStore, useCraftingStore, usePrestigeStore } from '@/lib/game/stores'; import { getUnifiedEffects } from '@/lib/game/effects'; -import { computeMaxMana, computeRegen, computeClickMana, getMeditationBonus, getIncursionStrength } from '@/lib/game/stores'; +import { getMeditationBonus, getIncursionStrength } from '@/lib/game/stores'; +import { computeTotalMaxMana, computeTotalRegen, computeTotalClickMana } from '@/lib/game/effects'; export function LeftPanel() { const [isGathering, setIsGathering] = useState(false); @@ -23,6 +24,8 @@ export function LeftPanel() { const skillTiers = useSkillStore((s) => s.skillTiers); const skillUpgrades = useSkillStore((s) => s.skillUpgrades); + const prestigeUpgrades = usePrestigeStore((s) => s.prestigeUpgrades); + const equippedInstances = useCraftingStore((s) => s.equippedInstances); const equipmentInstances = useCraftingStore((s) => s.equipmentInstances); @@ -77,19 +80,18 @@ export function LeftPanel() { equipmentInstances, }); - const maxMana = computeMaxMana( - { skills, skillTiers, skillUpgrades }, + const maxMana = computeTotalMaxMana( + { skills, prestigeUpgrades, skillUpgrades, skillTiers, equippedInstances, equipmentInstances }, upgradeEffects ); - const baseRegen = computeRegen( - { skills, skillTiers, skillUpgrades }, + const baseRegen = computeTotalRegen( + { skills, prestigeUpgrades, skillUpgrades, skillTiers, equippedInstances, equipmentInstances }, + upgradeEffects + ); + const clickMana = computeTotalClickMana( + { skills, skillUpgrades, skillTiers, equippedInstances, equipmentInstances }, upgradeEffects ); - const clickMana = computeClickMana({ - skills, - skillTiers, - skillUpgrades, - }); const meditationMultiplier = getMeditationBonus(meditateTicks, skills, upgradeEffects.meditationEfficiency); const incursionStrength = getIncursionStrength(day, hour); const effectiveRegen = baseRegen * (1 - incursionStrength) * meditationMultiplier; @@ -127,7 +129,7 @@ export function LeftPanel() {

{spell.desc}

-
Cost: {(spell.cost as any[]).map((c: any) => `${c.amount} ${c.type}`).join(', ')}
+
Cost: {spell.cost.amount} { + spell.cost.type === 'element' + ? spell.cost.element + : 'raw mana' + }
Power: {spell.power}
{spell.effect &&
Effect: {spell.effect}
}
diff --git a/src/components/game/tabs/CombatStatsPanel.tsx b/src/components/game/tabs/CombatStatsPanel.tsx index 34ae74f..892d09c 100644 --- a/src/components/game/tabs/CombatStatsPanel.tsx +++ b/src/components/game/tabs/CombatStatsPanel.tsx @@ -7,10 +7,12 @@ import { Zap, Shield, ShieldCheck, Wind, Heart, Mountain, BookOpen } from 'lucid import { ELEMENTS } from '@/lib/game/constants'; import { GOLEMS_DEF, getGolemDamage, getGolemAttackSpeed } from '@/lib/game/data/golems'; import type { CombatStatsPanelProps } from '@/lib/game/types'; +import { useCombatStore } from '@/lib/game/stores'; +import { useSkillStore } from '@/lib/game/stores'; +import { usePrestigeStore } from '@/lib/game/stores'; export function CombatStatsPanel({ activeEquipmentSpells, - store, totalDPS, calcDamage, formatSpellCost, @@ -21,7 +23,11 @@ export function CombatStatsPanel({ studySpeedMult, storeCurrentAction, }: CombatStatsPanelProps) { - const activeGolems = store.golemancy.summonedGolems; + const golemancy = useCombatStore((s) => s.golemancy); + const equipmentSpellStates = useCombatStore((s) => s.equipmentSpellStates); + const skills = useSkillStore((s) => s.skills); + const signedPacts = usePrestigeStore((s) => s.signedPacts); + const activeGolems = golemancy.summonedGolems; return ( @@ -39,7 +45,7 @@ export function CombatStatsPanel({ {activeEquipmentSpells.map(({ spellId, equipmentId }) => { const spellDef = SPELLS_DEF[spellId]; if (!spellDef) return null; - const spellState = store.equipmentSpellStates?.find( + const spellState = equipmentSpellStates?.find( s => s.spellId === spellId && s.sourceEquipment === equipmentId ); const progress = spellState?.castProgress || 0; @@ -58,11 +64,11 @@ export function CombatStatsPanel({
- ⚔️ {fmt(calcDamage(store, spellId))} dmg • {' '} + ⚔️ {fmt(calcDamage({ skills, signedPacts }, spellId))} dmg • {' '} {formatSpellCost(spellDef.cost)} - {' '}• ⚡ {fmt(Math.floor(calcDamage(store, spellId) * (spellDef.castSpeed || 1)))} dmg/hr + {' '}• ⚡ {fmt(Math.floor(calcDamage({ skills, signedPacts }, spellId) * (spellDef.castSpeed || 1)))} dmg/hr
{storeCurrentAction === 'climb' && (
@@ -97,8 +103,8 @@ export function CombatStatsPanel({ const golemDef = GOLEMS_DEF[summoned.golemId]; if (!golemDef) return null; const elemColor = ELEMENTS[golemDef.baseManaType]?.color || '#888'; - const damage = getGolemDamage(summoned.golemId, store.skills); - const attackSpeed = getGolemAttackSpeed(summoned.golemId, store.skills); + const damage = getGolemDamage(summoned.golemId, skills); + const attackSpeed = getGolemAttackSpeed(summoned.golemId, skills); return (
diff --git a/src/components/game/tabs/EquipmentTab.tsx b/src/components/game/tabs/EquipmentTab.tsx index d1a4717..a1177cc 100755 --- a/src/components/game/tabs/EquipmentTab.tsx +++ b/src/components/game/tabs/EquipmentTab.tsx @@ -160,7 +160,7 @@ export function EquipmentTab() { const handleUnequip = (slot: EquipmentSlot) => { const instanceId = equippedInstances[slot]; const instance = instanceId ? equipmentInstances[instanceId] : null; - unequipItem(slot, useCombatStore.getState, (fn) => useCombatStore.setState(fn)); + unequipItem(slot, (fn) => useCraftingStore.setState(fn as any)); showToast('success', 'Item Unequipped', `${instance?.name || 'Item'} removed from ${SLOT_NAMES[slot]}`); }; @@ -224,7 +224,7 @@ export function EquipmentTab() { const confirmDelete = () => { if (deleteConfirm) { - deleteEquipmentInstance(deleteConfirm.instanceId, useCombatStore.getState, (fn) => useCombatStore.setState(fn)); + deleteEquipmentInstance(deleteConfirm.instanceId, useCraftingStore.getState, (fn) => useCraftingStore.setState(fn)); showToast('success', 'Item Discarded', `${deleteConfirm.name} has been removed from inventory`); setDeleteConfirm(null); } diff --git a/src/lib/game/stores/__tests__/equipment.test.ts b/src/lib/game/stores/__tests__/equipment.test.ts new file mode 100644 index 0000000..20f9b44 --- /dev/null +++ b/src/lib/game/stores/__tests__/equipment.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useCraftingStore } from '@/lib/game/stores'; +import { initialCraftingState } from '@/lib/game/stores/craftingStore'; + +describe('useCraftingStore - Equipment Actions', () => { + beforeEach(() => { + useCraftingStore.setState(initialCraftingState); + }); + + it('equipItem sets equippedInstances[slot] to instanceId', () => { + const instanceId = 'test-instance-1'; + const slot = 'mainHand'; + + // First, add the instance to equipmentInstances + useCraftingStore.setState((state) => ({ + equipmentInstances: { + ...state.equipmentInstances, + [instanceId]: { + id: instanceId, + equipmentId: 'test-equip', + name: 'Test Sword', + rarity: 'common', + level: 1, + upgrades: [], + createdAt: Date.now(), + } as any, + }, + })); + + // Equip the item + useCraftingStore.getState().equipItem(slot, instanceId); + + expect(useCraftingStore.getState().equippedInstances[slot]).toBe(instanceId); + }); + + it('unequipItem sets equippedInstances[slot] to null', () => { + const instanceId = 'test-instance-1'; + const slot = 'mainHand'; + + // First equip the item + useCraftingStore.setState((state) => ({ + equipmentInstances: { + ...state.equipmentInstances, + [instanceId]: { + id: instanceId, + equipmentId: 'test-equip', + name: 'Test Sword', + rarity: 'common', + level: 1, + upgrades: [], + createdAt: Date.now(), + } as any, + }, + equippedInstances: { + ...state.equippedInstances, + [slot]: instanceId, + }, + })); + + // Unequip the item + useCraftingStore.getState().unequipItem(slot); + + expect(useCraftingStore.getState().equippedInstances[slot]).toBeNull(); + }); + + it('deleteEquipmentInstance removes from both equippedInstances and equipmentInstances', () => { + const instanceId = 'test-instance-1'; + const slot = 'mainHand'; + + // Add and equip the item + useCraftingStore.setState((state) => ({ + equipmentInstances: { + ...state.equipmentInstances, + [instanceId]: { + id: instanceId, + equipmentId: 'test-equip', + name: 'Test Sword', + rarity: 'common', + level: 1, + upgrades: [], + createdAt: Date.now(), + } as any, + }, + equippedInstances: { + ...state.equippedInstances, + [slot]: instanceId, + }, + })); + + // Delete the item + useCraftingStore.getState().deleteEquipmentInstance(instanceId); + + const state = useCraftingStore.getState(); + expect(state.equipmentInstances[instanceId]).toBeUndefined(); + expect(state.equippedInstances[slot]).toBeNull(); + }); +}); diff --git a/src/lib/game/stores/__tests__/mana.test.ts b/src/lib/game/stores/__tests__/mana.test.ts new file mode 100644 index 0000000..1f3eb62 --- /dev/null +++ b/src/lib/game/stores/__tests__/mana.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useManaStore } from '@/lib/game/stores'; +import { initialManaState } from '@/lib/game/stores/manaStore'; + +describe('useManaStore', () => { + beforeEach(() => { + useManaStore.setState(initialManaState); + }); + + it('spendRawMana reduces rawMana correctly', () => { + useManaStore.setState({ rawMana: 100 }); + const result = useManaStore.getState().spendRawMana(30); + expect(result).toBe(true); + expect(useManaStore.getState().rawMana).toBe(70); + }); + + it('spendRawMana returns false if insufficient mana', () => { + useManaStore.setState({ rawMana: 20 }); + const result = useManaStore.getState().spendRawMana(50); + expect(result).toBe(false); + expect(useManaStore.getState().rawMana).toBe(20); // unchanged + }); + + it('rawMana never goes below 0', () => { + useManaStore.setState({ rawMana: 10 }); + const result = useManaStore.getState().spendRawMana(20); + expect(result).toBe(false); + expect(useManaStore.getState().rawMana).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/src/lib/game/stores/__tests__/regen.test.ts b/src/lib/game/stores/__tests__/regen.test.ts new file mode 100644 index 0000000..bacb2eb --- /dev/null +++ b/src/lib/game/stores/__tests__/regen.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { computeRegen } from '@/lib/game/store-modules/computed-stats'; +import { getIncursionStrength } from '@/lib/game/store-modules/computed-stats'; +import { useSkillStore } from '@/lib/game/stores'; +import { initialSkillState } from '@/lib/game/stores/skillStore'; + +describe('computeRegen', () => { + beforeEach(() => { + useSkillStore.setState(initialSkillState); + }); + + it('Returns 0 when no skills', () => { + const result = computeRegen({ + skills: {}, + skillTiers: {}, + skillUpgrades: {}, + }); + expect(result).toBe(0); + }); + + it('Increases with manaWell skill level', () => { + const base = computeRegen({ + skills: { manaWell: 0 }, + skillTiers: {}, + skillUpgrades: {}, + }); + + const withSkill = computeRegen({ + skills: { manaWell: 5 }, + skillTiers: {}, + skillUpgrades: {}, + }); + + expect(withSkill).toBeGreaterThan(base); + }); +}); + +describe('effectiveRegen', () => { + it('effectiveRegen = baseRegen * (1 - incursionStrength)', () => { + const baseRegen = 10; + const day = 20; // After incursion start + const hour = 12; + const incursionStrength = getIncursionStrength(day, hour); + + const effectiveRegen = baseRegen * (1 - incursionStrength); + + expect(effectiveRegen).toBeLessThan(baseRegen); + expect(effectiveRegen).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/src/lib/game/stores/__tests__/skill.test.ts b/src/lib/game/stores/__tests__/skill.test.ts new file mode 100644 index 0000000..cf2cba4 --- /dev/null +++ b/src/lib/game/stores/__tests__/skill.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useSkillStore } from '@/lib/game/stores'; +import { useManaStore } from '@/lib/game/stores'; +import { initialSkillState } from '@/lib/game/stores/skillStore'; +import { initialManaState } from '@/lib/game/stores/manaStore'; + +describe('useSkillStore', () => { + beforeEach(() => { + useSkillStore.setState(initialSkillState); + useManaStore.setState(initialManaState); + }); + + it('startStudyingSkill returns { started: false } if rawMana < cost', () => { + // Set rawMana to 0 + useManaStore.setState({ rawMana: 0 }); + const result = useSkillStore.getState().startStudyingSkill('manaWell', false); + expect(result.started).toBe(false); + }); + + it('startStudyingSkill deducts mana via manaStore.spendRawMana when started', () => { + // Set rawMana to 100, skill cost is maybe 50? We need to know the cost. + // Let's mock spendRawMana to track calls + const spendRawManaSpy = vi.spyOn(useManaStore.getState(), 'spendRawMana'); + useManaStore.setState({ rawMana: 100 }); + + const result = useSkillStore.getState().startStudyingSkill('manaWell', false); + + if (result.started) { + expect(spendRawManaSpy).toHaveBeenCalledWith(result.cost); + } + }); + + it('startStudyingSkill does NOT deduct if isAlreadyPaid', () => { + const spendRawManaSpy = vi.spyOn(useManaStore.getState(), 'spendRawMana'); + useManaStore.setState({ rawMana: 100 }); + + const result = useSkillStore.getState().startStudyingSkill('manaWell', true); + + if (result.started) { + expect(spendRawManaSpy).not.toHaveBeenCalled(); + expect(result.cost).toBe(0); // cost should be 0 if already paid + } + }); + + it('cancelStudy clears currentStudyTarget', () => { + // First start studying + useManaStore.setState({ rawMana: 100 }); + useSkillStore.getState().startStudyingSkill('manaWell', false); + expect(useSkillStore.getState().currentStudyTarget).not.toBeNull(); + + // Cancel study + useSkillStore.getState().cancelStudy(); + expect(useSkillStore.getState().currentStudyTarget).toBeNull(); + }); +}); diff --git a/src/lib/game/stores/__tests__/spell-cost.test.ts b/src/lib/game/stores/__tests__/spell-cost.test.ts new file mode 100644 index 0000000..a23abe9 --- /dev/null +++ b/src/lib/game/stores/__tests__/spell-cost.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { canAffordSpellCost } from '@/lib/game/stores'; +import { formatSpellCost } from '@/lib/game/formatting'; +import { useManaStore } from '@/lib/game/stores'; +import { initialManaState } from '@/lib/game/stores/manaStore'; +import type { SpellCost } from '@/lib/game/types'; + +describe('canAffordSpellCost', () => { + beforeEach(() => { + useManaStore.setState(initialManaState); + }); + + it('returns true when rawMana >= cost.amount (type: raw)', () => { + useManaStore.setState({ rawMana: 100 }); + const cost: SpellCost = { type: 'raw', amount: 50 }; + expect(canAffordSpellCost(cost)).toBe(true); + }); + + it('returns false when insufficient rawMana', () => { + useManaStore.setState({ rawMana: 20 }); + const cost: SpellCost = { type: 'raw', amount: 50 }; + expect(canAffordSpellCost(cost)).toBe(false); + }); + + it('returns true when has enough element mana', () => { + // Set initial state first + useManaStore.setState(initialManaState); + // Then update the Fire element + useManaStore.setState((state) => ({ + elements: { + ...state.elements, + Fire: { amount: 100, max: 200, rate: 0 }, + }, + })); + const cost: SpellCost = { type: 'element', element: 'Fire', amount: 50 }; + expect(canAffordSpellCost(cost)).toBe(true); + }); +}); + +describe('formatSpellCost', () => { + it('returns a non-empty string for raw cost', () => { + const cost: SpellCost = { type: 'raw', amount: 50 }; + const result = formatSpellCost(cost); + expect(result).toBeTruthy(); + expect(typeof result).toBe('string'); + }); + + it('returns a non-empty string for element cost', () => { + const cost: SpellCost = { type: 'element', element: 'Fire', amount: 30 }; + const result = formatSpellCost(cost); + expect(result).toBeTruthy(); + expect(typeof result).toBe('string'); + }); + + it('does NOT throw when cost.type is element', () => { + const cost: SpellCost = { type: 'element', element: 'Water', amount: 25 }; + expect(() => formatSpellCost(cost)).not.toThrow(); + }); + + it('handles edge case: zero amount', () => { + const cost: SpellCost = { type: 'raw', amount: 0 }; + expect(() => formatSpellCost(cost)).not.toThrow(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index aed69b2..1d26efe 100755 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'vitest/config'; +import path from 'path'; export default defineConfig({ test: { @@ -14,4 +15,9 @@ export default defineConfig({ ], }, }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, });