From df316c2865f2c76fac316d9345a273d9cec73661 Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Wed, 20 May 2026 15:20:42 +0200 Subject: [PATCH] test: add store action and cross-store tick integration tests; fix pact ritual double-counting bug - Add test-setup.ts: shared test environment setup helper for tick integration tests - Add combat-store.test.ts (24 tests): setCurrentFloor, advanceFloor, setFloorHP, setMaxFloorReached, setAction, setSpell, setCastProgress, learnSpell, setSpellState, debugSetFloor, resetFloorHP, resetCombat, climbDownFloor, exitSpireMode - Add mana-store.test.ts (36 tests): setRawMana, addRawMana, spendRawMana, gatherMana, convertMana, unlockElement, addElementMana, spendElementMana, craftComposite, processConvertAction, resetMana, meditation ticks, setElementMax - Add tick-integration.test.ts (19 tests): time progression, mana regeneration, incursion penalty, meditation, loop end, paused/game over states - Add tick-integration-pact.test.ts (9 tests): victory condition, pact ritual progress, multiple ticks accumulation - Add tick-debug.test.ts (3 debug tests): regen trace, pact ritual trace, persist leak check - Fix bug in gameStore.ts: updatePactRitualProgress was called with absolute newProgress instead of incremental HOURS_PER_TICK, causing exponential progress accumulation - All 422 tests pass (18 test files), all files under 400-line limit --- docs/circular-deps.txt | 2 +- docs/dependency-graph.json | 2 +- docs/project-structure.txt | 10 +- src/lib/game/__tests__/combat-store.test.ts | 189 ++++++++++++ .../game/__tests__/discipline-store.test.ts | 135 +++++++++ src/lib/game/__tests__/mana-store.test.ts | 286 ++++++++++++++++++ src/lib/game/__tests__/prestige-store.test.ts | 230 ++++++++++++++ src/lib/game/__tests__/test-setup.ts | 48 +++ src/lib/game/__tests__/tick-debug.test.ts | 116 +++++++ .../__tests__/tick-integration-pact.test.ts | 135 +++++++++ .../game/__tests__/tick-integration.test.ts | 224 ++++++++++++++ src/lib/game/stores/gameStore.ts | 2 +- 12 files changed, 1375 insertions(+), 4 deletions(-) create mode 100644 src/lib/game/__tests__/combat-store.test.ts create mode 100644 src/lib/game/__tests__/discipline-store.test.ts create mode 100644 src/lib/game/__tests__/mana-store.test.ts create mode 100644 src/lib/game/__tests__/prestige-store.test.ts create mode 100644 src/lib/game/__tests__/test-setup.ts create mode 100644 src/lib/game/__tests__/tick-debug.test.ts create mode 100644 src/lib/game/__tests__/tick-integration-pact.test.ts create mode 100644 src/lib/game/__tests__/tick-integration.test.ts diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index b6aae72..54551af 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,5 +1,5 @@ # Circular Dependencies -Generated: 2026-05-20T10:36:04.388Z +Generated: 2026-05-20T11:01:19.491Z Found: 1 circular chain(s) — these MUST be fixed before modifying involved files. 1. Processed 125 files (1.3s) (4 warnings) diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index 860f8d7..b871b1d 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-05-20T10:36:02.953Z", + "generated": "2026-05-20T11:01:18.069Z", "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." }, diff --git a/docs/project-structure.txt b/docs/project-structure.txt index a0166fc..4effc46 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -190,15 +190,23 @@ Mana-Loop/ │ │ │ ├── store-method-tests/ │ │ │ ├── achievements.test.ts │ │ │ ├── bug-fixes.test.ts +│ │ │ ├── combat-store.test.ts │ │ │ ├── combat-utils.test.ts │ │ │ ├── computed-stats.test.ts │ │ │ ├── discipline-math.test.ts +│ │ │ ├── discipline-store.test.ts │ │ │ ├── enemy-generator.test.ts │ │ │ ├── floor-utils.test.ts │ │ │ ├── formatting.test.ts +│ │ │ ├── mana-store.test.ts │ │ │ ├── mana-utils.test.ts +│ │ │ ├── prestige-store.test.ts │ │ │ ├── regression-fixes.test.ts -│ │ │ └── spire-utils.test.ts +│ │ │ ├── spire-utils.test.ts +│ │ │ ├── test-setup.ts +│ │ │ ├── tick-debug.test.ts +│ │ │ ├── tick-integration-pact.test.ts +│ │ │ └── tick-integration.test.ts │ │ ├── constants/ │ │ │ ├── spells-modules/ │ │ │ │ ├── advanced-spells.ts diff --git a/src/lib/game/__tests__/combat-store.test.ts b/src/lib/game/__tests__/combat-store.test.ts new file mode 100644 index 0000000..9f3bc8f --- /dev/null +++ b/src/lib/game/__tests__/combat-store.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useCombatStore } from '../stores/combatStore'; +import { getFloorMaxHP } from '../utils'; + +beforeEach(() => { + useCombatStore.setState({ + currentFloor: 1, + floorHP: getFloorMaxHP(1), + floorMaxHP: getFloorMaxHP(1), + maxFloorReached: 1, + activeSpell: 'manaBolt', + currentAction: 'meditate', + castProgress: 0, + spireMode: false, + clearedFloors: {}, + climbDirection: null, + isDescending: false, + spells: { + manaBolt: { learned: true, level: 1, studyProgress: 0 }, + }, + }); +}); + +describe('CombatStore', () => { + describe('setCurrentFloor', () => { + it('should set current floor and reset HP', () => { + useCombatStore.getState().setCurrentFloor(5); + const state = useCombatStore.getState(); + expect(state.currentFloor).toBe(5); + expect(state.floorHP).toBe(state.floorMaxHP); + }); + }); + + describe('advanceFloor', () => { + it('should advance to next floor', () => { + useCombatStore.getState().advanceFloor(); + expect(useCombatStore.getState().currentFloor).toBe(2); + }); + it('should reset cast progress on advance', () => { + useCombatStore.setState({ castProgress: 0.5 }); + useCombatStore.getState().advanceFloor(); + expect(useCombatStore.getState().castProgress).toBe(0); + }); + it('should update max floor reached', () => { + useCombatStore.setState({ currentFloor: 3, maxFloorReached: 3 }); + useCombatStore.getState().advanceFloor(); + expect(useCombatStore.getState().maxFloorReached).toBe(4); + }); + it('should cap at floor 100', () => { + useCombatStore.setState({ currentFloor: 100 }); + useCombatStore.getState().advanceFloor(); + expect(useCombatStore.getState().currentFloor).toBe(100); + }); + }); + + describe('setFloorHP', () => { + it('should set floor HP', () => { + useCombatStore.getState().setFloorHP(50); + expect(useCombatStore.getState().floorHP).toBe(50); + }); + it('should clamp negative HP to zero', () => { + useCombatStore.getState().setFloorHP(-10); + expect(useCombatStore.getState().floorHP).toBe(0); + }); + }); + + describe('setMaxFloorReached', () => { + it('should set max floor reached', () => { + useCombatStore.getState().setMaxFloorReached(10); + expect(useCombatStore.getState().maxFloorReached).toBe(10); + }); + it('should not decrease max floor reached', () => { + useCombatStore.setState({ maxFloorReached: 10 }); + useCombatStore.getState().setMaxFloorReached(5); + expect(useCombatStore.getState().maxFloorReached).toBe(10); + }); + }); + + describe('setAction', () => { + it('should set current action', () => { + useCombatStore.getState().setAction('climb'); + expect(useCombatStore.getState().currentAction).toBe('climb'); + }); + }); + + describe('setSpell', () => { + it('should set active spell when learned', () => { + useCombatStore.getState().setSpell('manaBolt'); + expect(useCombatStore.getState().activeSpell).toBe('manaBolt'); + }); + it('should not set spell when not learned', () => { + useCombatStore.getState().setSpell('fireball'); + expect(useCombatStore.getState().activeSpell).toBe('manaBolt'); + }); + }); + + describe('setCastProgress', () => { + it('should set cast progress', () => { + useCombatStore.getState().setCastProgress(0.75); + expect(useCombatStore.getState().castProgress).toBe(0.75); + }); + }); + + describe('learnSpell', () => { + it('should learn a new spell', () => { + useCombatStore.getState().learnSpell('fireball'); + const spell = useCombatStore.getState().spells['fireball']; + expect(spell.learned).toBe(true); + expect(spell.level).toBe(1); + }); + }); + + describe('setSpellState', () => { + it('should update spell state', () => { + useCombatStore.getState().setSpellState('manaBolt', { level: 5 }); + expect(useCombatStore.getState().spells.manaBolt.level).toBe(5); + }); + it('should create spell if not exists', () => { + useCombatStore.getState().setSpellState('fireball', { learned: true, level: 3 }); + const spell = useCombatStore.getState().spells['fireball']; + expect(spell.learned).toBe(true); + expect(spell.level).toBe(3); + }); + }); + + describe('debugSetFloor', () => { + it('should set floor and reset HP', () => { + useCombatStore.getState().debugSetFloor(10); + const state = useCombatStore.getState(); + expect(state.currentFloor).toBe(10); + expect(state.floorHP).toBe(state.floorMaxHP); + }); + }); + + describe('resetFloorHP', () => { + it('should reset floor HP to max', () => { + useCombatStore.setState({ floorHP: 1 }); + useCombatStore.getState().resetFloorHP(); + expect(useCombatStore.getState().floorHP).toBe(useCombatStore.getState().floorMaxHP); + }); + }); + + describe('resetCombat', () => { + it('should reset combat to starting state', () => { + useCombatStore.setState({ currentFloor: 50, castProgress: 0.9 }); + useCombatStore.getState().resetCombat(1); + const state = useCombatStore.getState(); + expect(state.currentFloor).toBe(1); + expect(state.castProgress).toBe(0); + expect(state.activeSpell).toBe('manaBolt'); + }); + it('should keep specified spells', () => { + useCombatStore.getState().resetCombat(1, ['fireball']); + expect(useCombatStore.getState().spells['fireball'].learned).toBe(true); + }); + }); + + describe('climbDownFloor', () => { + it('should descend one floor', () => { + useCombatStore.setState({ currentFloor: 5 }); + useCombatStore.getState().climbDownFloor(); + expect(useCombatStore.getState().currentFloor).toBe(4); + }); + it('should not go below floor 1', () => { + useCombatStore.setState({ currentFloor: 1 }); + useCombatStore.getState().climbDownFloor(); + expect(useCombatStore.getState().currentFloor).toBe(1); + }); + it('should reset HP and cast progress on descent', () => { + useCombatStore.setState({ currentFloor: 5, castProgress: 0.5, floorHP: 1 }); + useCombatStore.getState().climbDownFloor(); + const state = useCombatStore.getState(); + expect(state.castProgress).toBe(0); + expect(state.floorHP).toBe(state.floorMaxHP); + }); + }); + + describe('exitSpireMode', () => { + it('should exit spire mode and reset state', () => { + useCombatStore.setState({ spireMode: true, climbDirection: 'up', isDescending: false }); + useCombatStore.getState().exitSpireMode(); + const state = useCombatStore.getState(); + expect(state.spireMode).toBe(false); + expect(state.climbDirection).toBeNull(); + expect(state.isDescending).toBe(false); + expect(state.currentAction).toBe('meditate'); + }); + }); +}); diff --git a/src/lib/game/__tests__/discipline-store.test.ts b/src/lib/game/__tests__/discipline-store.test.ts new file mode 100644 index 0000000..04a0c1b --- /dev/null +++ b/src/lib/game/__tests__/discipline-store.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useDisciplineStore } from '../stores/discipline-slice'; + +beforeEach(() => { + useDisciplineStore.setState({ + disciplines: {}, activeIds: [], concurrentLimit: 1, totalXP: 0, + }); +}); + +describe('DisciplineStore', () => { + describe('activate', () => { + it('should activate a discipline', () => { + useDisciplineStore.getState().activate('raw-mastery'); + const state = useDisciplineStore.getState(); + expect(state.activeIds).toContain('raw-mastery'); + expect(state.disciplines['raw-mastery']).toBeDefined(); + expect(state.disciplines['raw-mastery'].paused).toBe(false); + }); + it('should not activate unknown discipline', () => { + useDisciplineStore.getState().activate('nonexistent'); + expect(useDisciplineStore.getState().activeIds).toHaveLength(0); + }); + it('should not activate same discipline twice', () => { + useDisciplineStore.getState().activate('raw-mastery'); + useDisciplineStore.getState().activate('raw-mastery'); + expect(useDisciplineStore.getState().activeIds).toHaveLength(1); + }); + it('should respect concurrent limit', () => { + useDisciplineStore.setState({ concurrentLimit: 1 }); + useDisciplineStore.getState().activate('raw-mastery'); + useDisciplineStore.getState().activate('elemental-attunement'); + expect(useDisciplineStore.getState().activeIds).toHaveLength(1); + }); + it('should allow activation when under limit', () => { + useDisciplineStore.setState({ concurrentLimit: 2 }); + useDisciplineStore.getState().activate('raw-mastery'); + useDisciplineStore.getState().activate('elemental-attunement'); + expect(useDisciplineStore.getState().activeIds).toHaveLength(2); + }); + it('should activate element discipline on first call (no prior state)', () => { + useDisciplineStore.getState().activate('elemental-attunement', { elements: {} }); + expect(useDisciplineStore.getState().activeIds).toContain('elemental-attunement'); + }); + it('should activate element discipline with matching unlocked element', () => { + useDisciplineStore.getState().activate('elemental-attunement', { + elements: { fire: { unlocked: true } }, + }); + expect(useDisciplineStore.getState().activeIds).toContain('elemental-attunement'); + }); + it('should not re-activate paused discipline', () => { + useDisciplineStore.getState().activate('elemental-attunement', { + elements: { fire: { unlocked: true, current: 100 } }, + }); + useDisciplineStore.setState({ + disciplines: { + 'elemental-attunement': { id: 'elemental-attunement', xp: 200, paused: true }, + }, + }); + useDisciplineStore.getState().activate('elemental-attunement', { + elements: { fire: { unlocked: true, current: 100 } }, + }); + expect(useDisciplineStore.getState().disciplines['elemental-attunement'].paused).toBe(true); + }); + }); + + describe('deactivate', () => { + it('should deactivate a discipline', () => { + useDisciplineStore.getState().activate('raw-mastery'); + useDisciplineStore.getState().deactivate('raw-mastery'); + expect(useDisciplineStore.getState().activeIds).not.toContain('raw-mastery'); + }); + it('should not error on non-active discipline', () => { + useDisciplineStore.getState().deactivate('raw-mastery'); + expect(useDisciplineStore.getState().activeIds).toHaveLength(0); + }); + }); + + describe('processTick', () => { + it('should accrue XP for active discipline', () => { + useDisciplineStore.getState().activate('raw-mastery'); + useDisciplineStore.getState().processTick({ rawMana: 100, elements: {} }); + const state = useDisciplineStore.getState(); + expect(state.disciplines['raw-mastery'].xp).toBe(1); + expect(state.totalXP).toBe(1); + }); + it('should drain raw mana', () => { + useDisciplineStore.getState().activate('raw-mastery'); + const result = useDisciplineStore.getState().processTick({ rawMana: 100, elements: {} }); + expect(result.rawMana).toBeLessThan(100); + }); + it('should drain element mana for element discipline', () => { + useDisciplineStore.setState({ concurrentLimit: 2 }); + useDisciplineStore.getState().activate('elemental-attunement', { + elements: { fire: { unlocked: true } }, + }); + const result = useDisciplineStore.getState().processTick({ + rawMana: 0, elements: { fire: { current: 100 } }, + }); + expect(result.elements.fire.current).toBeLessThan(100); + }); + it('should pause discipline when insufficient mana', () => { + useDisciplineStore.getState().activate('raw-mastery'); + useDisciplineStore.getState().processTick({ rawMana: 0, elements: {} }); + expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(true); + }); + it('should not accrue XP when paused', () => { + useDisciplineStore.getState().activate('raw-mastery'); + useDisciplineStore.setState({ + disciplines: { 'raw-mastery': { id: 'raw-mastery', xp: 5, paused: true } }, + }); + useDisciplineStore.getState().processTick({ rawMana: 100, elements: {} }); + expect(useDisciplineStore.getState().disciplines['raw-mastery'].xp).toBe(5); + }); + it('should process multiple active disciplines', () => { + useDisciplineStore.setState({ concurrentLimit: 2 }); + useDisciplineStore.getState().activate('raw-mastery'); + useDisciplineStore.getState().activate('elemental-attunement', { + elements: { fire: { unlocked: true } }, + }); + useDisciplineStore.getState().processTick({ + rawMana: 100, elements: { fire: { current: 100 } }, + }); + expect(useDisciplineStore.getState().totalXP).toBe(2); + }); + it('should increase concurrent limit at XP thresholds', () => { + useDisciplineStore.setState({ + concurrentLimit: 1, totalXP: 499, activeIds: ['raw-mastery'], + disciplines: { 'raw-mastery': { id: 'raw-mastery', xp: 499, paused: false } }, + }); + useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} }); + expect(useDisciplineStore.getState().totalXP).toBe(500); + expect(useDisciplineStore.getState().concurrentLimit).toBeGreaterThan(1); + }); + }); +}); diff --git a/src/lib/game/__tests__/mana-store.test.ts b/src/lib/game/__tests__/mana-store.test.ts new file mode 100644 index 0000000..d2ce1fa --- /dev/null +++ b/src/lib/game/__tests__/mana-store.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useManaStore } from '../stores/manaStore'; +import { MANA_PER_ELEMENT, BASE_UNLOCKED_ELEMENTS } from '../constants'; +import type { ElementState } from '../types'; + +function makeElements(): Record { + const keys = [ + 'fire','water','air','earth','light','dark','death', + 'transference','metal','sand','lightning','crystal','stellar','void', + ]; + const elements: Record = {}; + for (const k of keys) { + elements[k] = { current: 0, max: 10, unlocked: BASE_UNLOCKED_ELEMENTS.includes(k) }; + } + return elements; +} + +beforeEach(() => { + useManaStore.setState({ + rawMana: 10, meditateTicks: 0, totalManaGathered: 0, elements: makeElements(), + }); +}); + +describe('ManaStore', () => { + describe('setRawMana', () => { + it('should set raw mana to the given amount', () => { + useManaStore.getState().setRawMana(50); + expect(useManaStore.getState().rawMana).toBe(50); + }); + it('should clamp negative values to zero', () => { + useManaStore.getState().setRawMana(-10); + expect(useManaStore.getState().rawMana).toBe(0); + }); + it('should allow setting to zero', () => { + useManaStore.getState().setRawMana(0); + expect(useManaStore.getState().rawMana).toBe(0); + }); + }); + + describe('addRawMana', () => { + it('should add raw mana up to max', () => { + useManaStore.getState().addRawMana(40, 100); + expect(useManaStore.getState().rawMana).toBe(50); + }); + it('should cap at max mana', () => { + useManaStore.getState().addRawMana(200, 100); + expect(useManaStore.getState().rawMana).toBe(100); + }); + it('should track total mana gathered', () => { + useManaStore.getState().addRawMana(30, 100); + useManaStore.getState().addRawMana(20, 100); + expect(useManaStore.getState().totalManaGathered).toBe(50); + }); + }); + + describe('spendRawMana', () => { + it('should return true and deduct when sufficient mana', () => { + useManaStore.getState().setRawMana(50); + expect(useManaStore.getState().spendRawMana(20)).toBe(true); + expect(useManaStore.getState().rawMana).toBe(30); + }); + it('should return false when insufficient mana', () => { + useManaStore.getState().setRawMana(5); + expect(useManaStore.getState().spendRawMana(20)).toBe(false); + expect(useManaStore.getState().rawMana).toBe(5); + }); + it('should allow spending exact amount', () => { + useManaStore.getState().setRawMana(10); + expect(useManaStore.getState().spendRawMana(10)).toBe(true); + expect(useManaStore.getState().rawMana).toBe(0); + }); + }); + + describe('gatherMana', () => { + it('should add mana and track total gathered', () => { + useManaStore.getState().gatherMana(5, 100); + expect(useManaStore.getState().rawMana).toBe(15); + expect(useManaStore.getState().totalManaGathered).toBe(5); + }); + it('should cap at max mana', () => { + useManaStore.getState().gatherMana(200, 50); + expect(useManaStore.getState().rawMana).toBe(50); + }); + }); + + describe('convertMana', () => { + beforeEach(() => { + useManaStore.setState({ + rawMana: 1000, + elements: { ...makeElements(), fire: { current: 0, max: 10, unlocked: true } }, + }); + }); + it('should convert raw mana to element mana', () => { + const result = useManaStore.getState().convertMana('fire', 5); + expect(result).toBe(true); + expect(useManaStore.getState().elements.fire.current).toBe(5); + expect(useManaStore.getState().rawMana).toBe(1000 - 5 * MANA_PER_ELEMENT); + }); + it('should return false for locked element', () => { + expect(useManaStore.getState().convertMana('water', 1)).toBe(false); + }); + it('should return false when insufficient raw mana for full amount', () => { + useManaStore.setState({ rawMana: 50 }); + expect(useManaStore.getState().convertMana('fire', 1)).toBe(false); + }); + it('should return false when element is at max', () => { + useManaStore.setState({ + elements: { ...makeElements(), fire: { current: 10, max: 10, unlocked: true } }, + }); + expect(useManaStore.getState().convertMana('fire', 1)).toBe(false); + }); + it('should return false when raw mana < cost for requested amount', () => { + useManaStore.setState({ rawMana: 250 }); + expect(useManaStore.getState().convertMana('fire', 5)).toBe(false); + }); + it('should succeed when raw mana covers full requested amount', () => { + useManaStore.setState({ rawMana: 500 }); + const result = useManaStore.getState().convertMana('fire', 5); + expect(result).toBe(true); + expect(useManaStore.getState().elements.fire.current).toBe(5); + expect(useManaStore.getState().rawMana).toBe(0); + }); + }); + + describe('unlockElement', () => { + it('should unlock element and deduct cost', () => { + useManaStore.setState({ rawMana: 100 }); + expect(useManaStore.getState().unlockElement('fire', 50)).toBe(true); + expect(useManaStore.getState().elements.fire.unlocked).toBe(true); + expect(useManaStore.getState().rawMana).toBe(50); + }); + it('should return false when already unlocked', () => { + useManaStore.setState({ rawMana: 100 }); + useManaStore.getState().unlockElement('transference', 10); + expect(useManaStore.getState().unlockElement('transference', 10)).toBe(false); + }); + it('should return false when insufficient mana', () => { + useManaStore.setState({ rawMana: 10 }); + expect(useManaStore.getState().unlockElement('fire', 50)).toBe(false); + expect(useManaStore.getState().elements.fire.unlocked).toBe(false); + }); + }); + + describe('addElementMana', () => { + it('should add element mana up to max', () => { + useManaStore.setState({ + elements: { ...makeElements(), fire: { current: 0, max: 10, unlocked: true } }, + }); + useManaStore.getState().addElementMana('fire', 5, 10); + expect(useManaStore.getState().elements.fire.current).toBe(5); + }); + it('should cap at max', () => { + useManaStore.setState({ + elements: { ...makeElements(), fire: { current: 8, max: 10, unlocked: true } }, + }); + useManaStore.getState().addElementMana('fire', 5, 10); + expect(useManaStore.getState().elements.fire.current).toBe(10); + }); + }); + + describe('spendElementMana', () => { + it('should spend element mana when sufficient', () => { + useManaStore.setState({ + elements: { ...makeElements(), fire: { current: 10, max: 10, unlocked: true } }, + }); + expect(useManaStore.getState().spendElementMana('fire', 5)).toBe(true); + expect(useManaStore.getState().elements.fire.current).toBe(5); + }); + it('should return false when insufficient', () => { + useManaStore.setState({ + elements: { ...makeElements(), fire: { current: 3, max: 10, unlocked: true } }, + }); + expect(useManaStore.getState().spendElementMana('fire', 5)).toBe(false); + expect(useManaStore.getState().elements.fire.current).toBe(3); + }); + }); + + describe('craftComposite', () => { + beforeEach(() => { + useManaStore.setState({ + elements: { + ...makeElements(), + fire: { current: 5, max: 10, unlocked: true }, + earth: { current: 5, max: 10, unlocked: true }, + metal: { current: 0, max: 10, unlocked: false }, + }, + }); + }); + it('should craft composite from recipe', () => { + const result = useManaStore.getState().craftComposite('metal', ['fire', 'earth']); + expect(result).toBe(true); + expect(useManaStore.getState().elements.metal.current).toBe(1); + expect(useManaStore.getState().elements.metal.unlocked).toBe(true); + expect(useManaStore.getState().elements.fire.current).toBe(4); + expect(useManaStore.getState().elements.earth.current).toBe(4); + }); + it('should return false when missing ingredients', () => { + useManaStore.setState({ + elements: { + ...makeElements(), + fire: { current: 0, max: 10, unlocked: true }, + earth: { current: 5, max: 10, unlocked: true }, + metal: { current: 0, max: 10, unlocked: false }, + }, + }); + expect(useManaStore.getState().craftComposite('metal', ['fire', 'earth'])).toBe(false); + }); + it('should handle multiple different ingredients in recipe', () => { + useManaStore.setState({ + elements: { + ...makeElements(), + sand: { current: 0, max: 10, unlocked: false }, + earth: { current: 5, max: 10, unlocked: true }, + water: { current: 5, max: 10, unlocked: true }, + }, + }); + const result = useManaStore.getState().craftComposite('sand', ['earth', 'water']); + expect(result).toBe(true); + expect(useManaStore.getState().elements.sand.current).toBe(1); + }); + }); + + describe('processConvertAction', () => { + it('should return null when no unlocked elements with room', () => { + expect(useManaStore.getState().processConvertAction(50)).toBeNull(); + }); + it('should return null when raw mana < 100', () => { + useManaStore.setState({ + elements: { ...makeElements(), fire: { current: 0, max: 10, unlocked: true } }, + }); + expect(useManaStore.getState().processConvertAction(50)).toBeNull(); + }); + it('should auto-convert raw to element mana', () => { + useManaStore.setState({ + elements: { ...makeElements(), fire: { current: 0, max: 10, unlocked: true } }, + }); + const result = useManaStore.getState().processConvertAction(500); + expect(result).not.toBeNull(); + expect(result!.elements.fire.current).toBe(5); + expect(result!.rawMana).toBe(0); + }); + }); + + describe('resetMana', () => { + it('should reset to initial state with default upgrades', () => { + useManaStore.setState({ rawMana: 500, meditateTicks: 100, totalManaGathered: 999 }); + useManaStore.getState().resetMana({}); + expect(useManaStore.getState().rawMana).toBe(10); + expect(useManaStore.getState().meditateTicks).toBe(0); + expect(useManaStore.getState().totalManaGathered).toBe(0); + }); + it('should apply prestige upgrades', () => { + useManaStore.getState().resetMana({ manaStart: 2, elemMax: 1, elemStart: 1 }); + expect(useManaStore.getState().rawMana).toBe(30); + for (const k of BASE_UNLOCKED_ELEMENTS) { + expect(useManaStore.getState().elements[k].current).toBe(5); + } + }); + }); + + describe('meditation ticks', () => { + it('should set meditate ticks', () => { + useManaStore.getState().setMeditateTicks(42); + expect(useManaStore.getState().meditateTicks).toBe(42); + }); + it('should increment meditate ticks', () => { + useManaStore.getState().incrementMeditateTicks(); + useManaStore.getState().incrementMeditateTicks(); + expect(useManaStore.getState().meditateTicks).toBe(2); + }); + it('should reset meditate ticks', () => { + useManaStore.getState().setMeditateTicks(100); + useManaStore.getState().resetMeditateTicks(); + expect(useManaStore.getState().meditateTicks).toBe(0); + }); + }); + + describe('setElementMax', () => { + it('should set max for all elements', () => { + useManaStore.getState().setElementMax(50); + for (const key of Object.keys(useManaStore.getState().elements)) { + expect(useManaStore.getState().elements[key].max).toBe(50); + } + }); + }); +}); diff --git a/src/lib/game/__tests__/prestige-store.test.ts b/src/lib/game/__tests__/prestige-store.test.ts new file mode 100644 index 0000000..c7ac782 --- /dev/null +++ b/src/lib/game/__tests__/prestige-store.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { usePrestigeStore } from '../stores/prestigeStore'; + +beforeEach(() => { + usePrestigeStore.setState({ + loopCount: 0, insight: 0, totalInsight: 0, loopInsight: 0, + prestigeUpgrades: {}, memorySlots: 3, pactSlots: 1, + memories: [], defeatedGuardians: [], signedPacts: [], + signedPactDetails: {}, pactRitualFloor: null, pactRitualProgress: 0, + }); +}); + +describe('PrestigeStore', () => { + describe('doPrestige', () => { + it('should purchase upgrade when sufficient insight', () => { + usePrestigeStore.setState({ insight: 500 }); + expect(usePrestigeStore.getState().doPrestige('manaWell')).toBe(true); + expect(usePrestigeStore.getState().prestigeUpgrades['manaWell']).toBe(1); + expect(usePrestigeStore.getState().insight).toBe(0); + }); + it('should return false when insufficient insight', () => { + usePrestigeStore.setState({ insight: 100 }); + expect(usePrestigeStore.getState().doPrestige('manaWell')).toBe(false); + }); + it('should return false for invalid upgrade id', () => { + usePrestigeStore.setState({ insight: 9999 }); + expect(usePrestigeStore.getState().doPrestige('nonexistent')).toBe(false); + }); + it('should return false when at max level', () => { + usePrestigeStore.setState({ insight: 5000, prestigeUpgrades: { manaWell: 5 } }); + expect(usePrestigeStore.getState().doPrestige('manaWell')).toBe(false); + }); + it('should increase memorySlots for deepMemory', () => { + usePrestigeStore.setState({ insight: 1000 }); + usePrestigeStore.getState().doPrestige('deepMemory'); + expect(usePrestigeStore.getState().memorySlots).toBe(4); + }); + it('should allow purchasing same upgrade multiple times', () => { + usePrestigeStore.setState({ insight: 1500 }); + usePrestigeStore.getState().doPrestige('manaWell'); + usePrestigeStore.getState().doPrestige('manaWell'); + expect(usePrestigeStore.getState().prestigeUpgrades['manaWell']).toBe(2); + expect(usePrestigeStore.getState().insight).toBe(500); + }); + }); + + describe('addMemory', () => { + it('should add a memory when slots available', () => { + usePrestigeStore.getState().addMemory({ skillId: 'fire', level: 5 }); + expect(usePrestigeStore.getState().memories).toHaveLength(1); + }); + it('should not add duplicate skillId', () => { + usePrestigeStore.getState().addMemory({ skillId: 'fire', level: 5 }); + usePrestigeStore.getState().addMemory({ skillId: 'fire', level: 3 }); + expect(usePrestigeStore.getState().memories).toHaveLength(1); + }); + it('should not exceed memory slots', () => { + usePrestigeStore.setState({ memorySlots: 1 }); + usePrestigeStore.getState().addMemory({ skillId: 'fire', level: 1 }); + usePrestigeStore.getState().addMemory({ skillId: 'water', level: 1 }); + expect(usePrestigeStore.getState().memories).toHaveLength(1); + }); + }); + + describe('removeMemory', () => { + it('should remove memory by skillId', () => { + usePrestigeStore.setState({ + memories: [{ skillId: 'fire', level: 5 }, { skillId: 'water', level: 3 }], + }); + usePrestigeStore.getState().removeMemory('fire'); + expect(usePrestigeStore.getState().memories).toHaveLength(1); + expect(usePrestigeStore.getState().memories[0].skillId).toBe('water'); + }); + }); + + describe('clearMemories', () => { + it('should clear all memories', () => { + usePrestigeStore.setState({ + memories: [{ skillId: 'fire', level: 5 }, { skillId: 'water', level: 3 }], + }); + usePrestigeStore.getState().clearMemories(); + expect(usePrestigeStore.getState().memories).toHaveLength(0); + }); + }); + + describe('startPactRitual', () => { + it('should start ritual when conditions met', () => { + usePrestigeStore.setState({ defeatedGuardians: [10] }); + expect(usePrestigeStore.getState().startPactRitual(10, 500)).toBe(true); + expect(usePrestigeStore.getState().pactRitualFloor).toBe(10); + }); + it('should return false when guardian not defeated', () => { + expect(usePrestigeStore.getState().startPactRitual(10, 500)).toBe(false); + }); + it('should return false when pact already signed', () => { + usePrestigeStore.setState({ defeatedGuardians: [10], signedPacts: [10] }); + expect(usePrestigeStore.getState().startPactRitual(10, 500)).toBe(false); + }); + it('should return false when insufficient raw mana', () => { + usePrestigeStore.setState({ defeatedGuardians: [10] }); + expect(usePrestigeStore.getState().startPactRitual(10, 10)).toBe(false); + }); + it('should return false when pact slots full', () => { + usePrestigeStore.setState({ defeatedGuardians: [10, 20], signedPacts: [20], pactSlots: 1 }); + expect(usePrestigeStore.getState().startPactRitual(10, 500)).toBe(false); + }); + it('should return false when ritual already in progress', () => { + usePrestigeStore.setState({ defeatedGuardians: [10], pactRitualFloor: 20 }); + expect(usePrestigeStore.getState().startPactRitual(10, 500)).toBe(false); + }); + }); + + describe('cancelPactRitual', () => { + it('should cancel ritual and reset progress', () => { + usePrestigeStore.setState({ pactRitualFloor: 10, pactRitualProgress: 5 }); + usePrestigeStore.getState().cancelPactRitual(); + expect(usePrestigeStore.getState().pactRitualFloor).toBeNull(); + expect(usePrestigeStore.getState().pactRitualProgress).toBe(0); + }); + }); + + describe('completePactRitual', () => { + it('should sign pact and remove from defeated', () => { + usePrestigeStore.setState({ pactRitualFloor: 10, defeatedGuardians: [10], signedPacts: [] }); + const logs: string[] = []; + usePrestigeStore.getState().completePactRitual((msg) => logs.push(msg)); + expect(usePrestigeStore.getState().signedPacts).toContain(10); + expect(usePrestigeStore.getState().defeatedGuardians).not.toContain(10); + expect(usePrestigeStore.getState().pactRitualFloor).toBeNull(); + expect(logs).toHaveLength(1); + }); + it('should do nothing when no ritual in progress', () => { + usePrestigeStore.setState({ pactRitualFloor: null }); + const logs: string[] = []; + usePrestigeStore.getState().completePactRitual((msg) => logs.push(msg)); + expect(logs).toHaveLength(0); + }); + }); + + describe('defeatGuardian', () => { + it('should add guardian to defeated list', () => { + usePrestigeStore.getState().defeatGuardian(10); + expect(usePrestigeStore.getState().defeatedGuardians).toContain(10); + }); + it('should not add if already defeated', () => { + usePrestigeStore.setState({ defeatedGuardians: [10] }); + usePrestigeStore.getState().defeatGuardian(10); + expect(usePrestigeStore.getState().defeatedGuardians).toHaveLength(1); + }); + it('should not add if pact already signed', () => { + usePrestigeStore.setState({ signedPacts: [10] }); + usePrestigeStore.getState().defeatGuardian(10); + expect(usePrestigeStore.getState().defeatedGuardians).not.toContain(10); + }); + }); + + describe('addSignedPact', () => { + it('should add a signed pact', () => { + usePrestigeStore.getState().addSignedPact(10); + expect(usePrestigeStore.getState().signedPacts).toContain(10); + }); + it('should not duplicate', () => { + usePrestigeStore.setState({ signedPacts: [10] }); + usePrestigeStore.getState().addSignedPact(10); + expect(usePrestigeStore.getState().signedPacts).toHaveLength(1); + }); + }); + + describe('removePact', () => { + it('should remove a signed pact', () => { + usePrestigeStore.setState({ signedPacts: [10, 20] }); + usePrestigeStore.getState().removePact(10); + expect(usePrestigeStore.getState().signedPacts).not.toContain(10); + expect(usePrestigeStore.getState().signedPacts).toContain(20); + }); + }); + + describe('startNewLoop', () => { + it('should increment loop count and add insight', () => { + usePrestigeStore.setState({ insight: 100, totalInsight: 500 }); + usePrestigeStore.getState().startNewLoop(50); + expect(usePrestigeStore.getState().loopCount).toBe(1); + expect(usePrestigeStore.getState().insight).toBe(150); + expect(usePrestigeStore.getState().totalInsight).toBe(550); + }); + it('should reset loop-specific state', () => { + usePrestigeStore.setState({ + defeatedGuardians: [10], signedPacts: [20], + pactRitualFloor: 10, pactRitualProgress: 5, + }); + usePrestigeStore.getState().startNewLoop(0); + expect(usePrestigeStore.getState().defeatedGuardians).toHaveLength(0); + expect(usePrestigeStore.getState().signedPacts).toHaveLength(0); + expect(usePrestigeStore.getState().pactRitualFloor).toBeNull(); + expect(usePrestigeStore.getState().pactRitualProgress).toBe(0); + }); + }); + + describe('setLoopInsight', () => { + it('should set loop insight', () => { + usePrestigeStore.getState().setLoopInsight(42); + expect(usePrestigeStore.getState().loopInsight).toBe(42); + }); + }); + + describe('incrementLoopCount', () => { + it('should increment loop count', () => { + usePrestigeStore.getState().incrementLoopCount(); + expect(usePrestigeStore.getState().loopCount).toBe(1); + usePrestigeStore.getState().incrementLoopCount(); + expect(usePrestigeStore.getState().loopCount).toBe(2); + }); + }); + + describe('resetPrestigeForNewLoop', () => { + it('should set insight, upgrades, memories, and slots', () => { + usePrestigeStore.getState().resetPrestigeForNewLoop( + 500, { manaWell: 2 }, [{ skillId: 'fire', level: 3 }], 4, + ); + const state = usePrestigeStore.getState(); + expect(state.insight).toBe(500); + expect(state.prestigeUpgrades).toEqual({ manaWell: 2 }); + expect(state.memories).toHaveLength(1); + expect(state.memorySlots).toBe(4); + expect(state.defeatedGuardians).toHaveLength(0); + expect(state.signedPacts).toHaveLength(0); + expect(state.loopInsight).toBe(0); + }); + }); +}); diff --git a/src/lib/game/__tests__/test-setup.ts b/src/lib/game/__tests__/test-setup.ts new file mode 100644 index 0000000..fd483d7 --- /dev/null +++ b/src/lib/game/__tests__/test-setup.ts @@ -0,0 +1,48 @@ +import { beforeEach } from 'vitest'; +import { useGameStore } from '../stores/gameStore'; +import { useManaStore, makeInitialElements } from '../stores/manaStore'; +import { useCombatStore } from '../stores/combatStore'; +import { usePrestigeStore } from '../stores/prestigeStore'; +import { useUIStore } from '../stores/uiStore'; +import { useAttunementStore } from '../stores/attunementStore'; +import { useCraftingStore } from '../stores/craftingStore'; +import { useDisciplineStore } from '../stores/discipline-slice'; + +// Clear all zustand persist localStorage keys to prevent cross-test contamination +const _persistKeys = [ + 'mana-loop-ui-storage', + 'mana-loop-game-storage', + 'mana-loop-mana', + 'mana-loop-combat', + 'mana-loop-prestige', + 'mana-loop-attunements', + 'mana-loop-crafting', + 'mana-loop-discipline-store', +]; + +export function setupTickTestEnvironment(): void { + for (const key of _persistKeys) { + localStorage.removeItem(key); + } + useUIStore.setState({ paused: false, gameOver: false, victory: false, logs: [] }); + useGameStore.setState({ day: 1, hour: 0, incursionStrength: 0, containmentWards: 0, initialized: true }); + const elements = makeInitialElements(10, {}); + useManaStore.setState({ rawMana: 50, meditateTicks: 0, totalManaGathered: 0, elements }); + useCombatStore.setState({ + currentAction: 'meditate', currentFloor: 1, floorHP: 100, floorMaxHP: 100, + maxFloorReached: 1, castProgress: 0, spireMode: false, climbDirection: null, + isDescending: false, comboHitCount: 0, floorHitCount: 0, + }); + usePrestigeStore.setState({ + prestigeUpgrades: {}, signedPacts: [], loopInsight: 0, defeatedGuardians: [], + pactRitualFloor: null, pactRitualProgress: 0, + }); + useAttunementStore.setState({ + attunements: { enchanter: { id: 'enchanter', active: true, level: 1, experience: 0 } }, + }); + useDisciplineStore.setState({ disciplines: {}, activeIds: [], concurrentLimit: 2, totalXP: 0 }); + useCraftingStore.setState({ + designProgress: null, designProgress2: null, preparationProgress: null, + applicationProgress: null, equipmentCraftingProgress: null, + }); +} diff --git a/src/lib/game/__tests__/tick-debug.test.ts b/src/lib/game/__tests__/tick-debug.test.ts new file mode 100644 index 0000000..696a90c --- /dev/null +++ b/src/lib/game/__tests__/tick-debug.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useGameStore } from '../stores/gameStore'; +import { useManaStore, makeInitialElements } from '../stores/manaStore'; +import { useCombatStore } from '../stores/combatStore'; +import { usePrestigeStore } from '../stores/prestigeStore'; +import { useUIStore } from '../stores/uiStore'; +import { useAttunementStore } from '../stores/attunementStore'; +import { useCraftingStore } from '../stores/craftingStore'; +import { useDisciplineStore } from '../stores/discipline-slice'; +import { computeRegen } from '../utils/mana-utils'; +import { computeDisciplineEffects } from '../effects/discipline-effects'; +import { getAttunementConversionRate, ATTUNEMENTS_DEF } from '../data/attunements'; +import { GUARDIANS, HOURS_PER_TICK } from '../constants'; + +beforeEach(() => { + useUIStore.setState({ paused: false, gameOver: false, victory: false, logs: [] }); + useGameStore.setState({ day: 1, hour: 0, incursionStrength: 0, containmentWards: 0, initialized: true }); + const elements = makeInitialElements(10, {}); + useManaStore.setState({ rawMana: 50, meditateTicks: 0, totalManaGathered: 0, elements }); + useCombatStore.setState({ + currentAction: 'meditate', currentFloor: 1, floorHP: 100, floorMaxHP: 100, + maxFloorReached: 1, castProgress: 0, spireMode: false, climbDirection: null, + isDescending: false, comboHitCount: 0, floorHitCount: 0, + }); + usePrestigeStore.setState({ + prestigeUpgrades: {}, signedPacts: [], loopInsight: 0, defeatedGuardians: [], + pactRitualFloor: null, pactRitualProgress: 0, + }); + useAttunementStore.setState({ + attunements: { enchanter: { id: 'enchanter', active: true, level: 1, experience: 0 } }, + }); + useDisciplineStore.setState({ disciplines: {}, activeIds: [], concurrentLimit: 2, totalXP: 0 }); + useCraftingStore.setState({ + designProgress: null, designProgress2: null, preparationProgress: null, + applicationProgress: null, equipmentCraftingProgress: null, + }); +}); + +describe('debug', () => { + it('trace regen values', () => { + // Measure actual regen empirically + const before = useManaStore.getState().rawMana; + useGameStore.getState().tick(); + const after = useManaStore.getState().rawMana; + const transferenceBefore = useManaStore.getState().elements.transference?.current || 0; + + // Do another tick to measure transference conversion + useGameStore.getState().tick(); + const transferenceAfter = useManaStore.getState().elements.transference?.current || 0; + + console.log('rawMana gain per tick:', after - before); + console.log('transference gain per tick:', transferenceAfter - transferenceBefore); + console.log('total regen (raw + conversion):', (after - before) + (transferenceAfter - transferenceBefore)); + + // Now compute what the tick does internally + const disciplineEffects = computeDisciplineEffects(useDisciplineStore.getState() as any); + console.log('disciplineEffects bonuses:', disciplineEffects.bonuses); + + const baseRegen = computeRegen( + { skills: {}, prestigeUpgrades: {}, skillUpgrades: {}, skillTiers: {}, attunements: {} }, + undefined, + disciplineEffects, + ); + console.log('baseRegen (as computed by tick):', baseRegen); + + let totalConversionPerHour = 0; + const attState = useAttunementStore.getState(); + Object.entries(attState.attunements).forEach(([id, state]) => { + if (!state.active) return; + const def = ATTUNEMENTS_DEF[id]; + if (!def || def.conversionRate <= 0 || !def.primaryManaType) return; + const scaledRate = getAttunementConversionRate(id, state.level || 1); + totalConversionPerHour += scaledRate; + }); + console.log('totalConversionPerHour:', totalConversionPerHour); + + const effectiveRegen = baseRegen - totalConversionPerHour; + console.log('effectiveRegen per hour:', effectiveRegen); + console.log('expected raw gain per tick:', effectiveRegen * HOURS_PER_TICK); + + expect(true).toBe(true); + }); + + it('check pact ritual with floor 30 (pactTime=6)', () => { + const g30 = GUARDIANS[30]; + console.log('Guardian 30 pactTime:', g30?.pactTime); + + usePrestigeStore.setState({ pactRitualFloor: 30, pactRitualProgress: 0 }); + + // Do 50 ticks (2 hours) + for (let i = 0; i < 50; i++) { + useGameStore.getState().tick(); + } + console.log('After 50 ticks (2 hours):'); + console.log(' pactRitualProgress:', usePrestigeStore.getState().pactRitualProgress); + console.log(' pactRitualFloor:', usePrestigeStore.getState().pactRitualFloor); + console.log(' signedPacts:', usePrestigeStore.getState().signedPacts); + + expect(true).toBe(true); + }); + + it('check if persist is leaking between tests', () => { + // Check prestige store state before any modifications + const prestigeState = usePrestigeStore.getState(); + console.log('prestigeState.pactRitualProgress:', prestigeState.pactRitualProgress); + console.log('prestigeState.pactRitualFloor:', prestigeState.pactRitualFloor); + console.log('prestigeState.signedPacts:', prestigeState.signedPacts); + + // Check discipline store + const disciplineState = useDisciplineStore.getState(); + console.log('disciplineState.disciplines:', JSON.stringify(disciplineState.disciplines)); + console.log('disciplineState.activeIds:', JSON.stringify(disciplineState.activeIds)); + + expect(true).toBe(true); + }); +}); diff --git a/src/lib/game/__tests__/tick-integration-pact.test.ts b/src/lib/game/__tests__/tick-integration-pact.test.ts new file mode 100644 index 0000000..e644bb3 --- /dev/null +++ b/src/lib/game/__tests__/tick-integration-pact.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useGameStore } from '../stores/gameStore'; +import { useManaStore } from '../stores/manaStore'; +import { useCombatStore } from '../stores/combatStore'; +import { usePrestigeStore } from '../stores/prestigeStore'; +import { useUIStore } from '../stores/uiStore'; +import { HOURS_PER_TICK } from '../constants'; +import { setupTickTestEnvironment } from './test-setup'; + +beforeEach(setupTickTestEnvironment); + +// ─── 8. Victory Condition ──────────────────────────────────────────────────── + +describe('victory condition', () => { + it('should trigger victory when maxFloorReached >= 100 and signedPacts includes 100', () => { + useCombatStore.setState({ maxFloorReached: 100 }); + usePrestigeStore.setState({ signedPacts: [100] }); + + useGameStore.getState().tick(); + + expect(useUIStore.getState().gameOver).toBe(true); + expect(useUIStore.getState().victory).toBe(true); + }); + + it('should set loopInsight on victory', () => { + useCombatStore.setState({ maxFloorReached: 100 }); + usePrestigeStore.setState({ signedPacts: [100] }); + + useGameStore.getState().tick(); + + expect(usePrestigeStore.getState().loopInsight).toBeGreaterThanOrEqual(0); + }); + + it('should not trigger victory with floor 100 but no pact', () => { + useCombatStore.setState({ maxFloorReached: 100 }); + usePrestigeStore.setState({ signedPacts: [] }); + + useGameStore.getState().tick(); + + expect(useUIStore.getState().victory).toBe(false); + expect(useUIStore.getState().gameOver).toBe(false); + }); + + it('should not trigger victory with pact 100 but floor < 100', () => { + useCombatStore.setState({ maxFloorReached: 99 }); + usePrestigeStore.setState({ signedPacts: [100] }); + + useGameStore.getState().tick(); + + expect(useUIStore.getState().victory).toBe(false); + }); +}); + +// ─── 9. Pact Ritual Progress ───────────────────────────────────────────────── + +describe('pact ritual progress', () => { + it('should increase pactRitualProgress by HOURS_PER_TICK per tick', () => { + usePrestigeStore.setState({ pactRitualFloor: 10, pactRitualProgress: 0 }); + useGameStore.getState().tick(); + expect(usePrestigeStore.getState().pactRitualProgress).toBeCloseTo(HOURS_PER_TICK, 10); + }); + + it('should accumulate pact ritual progress over multiple ticks', () => { + // Use floor 90 guardian (pactTime=20 hours) so 50 ticks (2 hours) doesn't complete it + usePrestigeStore.setState({ pactRitualFloor: 90, pactRitualProgress: 0 }); + const numTicks = 50; + for (let i = 0; i < numTicks; i++) { + useGameStore.getState().tick(); + } + expect(usePrestigeStore.getState().pactRitualProgress).toBeCloseTo( + numTicks * HOURS_PER_TICK, + 5, + ); + }); + + it('should not progress pact ritual when pactRitualFloor is null', () => { + usePrestigeStore.setState({ pactRitualFloor: null, pactRitualProgress: 0 }); + useGameStore.getState().tick(); + expect(usePrestigeStore.getState().pactRitualProgress).toBe(0); + }); +}); + +// ─── 10. Multiple Ticks Accumulation ───────────────────────────────────────── + +describe('multiple ticks accumulation', () => { + it('should correctly accumulate state over 100 ticks', () => { + const numTicks = 100; + const manaBefore = useManaStore.getState().rawMana; + + // Measure first tick regen (baseline, no meditation bonus yet) + useGameStore.getState().tick(); + const firstTickRegen = useManaStore.getState().rawMana - manaBefore; + + // Do 99 more ticks + for (let i = 0; i < 99; i++) { + useGameStore.getState().tick(); + } + + const game = useGameStore.getState(); + const mana = useManaStore.getState(); + + // Time should have advanced by 100 * HOURS_PER_TICK = 4 hours + const expectedTotalHours = numTicks * HOURS_PER_TICK; + const expectedDayIncrement = Math.floor(expectedTotalHours / 24); + const expectedHour = expectedTotalHours % 24; + + expect(game.day).toBe(1 + expectedDayIncrement); + expect(game.hour).toBeCloseTo(expectedHour, 5); + + // Mana should have accumulated (with meditation bonus increasing over time) + const totalGain = mana.rawMana - manaBefore; + const minExpected = firstTickRegen * numTicks; + const maxExpected = firstTickRegen * 1.5 * numTicks; + expect(totalGain).toBeGreaterThan(minExpected - 0.01); + expect(totalGain).toBeLessThan(maxExpected + 0.01); + + // Meditate ticks should match + expect(mana.meditateTicks).toBe(numTicks); + }); + + it('should correctly accumulate over enough ticks for a full day', () => { + const manaBefore = useManaStore.getState().rawMana; + + // Do 601 ticks (enough for a full day) + const numTicks = 601; + for (let i = 0; i < numTicks; i++) { + useGameStore.getState().tick(); + } + + expect(useGameStore.getState().day).toBe(2); + // Mana should have increased (capped at maxMana=100) + expect(useManaStore.getState().rawMana).toBeGreaterThan(manaBefore); + expect(useManaStore.getState().rawMana).toBeLessThanOrEqual(100); + }); +}); diff --git a/src/lib/game/__tests__/tick-integration.test.ts b/src/lib/game/__tests__/tick-integration.test.ts new file mode 100644 index 0000000..fc048c5 --- /dev/null +++ b/src/lib/game/__tests__/tick-integration.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useGameStore } from '../stores/gameStore'; +import { useManaStore } from '../stores/manaStore'; +import { useCombatStore } from '../stores/combatStore'; +import { usePrestigeStore } from '../stores/prestigeStore'; +import { useUIStore } from '../stores/uiStore'; +import { HOURS_PER_TICK, MAX_DAY, INCURSION_START_DAY } from '../constants'; +import { getMeditationBonus } from '../utils/mana-utils'; +import { getIncursionStrength } from '../utils/combat-utils'; +import { setupTickTestEnvironment } from './test-setup'; + +beforeEach(setupTickTestEnvironment); + +// ─── 1. Time Progression ───────────────────────────────────────────────────── + +describe('time progression', () => { + it('should increase hour by HOURS_PER_TICK after one tick', () => { + useGameStore.getState().tick(); + expect(useGameStore.getState().hour).toBeCloseTo(HOURS_PER_TICK, 10); + expect(useGameStore.getState().day).toBe(1); + }); + + it('should increment day after enough ticks for 24 hours', () => { + const ticks = 601; + for (let i = 0; i < ticks; i++) { + useGameStore.getState().tick(); + } + const { day, hour } = useGameStore.getState(); + expect(day).toBe(2); + expect(hour).toBeGreaterThanOrEqual(0); + expect(hour).toBeLessThan(1); + }); + + it('should advance multiple days correctly', () => { + const totalTicks = 601 * 3; + for (let i = 0; i < totalTicks; i++) { + useGameStore.getState().tick(); + } + expect(useGameStore.getState().day).toBe(4); + }); +}); + +// ─── 2. Mana Regeneration ──────────────────────────────────────────────────── + +describe('mana regeneration', () => { + it('should increase rawMana after one tick', () => { + const before = useManaStore.getState().rawMana; + useGameStore.getState().tick(); + const after = useManaStore.getState().rawMana; + expect(after).toBeGreaterThan(before); + }); + + it('should accumulate mana over multiple ticks', () => { + const mana0 = useManaStore.getState().rawMana; + useGameStore.getState().tick(); + const mana1 = useManaStore.getState().rawMana; + const firstTickRegen = mana1 - mana0; + + for (let i = 0; i < 99; i++) { + useGameStore.getState().tick(); + } + const mana100 = useManaStore.getState().rawMana; + + const minExpected = mana0 + firstTickRegen * 100; + expect(mana100).toBeGreaterThan(minExpected - 0.01); + + const maxExpected = mana0 + firstTickRegen * 2 * 100; + expect(mana100).toBeLessThan(maxExpected + 0.01); + }); + + it('should not exceed maxMana', () => { + useManaStore.setState({ rawMana: 99.999 }); + for (let i = 0; i < 100; i++) { + useGameStore.getState().tick(); + } + expect(useManaStore.getState().rawMana).toBeLessThanOrEqual(100); + }); +}); + +// ─── 3. Incursion Penalty ──────────────────────────────────────────────────── + +describe('incursion penalty', () => { + it('should have zero incursion before INCURSION_START_DAY', () => { + useGameStore.setState({ day: INCURSION_START_DAY - 1, hour: 23.9 }); + useGameStore.getState().tick(); + const strength = getIncursionStrength(useGameStore.getState().day, useGameStore.getState().hour); + expect(strength).toBeCloseTo(0, 5); + }); + + it('should reduce mana regen after INCURSION_START_DAY', () => { + const mana0 = useManaStore.getState().rawMana; + useGameStore.getState().tick(); + const baseRegen = useManaStore.getState().rawMana - mana0; + + useGameStore.setState({ day: 25, hour: 0 }); + const mana25 = useManaStore.getState().rawMana; + useGameStore.getState().tick(); + const incursionRegen = useManaStore.getState().rawMana - mana25; + + expect(incursionRegen).toBeLessThan(baseRegen); + const incursion = getIncursionStrength(25, HOURS_PER_TICK); + expect(incursion).toBeGreaterThan(0); + }); + + it('should have stronger incursion on later days', () => { + const s1 = getIncursionStrength(21, 0); + const s2 = getIncursionStrength(28, 0); + expect(s2).toBeGreaterThan(s1); + }); +}); + +// ─── 4. Meditation ─────────────────────────────────────────────────────────── + +describe('meditation', () => { + it('should increment meditateTicks when currentAction is meditate', () => { + useCombatStore.setState({ currentAction: 'meditate' }); + useGameStore.getState().tick(); + expect(useManaStore.getState().meditateTicks).toBe(1); + }); + + it('should increase meditateTicks over multiple ticks', () => { + useCombatStore.setState({ currentAction: 'meditate' }); + for (let i = 0; i < 10; i++) { + useGameStore.getState().tick(); + } + expect(useManaStore.getState().meditateTicks).toBe(10); + }); + + it('should reset meditateTicks when action changes from meditate', () => { + useCombatStore.setState({ currentAction: 'meditate' }); + for (let i = 0; i < 5; i++) { + useGameStore.getState().tick(); + } + expect(useManaStore.getState().meditateTicks).toBe(5); + + useCombatStore.setState({ currentAction: 'climb' }); + useGameStore.getState().tick(); + expect(useManaStore.getState().meditateTicks).toBe(0); + }); + + it('should apply meditation multiplier to regen', () => { + useCombatStore.setState({ currentAction: 'meditate' }); + const ticksFor4Hours = Math.round(4 / HOURS_PER_TICK); + for (let i = 0; i < ticksFor4Hours; i++) { + useGameStore.getState().tick(); + } + + const meditateTicks = useManaStore.getState().meditateTicks; + const medMult = getMeditationBonus(meditateTicks, {}, 1); + expect(medMult).toBeGreaterThan(1); + + const manaBefore = useManaStore.getState().rawMana; + useGameStore.getState().tick(); + const meditatedRegen = useManaStore.getState().rawMana - manaBefore; + + useManaStore.setState({ rawMana: 50 }); + useCombatStore.setState({ currentAction: 'climb' }); + useGameStore.getState().tick(); + const unMeditatedRegen = useManaStore.getState().rawMana - 50; + + expect(meditatedRegen).toBeGreaterThan(unMeditatedRegen); + }); +}); + +// ─── 5. Loop End ────────────────────────────────────────────────────────────── + +describe('loop end', () => { + it('should set gameOver when day exceeds MAX_DAY', () => { + useGameStore.setState({ day: MAX_DAY, hour: 23.96 }); + useGameStore.getState().tick(); + expect(useUIStore.getState().gameOver).toBe(true); + }); + + it('should set loopInsight in prestigeStore when loop ends', () => { + useGameStore.setState({ day: MAX_DAY, hour: 23.96 }); + useGameStore.getState().tick(); + expect(usePrestigeStore.getState().loopInsight).toBeGreaterThanOrEqual(0); + }); + + it('should not set victory on normal loop end', () => { + useGameStore.setState({ day: MAX_DAY, hour: 23.96 }); + useGameStore.getState().tick(); + expect(useUIStore.getState().victory).toBe(false); + }); + + it('should log the loop end message', () => { + useGameStore.setState({ day: MAX_DAY, hour: 23.96 }); + useGameStore.getState().tick(); + const logs = useUIStore.getState().logs; + expect(logs.some(l => l.includes('loop ends'))).toBe(true); + }); +}); + +// ─── 6. Paused Game ─────────────────────────────────────────────────────────── + +describe('paused game', () => { + it('should be a no-op when paused is true', () => { + useUIStore.setState({ paused: true }); + const gameBefore = { ...useGameStore.getState() }; + const manaBefore = useManaStore.getState().rawMana; + + useGameStore.getState().tick(); + + expect(useGameStore.getState().hour).toBe(gameBefore.hour); + expect(useGameStore.getState().day).toBe(gameBefore.day); + expect(useManaStore.getState().rawMana).toBe(manaBefore); + }); +}); + +// ─── 7. Game Over ───────────────────────────────────────────────────────────── + +describe('game over', () => { + it('should be a no-op when gameOver is true', () => { + useUIStore.setState({ gameOver: true }); + const gameBefore = { ...useGameStore.getState() }; + const manaBefore = useManaStore.getState().rawMana; + + useGameStore.getState().tick(); + + expect(useGameStore.getState().hour).toBe(gameBefore.hour); + expect(useGameStore.getState().day).toBe(gameBefore.day); + expect(useManaStore.getState().rawMana).toBe(manaBefore); + }); +}); diff --git a/src/lib/game/stores/gameStore.ts b/src/lib/game/stores/gameStore.ts index c29d037..1dfded2 100755 --- a/src/lib/game/stores/gameStore.ts +++ b/src/lib/game/stores/gameStore.ts @@ -222,7 +222,7 @@ export const useGameStore = create()( usePrestigeStore.getState().removeDefeatedGuardian(prestigeState.pactRitualFloor); usePrestigeStore.getState().setPactRitualFloor(null); } else { - usePrestigeStore.getState().updatePactRitualProgress(newProgress); + usePrestigeStore.getState().updatePactRitualProgress(HOURS_PER_TICK); } } }