diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt
index 75018d4..bef01ce 100644
--- a/docs/circular-deps.txt
+++ b/docs/circular-deps.txt
@@ -1,5 +1,5 @@
# Circular Dependencies
-Generated: 2026-05-25T16:26:37.693Z
+Generated: 2026-05-25T18:18:45.184Z
Found: 6 circular chain(s) — these MUST be fixed before modifying involved files.
1. Processed 135 files (1.6s) (2 warnings)
diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json
index f65cc2e..ba6af3b 100644
--- a/docs/dependency-graph.json
+++ b/docs/dependency-graph.json
@@ -1,6 +1,6 @@
{
"_meta": {
- "generated": "2026-05-25T16:26:35.898Z",
+ "generated": "2026-05-25T18:18:43.369Z",
"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."
},
@@ -143,7 +143,6 @@
"types.ts"
],
"crafting-equipment.ts": [
- "crafting-utils.ts",
"data/crafting-recipes.ts",
"data/equipment/index.ts",
"types.ts",
@@ -168,9 +167,7 @@
"data/attunements.ts": [
"types.ts"
],
- "data/crafting-recipes.ts": [
- "data/equipment/types.ts"
- ],
+ "data/crafting-recipes.ts": [],
"data/disciplines/base.ts": [
"types/disciplines.ts"
],
@@ -226,8 +223,7 @@
"data/equipment/index.ts"
],
"data/enchantments/defense-effects.ts": [
- "data/enchantment-types.ts",
- "data/equipment/index.ts"
+ "data/enchantment-types.ts"
],
"data/enchantments/elemental-effects.ts": [
"data/enchantment-types.ts",
@@ -439,8 +435,7 @@
"effects/discipline-effects.ts",
"stores/combat-state.types.ts",
"types.ts",
- "utils/index.ts",
- "utils/result.ts"
+ "utils/index.ts"
],
"stores/combat-state.types.ts": [
"types.ts"
@@ -448,7 +443,6 @@
"stores/combatStore.ts": [
"stores/combat-actions.ts",
"stores/combat-state.types.ts",
- "stores/prestigeStore.ts",
"types.ts",
"utils/activity-log.ts",
"utils/index.ts",
@@ -471,7 +465,6 @@
"stores/craftingStore.types.ts",
"stores/manaStore.ts",
"stores/uiStore.ts",
- "types.ts",
"types/equipmentSlot.ts",
"utils/result.ts",
"utils/safe-persist.ts"
@@ -499,7 +492,6 @@
"stores/gameActions.ts": [
"effects/discipline-effects.ts",
"stores/combatStore.ts",
- "stores/discipline-slice.ts",
"stores/gameStore.ts",
"stores/manaStore.ts",
"stores/prestigeStore.ts",
@@ -510,20 +502,16 @@
"constants.ts",
"effects.ts",
"effects/discipline-effects.ts",
- "stores/combatStore.ts",
"stores/craftingStore.ts",
- "stores/discipline-slice.ts",
"stores/gameStore.ts",
"stores/manaStore.ts",
"stores/prestigeStore.ts",
- "stores/uiStore.ts",
"utils/index.ts"
],
"stores/gameLoopActions.ts": [
"constants.ts",
"effects/discipline-effects.ts",
"stores/combatStore.ts",
- "stores/discipline-slice.ts",
"stores/gameStore.ts",
"stores/manaStore.ts",
"stores/prestigeStore.ts",
diff --git a/docs/project-structure.txt b/docs/project-structure.txt
index 2c4660a..a10a047 100644
--- a/docs/project-structure.txt
+++ b/docs/project-structure.txt
@@ -181,6 +181,7 @@ Mana-Loop/
│ │ │ ├── toggle.tsx
│ │ │ ├── tooltip-info.tsx
│ │ │ ├── tooltip.tsx
+│ │ │ ├── ui-components.test.tsx
│ │ │ └── value-display.tsx
│ │ └── ErrorBoundary.tsx
│ ├── hooks/
@@ -193,6 +194,7 @@ Mana-Loop/
│ │ │ ├── achievements.test.ts
│ │ │ ├── activity-log.test.ts
│ │ │ ├── bug-fixes.test.ts
+│ │ │ ├── combat-actions.test.ts
│ │ │ ├── combat-utils.test.ts
│ │ │ ├── computed-stats.test.ts
│ │ │ ├── crafting-utils-basic.test.ts
diff --git a/scorecard.png b/scorecard.png
index 60ca011..afa39e9 100644
Binary files a/scorecard.png and b/scorecard.png differ
diff --git a/src/components/ui/ui-components.test.tsx b/src/components/ui/ui-components.test.tsx
new file mode 100644
index 0000000..81d9412
--- /dev/null
+++ b/src/components/ui/ui-components.test.tsx
@@ -0,0 +1,186 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import {
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+ CardFooter,
+} from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// UI COMPONENT TESTS — Card
+// ═══════════════════════════════════════════════════════════════════════════════
+
+describe('Card', () => {
+ it('should render with children', () => {
+ render(Hello World);
+ expect(screen.getByTestId('card')).toHaveTextContent('Hello World');
+ });
+
+ it('should accept custom className', () => {
+ render(Test);
+ expect(screen.getByTestId('card')).toHaveClass('custom-class');
+ });
+
+ it('should have data-slot attribute', () => {
+ render(Test);
+ expect(screen.getByTestId('card')).toHaveAttribute('data-slot', 'card');
+ });
+
+ it('should render CardHeader with children', () => {
+ render(
+
+ Header Text
+ ,
+ );
+ expect(screen.getByTestId('header')).toHaveTextContent('Header Text');
+ });
+
+ it('should render CardTitle', () => {
+ render(
+
+
+ My Title
+
+ ,
+ );
+ expect(screen.getByTestId('title')).toHaveTextContent('My Title');
+ });
+
+ it('should render CardDescription', () => {
+ render(
+
+
+ Description text
+
+ ,
+ );
+ expect(screen.getByTestId('desc')).toHaveTextContent('Description text');
+ });
+
+ it('should render CardContent', () => {
+ render(
+
+ Content here
+ ,
+ );
+ expect(screen.getByTestId('content')).toHaveTextContent('Content here');
+ });
+
+ it('should render CardFooter', () => {
+ render(
+
+ Footer text
+ ,
+ );
+ expect(screen.getByTestId('footer')).toHaveTextContent('Footer text');
+ });
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// UI COMPONENT TESTS — Button
+// ═══════════════════════════════════════════════════════════════════════════════
+
+describe('Button', () => {
+ it('should render with children text', () => {
+ render();
+ expect(screen.getByTestId('btn')).toHaveTextContent('Click Me');
+ });
+
+ it('should have button role', () => {
+ render();
+ expect(screen.getByTestId('btn').tagName).toBe('BUTTON');
+ });
+
+ it('should apply variant classes', () => {
+ render();
+ expect(screen.getByTestId('btn')).toHaveClass('bg-destructive');
+ });
+
+ it('should apply size classes', () => {
+ render();
+ expect(screen.getByTestId('btn')).toHaveClass('h-8');
+ });
+
+ it('should apply custom className', () => {
+ render();
+ expect(screen.getByTestId('btn')).toHaveClass('custom');
+ });
+
+ it('should render as child component when asChild is true', () => {
+ render(
+ ,
+ );
+ const el = screen.getByTestId('btn');
+ expect(el.tagName).toBe('A');
+ expect(el).toHaveAttribute('href', '/test');
+ });
+
+ it('should have data-slot attribute', () => {
+ render();
+ expect(screen.getByTestId('btn')).toHaveAttribute('data-slot', 'button');
+ });
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// UI COMPONENT TESTS — Badge
+// ═══════════════════════════════════════════════════════════════════════════════
+
+describe('Badge', () => {
+ it('should render with children text', () => {
+ render(New);
+ expect(screen.getByTestId('badge')).toHaveTextContent('New');
+ });
+
+ it('should render as span by default', () => {
+ render(Test);
+ expect(screen.getByTestId('badge').tagName).toBe('SPAN');
+ });
+
+ it('should apply default variant classes', () => {
+ render(Default);
+ expect(screen.getByTestId('badge')).toHaveClass('bg-primary');
+ });
+
+ it('should apply destructive variant', () => {
+ render(Error);
+ expect(screen.getByTestId('badge')).toHaveClass('bg-destructive');
+ });
+
+ it('should apply outline variant', () => {
+ render(Outline);
+ expect(screen.getByTestId('badge')).toHaveClass('text-foreground');
+ });
+
+ it('should apply secondary variant', () => {
+ render(Secondary);
+ expect(screen.getByTestId('badge')).toHaveClass('bg-secondary');
+ });
+
+ it('should apply custom className', () => {
+ render(Custom);
+ expect(screen.getByTestId('badge')).toHaveClass('custom-badge');
+ });
+
+ it('should accept asChild prop', () => {
+ render(
+
+ Link Badge
+ ,
+ );
+ const el = screen.getByTestId('badge');
+ expect(el.tagName).toBe('A');
+ });
+
+ it('should have data-slot attribute', () => {
+ render(Test);
+ expect(screen.getByTestId('badge')).toHaveAttribute('data-slot', 'badge');
+ });
+});
diff --git a/src/lib/game/__tests__/combat-actions.test.ts b/src/lib/game/__tests__/combat-actions.test.ts
new file mode 100644
index 0000000..aa6a931
--- /dev/null
+++ b/src/lib/game/__tests__/combat-actions.test.ts
@@ -0,0 +1,236 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { processCombatTick, makeInitialSpells } from '../stores/combat-actions';
+import { useCombatStore, makeInitialSpells as makeStoreInitialSpells } from '../stores/combatStore';
+import { useManaStore, makeInitialElements } from '../stores/manaStore';
+import { useGameStore } from '../stores/gameStore';
+import { usePrestigeStore } from '../stores/prestigeStore';
+import { useUIStore } from '../stores/uiStore';
+import { useDisciplineStore } from '../stores/discipline-slice';
+import { getFloorMaxHP } from '../utils';
+import type { CombatTickResult } from '../stores/combat-actions';
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function resetStores() {
+ useUIStore.setState({ paused: false, gameOver: false, victory: false, logs: [] });
+ useGameStore.setState({ day: 1, hour: 0, incursionStrength: 0, containmentWards: 0, initialized: true });
+ useManaStore.setState({ rawMana: 1000, meditateTicks: 0, totalManaGathered: 0, elements: makeInitialElements(500, {}) });
+ useCombatStore.setState({
+ currentFloor: 1,
+ floorHP: getFloorMaxHP(1),
+ floorMaxHP: getFloorMaxHP(1),
+ maxFloorReached: 1,
+ activeSpell: 'manaBolt',
+ currentAction: 'climb',
+ castProgress: 0,
+ spireMode: false,
+ currentRoom: { roomType: 'combat', enemies: [] },
+ clearedFloors: {},
+ climbDirection: null,
+ isDescending: false,
+ golemancy: { enabledGolems: [], summonedGolems: [], lastSummonFloor: 0 },
+ equipmentSpellStates: [],
+ comboHitCount: 0,
+ floorHitCount: 0,
+ spells: makeStoreInitialSpells(),
+ activityLog: [],
+ achievements: { unlocked: [], progress: {} },
+ totalSpellsCast: 0,
+ totalDamageDealt: 0,
+ totalCraftsCompleted: 0,
+ });
+ usePrestigeStore.setState({
+ loopCount: 0, insight: 0, totalInsight: 0, loopInsight: 0,
+ prestigeUpgrades: {}, pactSlots: 1, defeatedGuardians: [],
+ signedPacts: [], signedPactDetails: {},
+ pactRitualFloor: null, pactRitualProgress: 0,
+ });
+ useDisciplineStore.setState({ disciplines: {}, activeIds: [], concurrentLimit: 1, totalXP: 0, processedPerks: [] });
+}
+
+function runCombatTick(rawMana: number, elements: Record): CombatTickResult {
+ return processCombatTick(
+ () => useCombatStore.getState(),
+ (partial) => useCombatStore.setState(partial),
+ rawMana,
+ elements,
+ 1000, // maxMana
+ 1, // attackSpeedMult
+ vi.fn(), // onFloorCleared
+ (dmg) => ({ rawMana, elements, modifiedDamage: dmg }), // onDamageDealt (no modifiers)
+ [], // signedPacts
+ );
+}
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// COMBAT ACTIONS — processCombatTick
+// ═══════════════════════════════════════════════════════════════════════════════
+
+describe('processCombatTick', () => {
+ beforeEach(resetStores);
+
+ describe('basic mechanics', () => {
+ it('should return default result when currentAction is not climb', () => {
+ useCombatStore.setState({ currentAction: 'meditate' });
+ const elements = makeInitialElements(500, {});
+ const result = runCombatTick(1000, elements);
+ expect(result.currentFloor).toBe(1);
+ expect(result.floorHP).toBe(getFloorMaxHP(1));
+ });
+
+ it('should return default result when spell is not found', () => {
+ useCombatStore.setState({ activeSpell: 'nonexistentSpell' });
+ const elements = makeInitialElements(500, {});
+ const result = runCombatTick(1000, elements);
+ expect(result.currentFloor).toBe(1);
+ });
+
+ it('should progress cast bar on each tick', () => {
+ const elements = makeInitialElements(500, {});
+ const result = runCombatTick(1000, elements);
+ expect(result.castProgress).toBeGreaterThan(0);
+ });
+
+ it('should reduce floor HP when cast completes', () => {
+ // Set cast progress close to 1 so next cast completes
+ useCombatStore.setState({ castProgress: 0.99 });
+ const elements = makeInitialElements(500, {});
+ const initialHP = useCombatStore.getState().floorHP;
+ const result = runCombatTick(1000, elements);
+ // Either HP decreased or floor advanced (if HP reached 0)
+ expect(result.floorHP).toBeLessThanOrEqual(initialHP);
+ });
+ });
+
+ describe('floor advancement', () => {
+ it('should advance floor when HP reaches 0', () => {
+ // Set floor HP very low so it's cleared in one cast
+ useCombatStore.setState({ floorHP: 1, castProgress: 0.99 });
+ const elements = makeInitialElements(500, {});
+ const result = runCombatTick(1000, elements);
+ expect(result.currentFloor).toBe(2);
+ expect(result.floorHP).toBe(getFloorMaxHP(2));
+ });
+
+ it('should update maxFloorReached when advancing', () => {
+ useCombatStore.setState({ floorHP: 1, castProgress: 0.99, maxFloorReached: 5 });
+ const elements = makeInitialElements(500, {});
+ const result = runCombatTick(1000, elements);
+ expect(result.maxFloorReached).toBeGreaterThanOrEqual(5);
+ });
+
+ it('should not advance beyond floor 100', () => {
+ useCombatStore.setState({ currentFloor: 100, floorHP: 1, castProgress: 0.99 });
+ const elements = makeInitialElements(500, {});
+ const result = runCombatTick(1000, elements);
+ expect(result.currentFloor).toBeLessThanOrEqual(100);
+ });
+ });
+
+ describe('mana cost', () => {
+ it('should deduct raw mana when spell costs raw mana', () => {
+ const elements = makeInitialElements(500, {});
+ const startMana = 1000;
+ const result = runCombatTick(startMana, elements);
+ // manaBolt costs raw mana, so after casting, rawMana should decrease
+ expect(result.rawMana).toBeLessThanOrEqual(startMana);
+ });
+
+ it('should not cast when insufficient mana', () => {
+ const elements = makeInitialElements(500, {});
+ const state = useCombatStore.getState();
+ // With 0 mana and 0 progress, no cast should complete
+ const result = processCombatTick(
+ () => state,
+ () => {},
+ 0, // no mana
+ elements,
+ 1000,
+ 1,
+ vi.fn(),
+ (dmg) => ({ rawMana: 0, elements, modifiedDamage: dmg }),
+ [],
+ );
+ expect(result.rawMana).toBe(0);
+ });
+ });
+
+ describe('equipment spell states', () => {
+ it('should initialize empty equipmentSpellStates', () => {
+ const elements = makeInitialElements(500, {});
+ const result = runCombatTick(1000, elements);
+ expect(result.equipmentSpellStates).toBeDefined();
+ });
+
+ it('should not error with empty equipment spell states', () => {
+ useCombatStore.setState({ equipmentSpellStates: [] });
+ const elements = makeInitialElements(500, {});
+ expect(() => runCombatTick(1000, elements)).not.toThrow();
+ });
+ });
+
+ describe('error handling', () => {
+ it('should return safe defaults when spell def is missing (early return path)', () => {
+ // When the spell definition is not found, processCombatTick returns
+ // a default result without crashing — this exercises the early return
+ // path that acts as a safety net.
+ useCombatStore.setState({ activeSpell: 'totallyInvalidSpell' });
+ const elements = makeInitialElements(500, {});
+ const result = processCombatTick(
+ () => useCombatStore.getState(),
+ () => {},
+ 1000,
+ elements,
+ 1000,
+ 1,
+ vi.fn(),
+ (dmg) => ({ rawMana: 1000, elements, modifiedDamage: dmg }),
+ [],
+ );
+ expect(result).toBeDefined();
+ expect(result.rawMana).toBe(1000);
+ expect(result.logMessages).toBeDefined();
+ });
+
+ it('should not throw when onDamageDealt callback throws', () => {
+ // The try/catch in processCombatTick should catch errors from callbacks
+ // and return safe defaults instead of crashing
+ const elements = makeInitialElements(500, {});
+ useCombatStore.setState({ castProgress: 0.99 });
+ expect(() => {
+ processCombatTick(
+ () => useCombatStore.getState(),
+ () => {},
+ 1000,
+ elements,
+ 1000,
+ 1,
+ vi.fn(),
+ (_dmg) => { throw new Error('damage callback error'); },
+ [],
+ );
+ }).not.toThrow();
+ });
+ });
+
+ describe('helper: makeInitialSpells', () => {
+ it('should create manaBolt as default spell', () => {
+ const spells = makeInitialSpells();
+ expect(spells.manaBolt).toBeDefined();
+ expect(spells.manaBolt.learned).toBe(true);
+ expect(spells.manaBolt.level).toBe(1);
+ });
+
+ it('should keep additional spells when specified', () => {
+ const spells = makeInitialSpells(['fireball']);
+ expect(spells.manaBolt).toBeDefined();
+ expect(spells.fireball).toBeDefined();
+ expect(spells.fireball.learned).toBe(true);
+ });
+
+ it('should not duplicate manaBolt when included in keep list', () => {
+ const spells = makeInitialSpells(['manaBolt']);
+ expect(Object.keys(spells).length).toBe(1);
+ });
+ });
+});
diff --git a/vitest.config.ts b/vitest.config.ts
index 1d26efe..cdccbe6 100755
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -6,7 +6,7 @@ export default defineConfig({
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
- include: ['src/**/*.{test,spec}.ts'],
+ include: ['src/**/*.{test,spec}.{ts,tsx}'],
coverage: {
reporter: ['text', 'json'],
exclude: [