Initial commit
This commit is contained in:
+583
@@ -0,0 +1,583 @@
|
||||
/**
|
||||
* Store Method Tests
|
||||
*
|
||||
* Tests for individual store methods: skillStore, manaStore, combatStore, prestigeStore
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'bun:test';
|
||||
import { useSkillStore } from '../skillStore';
|
||||
import { useManaStore } from '../manaStore';
|
||||
import { useCombatStore } from '../combatStore';
|
||||
import { usePrestigeStore } from '../prestigeStore';
|
||||
import { useUIStore } from '../uiStore';
|
||||
import { SKILLS_DEF, SPELLS_DEF, GUARDIANS, BASE_UNLOCKED_ELEMENTS, ELEMENTS } from '../../constants';
|
||||
|
||||
// Reset stores before each test
|
||||
beforeEach(() => {
|
||||
// Reset all stores to initial state
|
||||
useSkillStore.getState().resetSkills();
|
||||
useManaStore.getState().resetMana({}, {}, {}, {});
|
||||
usePrestigeStore.getState().resetPrestige();
|
||||
useUIStore.getState().resetUI();
|
||||
useCombatStore.getState().resetCombat(1);
|
||||
});
|
||||
|
||||
// ─── Skill Store Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('SkillStore', () => {
|
||||
describe('startStudyingSkill', () => {
|
||||
it('should start studying a skill when have enough mana', () => {
|
||||
const skillStore = useSkillStore.getState();
|
||||
const result = skillStore.startStudyingSkill('manaWell', 100);
|
||||
|
||||
expect(result.started).toBe(true);
|
||||
expect(result.cost).toBe(100); // base cost for level 1
|
||||
|
||||
const newState = useSkillStore.getState();
|
||||
expect(newState.currentStudyTarget).not.toBeNull();
|
||||
expect(newState.currentStudyTarget?.type).toBe('skill');
|
||||
expect(newState.currentStudyTarget?.id).toBe('manaWell');
|
||||
});
|
||||
|
||||
it('should not start studying when not enough mana', () => {
|
||||
const skillStore = useSkillStore.getState();
|
||||
const result = skillStore.startStudyingSkill('manaWell', 50);
|
||||
|
||||
expect(result.started).toBe(false);
|
||||
|
||||
const newState = useSkillStore.getState();
|
||||
expect(newState.currentStudyTarget).toBeNull();
|
||||
});
|
||||
|
||||
it('should not start studying skill at max level', () => {
|
||||
// Set skill to max level
|
||||
useSkillStore.setState({ skills: { manaWell: SKILLS_DEF.manaWell.max } });
|
||||
|
||||
const skillStore = useSkillStore.getState();
|
||||
const result = skillStore.startStudyingSkill('manaWell', 1000);
|
||||
|
||||
expect(result.started).toBe(false);
|
||||
});
|
||||
|
||||
it('should not start studying without prerequisites', () => {
|
||||
const skillStore = useSkillStore.getState();
|
||||
// deepReservoir requires manaWell level 5
|
||||
const result = skillStore.startStudyingSkill('deepReservoir', 1000);
|
||||
|
||||
expect(result.started).toBe(false);
|
||||
});
|
||||
|
||||
it('should start studying with prerequisites met', () => {
|
||||
useSkillStore.setState({ skills: { manaWell: 5 } });
|
||||
|
||||
const skillStore = useSkillStore.getState();
|
||||
const result = skillStore.startStudyingSkill('deepReservoir', 1000);
|
||||
|
||||
expect(result.started).toBe(true);
|
||||
});
|
||||
|
||||
it('should be free to resume if already paid', () => {
|
||||
// First, start studying (which marks as paid)
|
||||
const skillStore = useSkillStore.getState();
|
||||
skillStore.startStudyingSkill('manaWell', 100);
|
||||
|
||||
// Cancel study
|
||||
skillStore.cancelStudy(0);
|
||||
|
||||
// Resume should be free
|
||||
const newState = useSkillStore.getState();
|
||||
const result = newState.startStudyingSkill('manaWell', 0);
|
||||
|
||||
expect(result.started).toBe(true);
|
||||
expect(result.cost).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStudyProgress', () => {
|
||||
it('should progress study target', () => {
|
||||
// Start studying
|
||||
useSkillStore.getState().startStudyingSkill('manaWell', 100);
|
||||
|
||||
// Update progress
|
||||
const result = useSkillStore.getState().updateStudyProgress(1);
|
||||
|
||||
expect(result.completed).toBe(false);
|
||||
|
||||
const state = useSkillStore.getState();
|
||||
expect(state.currentStudyTarget?.progress).toBe(1);
|
||||
});
|
||||
|
||||
it('should complete study when progress reaches required', () => {
|
||||
// Start studying manaWell (4 hours study time)
|
||||
useSkillStore.getState().startStudyingSkill('manaWell', 100);
|
||||
|
||||
// Update with enough progress
|
||||
const result = useSkillStore.getState().updateStudyProgress(4);
|
||||
|
||||
expect(result.completed).toBe(true);
|
||||
|
||||
const state = useSkillStore.getState();
|
||||
expect(state.currentStudyTarget).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('incrementSkillLevel', () => {
|
||||
it('should increment skill level', () => {
|
||||
useSkillStore.setState({ skills: { manaWell: 0 } });
|
||||
|
||||
useSkillStore.getState().incrementSkillLevel('manaWell');
|
||||
|
||||
const state = useSkillStore.getState();
|
||||
expect(state.skills.manaWell).toBe(1);
|
||||
});
|
||||
|
||||
it('should clear skill progress', () => {
|
||||
useSkillStore.setState({
|
||||
skills: { manaWell: 0 },
|
||||
skillProgress: { manaWell: 2 }
|
||||
});
|
||||
|
||||
useSkillStore.getState().incrementSkillLevel('manaWell');
|
||||
|
||||
const state = useSkillStore.getState();
|
||||
expect(state.skillProgress.manaWell).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelStudy', () => {
|
||||
it('should clear study target', () => {
|
||||
useSkillStore.getState().startStudyingSkill('manaWell', 100);
|
||||
|
||||
useSkillStore.getState().cancelStudy(0);
|
||||
|
||||
const state = useSkillStore.getState();
|
||||
expect(state.currentStudyTarget).toBeNull();
|
||||
});
|
||||
|
||||
it('should save progress with retention bonus', () => {
|
||||
useSkillStore.getState().startStudyingSkill('manaWell', 100);
|
||||
useSkillStore.getState().updateStudyProgress(2); // 2 hours progress
|
||||
|
||||
// Cancel with 50% retention bonus
|
||||
// Retention bonus limits how much of the *required* time can be saved
|
||||
// Required = 4 hours, so 50% = 2 hours max
|
||||
// Progress = 2 hours, so we save all of it (within limit)
|
||||
useSkillStore.getState().cancelStudy(0.5);
|
||||
|
||||
const state = useSkillStore.getState();
|
||||
// Saved progress should be min(progress, required * retentionBonus) = min(2, 4*0.5) = min(2, 2) = 2
|
||||
expect(state.skillProgress.manaWell).toBe(2);
|
||||
});
|
||||
|
||||
it('should limit saved progress to retention bonus cap', () => {
|
||||
useSkillStore.getState().startStudyingSkill('manaWell', 100);
|
||||
useSkillStore.getState().updateStudyProgress(3); // 3 hours progress (out of 4 required)
|
||||
|
||||
// Cancel with 50% retention bonus
|
||||
// Cap is 4 * 0.5 = 2 hours, progress is 3, so we save 2 (the cap)
|
||||
useSkillStore.getState().cancelStudy(0.5);
|
||||
|
||||
const state = useSkillStore.getState();
|
||||
expect(state.skillProgress.manaWell).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Mana Store Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('ManaStore', () => {
|
||||
describe('initial state', () => {
|
||||
it('should have base elements unlocked', () => {
|
||||
const state = useManaStore.getState();
|
||||
|
||||
expect(state.elements.fire.unlocked).toBe(true);
|
||||
expect(state.elements.water.unlocked).toBe(true);
|
||||
expect(state.elements.air.unlocked).toBe(true);
|
||||
expect(state.elements.earth.unlocked).toBe(true);
|
||||
});
|
||||
|
||||
it('should have exotic elements locked', () => {
|
||||
const state = useManaStore.getState();
|
||||
|
||||
expect(state.elements.void.unlocked).toBe(false);
|
||||
expect(state.elements.stellar.unlocked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertMana', () => {
|
||||
it('should convert raw mana to elemental', () => {
|
||||
useManaStore.setState({ rawMana: 200 });
|
||||
|
||||
const result = useManaStore.getState().convertMana('fire', 1);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const state = useManaStore.getState();
|
||||
expect(state.rawMana).toBe(100);
|
||||
expect(state.elements.fire.current).toBe(1);
|
||||
});
|
||||
|
||||
it('should not convert when not enough raw mana', () => {
|
||||
useManaStore.setState({ rawMana: 50 });
|
||||
|
||||
const result = useManaStore.getState().convertMana('fire', 1);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should not convert when element at max', () => {
|
||||
useManaStore.setState({
|
||||
rawMana: 500,
|
||||
elements: {
|
||||
...useManaStore.getState().elements,
|
||||
fire: { current: 10, max: 10, unlocked: true }
|
||||
}
|
||||
});
|
||||
|
||||
const result = useManaStore.getState().convertMana('fire', 1);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should not convert to locked element', () => {
|
||||
useManaStore.setState({ rawMana: 500 });
|
||||
|
||||
const result = useManaStore.getState().convertMana('void', 1);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unlockElement', () => {
|
||||
it('should unlock element when have enough mana', () => {
|
||||
useManaStore.setState({ rawMana: 500 });
|
||||
|
||||
const result = useManaStore.getState().unlockElement('light', 500);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const state = useManaStore.getState();
|
||||
expect(state.elements.light.unlocked).toBe(true);
|
||||
});
|
||||
|
||||
it('should not unlock when not enough mana', () => {
|
||||
useManaStore.setState({ rawMana: 100 });
|
||||
|
||||
const result = useManaStore.getState().unlockElement('light', 500);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('craftComposite', () => {
|
||||
it('should craft composite element with correct ingredients', () => {
|
||||
// Set up ingredients for blood (life + water)
|
||||
useManaStore.setState({
|
||||
elements: {
|
||||
...useManaStore.getState().elements,
|
||||
life: { current: 5, max: 10, unlocked: true },
|
||||
water: { current: 5, max: 10, unlocked: true },
|
||||
}
|
||||
});
|
||||
|
||||
const result = useManaStore.getState().craftComposite('blood', ['life', 'water']);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const state = useManaStore.getState();
|
||||
expect(state.elements.life.current).toBe(4);
|
||||
expect(state.elements.water.current).toBe(4);
|
||||
expect(state.elements.blood.current).toBe(1);
|
||||
expect(state.elements.blood.unlocked).toBe(true);
|
||||
});
|
||||
|
||||
it('should not craft without ingredients', () => {
|
||||
useManaStore.setState({
|
||||
elements: {
|
||||
...useManaStore.getState().elements,
|
||||
life: { current: 0, max: 10, unlocked: true },
|
||||
water: { current: 0, max: 10, unlocked: true },
|
||||
}
|
||||
});
|
||||
|
||||
const result = useManaStore.getState().craftComposite('blood', ['life', 'water']);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Combat Store Tests ────────────────────────────────────────────────────────
|
||||
|
||||
describe('CombatStore', () => {
|
||||
describe('initial state', () => {
|
||||
it('should start with manaBolt learned', () => {
|
||||
const state = useCombatStore.getState();
|
||||
|
||||
expect(state.spells.manaBolt.learned).toBe(true);
|
||||
});
|
||||
|
||||
it('should start at floor 1', () => {
|
||||
const state = useCombatStore.getState();
|
||||
|
||||
expect(state.currentFloor).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAction', () => {
|
||||
it('should change current action', () => {
|
||||
useCombatStore.getState().setAction('climb');
|
||||
|
||||
const state = useCombatStore.getState();
|
||||
expect(state.currentAction).toBe('climb');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSpell', () => {
|
||||
it('should change active spell if learned', () => {
|
||||
// Learn another spell
|
||||
useCombatStore.getState().learnSpell('fireball');
|
||||
|
||||
useCombatStore.getState().setSpell('fireball');
|
||||
|
||||
const state = useCombatStore.getState();
|
||||
expect(state.activeSpell).toBe('fireball');
|
||||
});
|
||||
|
||||
it('should not change to unlearned spell', () => {
|
||||
useCombatStore.getState().setSpell('fireball');
|
||||
|
||||
const state = useCombatStore.getState();
|
||||
expect(state.activeSpell).toBe('manaBolt'); // Still manaBolt
|
||||
});
|
||||
});
|
||||
|
||||
describe('learnSpell', () => {
|
||||
it('should add spell to learned spells', () => {
|
||||
useCombatStore.getState().learnSpell('fireball');
|
||||
|
||||
const state = useCombatStore.getState();
|
||||
expect(state.spells.fireball.learned).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('advanceFloor', () => {
|
||||
it('should increment floor', () => {
|
||||
useCombatStore.getState().advanceFloor();
|
||||
|
||||
const state = useCombatStore.getState();
|
||||
expect(state.currentFloor).toBe(2);
|
||||
});
|
||||
|
||||
it('should not exceed floor 100', () => {
|
||||
useCombatStore.setState({ currentFloor: 100 });
|
||||
|
||||
useCombatStore.getState().advanceFloor();
|
||||
|
||||
const state = useCombatStore.getState();
|
||||
expect(state.currentFloor).toBe(100);
|
||||
});
|
||||
|
||||
it('should update maxFloorReached', () => {
|
||||
useCombatStore.setState({ maxFloorReached: 1 });
|
||||
|
||||
useCombatStore.getState().advanceFloor();
|
||||
|
||||
const state = useCombatStore.getState();
|
||||
expect(state.maxFloorReached).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Prestige Store Tests ──────────────────────────────────────────────────────
|
||||
|
||||
describe('PrestigeStore', () => {
|
||||
describe('initial state', () => {
|
||||
it('should start with 0 insight', () => {
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(state.insight).toBe(0);
|
||||
});
|
||||
|
||||
it('should start with 3 memory slots', () => {
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(state.memorySlots).toBe(3);
|
||||
});
|
||||
|
||||
it('should start with 1 pact slot', () => {
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(state.pactSlots).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('doPrestige', () => {
|
||||
it('should deduct insight and add upgrade', () => {
|
||||
usePrestigeStore.setState({ insight: 1000 });
|
||||
|
||||
usePrestigeStore.getState().doPrestige('manaWell');
|
||||
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(state.prestigeUpgrades.manaWell).toBe(1);
|
||||
expect(state.insight).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it('should not upgrade without enough insight', () => {
|
||||
usePrestigeStore.setState({ insight: 100 });
|
||||
|
||||
usePrestigeStore.getState().doPrestige('manaWell');
|
||||
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(state.prestigeUpgrades.manaWell).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addMemory', () => {
|
||||
it('should add memory within slot limit', () => {
|
||||
const memory = { skillId: 'manaWell', level: 5, tier: 1, upgrades: [] };
|
||||
|
||||
usePrestigeStore.getState().addMemory(memory);
|
||||
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(state.memories.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should not add duplicate memory', () => {
|
||||
const memory = { skillId: 'manaWell', level: 5, tier: 1, upgrades: [] };
|
||||
|
||||
usePrestigeStore.getState().addMemory(memory);
|
||||
usePrestigeStore.getState().addMemory(memory);
|
||||
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(state.memories.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should not exceed memory slots', () => {
|
||||
// Fill memory slots
|
||||
for (let i = 0; i < 5; i++) {
|
||||
usePrestigeStore.getState().addMemory({
|
||||
skillId: `skill${i}`,
|
||||
level: 5,
|
||||
tier: 1,
|
||||
upgrades: []
|
||||
});
|
||||
}
|
||||
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(state.memories.length).toBe(3); // Default 3 slots
|
||||
});
|
||||
});
|
||||
|
||||
describe('startPactRitual', () => {
|
||||
it('should start ritual for defeated guardian', () => {
|
||||
usePrestigeStore.setState({
|
||||
defeatedGuardians: [10],
|
||||
signedPacts: []
|
||||
});
|
||||
useManaStore.setState({ rawMana: 1000 });
|
||||
|
||||
const result = usePrestigeStore.getState().startPactRitual(10, 1000);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(state.pactRitualFloor).toBe(10);
|
||||
});
|
||||
|
||||
it('should not start ritual for undefeated guardian', () => {
|
||||
usePrestigeStore.setState({
|
||||
defeatedGuardians: [],
|
||||
signedPacts: []
|
||||
});
|
||||
useManaStore.setState({ rawMana: 1000 });
|
||||
|
||||
const result = usePrestigeStore.getState().startPactRitual(10, 1000);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should not start ritual without enough mana', () => {
|
||||
usePrestigeStore.setState({
|
||||
defeatedGuardians: [10],
|
||||
signedPacts: []
|
||||
});
|
||||
useManaStore.setState({ rawMana: 100 });
|
||||
|
||||
const result = usePrestigeStore.getState().startPactRitual(10, 100);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addSignedPact', () => {
|
||||
it('should add pact to signed list', () => {
|
||||
usePrestigeStore.getState().addSignedPact(10);
|
||||
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(state.signedPacts).toContain(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addDefeatedGuardian', () => {
|
||||
it('should add guardian to defeated list', () => {
|
||||
usePrestigeStore.getState().addDefeatedGuardian(10);
|
||||
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(state.defeatedGuardians).toContain(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── UI Store Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('UIStore', () => {
|
||||
describe('addLog', () => {
|
||||
it('should add message to logs', () => {
|
||||
useUIStore.getState().addLog('Test message');
|
||||
|
||||
const state = useUIStore.getState();
|
||||
expect(state.logs[0]).toBe('Test message');
|
||||
});
|
||||
|
||||
it('should limit log size', () => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
useUIStore.getState().addLog(`Message ${i}`);
|
||||
}
|
||||
|
||||
const state = useUIStore.getState();
|
||||
expect(state.logs.length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('togglePause', () => {
|
||||
it('should toggle pause state', () => {
|
||||
const initial = useUIStore.getState().paused;
|
||||
|
||||
useUIStore.getState().togglePause();
|
||||
|
||||
expect(useUIStore.getState().paused).toBe(!initial);
|
||||
|
||||
useUIStore.getState().togglePause();
|
||||
|
||||
expect(useUIStore.getState().paused).toBe(initial);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setGameOver', () => {
|
||||
it('should set game over state', () => {
|
||||
useUIStore.getState().setGameOver(true, false);
|
||||
|
||||
const state = useUIStore.getState();
|
||||
expect(state.gameOver).toBe(true);
|
||||
expect(state.victory).toBe(false);
|
||||
});
|
||||
|
||||
it('should set victory state', () => {
|
||||
useUIStore.getState().setGameOver(true, true);
|
||||
|
||||
const state = useUIStore.getState();
|
||||
expect(state.gameOver).toBe(true);
|
||||
expect(state.victory).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
console.log('✅ All store method tests defined.');
|
||||
Executable
+458
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* Comprehensive Store Tests
|
||||
*
|
||||
* Tests for the split store architecture after refactoring.
|
||||
* Each store is tested individually and for cross-store communication.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'bun:test';
|
||||
import {
|
||||
fmt,
|
||||
fmtDec,
|
||||
getFloorMaxHP,
|
||||
getFloorElement,
|
||||
computeMaxMana,
|
||||
computeElementMax,
|
||||
computeRegen,
|
||||
computeClickMana,
|
||||
calcDamage,
|
||||
calcInsight,
|
||||
getMeditationBonus,
|
||||
getIncursionStrength,
|
||||
canAffordSpellCost,
|
||||
} from '../../utils';
|
||||
import {
|
||||
ELEMENTS,
|
||||
GUARDIANS,
|
||||
SPELLS_DEF,
|
||||
SKILLS_DEF,
|
||||
PRESTIGE_DEF,
|
||||
MAX_DAY,
|
||||
INCURSION_START_DAY,
|
||||
getStudySpeedMultiplier,
|
||||
getStudyCostMultiplier,
|
||||
rawCost,
|
||||
elemCost,
|
||||
BASE_UNLOCKED_ELEMENTS,
|
||||
} from '../../constants';
|
||||
import type { GameState } from '../../types';
|
||||
|
||||
// ─── Test Fixtures ───────────────────────────────────────────────────────────
|
||||
|
||||
function createMockState(overrides: Partial<GameState> = {}): GameState {
|
||||
const elements: Record<string, { current: number; max: number; unlocked: boolean }> = {};
|
||||
Object.keys(ELEMENTS).forEach((k) => {
|
||||
elements[k] = { current: 0, max: 10, unlocked: BASE_UNLOCKED_ELEMENTS.includes(k) };
|
||||
});
|
||||
|
||||
return {
|
||||
day: 1,
|
||||
hour: 0,
|
||||
loopCount: 0,
|
||||
gameOver: false,
|
||||
victory: false,
|
||||
paused: false,
|
||||
rawMana: 100,
|
||||
meditateTicks: 0,
|
||||
totalManaGathered: 0,
|
||||
elements,
|
||||
currentFloor: 1,
|
||||
floorHP: 100,
|
||||
floorMaxHP: 100,
|
||||
maxFloorReached: 1,
|
||||
signedPacts: [],
|
||||
activeSpell: 'manaBolt',
|
||||
currentAction: 'meditate',
|
||||
castProgress: 0,
|
||||
currentRoom: {
|
||||
roomType: 'combat',
|
||||
enemies: [{ id: 'enemy', hp: 100, maxHP: 100, armor: 0, dodgeChance: 0, element: 'fire' }],
|
||||
},
|
||||
spells: { manaBolt: { learned: true, level: 1, studyProgress: 0 } },
|
||||
skills: {},
|
||||
skillProgress: {},
|
||||
skillUpgrades: {},
|
||||
skillTiers: {},
|
||||
parallelStudyTarget: null,
|
||||
equipment: { mainHand: null, offHand: null, head: null, body: null, hands: null, accessory: null },
|
||||
inventory: [],
|
||||
blueprints: {},
|
||||
schedule: [],
|
||||
autoSchedule: false,
|
||||
studyQueue: [],
|
||||
craftQueue: [],
|
||||
currentStudyTarget: null,
|
||||
insight: 0,
|
||||
totalInsight: 0,
|
||||
prestigeUpgrades: {},
|
||||
memorySlots: 3,
|
||||
memories: [],
|
||||
incursionStrength: 0,
|
||||
containmentWards: 0,
|
||||
log: [],
|
||||
loopInsight: 0,
|
||||
attunements: {
|
||||
enchanter: { id: 'enchanter', active: true, level: 1, experience: 0 },
|
||||
},
|
||||
equippedInstances: {},
|
||||
equipmentInstances: {},
|
||||
enchantmentDesigns: [],
|
||||
designProgress: null,
|
||||
preparationProgress: null,
|
||||
applicationProgress: null,
|
||||
equipmentCraftingProgress: null,
|
||||
unlockedEffects: [],
|
||||
equipmentSpellStates: [],
|
||||
lootInventory: { materials: {}, blueprints: [] },
|
||||
golemancy: {
|
||||
enabledGolems: [],
|
||||
summonedGolems: [],
|
||||
lastSummonFloor: 0,
|
||||
},
|
||||
achievements: {
|
||||
unlocked: [],
|
||||
progress: {},
|
||||
},
|
||||
totalSpellsCast: 0,
|
||||
totalDamageDealt: 0,
|
||||
totalCraftsCompleted: 0,
|
||||
...overrides,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
// ─── Formatting Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Formatting Functions', () => {
|
||||
describe('fmt (format number)', () => {
|
||||
it('should format numbers less than 1000 as integers', () => {
|
||||
expect(fmt(0)).toBe('0');
|
||||
expect(fmt(1)).toBe('1');
|
||||
expect(fmt(999)).toBe('999');
|
||||
});
|
||||
|
||||
it('should format thousands with K suffix', () => {
|
||||
expect(fmt(1000)).toBe('1.0K');
|
||||
expect(fmt(1500)).toBe('1.5K');
|
||||
});
|
||||
|
||||
it('should format millions with M suffix', () => {
|
||||
expect(fmt(1000000)).toBe('1.00M');
|
||||
expect(fmt(1500000)).toBe('1.50M');
|
||||
});
|
||||
|
||||
it('should format billions with B suffix', () => {
|
||||
expect(fmt(1000000000)).toBe('1.00B');
|
||||
});
|
||||
|
||||
it('should handle non-finite numbers', () => {
|
||||
expect(fmt(Infinity)).toBe('0');
|
||||
expect(fmt(NaN)).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fmtDec (format decimal)', () => {
|
||||
it('should format numbers with specified decimal places', () => {
|
||||
expect(fmtDec(1.234, 2)).toBe('1.23');
|
||||
expect(fmtDec(1.567, 1)).toBe('1.6');
|
||||
});
|
||||
|
||||
it('should handle non-finite numbers', () => {
|
||||
expect(fmtDec(Infinity, 2)).toBe('0');
|
||||
expect(fmtDec(NaN, 2)).toBe('0');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Floor Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Floor Functions', () => {
|
||||
describe('getFloorMaxHP', () => {
|
||||
it('should return guardian HP for guardian floors', () => {
|
||||
expect(getFloorMaxHP(10)).toBe(GUARDIANS[10].hp);
|
||||
expect(getFloorMaxHP(100)).toBe(GUARDIANS[100].hp);
|
||||
});
|
||||
|
||||
it('should scale HP for non-guardian floors', () => {
|
||||
expect(getFloorMaxHP(1)).toBeGreaterThan(0);
|
||||
expect(getFloorMaxHP(5)).toBeGreaterThan(getFloorMaxHP(1));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFloorElement', () => {
|
||||
it('should cycle through elements in order', () => {
|
||||
expect(getFloorElement(1)).toBe('fire');
|
||||
expect(getFloorElement(2)).toBe('water');
|
||||
expect(getFloorElement(3)).toBe('air');
|
||||
expect(getFloorElement(4)).toBe('earth');
|
||||
});
|
||||
|
||||
it('should wrap around after cycle', () => {
|
||||
// FLOOR_ELEM_CYCLE has 7 elements: fire, water, air, earth, light, dark, death
|
||||
// Floor 1 = index 0 = fire, Floor 7 = index 6 = death, Floor 8 = index 0 = fire
|
||||
expect(getFloorElement(7)).toBe('death');
|
||||
expect(getFloorElement(8)).toBe('fire');
|
||||
expect(getFloorElement(9)).toBe('water');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Mana Calculation Tests ───────────────────────────────────────────────────
|
||||
|
||||
describe('Mana Calculation Functions', () => {
|
||||
describe('computeMaxMana', () => {
|
||||
it('should return base mana with no upgrades', () => {
|
||||
const state = createMockState();
|
||||
expect(computeMaxMana(state)).toBe(100);
|
||||
});
|
||||
|
||||
it('should add mana from manaWell skill', () => {
|
||||
const state = createMockState({ skills: { manaWell: 5 } });
|
||||
expect(computeMaxMana(state)).toBe(100 + 5 * 100);
|
||||
});
|
||||
|
||||
it('should add mana from prestige upgrades', () => {
|
||||
const state = createMockState({ prestigeUpgrades: { manaWell: 3 } });
|
||||
expect(computeMaxMana(state)).toBe(100 + 3 * 500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeRegen', () => {
|
||||
it('should return base regen with no upgrades', () => {
|
||||
const state = createMockState();
|
||||
expect(computeRegen(state)).toBe(2);
|
||||
});
|
||||
|
||||
it('should add regen from manaFlow skill', () => {
|
||||
const state = createMockState({ skills: { manaFlow: 5 } });
|
||||
expect(computeRegen(state)).toBe(2 + 5 * 1);
|
||||
});
|
||||
|
||||
it('should add regen from manaSpring skill', () => {
|
||||
const state = createMockState({ skills: { manaSpring: 1 } });
|
||||
expect(computeRegen(state)).toBe(2 + 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeClickMana', () => {
|
||||
it('should return base click mana with no upgrades', () => {
|
||||
const state = createMockState();
|
||||
expect(computeClickMana(state)).toBe(1);
|
||||
});
|
||||
|
||||
it('should add mana from manaTap skill', () => {
|
||||
const state = createMockState({ skills: { manaTap: 1 } });
|
||||
expect(computeClickMana(state)).toBe(1 + 1);
|
||||
});
|
||||
|
||||
it('should add mana from manaSurge skill', () => {
|
||||
const state = createMockState({ skills: { manaSurge: 1 } });
|
||||
expect(computeClickMana(state)).toBe(1 + 3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Damage Calculation Tests ─────────────────────────────────────────────────
|
||||
|
||||
describe('Damage Calculation', () => {
|
||||
describe('calcDamage', () => {
|
||||
it('should return spell base damage with no bonuses', () => {
|
||||
const state = createMockState();
|
||||
const dmg = calcDamage(state, 'manaBolt');
|
||||
expect(dmg).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Insight Calculation Tests ─────────────────────────────────────────────────
|
||||
|
||||
describe('Insight Calculation', () => {
|
||||
describe('calcInsight', () => {
|
||||
it('should calculate insight from floor progress', () => {
|
||||
const state = createMockState({ maxFloorReached: 10 });
|
||||
const insight = calcInsight(state);
|
||||
expect(insight).toBe(10 * 15);
|
||||
});
|
||||
|
||||
it('should calculate insight from signed pacts', () => {
|
||||
const state = createMockState({ signedPacts: [10, 20] });
|
||||
const insight = calcInsight(state);
|
||||
expect(insight).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Meditation Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Meditation Bonus', () => {
|
||||
describe('getMeditationBonus', () => {
|
||||
it('should start at 1x with no meditation', () => {
|
||||
expect(getMeditationBonus(0, {})).toBe(1);
|
||||
});
|
||||
|
||||
it('should cap at 1.5x without meditation skill', () => {
|
||||
const bonus = getMeditationBonus(200, {}); // 8 hours
|
||||
expect(bonus).toBe(1.5);
|
||||
});
|
||||
|
||||
it('should give 2.5x with meditation skill after 4 hours', () => {
|
||||
const bonus = getMeditationBonus(100, { meditation: 1 });
|
||||
expect(bonus).toBe(2.5);
|
||||
});
|
||||
|
||||
it('should give 3.0x with deepTrance skill after 6 hours', () => {
|
||||
const bonus = getMeditationBonus(150, { meditation: 1, deepTrance: 1 });
|
||||
expect(bonus).toBe(3.0);
|
||||
});
|
||||
|
||||
it('should give 5.0x with voidMeditation skill after 8 hours', () => {
|
||||
const bonus = getMeditationBonus(200, { meditation: 1, deepTrance: 1, voidMeditation: 1 });
|
||||
expect(bonus).toBe(5.0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Incursion Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('Incursion Strength', () => {
|
||||
describe('getIncursionStrength', () => {
|
||||
it('should be 0 before incursion start day', () => {
|
||||
expect(getIncursionStrength(19, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('should start at incursion start day', () => {
|
||||
expect(getIncursionStrength(INCURSION_START_DAY, 1)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should cap at 95%', () => {
|
||||
const strength = getIncursionStrength(MAX_DAY, 23);
|
||||
expect(strength).toBeLessThanOrEqual(0.95);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Spell Cost Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Spell Cost System', () => {
|
||||
describe('rawCost', () => {
|
||||
it('should create a raw mana cost', () => {
|
||||
const cost = rawCost(10);
|
||||
expect(cost.type).toBe('raw');
|
||||
expect(cost.amount).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('elemCost', () => {
|
||||
it('should create an elemental mana cost', () => {
|
||||
const cost = elemCost('fire', 5);
|
||||
expect(cost.type).toBe('element');
|
||||
expect(cost.element).toBe('fire');
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAffordSpellCost', () => {
|
||||
it('should allow raw mana costs when enough raw mana', () => {
|
||||
const cost = rawCost(10);
|
||||
const elements = { fire: { current: 0, max: 10, unlocked: true } };
|
||||
expect(canAffordSpellCost(cost, 100, elements)).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny raw mana costs when not enough raw mana', () => {
|
||||
const cost = rawCost(100);
|
||||
const elements = { fire: { current: 0, max: 10, unlocked: true } };
|
||||
expect(canAffordSpellCost(cost, 50, elements)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Study Speed Tests ────────────────────────────────────────────────────────
|
||||
|
||||
describe('Study Speed Functions', () => {
|
||||
describe('getStudySpeedMultiplier', () => {
|
||||
it('should return 1 with no quickLearner skill', () => {
|
||||
expect(getStudySpeedMultiplier({})).toBe(1);
|
||||
});
|
||||
|
||||
it('should increase by 10% per level', () => {
|
||||
expect(getStudySpeedMultiplier({ quickLearner: 1 })).toBe(1.1);
|
||||
expect(getStudySpeedMultiplier({ quickLearner: 5 })).toBe(1.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStudyCostMultiplier', () => {
|
||||
it('should return 1 with no focusedMind skill', () => {
|
||||
expect(getStudyCostMultiplier({})).toBe(1);
|
||||
});
|
||||
|
||||
it('should decrease by 5% per level', () => {
|
||||
expect(getStudyCostMultiplier({ focusedMind: 1 })).toBe(0.95);
|
||||
expect(getStudyCostMultiplier({ focusedMind: 5 })).toBe(0.75);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Guardian Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('Guardians', () => {
|
||||
it('should have guardians on expected floors', () => {
|
||||
const guardianFloors = [10, 20, 30, 40, 50, 60, 80, 90, 100];
|
||||
guardianFloors.forEach(floor => {
|
||||
expect(GUARDIANS[floor]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have increasing HP across guardians', () => {
|
||||
const guardianFloors = [10, 20, 30, 40, 50, 60, 80, 90, 100];
|
||||
let prevHP = 0;
|
||||
guardianFloors.forEach(floor => {
|
||||
expect(GUARDIANS[floor].hp).toBeGreaterThan(prevHP);
|
||||
prevHP = GUARDIANS[floor].hp;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Skill Definition Tests ───────────────────────────────────────────────────
|
||||
|
||||
describe('Skill Definitions', () => {
|
||||
it('should have skills with valid categories', () => {
|
||||
const validCategories = ['mana', 'study', 'research', 'ascension', 'enchant', 'effectResearch', 'craft', 'golemancy', 'invocation', 'pact'];
|
||||
Object.values(SKILLS_DEF).forEach(skill => {
|
||||
expect(validCategories).toContain(skill.cat);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have reasonable study times', () => {
|
||||
Object.values(SKILLS_DEF).forEach(skill => {
|
||||
expect(skill.studyTime).toBeGreaterThan(0);
|
||||
expect(skill.studyTime).toBeLessThanOrEqual(72);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Prestige Upgrade Tests ───────────────────────────────────────────────────
|
||||
|
||||
describe('Prestige Upgrades', () => {
|
||||
it('should have prestige upgrades with valid costs', () => {
|
||||
Object.values(PRESTIGE_DEF).forEach(def => {
|
||||
expect(def.cost).toBeGreaterThan(0);
|
||||
expect(def.max).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Spell Definition Tests ───────────────────────────────────────────────────
|
||||
|
||||
describe('Spell Definitions', () => {
|
||||
it('should have manaBolt as a basic spell', () => {
|
||||
expect(SPELLS_DEF.manaBolt).toBeDefined();
|
||||
expect(SPELLS_DEF.manaBolt.tier).toBe(0);
|
||||
expect(SPELLS_DEF.manaBolt.cost.type).toBe('raw');
|
||||
});
|
||||
|
||||
it('should have increasing damage for higher tiers', () => {
|
||||
const tier0Avg = Object.values(SPELLS_DEF).filter(s => s.tier === 0).reduce((a, s) => a + s.dmg, 0);
|
||||
const tier1Avg = Object.values(SPELLS_DEF).filter(s => s.tier === 1).reduce((a, s) => a + s.dmg, 0);
|
||||
expect(tier1Avg).toBeGreaterThan(tier0Avg);
|
||||
});
|
||||
});
|
||||
|
||||
console.log('✅ All store tests defined.');
|
||||
Executable
+268
@@ -0,0 +1,268 @@
|
||||
// ─── Combat Store ─────────────────────────────────────────────────────────────
|
||||
// Handles floors, spells, guardians, combat, and casting
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { SPELLS_DEF, GUARDIANS, HOURS_PER_TICK } from '../constants';
|
||||
import type { GameAction, SpellState } from '../types';
|
||||
import { getFloorMaxHP, getFloorElement, calcDamage } from '../utils';
|
||||
import { usePrestigeStore } from './prestigeStore';
|
||||
|
||||
export interface CombatState {
|
||||
// Floor state
|
||||
currentFloor: number;
|
||||
floorHP: number;
|
||||
floorMaxHP: number;
|
||||
maxFloorReached: number;
|
||||
|
||||
// Action state
|
||||
activeSpell: string;
|
||||
currentAction: GameAction;
|
||||
castProgress: number;
|
||||
|
||||
// Spells
|
||||
spells: Record<string, SpellState>;
|
||||
|
||||
// Actions
|
||||
setCurrentFloor: (floor: number) => void;
|
||||
advanceFloor: () => void;
|
||||
setFloorHP: (hp: number) => void;
|
||||
setMaxFloorReached: (floor: number) => void;
|
||||
|
||||
setAction: (action: GameAction) => void;
|
||||
setSpell: (spellId: string) => void;
|
||||
setCastProgress: (progress: number) => void;
|
||||
|
||||
// Spells
|
||||
learnSpell: (spellId: string) => void;
|
||||
setSpellState: (spellId: string, state: Partial<SpellState>) => void;
|
||||
|
||||
// Combat tick
|
||||
processCombatTick: (
|
||||
skills: Record<string, number>,
|
||||
rawMana: number,
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
||||
maxMana: number,
|
||||
attackSpeedMult: number,
|
||||
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
||||
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
|
||||
) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }>; logMessages: string[] };
|
||||
|
||||
// Reset
|
||||
resetCombat: (startFloor: number, spellsToKeep?: string[]) => void;
|
||||
}
|
||||
|
||||
export const useCombatStore = create<CombatState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
currentFloor: 1,
|
||||
floorHP: getFloorMaxHP(1),
|
||||
floorMaxHP: getFloorMaxHP(1),
|
||||
maxFloorReached: 1,
|
||||
activeSpell: 'manaBolt',
|
||||
currentAction: 'meditate',
|
||||
castProgress: 0,
|
||||
spells: {
|
||||
manaBolt: { learned: true, level: 1, studyProgress: 0 },
|
||||
},
|
||||
|
||||
setCurrentFloor: (floor: number) => {
|
||||
set({
|
||||
currentFloor: floor,
|
||||
floorHP: getFloorMaxHP(floor),
|
||||
floorMaxHP: getFloorMaxHP(floor),
|
||||
});
|
||||
},
|
||||
|
||||
advanceFloor: () => {
|
||||
set((state) => {
|
||||
const newFloor = Math.min(state.currentFloor + 1, 100);
|
||||
return {
|
||||
currentFloor: newFloor,
|
||||
floorHP: getFloorMaxHP(newFloor),
|
||||
floorMaxHP: getFloorMaxHP(newFloor),
|
||||
maxFloorReached: Math.max(state.maxFloorReached, newFloor),
|
||||
castProgress: 0,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setFloorHP: (hp: number) => {
|
||||
set({ floorHP: Math.max(0, hp) });
|
||||
},
|
||||
|
||||
setMaxFloorReached: (floor: number) => {
|
||||
set((state) => ({
|
||||
maxFloorReached: Math.max(state.maxFloorReached, floor),
|
||||
}));
|
||||
},
|
||||
|
||||
setAction: (action: GameAction) => {
|
||||
set({ currentAction: action });
|
||||
},
|
||||
|
||||
setSpell: (spellId: string) => {
|
||||
const state = get();
|
||||
if (state.spells[spellId]?.learned) {
|
||||
set({ activeSpell: spellId });
|
||||
}
|
||||
},
|
||||
|
||||
setCastProgress: (progress: number) => {
|
||||
set({ castProgress: progress });
|
||||
},
|
||||
|
||||
learnSpell: (spellId: string) => {
|
||||
set((state) => ({
|
||||
spells: {
|
||||
...state.spells,
|
||||
[spellId]: { learned: true, level: 1, studyProgress: 0 },
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
setSpellState: (spellId: string, spellState: Partial<SpellState>) => {
|
||||
set((state) => ({
|
||||
spells: {
|
||||
...state.spells,
|
||||
[spellId]: { ...(state.spells[spellId] || { learned: false, level: 0, studyProgress: 0 }), ...spellState },
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
processCombatTick: (
|
||||
skills: Record<string, number>,
|
||||
rawMana: number,
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
||||
maxMana: number,
|
||||
attackSpeedMult: number,
|
||||
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
||||
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
|
||||
) => {
|
||||
const state = get();
|
||||
const logMessages: string[] = [];
|
||||
|
||||
if (state.currentAction !== 'climb') {
|
||||
return { rawMana, elements, logMessages };
|
||||
}
|
||||
|
||||
const spellId = state.activeSpell;
|
||||
const spellDef = SPELLS_DEF[spellId];
|
||||
if (!spellDef) {
|
||||
return { rawMana, elements, logMessages };
|
||||
}
|
||||
|
||||
// Calculate cast speed
|
||||
const baseAttackSpeed = 1 + (skills.quickCast || 0) * 0.05;
|
||||
const totalAttackSpeed = baseAttackSpeed * attackSpeedMult;
|
||||
const spellCastSpeed = spellDef.castSpeed || 1;
|
||||
const progressPerTick = HOURS_PER_TICK * spellCastSpeed * totalAttackSpeed;
|
||||
|
||||
let castProgress = (state.castProgress || 0) + progressPerTick;
|
||||
let floorHP = state.floorHP;
|
||||
let currentFloor = state.currentFloor;
|
||||
let floorMaxHP = state.floorMaxHP;
|
||||
|
||||
// Process complete casts
|
||||
while (castProgress >= 1) {
|
||||
// Check if we can afford the spell
|
||||
const cost = spellDef.cost;
|
||||
let canCast = false;
|
||||
|
||||
if (cost.type === 'raw') {
|
||||
canCast = rawMana >= cost.amount;
|
||||
if (canCast) rawMana -= cost.amount;
|
||||
} else if (cost.element) {
|
||||
const elem = elements[cost.element];
|
||||
canCast = elem && elem.unlocked && elem.current >= cost.amount;
|
||||
if (canCast) {
|
||||
elements = {
|
||||
...elements,
|
||||
[cost.element]: { ...elem, current: elem.current - cost.amount },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!canCast) break;
|
||||
|
||||
// Calculate damage
|
||||
const floorElement = getFloorElement(currentFloor);
|
||||
const damage = calcDamage(
|
||||
{ skills, signedPacts: usePrestigeStore.getState().signedPacts },
|
||||
spellId,
|
||||
floorElement
|
||||
);
|
||||
|
||||
// Apply damage
|
||||
floorHP = Math.max(0, floorHP - damage);
|
||||
castProgress -= 1;
|
||||
|
||||
// Check if floor is cleared
|
||||
if (floorHP <= 0) {
|
||||
const wasGuardian = GUARDIANS[currentFloor];
|
||||
onFloorCleared(currentFloor, !!wasGuardian);
|
||||
|
||||
currentFloor = Math.min(currentFloor + 1, 100);
|
||||
floorMaxHP = getFloorMaxHP(currentFloor);
|
||||
floorHP = floorMaxHP;
|
||||
castProgress = 0;
|
||||
|
||||
if (wasGuardian) {
|
||||
logMessages.push(`⚔️ ${wasGuardian.name} defeated!`);
|
||||
} else if (currentFloor % 5 === 0) {
|
||||
logMessages.push(`🏰 Floor ${currentFloor - 1} cleared!`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
currentFloor,
|
||||
floorHP,
|
||||
floorMaxHP,
|
||||
maxFloorReached: Math.max(state.maxFloorReached, currentFloor),
|
||||
castProgress,
|
||||
});
|
||||
|
||||
return { rawMana, elements, logMessages };
|
||||
},
|
||||
|
||||
resetCombat: (startFloor: number, spellsToKeep: string[] = []) => {
|
||||
const startSpells = makeInitialSpells(spellsToKeep);
|
||||
|
||||
set({
|
||||
currentFloor: startFloor,
|
||||
floorHP: getFloorMaxHP(startFloor),
|
||||
floorMaxHP: getFloorMaxHP(startFloor),
|
||||
maxFloorReached: startFloor,
|
||||
activeSpell: 'manaBolt',
|
||||
currentAction: 'meditate',
|
||||
castProgress: 0,
|
||||
spells: startSpells,
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'mana-loop-combat',
|
||||
partialize: (state) => ({
|
||||
currentFloor: state.currentFloor,
|
||||
maxFloorReached: state.maxFloorReached,
|
||||
spells: state.spells,
|
||||
activeSpell: state.activeSpell,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Helper function to create initial spells
|
||||
export function makeInitialSpells(spellsToKeep: string[] = []): Record<string, SpellState> {
|
||||
const startSpells: Record<string, SpellState> = {
|
||||
manaBolt: { learned: true, level: 1, studyProgress: 0 },
|
||||
};
|
||||
|
||||
// Add kept spells
|
||||
for (const spellId of spellsToKeep) {
|
||||
startSpells[spellId] = { learned: true, level: 1, studyProgress: 0 };
|
||||
}
|
||||
|
||||
return startSpells;
|
||||
}
|
||||
Executable
+509
@@ -0,0 +1,509 @@
|
||||
// ─── Game Store (Coordinator) ─────────────────────────────────────────────────
|
||||
// Manages: day, hour, incursionStrength, containmentWards
|
||||
// Coordinates tick function across all stores
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { TICK_MS, HOURS_PER_TICK, MAX_DAY, SPELLS_DEF, GUARDIANS, ELEMENTS, BASE_UNLOCKED_ELEMENTS, getStudySpeedMultiplier } from '../constants';
|
||||
import { computeEffects, hasSpecial, SPECIAL_EFFECTS } from '../upgrade-effects';
|
||||
import {
|
||||
computeMaxMana,
|
||||
computeRegen,
|
||||
getFloorElement,
|
||||
getFloorMaxHP,
|
||||
getMeditationBonus,
|
||||
getIncursionStrength,
|
||||
calcInsight,
|
||||
calcDamage,
|
||||
deductSpellCost,
|
||||
} from '../utils';
|
||||
import { useUIStore } from './uiStore';
|
||||
import { usePrestigeStore } from './prestigeStore';
|
||||
import { useManaStore } from './manaStore';
|
||||
import { useSkillStore } from './skillStore';
|
||||
import { useCombatStore, makeInitialSpells } from './combatStore';
|
||||
import type { Memory } from '../types';
|
||||
|
||||
export interface GameCoordinatorState {
|
||||
day: number;
|
||||
hour: number;
|
||||
incursionStrength: number;
|
||||
containmentWards: number;
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
export interface GameCoordinatorStore extends GameCoordinatorState {
|
||||
tick: () => void;
|
||||
resetGame: () => void;
|
||||
togglePause: () => void;
|
||||
startNewLoop: () => void;
|
||||
gatherMana: () => void;
|
||||
initGame: () => void;
|
||||
}
|
||||
|
||||
const initialState: GameCoordinatorState = {
|
||||
day: 1,
|
||||
hour: 0,
|
||||
incursionStrength: 0,
|
||||
containmentWards: 0,
|
||||
initialized: false,
|
||||
};
|
||||
|
||||
// Helper function for checking spell cost affordability
|
||||
function canAffordSpell(
|
||||
cost: { type: string; element?: string; amount: number },
|
||||
rawMana: number,
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>
|
||||
): boolean {
|
||||
if (cost.type === 'raw') {
|
||||
return rawMana >= cost.amount;
|
||||
} else if (cost.element) {
|
||||
const elem = elements[cost.element];
|
||||
return elem && elem.unlocked && elem.current >= cost.amount;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const useGameStore = create<GameCoordinatorStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
initGame: () => {
|
||||
set({ initialized: true });
|
||||
},
|
||||
|
||||
tick: () => {
|
||||
const uiState = useUIStore.getState();
|
||||
if (uiState.gameOver || uiState.paused) return;
|
||||
|
||||
// Helper for logging
|
||||
const addLog = (msg: string) => useUIStore.getState().addLog(msg);
|
||||
|
||||
// Get all store states
|
||||
const prestigeState = usePrestigeStore.getState();
|
||||
const manaState = useManaStore.getState();
|
||||
const skillState = useSkillStore.getState();
|
||||
const combatState = useCombatStore.getState();
|
||||
|
||||
// Compute effects from upgrades
|
||||
const effects = computeEffects(skillState.skillUpgrades || {}, skillState.skillTiers || {});
|
||||
|
||||
const maxMana = computeMaxMana(
|
||||
{ skills: skillState.skills, prestigeUpgrades: prestigeState.prestigeUpgrades, skillUpgrades: skillState.skillUpgrades, skillTiers: skillState.skillTiers },
|
||||
effects
|
||||
);
|
||||
const baseRegen = computeRegen(
|
||||
{ skills: skillState.skills, prestigeUpgrades: prestigeState.prestigeUpgrades, skillUpgrades: skillState.skillUpgrades, skillTiers: skillState.skillTiers },
|
||||
effects
|
||||
);
|
||||
|
||||
// Time progression
|
||||
let hour = get().hour + HOURS_PER_TICK;
|
||||
let day = get().day;
|
||||
if (hour >= 24) {
|
||||
hour -= 24;
|
||||
day += 1;
|
||||
}
|
||||
|
||||
// Check for loop end
|
||||
if (day > MAX_DAY) {
|
||||
const insightGained = calcInsight({
|
||||
maxFloorReached: combatState.maxFloorReached,
|
||||
totalManaGathered: manaState.totalManaGathered,
|
||||
signedPacts: prestigeState.signedPacts,
|
||||
prestigeUpgrades: prestigeState.prestigeUpgrades,
|
||||
skills: skillState.skills,
|
||||
});
|
||||
|
||||
addLog(`⏰ The loop ends. Gained ${insightGained} Insight.`);
|
||||
useUIStore.getState().setGameOver(true, false);
|
||||
usePrestigeStore.getState().setLoopInsight(insightGained);
|
||||
set({ day, hour });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for victory
|
||||
if (combatState.maxFloorReached >= 100 && prestigeState.signedPacts.includes(100)) {
|
||||
const insightGained = calcInsight({
|
||||
maxFloorReached: combatState.maxFloorReached,
|
||||
totalManaGathered: manaState.totalManaGathered,
|
||||
signedPacts: prestigeState.signedPacts,
|
||||
prestigeUpgrades: prestigeState.prestigeUpgrades,
|
||||
skills: skillState.skills,
|
||||
}) * 3;
|
||||
|
||||
addLog(`🏆 VICTORY! The Awakened One falls! Gained ${insightGained} Insight!`);
|
||||
useUIStore.getState().setGameOver(true, true);
|
||||
usePrestigeStore.getState().setLoopInsight(insightGained);
|
||||
return;
|
||||
}
|
||||
|
||||
// Incursion
|
||||
const incursionStrength = getIncursionStrength(day, hour);
|
||||
|
||||
// Meditation bonus tracking and regen calculation
|
||||
let meditateTicks = manaState.meditateTicks;
|
||||
let meditationMultiplier = 1;
|
||||
|
||||
if (combatState.currentAction === 'meditate') {
|
||||
meditateTicks++;
|
||||
meditationMultiplier = getMeditationBonus(meditateTicks, skillState.skills, effects.meditationEfficiency);
|
||||
} else {
|
||||
meditateTicks = 0;
|
||||
}
|
||||
|
||||
// Calculate effective regen with incursion and meditation
|
||||
const effectiveRegen = baseRegen * (1 - incursionStrength) * meditationMultiplier;
|
||||
|
||||
// Mana regeneration
|
||||
let rawMana = Math.min(manaState.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana);
|
||||
let totalManaGathered = manaState.totalManaGathered;
|
||||
let elements = { ...manaState.elements };
|
||||
|
||||
// Study progress - handled by skillStore
|
||||
if (combatState.currentAction === 'study' && skillState.currentStudyTarget) {
|
||||
const studySpeedMult = getStudySpeedMultiplier(skillState.skills);
|
||||
const progressGain = HOURS_PER_TICK * studySpeedMult;
|
||||
|
||||
const result = useSkillStore.getState().updateStudyProgress(progressGain);
|
||||
|
||||
if (result.completed && result.target) {
|
||||
if (result.target.type === 'skill') {
|
||||
const skillId = result.target.id;
|
||||
const currentLevel = skillState.skills[skillId] || 0;
|
||||
// Update skill level
|
||||
useSkillStore.getState().incrementSkillLevel(skillId);
|
||||
useSkillStore.getState().clearPaidStudySkill(skillId);
|
||||
useCombatStore.getState().setAction('meditate');
|
||||
addLog(`✅ ${skillId} Lv.${currentLevel + 1} mastered!`);
|
||||
} else if (result.target.type === 'spell') {
|
||||
const spellId = result.target.id;
|
||||
useCombatStore.getState().learnSpell(spellId);
|
||||
useSkillStore.getState().setCurrentStudyTarget(null);
|
||||
useCombatStore.getState().setAction('meditate');
|
||||
addLog(`📖 ${SPELLS_DEF[spellId]?.name || spellId} learned!`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert action - auto convert mana
|
||||
if (combatState.currentAction === 'convert') {
|
||||
const unlockedElements = Object.entries(elements)
|
||||
.filter(([, e]) => e.unlocked && e.current < e.max);
|
||||
|
||||
if (unlockedElements.length > 0 && rawMana >= 100) {
|
||||
unlockedElements.sort((a, b) => (b[1].max - b[1].current) - (a[1].max - a[1].current));
|
||||
const [targetId, targetState] = unlockedElements[0];
|
||||
const canConvert = Math.min(
|
||||
Math.floor(rawMana / 100),
|
||||
targetState.max - targetState.current
|
||||
);
|
||||
if (canConvert > 0) {
|
||||
rawMana -= canConvert * 100;
|
||||
elements = {
|
||||
...elements,
|
||||
[targetId]: { ...targetState, current: targetState.current + canConvert }
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pact ritual progress
|
||||
if (prestigeState.pactRitualFloor !== null) {
|
||||
const guardian = GUARDIANS[prestigeState.pactRitualFloor];
|
||||
if (guardian) {
|
||||
const pactAffinityBonus = 1 - (prestigeState.prestigeUpgrades.pactAffinity || 0) * 0.1;
|
||||
const requiredTime = guardian.pactTime * pactAffinityBonus;
|
||||
const newProgress = prestigeState.pactRitualProgress + HOURS_PER_TICK;
|
||||
|
||||
if (newProgress >= requiredTime) {
|
||||
addLog(`📜 Pact signed with ${guardian.name}! You have gained their boons.`);
|
||||
usePrestigeStore.getState().addSignedPact(prestigeState.pactRitualFloor);
|
||||
usePrestigeStore.getState().removeDefeatedGuardian(prestigeState.pactRitualFloor);
|
||||
usePrestigeStore.getState().setPactRitualFloor(null);
|
||||
} else {
|
||||
usePrestigeStore.getState().updatePactRitualProgress(newProgress);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Combat
|
||||
let { currentFloor, floorHP, floorMaxHP, maxFloorReached, castProgress } = combatState;
|
||||
const floorElement = getFloorElement(currentFloor);
|
||||
|
||||
if (combatState.currentAction === 'climb') {
|
||||
const spellId = combatState.activeSpell;
|
||||
const spellDef = SPELLS_DEF[spellId];
|
||||
|
||||
if (spellDef) {
|
||||
const baseAttackSpeed = 1 + (skillState.skills.quickCast || 0) * 0.05;
|
||||
const totalAttackSpeed = baseAttackSpeed * effects.attackSpeedMultiplier;
|
||||
const spellCastSpeed = spellDef.castSpeed || 1;
|
||||
const progressPerTick = HOURS_PER_TICK * spellCastSpeed * totalAttackSpeed;
|
||||
|
||||
castProgress = (castProgress || 0) + progressPerTick;
|
||||
|
||||
// Process complete casts
|
||||
while (castProgress >= 1 && canAffordSpell(spellDef.cost, rawMana, elements)) {
|
||||
const afterCost = deductSpellCost(spellDef.cost, rawMana, elements);
|
||||
rawMana = afterCost.rawMana;
|
||||
elements = afterCost.elements;
|
||||
totalManaGathered += spellDef.cost.amount;
|
||||
|
||||
// Calculate damage
|
||||
let dmg = calcDamage(
|
||||
{ skills: skillState.skills, signedPacts: prestigeState.signedPacts },
|
||||
spellId,
|
||||
floorElement
|
||||
);
|
||||
|
||||
// Apply upgrade damage multipliers and bonuses
|
||||
dmg = dmg * effects.baseDamageMultiplier + effects.baseDamageBonus;
|
||||
|
||||
// Executioner: +100% damage to enemies below 25% HP
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && floorHP / floorMaxHP < 0.25) {
|
||||
dmg *= 2;
|
||||
}
|
||||
|
||||
// Berserker: +50% damage when below 50% mana
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) {
|
||||
dmg *= 1.5;
|
||||
}
|
||||
|
||||
// Spell echo - chance to cast again
|
||||
const echoChance = (skillState.skills.spellEcho || 0) * 0.1;
|
||||
if (Math.random() < echoChance) {
|
||||
dmg *= 2;
|
||||
addLog(`✨ Spell Echo! Double damage!`);
|
||||
}
|
||||
|
||||
// Apply damage
|
||||
floorHP = Math.max(0, floorHP - dmg);
|
||||
castProgress -= 1;
|
||||
|
||||
if (floorHP <= 0) {
|
||||
// Floor cleared
|
||||
const wasGuardian = GUARDIANS[currentFloor];
|
||||
if (wasGuardian && !prestigeState.defeatedGuardians.includes(currentFloor) && !prestigeState.signedPacts.includes(currentFloor)) {
|
||||
usePrestigeStore.getState().addDefeatedGuardian(currentFloor);
|
||||
addLog(`⚔️ ${wasGuardian.name} defeated! Visit the Grimoire to sign a pact.`);
|
||||
} else if (!wasGuardian) {
|
||||
if (currentFloor % 5 === 0) {
|
||||
addLog(`🏰 Floor ${currentFloor} cleared!`);
|
||||
}
|
||||
}
|
||||
|
||||
currentFloor = currentFloor + 1;
|
||||
if (currentFloor > 100) {
|
||||
currentFloor = 100;
|
||||
}
|
||||
floorMaxHP = getFloorMaxHP(currentFloor);
|
||||
floorHP = floorMaxHP;
|
||||
maxFloorReached = Math.max(maxFloorReached, currentFloor);
|
||||
castProgress = 0;
|
||||
|
||||
useCombatStore.getState().advanceFloor();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update all stores with new state
|
||||
useManaStore.setState({
|
||||
rawMana,
|
||||
meditateTicks,
|
||||
totalManaGathered,
|
||||
elements,
|
||||
});
|
||||
|
||||
useCombatStore.setState({
|
||||
floorHP,
|
||||
floorMaxHP,
|
||||
maxFloorReached,
|
||||
castProgress,
|
||||
});
|
||||
|
||||
set({
|
||||
day,
|
||||
hour,
|
||||
incursionStrength,
|
||||
});
|
||||
},
|
||||
|
||||
gatherMana: () => {
|
||||
const skillState = useSkillStore.getState();
|
||||
const manaState = useManaStore.getState();
|
||||
const prestigeState = usePrestigeStore.getState();
|
||||
|
||||
// Compute click mana
|
||||
let cm = 1 +
|
||||
(skillState.skills.manaTap || 0) * 1 +
|
||||
(skillState.skills.manaSurge || 0) * 3;
|
||||
|
||||
// Mana overflow bonus
|
||||
const overflowBonus = 1 + (skillState.skills.manaOverflow || 0) * 0.25;
|
||||
cm = Math.floor(cm * overflowBonus);
|
||||
|
||||
const effects = computeEffects(skillState.skillUpgrades || {}, skillState.skillTiers || {});
|
||||
const max = computeMaxMana(
|
||||
{ skills: skillState.skills, prestigeUpgrades: prestigeState.prestigeUpgrades, skillUpgrades: skillState.skillUpgrades, skillTiers: skillState.skillTiers },
|
||||
effects
|
||||
);
|
||||
|
||||
useManaStore.setState({
|
||||
rawMana: Math.min(manaState.rawMana + cm, max),
|
||||
totalManaGathered: manaState.totalManaGathered + cm,
|
||||
});
|
||||
},
|
||||
|
||||
resetGame: () => {
|
||||
// Clear all persisted state
|
||||
localStorage.removeItem('mana-loop-ui-storage');
|
||||
localStorage.removeItem('mana-loop-prestige-storage');
|
||||
localStorage.removeItem('mana-loop-mana-storage');
|
||||
localStorage.removeItem('mana-loop-skill-storage');
|
||||
localStorage.removeItem('mana-loop-combat-storage');
|
||||
localStorage.removeItem('mana-loop-game-storage');
|
||||
|
||||
const startFloor = 1;
|
||||
const elemMax = 10;
|
||||
|
||||
const elements: Record<string, { current: number; max: number; unlocked: boolean }> = {};
|
||||
Object.keys(ELEMENTS).forEach((k) => {
|
||||
elements[k] = {
|
||||
current: 0,
|
||||
max: elemMax,
|
||||
unlocked: BASE_UNLOCKED_ELEMENTS.includes(k),
|
||||
};
|
||||
});
|
||||
|
||||
useUIStore.getState().resetUI();
|
||||
usePrestigeStore.getState().resetPrestige();
|
||||
useManaStore.getState().resetMana({}, {}, {}, {});
|
||||
useSkillStore.getState().resetSkills();
|
||||
useCombatStore.getState().resetCombat(startFloor);
|
||||
|
||||
set({
|
||||
...initialState,
|
||||
initialized: true,
|
||||
});
|
||||
},
|
||||
|
||||
togglePause: () => {
|
||||
useUIStore.getState().togglePause();
|
||||
},
|
||||
|
||||
startNewLoop: () => {
|
||||
const prestigeState = usePrestigeStore.getState();
|
||||
const combatState = useCombatStore.getState();
|
||||
const manaState = useManaStore.getState();
|
||||
const skillState = useSkillStore.getState();
|
||||
|
||||
const insightGained = prestigeState.loopInsight || calcInsight({
|
||||
maxFloorReached: combatState.maxFloorReached,
|
||||
totalManaGathered: manaState.totalManaGathered,
|
||||
signedPacts: prestigeState.signedPacts,
|
||||
prestigeUpgrades: prestigeState.prestigeUpgrades,
|
||||
skills: skillState.skills,
|
||||
});
|
||||
|
||||
const total = prestigeState.insight + insightGained;
|
||||
|
||||
// Spell preservation is only through prestige upgrade "spellMemory" (purchased with insight)
|
||||
// Not through a skill - that would undermine the insight economy
|
||||
|
||||
const pu = prestigeState.prestigeUpgrades;
|
||||
const startFloor = 1 + (pu.spireKey || 0) * 2;
|
||||
|
||||
// Apply saved memories - restore skill levels, tiers, and upgrades
|
||||
const memories = prestigeState.memories || [];
|
||||
const newSkills: Record<string, number> = {};
|
||||
const newSkillTiers: Record<string, number> = {};
|
||||
const newSkillUpgrades: Record<string, string[]> = {};
|
||||
|
||||
if (memories.length > 0) {
|
||||
for (const memory of memories) {
|
||||
const tieredSkillId = memory.tier > 1 ? `${memory.skillId}_t${memory.tier}` : memory.skillId;
|
||||
newSkills[tieredSkillId] = memory.level;
|
||||
|
||||
if (memory.tier > 1) {
|
||||
newSkillTiers[memory.skillId] = memory.tier;
|
||||
}
|
||||
|
||||
newSkillUpgrades[tieredSkillId] = memory.upgrades || [];
|
||||
}
|
||||
}
|
||||
|
||||
// Reset and update all stores for new loop
|
||||
useUIStore.setState({
|
||||
gameOver: false,
|
||||
victory: false,
|
||||
paused: false,
|
||||
logs: ['✨ The loop begins. You start with Mana Bolt. Gather your strength, mage.'],
|
||||
});
|
||||
|
||||
usePrestigeStore.getState().resetPrestigeForNewLoop(
|
||||
total,
|
||||
pu,
|
||||
prestigeState.memories,
|
||||
3 + (pu.deepMemory || 0)
|
||||
);
|
||||
usePrestigeStore.getState().incrementLoopCount();
|
||||
|
||||
useManaStore.getState().resetMana(pu, newSkills, newSkillUpgrades, newSkillTiers);
|
||||
|
||||
useSkillStore.getState().resetSkills(newSkills, newSkillUpgrades, newSkillTiers);
|
||||
|
||||
// Reset combat with starting floor and any spells from prestige upgrades
|
||||
const startSpells = makeInitialSpells();
|
||||
if (pu.spellMemory) {
|
||||
const availableSpells = Object.keys(SPELLS_DEF).filter(s => s !== 'manaBolt');
|
||||
const shuffled = availableSpells.sort(() => Math.random() - 0.5);
|
||||
for (let i = 0; i < Math.min(pu.spellMemory, shuffled.length); i++) {
|
||||
startSpells[shuffled[i]] = { learned: true, level: 1, studyProgress: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
useCombatStore.setState({
|
||||
currentFloor: startFloor,
|
||||
floorHP: getFloorMaxHP(startFloor),
|
||||
floorMaxHP: getFloorMaxHP(startFloor),
|
||||
maxFloorReached: startFloor,
|
||||
activeSpell: 'manaBolt',
|
||||
currentAction: 'meditate',
|
||||
castProgress: 0,
|
||||
spells: startSpells,
|
||||
});
|
||||
|
||||
set({
|
||||
day: 1,
|
||||
hour: 0,
|
||||
incursionStrength: 0,
|
||||
containmentWards: 0,
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'mana-loop-game-storage',
|
||||
partialize: (state) => ({
|
||||
day: state.day,
|
||||
hour: state.hour,
|
||||
incursionStrength: state.incursionStrength,
|
||||
containmentWards: state.containmentWards,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Re-export the game loop hook for convenience
|
||||
export function useGameLoop() {
|
||||
const tick = useGameStore((s) => s.tick);
|
||||
|
||||
return {
|
||||
start: () => {
|
||||
const interval = setInterval(tick, TICK_MS);
|
||||
return () => clearInterval(interval);
|
||||
},
|
||||
};
|
||||
}
|
||||
Executable
+563
@@ -0,0 +1,563 @@
|
||||
/**
|
||||
* Comprehensive Store Tests
|
||||
*
|
||||
* Tests the split store architecture to ensure all stores work correctly together.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'bun:test';
|
||||
import {
|
||||
computeMaxMana,
|
||||
computeElementMax,
|
||||
computeRegen,
|
||||
computeClickMana,
|
||||
calcDamage,
|
||||
calcInsight,
|
||||
getMeditationBonus,
|
||||
getFloorMaxHP,
|
||||
getFloorElement,
|
||||
getIncursionStrength,
|
||||
canAffordSpellCost,
|
||||
fmt,
|
||||
fmtDec,
|
||||
} from './index';
|
||||
import {
|
||||
ELEMENTS,
|
||||
GUARDIANS,
|
||||
SPELLS_DEF,
|
||||
SKILLS_DEF,
|
||||
PRESTIGE_DEF,
|
||||
getStudySpeedMultiplier,
|
||||
getStudyCostMultiplier,
|
||||
HOURS_PER_TICK,
|
||||
MAX_DAY,
|
||||
INCURSION_START_DAY,
|
||||
} from '../constants';
|
||||
import type { GameState, SkillUpgradeChoice } from '../types';
|
||||
|
||||
// ─── Test Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function createMockState(overrides: Partial<GameState> = {}): GameState {
|
||||
const elements: Record<string, { current: number; max: number; unlocked: boolean }> = {};
|
||||
Object.keys(ELEMENTS).forEach((k) => {
|
||||
elements[k] = { current: 0, max: 10, unlocked: ['fire', 'water', 'air', 'earth'].includes(k) };
|
||||
});
|
||||
|
||||
return {
|
||||
day: 1,
|
||||
hour: 0,
|
||||
loopCount: 0,
|
||||
gameOver: false,
|
||||
victory: false,
|
||||
paused: false,
|
||||
rawMana: 100,
|
||||
meditateTicks: 0,
|
||||
totalManaGathered: 0,
|
||||
elements,
|
||||
currentFloor: 1,
|
||||
floorHP: 100,
|
||||
floorMaxHP: 100,
|
||||
maxFloorReached: 1,
|
||||
defeatedGuardians: [],
|
||||
signedPacts: [],
|
||||
pactSlots: 1,
|
||||
pactRitualFloor: null,
|
||||
pactRitualProgress: 0,
|
||||
activeSpell: 'manaBolt',
|
||||
currentAction: 'meditate',
|
||||
castProgress: 0,
|
||||
spells: { manaBolt: { learned: true, level: 1, studyProgress: 0 } },
|
||||
skills: {},
|
||||
skillProgress: {},
|
||||
skillUpgrades: {},
|
||||
skillTiers: {},
|
||||
paidStudySkills: {},
|
||||
currentStudyTarget: null,
|
||||
parallelStudyTarget: null,
|
||||
insight: 0,
|
||||
totalInsight: 0,
|
||||
prestigeUpgrades: {},
|
||||
memorySlots: 3,
|
||||
memories: [],
|
||||
incursionStrength: 0,
|
||||
containmentWards: 0,
|
||||
log: [],
|
||||
loopInsight: 0,
|
||||
equipment: { mainHand: null, offHand: null, head: null, body: null, hands: null, accessory: null },
|
||||
inventory: [],
|
||||
blueprints: {},
|
||||
schedule: [],
|
||||
autoSchedule: false,
|
||||
studyQueue: [],
|
||||
craftQueue: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Utility Function Tests ─────────────────────────────────────────────────
|
||||
|
||||
describe('Utility Functions', () => {
|
||||
describe('fmt', () => {
|
||||
it('should format small numbers', () => {
|
||||
expect(fmt(0)).toBe('0');
|
||||
expect(fmt(1)).toBe('1');
|
||||
expect(fmt(999)).toBe('999');
|
||||
});
|
||||
|
||||
it('should format thousands', () => {
|
||||
expect(fmt(1000)).toBe('1.0K');
|
||||
expect(fmt(1500)).toBe('1.5K');
|
||||
});
|
||||
|
||||
it('should format millions', () => {
|
||||
expect(fmt(1000000)).toBe('1.00M');
|
||||
expect(fmt(1500000)).toBe('1.50M');
|
||||
});
|
||||
|
||||
it('should format billions', () => {
|
||||
expect(fmt(1000000000)).toBe('1.00B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fmtDec', () => {
|
||||
it('should format decimals', () => {
|
||||
expect(fmtDec(1.234, 2)).toBe('1.23');
|
||||
expect(fmtDec(1.5, 1)).toBe('1.5');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Mana Calculation Tests ───────────────────────────────────────────────
|
||||
|
||||
describe('Mana Calculations', () => {
|
||||
describe('computeMaxMana', () => {
|
||||
it('should return base mana with no upgrades', () => {
|
||||
const state = createMockState();
|
||||
expect(computeMaxMana(state)).toBe(100);
|
||||
});
|
||||
|
||||
it('should add mana from manaWell skill', () => {
|
||||
const state = createMockState({ skills: { manaWell: 5 } });
|
||||
expect(computeMaxMana(state)).toBe(100 + 5 * 100);
|
||||
});
|
||||
|
||||
it('should add mana from deepReservoir skill', () => {
|
||||
const state = createMockState({ skills: { deepReservoir: 3 } });
|
||||
expect(computeMaxMana(state)).toBe(100 + 3 * 500);
|
||||
});
|
||||
|
||||
it('should add mana from prestige upgrades', () => {
|
||||
const state = createMockState({ prestigeUpgrades: { manaWell: 3 } });
|
||||
expect(computeMaxMana(state)).toBe(100 + 3 * 500);
|
||||
});
|
||||
|
||||
it('should stack all mana bonuses', () => {
|
||||
const state = createMockState({
|
||||
skills: { manaWell: 5, deepReservoir: 2 },
|
||||
prestigeUpgrades: { manaWell: 2 },
|
||||
});
|
||||
expect(computeMaxMana(state)).toBe(100 + 5 * 100 + 2 * 500 + 2 * 500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeRegen', () => {
|
||||
it('should return base regen with no upgrades', () => {
|
||||
const state = createMockState();
|
||||
expect(computeRegen(state)).toBe(2);
|
||||
});
|
||||
|
||||
it('should add regen from manaFlow skill', () => {
|
||||
const state = createMockState({ skills: { manaFlow: 5 } });
|
||||
expect(computeRegen(state)).toBe(2 + 5 * 1);
|
||||
});
|
||||
|
||||
it('should add regen from manaSpring skill', () => {
|
||||
const state = createMockState({ skills: { manaSpring: 1 } });
|
||||
expect(computeRegen(state)).toBe(2 + 2);
|
||||
});
|
||||
|
||||
it('should multiply by temporal echo prestige', () => {
|
||||
const state = createMockState({ prestigeUpgrades: { temporalEcho: 2 } });
|
||||
expect(computeRegen(state)).toBe(2 * 1.2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeClickMana', () => {
|
||||
it('should return base click mana with no upgrades', () => {
|
||||
const state = createMockState();
|
||||
expect(computeClickMana(state)).toBe(1);
|
||||
});
|
||||
|
||||
it('should add mana from manaTap skill', () => {
|
||||
const state = createMockState({ skills: { manaTap: 1 } });
|
||||
expect(computeClickMana(state)).toBe(1 + 1);
|
||||
});
|
||||
|
||||
it('should add mana from manaSurge skill', () => {
|
||||
const state = createMockState({ skills: { manaSurge: 1 } });
|
||||
expect(computeClickMana(state)).toBe(1 + 3);
|
||||
});
|
||||
|
||||
it('should stack manaTap and manaSurge', () => {
|
||||
const state = createMockState({ skills: { manaTap: 1, manaSurge: 1 } });
|
||||
expect(computeClickMana(state)).toBe(1 + 1 + 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeElementMax', () => {
|
||||
it('should return base element cap with no upgrades', () => {
|
||||
const state = createMockState();
|
||||
expect(computeElementMax(state)).toBe(10);
|
||||
});
|
||||
|
||||
it('should add cap from elemAttune skill', () => {
|
||||
const state = createMockState({ skills: { elemAttune: 5 } });
|
||||
expect(computeElementMax(state)).toBe(10 + 5 * 50);
|
||||
});
|
||||
|
||||
it('should add cap from prestige upgrades', () => {
|
||||
const state = createMockState({ prestigeUpgrades: { elementalAttune: 3 } });
|
||||
expect(computeElementMax(state)).toBe(10 + 3 * 25);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Combat Calculation Tests ─────────────────────────────────────────────
|
||||
|
||||
describe('Combat Calculations', () => {
|
||||
describe('calcDamage', () => {
|
||||
it('should return spell base damage with no bonuses', () => {
|
||||
const state = createMockState();
|
||||
const dmg = calcDamage(state, 'manaBolt');
|
||||
expect(dmg).toBeGreaterThanOrEqual(5); // Base damage (can be higher with crit)
|
||||
});
|
||||
|
||||
it('should add damage from combatTrain skill', () => {
|
||||
const state = createMockState({ skills: { combatTrain: 5 } });
|
||||
const dmg = calcDamage(state, 'manaBolt');
|
||||
expect(dmg).toBeGreaterThanOrEqual(5 + 5 * 5); // 5 base + 25 from skill
|
||||
});
|
||||
|
||||
it('should multiply by arcaneFury skill', () => {
|
||||
const state = createMockState({ skills: { arcaneFury: 3 } });
|
||||
const dmg = calcDamage(state, 'manaBolt');
|
||||
// 5 * 1.3 = 6.5 minimum (without crit)
|
||||
expect(dmg).toBeGreaterThanOrEqual(5 * 1.3 * 0.8);
|
||||
});
|
||||
|
||||
it('should have elemental bonuses', () => {
|
||||
const state = createMockState({
|
||||
spells: {
|
||||
manaBolt: { learned: true, level: 1 },
|
||||
fireball: { learned: true, level: 1 },
|
||||
waterJet: { learned: true, level: 1 },
|
||||
}
|
||||
});
|
||||
// Test elemental bonus by comparing same spell vs different elements
|
||||
// Fireball vs fire floor (same element, +25%) vs vs air floor (neutral)
|
||||
let fireVsFire = 0, fireVsAir = 0;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
fireVsFire += calcDamage(state, 'fireball', 'fire');
|
||||
fireVsAir += calcDamage(state, 'fireball', 'air');
|
||||
}
|
||||
const sameAvg = fireVsFire / 100;
|
||||
const neutralAvg = fireVsAir / 100;
|
||||
// Same element should do more damage
|
||||
expect(sameAvg).toBeGreaterThan(neutralAvg * 1.1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFloorMaxHP', () => {
|
||||
it('should return guardian HP for guardian floors', () => {
|
||||
expect(getFloorMaxHP(10)).toBe(GUARDIANS[10].hp);
|
||||
expect(getFloorMaxHP(100)).toBe(GUARDIANS[100].hp);
|
||||
});
|
||||
|
||||
it('should scale HP for non-guardian floors', () => {
|
||||
expect(getFloorMaxHP(1)).toBeGreaterThan(0);
|
||||
expect(getFloorMaxHP(5)).toBeGreaterThan(getFloorMaxHP(1));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFloorElement', () => {
|
||||
it('should cycle through elements in order', () => {
|
||||
expect(getFloorElement(1)).toBe('fire');
|
||||
expect(getFloorElement(2)).toBe('water');
|
||||
expect(getFloorElement(3)).toBe('air');
|
||||
expect(getFloorElement(4)).toBe('earth');
|
||||
});
|
||||
|
||||
it('should wrap around after 8 floors', () => {
|
||||
expect(getFloorElement(9)).toBe('fire');
|
||||
expect(getFloorElement(10)).toBe('water');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Study Speed Tests ────────────────────────────────────────────────────
|
||||
|
||||
describe('Study Speed Functions', () => {
|
||||
describe('getStudySpeedMultiplier', () => {
|
||||
it('should return 1 with no quickLearner skill', () => {
|
||||
expect(getStudySpeedMultiplier({})).toBe(1);
|
||||
});
|
||||
|
||||
it('should increase by 10% per level', () => {
|
||||
expect(getStudySpeedMultiplier({ quickLearner: 1 })).toBe(1.1);
|
||||
expect(getStudySpeedMultiplier({ quickLearner: 5 })).toBe(1.5);
|
||||
expect(getStudySpeedMultiplier({ quickLearner: 10 })).toBe(2.0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStudyCostMultiplier', () => {
|
||||
it('should return 1 with no focusedMind skill', () => {
|
||||
expect(getStudyCostMultiplier({})).toBe(1);
|
||||
});
|
||||
|
||||
it('should decrease by 5% per level', () => {
|
||||
expect(getStudyCostMultiplier({ focusedMind: 1 })).toBe(0.95);
|
||||
expect(getStudyCostMultiplier({ focusedMind: 5 })).toBe(0.75);
|
||||
expect(getStudyCostMultiplier({ focusedMind: 10 })).toBe(0.5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Meditation Tests ─────────────────────────────────────────────────────
|
||||
|
||||
describe('Meditation Bonus', () => {
|
||||
describe('getMeditationBonus', () => {
|
||||
it('should start at 1x with no meditation', () => {
|
||||
expect(getMeditationBonus(0, {})).toBe(1);
|
||||
});
|
||||
|
||||
it('should ramp up over time', () => {
|
||||
const bonus1hr = getMeditationBonus(25, {}); // 1 hour of ticks
|
||||
expect(bonus1hr).toBeGreaterThan(1);
|
||||
|
||||
const bonus4hr = getMeditationBonus(100, {}); // 4 hours
|
||||
expect(bonus4hr).toBeGreaterThan(bonus1hr);
|
||||
});
|
||||
|
||||
it('should cap at 1.5x without meditation skill', () => {
|
||||
const bonus = getMeditationBonus(200, {}); // 8 hours
|
||||
expect(bonus).toBe(1.5);
|
||||
});
|
||||
|
||||
it('should give 2.5x with meditation skill after 4 hours', () => {
|
||||
const bonus = getMeditationBonus(100, { meditation: 1 });
|
||||
expect(bonus).toBe(2.5);
|
||||
});
|
||||
|
||||
it('should give 3.0x with deepTrance skill after 6 hours', () => {
|
||||
const bonus = getMeditationBonus(150, { meditation: 1, deepTrance: 1 });
|
||||
expect(bonus).toBe(3.0);
|
||||
});
|
||||
|
||||
it('should give 5.0x with voidMeditation skill after 8 hours', () => {
|
||||
const bonus = getMeditationBonus(200, { meditation: 1, deepTrance: 1, voidMeditation: 1 });
|
||||
expect(bonus).toBe(5.0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Insight Tests ────────────────────────────────────────────────────────
|
||||
|
||||
describe('Insight Calculations', () => {
|
||||
describe('calcInsight', () => {
|
||||
it('should calculate insight from floor progress', () => {
|
||||
const state = createMockState({ maxFloorReached: 10 });
|
||||
const insight = calcInsight(state);
|
||||
expect(insight).toBe(10 * 15);
|
||||
});
|
||||
|
||||
it('should calculate insight from mana gathered', () => {
|
||||
const state = createMockState({ totalManaGathered: 5000 });
|
||||
const insight = calcInsight(state);
|
||||
// 1*15 + 5000/500 + 0 = 25
|
||||
expect(insight).toBe(25);
|
||||
});
|
||||
|
||||
it('should calculate insight from signed pacts', () => {
|
||||
const state = createMockState({ signedPacts: [10, 20] });
|
||||
const insight = calcInsight(state);
|
||||
// 1*15 + 0 + 2*150 = 315
|
||||
expect(insight).toBe(315);
|
||||
});
|
||||
|
||||
it('should multiply by insightAmp prestige', () => {
|
||||
const state = createMockState({
|
||||
maxFloorReached: 10,
|
||||
prestigeUpgrades: { insightAmp: 2 },
|
||||
});
|
||||
const insight = calcInsight(state);
|
||||
expect(insight).toBe(Math.floor(10 * 15 * 1.5));
|
||||
});
|
||||
|
||||
it('should multiply by insightHarvest skill', () => {
|
||||
const state = createMockState({
|
||||
maxFloorReached: 10,
|
||||
skills: { insightHarvest: 3 },
|
||||
});
|
||||
const insight = calcInsight(state);
|
||||
expect(insight).toBe(Math.floor(10 * 15 * 1.3));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Incursion Tests ──────────────────────────────────────────────────────
|
||||
|
||||
describe('Incursion Strength', () => {
|
||||
describe('getIncursionStrength', () => {
|
||||
it('should be 0 before incursion start day', () => {
|
||||
expect(getIncursionStrength(19, 0)).toBe(0);
|
||||
expect(getIncursionStrength(19, 23)).toBe(0);
|
||||
});
|
||||
|
||||
it('should start at incursion start day', () => {
|
||||
expect(getIncursionStrength(INCURSION_START_DAY, 1)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should increase over time', () => {
|
||||
const early = getIncursionStrength(INCURSION_START_DAY, 12);
|
||||
const late = getIncursionStrength(25, 12);
|
||||
expect(late).toBeGreaterThan(early);
|
||||
});
|
||||
|
||||
it('should cap at 95%', () => {
|
||||
const strength = getIncursionStrength(MAX_DAY, 23);
|
||||
expect(strength).toBeLessThanOrEqual(0.95);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Spell Cost Tests ────────────────────────────────────────────────────
|
||||
|
||||
describe('Spell Cost System', () => {
|
||||
describe('canAffordSpellCost', () => {
|
||||
it('should allow raw mana costs when enough raw mana', () => {
|
||||
const cost = { type: 'raw' as const, amount: 10 };
|
||||
const elements = { fire: { current: 0, max: 10, unlocked: true } };
|
||||
expect(canAffordSpellCost(cost, 100, elements)).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny raw mana costs when not enough raw mana', () => {
|
||||
const cost = { type: 'raw' as const, amount: 100 };
|
||||
const elements = { fire: { current: 0, max: 10, unlocked: true } };
|
||||
expect(canAffordSpellCost(cost, 50, elements)).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow elemental costs when enough element mana', () => {
|
||||
const cost = { type: 'element' as const, element: 'fire', amount: 5 };
|
||||
const elements = { fire: { current: 10, max: 10, unlocked: true } };
|
||||
expect(canAffordSpellCost(cost, 0, elements)).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny elemental costs when element not unlocked', () => {
|
||||
const cost = { type: 'element' as const, element: 'fire', amount: 5 };
|
||||
const elements = { fire: { current: 10, max: 10, unlocked: false } };
|
||||
expect(canAffordSpellCost(cost, 100, elements)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Skill Definition Tests ──────────────────────────────────────────────
|
||||
|
||||
describe('Skill Definitions', () => {
|
||||
it('all skills should have valid categories', () => {
|
||||
const validCategories = ['mana', 'combat', 'study', 'craft', 'research', 'ascension'];
|
||||
Object.values(SKILLS_DEF).forEach(skill => {
|
||||
expect(validCategories).toContain(skill.cat);
|
||||
});
|
||||
});
|
||||
|
||||
it('all skills should have reasonable study times', () => {
|
||||
Object.values(SKILLS_DEF).forEach(skill => {
|
||||
expect(skill.studyTime).toBeGreaterThan(0);
|
||||
expect(skill.studyTime).toBeLessThanOrEqual(72);
|
||||
});
|
||||
});
|
||||
|
||||
it('all prerequisite skills should exist', () => {
|
||||
Object.entries(SKILLS_DEF).forEach(([id, skill]) => {
|
||||
if (skill.req) {
|
||||
Object.keys(skill.req).forEach(reqId => {
|
||||
expect(SKILLS_DEF[reqId]).toBeDefined();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('all prerequisite levels should be within skill max', () => {
|
||||
Object.entries(SKILLS_DEF).forEach(([id, skill]) => {
|
||||
if (skill.req) {
|
||||
Object.entries(skill.req).forEach(([reqId, reqLevel]) => {
|
||||
expect(reqLevel).toBeLessThanOrEqual(SKILLS_DEF[reqId].max);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Prestige Upgrade Tests ──────────────────────────────────────────────
|
||||
|
||||
describe('Prestige Upgrades', () => {
|
||||
it('all prestige upgrades should have valid costs', () => {
|
||||
Object.entries(PRESTIGE_DEF).forEach(([id, upgrade]) => {
|
||||
expect(upgrade.cost).toBeGreaterThan(0);
|
||||
expect(upgrade.max).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('Mana Well prestige should add 500 starting max mana', () => {
|
||||
const state0 = createMockState({ prestigeUpgrades: { manaWell: 0 } });
|
||||
const state1 = createMockState({ prestigeUpgrades: { manaWell: 1 } });
|
||||
const state5 = createMockState({ prestigeUpgrades: { manaWell: 5 } });
|
||||
|
||||
expect(computeMaxMana(state0)).toBe(100);
|
||||
expect(computeMaxMana(state1)).toBe(100 + 500);
|
||||
expect(computeMaxMana(state5)).toBe(100 + 2500);
|
||||
});
|
||||
|
||||
it('Elemental Attunement prestige should add 25 element cap', () => {
|
||||
const state0 = createMockState({ prestigeUpgrades: { elementalAttune: 0 } });
|
||||
const state1 = createMockState({ prestigeUpgrades: { elementalAttune: 1 } });
|
||||
const state10 = createMockState({ prestigeUpgrades: { elementalAttune: 10 } });
|
||||
|
||||
expect(computeElementMax(state0)).toBe(10);
|
||||
expect(computeElementMax(state1)).toBe(10 + 25);
|
||||
expect(computeElementMax(state10)).toBe(10 + 250);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Guardian Tests ──────────────────────────────────────────────────────
|
||||
|
||||
describe('Guardian Definitions', () => {
|
||||
it('should have guardians every 10 floors', () => {
|
||||
[10, 20, 30, 40, 50, 60, 70, 80, 90, 100].forEach(floor => {
|
||||
expect(GUARDIANS[floor]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have increasing HP', () => {
|
||||
let prevHP = 0;
|
||||
[10, 20, 30, 40, 50, 60, 70, 80, 90, 100].forEach(floor => {
|
||||
expect(GUARDIANS[floor].hp).toBeGreaterThan(prevHP);
|
||||
prevHP = GUARDIANS[floor].hp;
|
||||
});
|
||||
});
|
||||
|
||||
it('should have boons defined', () => {
|
||||
Object.values(GUARDIANS).forEach(guardian => {
|
||||
expect(guardian.boons).toBeDefined();
|
||||
expect(guardian.boons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have pact costs defined', () => {
|
||||
Object.values(GUARDIANS).forEach(guardian => {
|
||||
expect(guardian.pactCost).toBeGreaterThan(0);
|
||||
expect(guardian.pactTime).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
console.log('✅ All store tests defined.');
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
// ─── Store Index ──────────────────────────────────────────────────────────────
|
||||
// Exports all stores and re-exports commonly used utilities
|
||||
|
||||
// Stores
|
||||
export { useUIStore } from './uiStore';
|
||||
export type { UIState } from './uiStore';
|
||||
|
||||
export { usePrestigeStore } from './prestigeStore';
|
||||
export type { PrestigeState } from './prestigeStore';
|
||||
|
||||
export { useManaStore, makeInitialElements } from './manaStore';
|
||||
export type { ManaState } from './manaStore';
|
||||
|
||||
export { useSkillStore } from './skillStore';
|
||||
export type { SkillState } from './skillStore';
|
||||
|
||||
export { useCombatStore, makeInitialSpells } from './combatStore';
|
||||
export type { CombatState } from './combatStore';
|
||||
|
||||
export { useGameStore, useGameLoop } from './gameStore';
|
||||
export type { GameCoordinatorState, GameCoordinatorStore } from './gameStore';
|
||||
|
||||
// Re-export utilities from utils.ts
|
||||
export {
|
||||
fmt,
|
||||
fmtDec,
|
||||
getFloorMaxHP,
|
||||
getFloorElement,
|
||||
computeMaxMana,
|
||||
computeElementMax,
|
||||
computeRegen,
|
||||
computeEffectiveRegen,
|
||||
computeClickMana,
|
||||
getElementalBonus,
|
||||
getBoonBonuses,
|
||||
calcDamage,
|
||||
calcInsight,
|
||||
getMeditationBonus,
|
||||
getIncursionStrength,
|
||||
canAffordSpellCost,
|
||||
deductSpellCost,
|
||||
} from '../utils';
|
||||
Executable
+264
@@ -0,0 +1,264 @@
|
||||
// ─── Mana Store ───────────────────────────────────────────────────────────────
|
||||
// Handles raw mana, elements, meditation, and mana regeneration
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { ELEMENTS, MANA_PER_ELEMENT, BASE_UNLOCKED_ELEMENTS } from '../constants';
|
||||
import type { ElementState } from '../types';
|
||||
|
||||
export interface ManaState {
|
||||
rawMana: number;
|
||||
meditateTicks: number;
|
||||
totalManaGathered: number;
|
||||
elements: Record<string, ElementState>;
|
||||
|
||||
// Actions
|
||||
setRawMana: (amount: number) => void;
|
||||
addRawMana: (amount: number, maxMana: number) => void;
|
||||
spendRawMana: (amount: number) => boolean;
|
||||
gatherMana: (amount: number, maxMana: number) => void;
|
||||
|
||||
// Meditation
|
||||
setMeditateTicks: (ticks: number) => void;
|
||||
incrementMeditateTicks: () => void;
|
||||
resetMeditateTicks: () => void;
|
||||
|
||||
// Elements
|
||||
convertMana: (element: string, amount: number) => boolean;
|
||||
unlockElement: (element: string, cost: number) => boolean;
|
||||
addElementMana: (element: string, amount: number, max: number) => void;
|
||||
spendElementMana: (element: string, amount: number) => boolean;
|
||||
setElementMax: (max: number) => void;
|
||||
craftComposite: (target: string, recipe: string[]) => boolean;
|
||||
|
||||
// Reset
|
||||
resetMana: (
|
||||
prestigeUpgrades: Record<string, number>,
|
||||
skills?: Record<string, number>,
|
||||
skillUpgrades?: Record<string, string[]>,
|
||||
skillTiers?: Record<string, number>
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const useManaStore = create<ManaState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
rawMana: 10,
|
||||
meditateTicks: 0,
|
||||
totalManaGathered: 0,
|
||||
elements: Object.fromEntries(
|
||||
Object.keys(ELEMENTS).map(k => [
|
||||
k,
|
||||
{
|
||||
current: 0,
|
||||
max: 10,
|
||||
unlocked: BASE_UNLOCKED_ELEMENTS.includes(k),
|
||||
}
|
||||
])
|
||||
) as Record<string, ElementState>,
|
||||
|
||||
setRawMana: (amount: number) => {
|
||||
set({ rawMana: Math.max(0, amount) });
|
||||
},
|
||||
|
||||
addRawMana: (amount: number, maxMana: number) => {
|
||||
set((state) => ({
|
||||
rawMana: Math.min(state.rawMana + amount, maxMana),
|
||||
totalManaGathered: state.totalManaGathered + amount,
|
||||
}));
|
||||
},
|
||||
|
||||
spendRawMana: (amount: number) => {
|
||||
const state = get();
|
||||
if (state.rawMana < amount) return false;
|
||||
|
||||
set({ rawMana: state.rawMana - amount });
|
||||
return true;
|
||||
},
|
||||
|
||||
gatherMana: (amount: number, maxMana: number) => {
|
||||
set((state) => ({
|
||||
rawMana: Math.min(state.rawMana + amount, maxMana),
|
||||
totalManaGathered: state.totalManaGathered + amount,
|
||||
}));
|
||||
},
|
||||
|
||||
setMeditateTicks: (ticks: number) => {
|
||||
set({ meditateTicks: ticks });
|
||||
},
|
||||
|
||||
incrementMeditateTicks: () => {
|
||||
set((state) => ({ meditateTicks: state.meditateTicks + 1 }));
|
||||
},
|
||||
|
||||
resetMeditateTicks: () => {
|
||||
set({ meditateTicks: 0 });
|
||||
},
|
||||
|
||||
convertMana: (element: string, amount: number) => {
|
||||
const state = get();
|
||||
const elem = state.elements[element];
|
||||
if (!elem?.unlocked) return false;
|
||||
|
||||
const cost = MANA_PER_ELEMENT * amount;
|
||||
if (state.rawMana < cost) return false;
|
||||
if (elem.current >= elem.max) return false;
|
||||
|
||||
const canConvert = Math.min(
|
||||
amount,
|
||||
Math.floor(state.rawMana / MANA_PER_ELEMENT),
|
||||
elem.max - elem.current
|
||||
);
|
||||
|
||||
if (canConvert <= 0) return false;
|
||||
|
||||
set({
|
||||
rawMana: state.rawMana - canConvert * MANA_PER_ELEMENT,
|
||||
elements: {
|
||||
...state.elements,
|
||||
[element]: { ...elem, current: elem.current + canConvert },
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
unlockElement: (element: string, cost: number) => {
|
||||
const state = get();
|
||||
if (state.elements[element]?.unlocked) return false;
|
||||
if (state.rawMana < cost) return false;
|
||||
|
||||
set({
|
||||
rawMana: state.rawMana - cost,
|
||||
elements: {
|
||||
...state.elements,
|
||||
[element]: { ...state.elements[element], unlocked: true },
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
addElementMana: (element: string, amount: number, max: number) => {
|
||||
set((state) => {
|
||||
const elem = state.elements[element];
|
||||
if (!elem) return state;
|
||||
|
||||
return {
|
||||
elements: {
|
||||
...state.elements,
|
||||
[element]: {
|
||||
...elem,
|
||||
current: Math.min(elem.current + amount, max),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
spendElementMana: (element: string, amount: number) => {
|
||||
const state = get();
|
||||
const elem = state.elements[element];
|
||||
if (!elem || elem.current < amount) return false;
|
||||
|
||||
set({
|
||||
elements: {
|
||||
...state.elements,
|
||||
[element]: { ...elem, current: elem.current - amount },
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
setElementMax: (max: number) => {
|
||||
set((state) => ({
|
||||
elements: Object.fromEntries(
|
||||
Object.entries(state.elements).map(([k, v]) => [k, { ...v, max }])
|
||||
) as Record<string, ElementState>,
|
||||
}));
|
||||
},
|
||||
|
||||
craftComposite: (target: string, recipe: string[]) => {
|
||||
const state = get();
|
||||
|
||||
// Count required ingredients
|
||||
const costs: Record<string, number> = {};
|
||||
recipe.forEach(r => {
|
||||
costs[r] = (costs[r] || 0) + 1;
|
||||
});
|
||||
|
||||
// Check if we have all ingredients
|
||||
for (const [r, amt] of Object.entries(costs)) {
|
||||
if ((state.elements[r]?.current || 0) < amt) return false;
|
||||
}
|
||||
|
||||
// Deduct ingredients
|
||||
const newElems = { ...state.elements };
|
||||
for (const [r, amt] of Object.entries(costs)) {
|
||||
newElems[r] = {
|
||||
...newElems[r],
|
||||
current: newElems[r].current - amt,
|
||||
};
|
||||
}
|
||||
|
||||
// Add crafted element
|
||||
const targetElem = newElems[target];
|
||||
newElems[target] = {
|
||||
...(targetElem || { current: 0, max: 10, unlocked: false }),
|
||||
current: (targetElem?.current || 0) + 1,
|
||||
unlocked: true,
|
||||
};
|
||||
|
||||
set({ elements: newElems });
|
||||
return true;
|
||||
},
|
||||
|
||||
resetMana: (
|
||||
prestigeUpgrades: Record<string, number>,
|
||||
skills: Record<string, number> = {},
|
||||
skillUpgrades: Record<string, string[]> = {},
|
||||
skillTiers: Record<string, number> = {}
|
||||
) => {
|
||||
const elementMax = 10 + (prestigeUpgrades.elemMax || 0) * 5;
|
||||
const startingMana = 10 + (prestigeUpgrades.manaStart || 0) * 10;
|
||||
const elements = makeInitialElements(elementMax, prestigeUpgrades);
|
||||
|
||||
set({
|
||||
rawMana: startingMana,
|
||||
meditateTicks: 0,
|
||||
totalManaGathered: 0,
|
||||
elements,
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'mana-loop-mana',
|
||||
partialize: (state) => ({
|
||||
rawMana: state.rawMana,
|
||||
totalManaGathered: state.totalManaGathered,
|
||||
elements: state.elements,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Helper function to create initial elements
|
||||
export function makeInitialElements(
|
||||
elementMax: number,
|
||||
prestigeUpgrades: Record<string, number> = {}
|
||||
): Record<string, ElementState> {
|
||||
const elemStart = (prestigeUpgrades.elemStart || 0) * 5;
|
||||
|
||||
const elements: Record<string, ElementState> = {};
|
||||
Object.keys(ELEMENTS).forEach(k => {
|
||||
const isUnlocked = BASE_UNLOCKED_ELEMENTS.includes(k);
|
||||
elements[k] = {
|
||||
current: isUnlocked ? elemStart : 0,
|
||||
max: elementMax,
|
||||
unlocked: isUnlocked,
|
||||
};
|
||||
});
|
||||
|
||||
return elements;
|
||||
}
|
||||
Executable
+266
@@ -0,0 +1,266 @@
|
||||
// ─── Prestige Store ───────────────────────────────────────────────────────────
|
||||
// Handles insight, prestige upgrades, memories, loops, pacts
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { Memory } from '../types';
|
||||
import { GUARDIANS, PRESTIGE_DEF } from '../constants';
|
||||
|
||||
export interface PrestigeState {
|
||||
// Loop counter
|
||||
loopCount: number;
|
||||
|
||||
// Insight
|
||||
insight: number;
|
||||
totalInsight: number;
|
||||
loopInsight: number; // Insight earned at end of current loop
|
||||
|
||||
// Prestige upgrades
|
||||
prestigeUpgrades: Record<string, number>;
|
||||
memorySlots: number;
|
||||
pactSlots: number;
|
||||
|
||||
// Memories (skills preserved across loops)
|
||||
memories: Memory[];
|
||||
|
||||
// Guardian pacts
|
||||
defeatedGuardians: number[];
|
||||
signedPacts: number[];
|
||||
pactRitualFloor: number | null;
|
||||
pactRitualProgress: number;
|
||||
|
||||
// Actions
|
||||
doPrestige: (id: string) => void;
|
||||
addMemory: (memory: Memory) => void;
|
||||
removeMemory: (skillId: string) => void;
|
||||
clearMemories: () => void;
|
||||
startPactRitual: (floor: number, rawMana: number) => boolean;
|
||||
cancelPactRitual: () => void;
|
||||
completePactRitual: (addLog: (msg: string) => void) => void;
|
||||
updatePactRitualProgress: (hours: number) => void;
|
||||
removePact: (floor: number) => void;
|
||||
defeatGuardian: (floor: number) => void;
|
||||
|
||||
// Methods called by gameStore
|
||||
addSignedPact: (floor: number) => void;
|
||||
removeDefeatedGuardian: (floor: number) => void;
|
||||
setPactRitualFloor: (floor: number | null) => void;
|
||||
addDefeatedGuardian: (floor: number) => void;
|
||||
incrementLoopCount: () => void;
|
||||
resetPrestigeForNewLoop: (
|
||||
totalInsight: number,
|
||||
prestigeUpgrades: Record<string, number>,
|
||||
memories: Memory[],
|
||||
memorySlots: number
|
||||
) => void;
|
||||
|
||||
// Loop management
|
||||
startNewLoop: (insightGained: number) => void;
|
||||
setLoopInsight: (insight: number) => void;
|
||||
|
||||
// Reset
|
||||
resetPrestige: () => void;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
loopCount: 0,
|
||||
insight: 0,
|
||||
totalInsight: 0,
|
||||
loopInsight: 0,
|
||||
prestigeUpgrades: {} as Record<string, number>,
|
||||
memorySlots: 3,
|
||||
pactSlots: 1,
|
||||
memories: [] as Memory[],
|
||||
defeatedGuardians: [] as number[],
|
||||
signedPacts: [] as number[],
|
||||
pactRitualFloor: null as number | null,
|
||||
pactRitualProgress: 0,
|
||||
};
|
||||
|
||||
export const usePrestigeStore = create<PrestigeState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
doPrestige: (id: string) => {
|
||||
const state = get();
|
||||
const pd = PRESTIGE_DEF[id];
|
||||
if (!pd) return false;
|
||||
|
||||
const lvl = state.prestigeUpgrades[id] || 0;
|
||||
if (lvl >= pd.max || state.insight < pd.cost) return false;
|
||||
|
||||
const newPU = { ...state.prestigeUpgrades, [id]: lvl + 1 };
|
||||
set({
|
||||
insight: state.insight - pd.cost,
|
||||
prestigeUpgrades: newPU,
|
||||
memorySlots: id === 'deepMemory' ? state.memorySlots + 1 : state.memorySlots,
|
||||
pactSlots: id === 'pactBinding' ? state.pactSlots + 1 : state.pactSlots,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
addMemory: (memory: Memory) => {
|
||||
const state = get();
|
||||
if (state.memories.length >= state.memorySlots) return;
|
||||
if (state.memories.some(m => m.skillId === memory.skillId)) return;
|
||||
|
||||
set({ memories: [...state.memories, memory] });
|
||||
},
|
||||
|
||||
removeMemory: (skillId: string) => {
|
||||
set((state) => ({
|
||||
memories: state.memories.filter(m => m.skillId !== skillId),
|
||||
}));
|
||||
},
|
||||
|
||||
clearMemories: () => {
|
||||
set({ memories: [] });
|
||||
},
|
||||
|
||||
startPactRitual: (floor: number, rawMana: number) => {
|
||||
const state = get();
|
||||
const guardian = GUARDIANS[floor];
|
||||
if (!guardian) return false;
|
||||
|
||||
if (!state.defeatedGuardians.includes(floor)) return false;
|
||||
if (state.signedPacts.includes(floor)) return false;
|
||||
if (state.signedPacts.length >= state.pactSlots) return false;
|
||||
if (rawMana < guardian.pactCost) return false;
|
||||
if (state.pactRitualFloor !== null) return false;
|
||||
|
||||
set({
|
||||
pactRitualFloor: floor,
|
||||
pactRitualProgress: 0,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
cancelPactRitual: () => {
|
||||
set({
|
||||
pactRitualFloor: null,
|
||||
pactRitualProgress: 0,
|
||||
});
|
||||
},
|
||||
|
||||
completePactRitual: (addLog: (msg: string) => void) => {
|
||||
const state = get();
|
||||
if (state.pactRitualFloor === null) return;
|
||||
|
||||
const guardian = GUARDIANS[state.pactRitualFloor];
|
||||
if (!guardian) return;
|
||||
|
||||
set({
|
||||
signedPacts: [...state.signedPacts, state.pactRitualFloor],
|
||||
defeatedGuardians: state.defeatedGuardians.filter(f => f !== state.pactRitualFloor),
|
||||
pactRitualFloor: null,
|
||||
pactRitualProgress: 0,
|
||||
});
|
||||
|
||||
addLog(`📜 Pact signed with ${guardian.name}! You have gained their boons.`);
|
||||
},
|
||||
|
||||
updatePactRitualProgress: (hours: number) => {
|
||||
set((state) => ({
|
||||
pactRitualProgress: state.pactRitualProgress + hours,
|
||||
}));
|
||||
},
|
||||
|
||||
removePact: (floor: number) => {
|
||||
set((state) => ({
|
||||
signedPacts: state.signedPacts.filter(f => f !== floor),
|
||||
}));
|
||||
},
|
||||
|
||||
defeatGuardian: (floor: number) => {
|
||||
const state = get();
|
||||
if (state.defeatedGuardians.includes(floor) || state.signedPacts.includes(floor)) return;
|
||||
|
||||
set({
|
||||
defeatedGuardians: [...state.defeatedGuardians, floor],
|
||||
});
|
||||
},
|
||||
|
||||
addSignedPact: (floor: number) => {
|
||||
const state = get();
|
||||
if (state.signedPacts.includes(floor)) return;
|
||||
set({ signedPacts: [...state.signedPacts, floor] });
|
||||
},
|
||||
|
||||
removeDefeatedGuardian: (floor: number) => {
|
||||
set((state) => ({
|
||||
defeatedGuardians: state.defeatedGuardians.filter(f => f !== floor),
|
||||
}));
|
||||
},
|
||||
|
||||
setPactRitualFloor: (floor: number | null) => {
|
||||
set({ pactRitualFloor: floor, pactRitualProgress: 0 });
|
||||
},
|
||||
|
||||
addDefeatedGuardian: (floor: number) => {
|
||||
const state = get();
|
||||
if (state.defeatedGuardians.includes(floor) || state.signedPacts.includes(floor)) return;
|
||||
set({ defeatedGuardians: [...state.defeatedGuardians, floor] });
|
||||
},
|
||||
|
||||
incrementLoopCount: () => {
|
||||
set((state) => ({ loopCount: state.loopCount + 1 }));
|
||||
},
|
||||
|
||||
resetPrestigeForNewLoop: (
|
||||
totalInsight: number,
|
||||
prestigeUpgrades: Record<string, number>,
|
||||
memories: Memory[],
|
||||
memorySlots: number
|
||||
) => {
|
||||
set({
|
||||
insight: totalInsight,
|
||||
prestigeUpgrades,
|
||||
memories,
|
||||
memorySlots,
|
||||
// Reset loop-specific state
|
||||
defeatedGuardians: [],
|
||||
signedPacts: [],
|
||||
pactRitualFloor: null,
|
||||
pactRitualProgress: 0,
|
||||
loopInsight: 0,
|
||||
});
|
||||
},
|
||||
|
||||
startNewLoop: (insightGained: number) => {
|
||||
const state = get();
|
||||
set({
|
||||
loopCount: state.loopCount + 1,
|
||||
insight: state.insight + insightGained,
|
||||
totalInsight: state.totalInsight + insightGained,
|
||||
loopInsight: 0,
|
||||
// Reset loop-specific state
|
||||
defeatedGuardians: [],
|
||||
signedPacts: [],
|
||||
pactRitualFloor: null,
|
||||
pactRitualProgress: 0,
|
||||
});
|
||||
},
|
||||
|
||||
setLoopInsight: (insight: number) => {
|
||||
set({ loopInsight: insight });
|
||||
},
|
||||
|
||||
resetPrestige: () => {
|
||||
set(initialState);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'mana-loop-prestige',
|
||||
partialize: (state) => ({
|
||||
loopCount: state.loopCount,
|
||||
insight: state.insight,
|
||||
totalInsight: state.totalInsight,
|
||||
prestigeUpgrades: state.prestigeUpgrades,
|
||||
memorySlots: state.memorySlots,
|
||||
pactSlots: state.pactSlots,
|
||||
memories: state.memories,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
Executable
+332
@@ -0,0 +1,332 @@
|
||||
// ─── Skill Store ──────────────────────────────────────────────────────────────
|
||||
// Handles skills, upgrades, tiers, and study progress
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { SKILLS_DEF, getStudySpeedMultiplier, getStudyCostMultiplier } from '../constants';
|
||||
import type { StudyTarget, SkillUpgradeChoice } from '../types';
|
||||
import { SKILL_EVOLUTION_PATHS, getBaseSkillId } from '../skill-evolution';
|
||||
|
||||
export interface SkillState {
|
||||
// Skills
|
||||
skills: Record<string, number>;
|
||||
skillProgress: Record<string, number>; // Saved study progress for skills
|
||||
skillUpgrades: Record<string, string[]>; // Selected upgrade IDs per skill
|
||||
skillTiers: Record<string, number>; // Current tier for each base skill
|
||||
paidStudySkills: Record<string, number>; // skillId -> level that was paid for
|
||||
|
||||
// Study
|
||||
currentStudyTarget: StudyTarget | null;
|
||||
parallelStudyTarget: StudyTarget | null;
|
||||
|
||||
// Actions - Skills
|
||||
setSkillLevel: (skillId: string, level: number) => void;
|
||||
incrementSkillLevel: (skillId: string) => void;
|
||||
clearPaidStudySkill: (skillId: string) => void;
|
||||
setPaidStudySkill: (skillId: string, level: number) => void;
|
||||
|
||||
// Actions - Study
|
||||
startStudyingSkill: (skillId: string, rawMana: number) => { started: boolean; cost: number };
|
||||
startStudyingSpell: (spellId: string, rawMana: number, studyTime: number) => { started: boolean; cost: number };
|
||||
updateStudyProgress: (progressGain: number) => { completed: boolean; target: StudyTarget | null };
|
||||
cancelStudy: (retentionBonus: number) => void;
|
||||
setStudyTarget: (target: StudyTarget | null) => void;
|
||||
setCurrentStudyTarget: (target: StudyTarget | null) => void;
|
||||
|
||||
// Actions - Upgrades
|
||||
selectSkillUpgrade: (skillId: string, upgradeId: string) => void;
|
||||
deselectSkillUpgrade: (skillId: string, upgradeId: string) => void;
|
||||
commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => void;
|
||||
tierUpSkill: (skillId: string) => void;
|
||||
|
||||
// Computed
|
||||
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => { available: SkillUpgradeChoice[]; selected: string[] };
|
||||
|
||||
// Reset
|
||||
resetSkills: (
|
||||
skills?: Record<string, number>,
|
||||
skillUpgrades?: Record<string, string[]>,
|
||||
skillTiers?: Record<string, number>
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const useSkillStore = create<SkillState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
skills: {},
|
||||
skillProgress: {},
|
||||
skillUpgrades: {},
|
||||
skillTiers: {},
|
||||
paidStudySkills: {},
|
||||
currentStudyTarget: null,
|
||||
parallelStudyTarget: null,
|
||||
|
||||
setSkillLevel: (skillId: string, level: number) => {
|
||||
set((state) => ({
|
||||
skills: { ...state.skills, [skillId]: level },
|
||||
}));
|
||||
},
|
||||
|
||||
incrementSkillLevel: (skillId: string) => {
|
||||
set((state) => ({
|
||||
skills: { ...state.skills, [skillId]: (state.skills[skillId] || 0) + 1 },
|
||||
skillProgress: { ...state.skillProgress, [skillId]: 0 },
|
||||
}));
|
||||
},
|
||||
|
||||
clearPaidStudySkill: (skillId: string) => {
|
||||
set((state) => {
|
||||
const { [skillId]: _, ...remaining } = state.paidStudySkills;
|
||||
return { paidStudySkills: remaining };
|
||||
});
|
||||
},
|
||||
|
||||
setPaidStudySkill: (skillId: string, level: number) => {
|
||||
set((state) => ({
|
||||
paidStudySkills: { ...state.paidStudySkills, [skillId]: level },
|
||||
}));
|
||||
},
|
||||
|
||||
startStudyingSkill: (skillId: string, rawMana: number) => {
|
||||
const state = get();
|
||||
const sk = SKILLS_DEF[skillId];
|
||||
if (!sk) return { started: false, cost: 0 };
|
||||
|
||||
const currentLevel = state.skills[skillId] || 0;
|
||||
if (currentLevel >= sk.max) return { started: false, cost: 0 };
|
||||
|
||||
// Check prerequisites
|
||||
if (sk.req) {
|
||||
for (const [r, rl] of Object.entries(sk.req)) {
|
||||
if ((state.skills[r] || 0) < rl) return { started: false, cost: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already paid for this level
|
||||
const paidForLevel = state.paidStudySkills[skillId];
|
||||
const isAlreadyPaid = paidForLevel === currentLevel;
|
||||
|
||||
// Calculate cost
|
||||
const costMult = getStudyCostMultiplier(state.skills);
|
||||
const cost = Math.floor(sk.base * (currentLevel + 1) * costMult);
|
||||
|
||||
if (!isAlreadyPaid && rawMana < cost) return { started: false, cost };
|
||||
|
||||
// Get saved progress
|
||||
const savedProgress = state.skillProgress[skillId] || 0;
|
||||
|
||||
// Mark as paid (this is done here so resume works for free)
|
||||
const newPaidSkills = isAlreadyPaid
|
||||
? state.paidStudySkills
|
||||
: { ...state.paidStudySkills, [skillId]: currentLevel };
|
||||
|
||||
// Start studying
|
||||
set({
|
||||
paidStudySkills: newPaidSkills,
|
||||
currentStudyTarget: {
|
||||
type: 'skill',
|
||||
id: skillId,
|
||||
progress: savedProgress,
|
||||
required: sk.studyTime,
|
||||
},
|
||||
});
|
||||
|
||||
return { started: true, cost: isAlreadyPaid ? 0 : cost };
|
||||
},
|
||||
|
||||
startStudyingSpell: (spellId: string, rawMana: number, studyTime: number) => {
|
||||
const state = get();
|
||||
|
||||
// Start studying the spell
|
||||
set({
|
||||
currentStudyTarget: {
|
||||
type: 'spell',
|
||||
id: spellId,
|
||||
progress: 0,
|
||||
required: studyTime,
|
||||
},
|
||||
});
|
||||
|
||||
// Spell study has no mana cost upfront - cost is paid via study time
|
||||
return { started: true, cost: 0 };
|
||||
},
|
||||
|
||||
updateStudyProgress: (progressGain: number) => {
|
||||
const state = get();
|
||||
if (!state.currentStudyTarget) return { completed: false, target: null };
|
||||
|
||||
const newProgress = state.currentStudyTarget.progress + progressGain;
|
||||
const completed = newProgress >= state.currentStudyTarget.required;
|
||||
|
||||
const newTarget = completed ? null : {
|
||||
...state.currentStudyTarget,
|
||||
progress: newProgress,
|
||||
};
|
||||
|
||||
set({ currentStudyTarget: newTarget });
|
||||
|
||||
return {
|
||||
completed,
|
||||
target: completed ? state.currentStudyTarget : null
|
||||
};
|
||||
},
|
||||
|
||||
cancelStudy: (retentionBonus: number) => {
|
||||
const state = get();
|
||||
if (!state.currentStudyTarget) return;
|
||||
|
||||
// Save progress with retention bonus
|
||||
const savedProgress = Math.min(
|
||||
state.currentStudyTarget.progress,
|
||||
state.currentStudyTarget.required * retentionBonus
|
||||
);
|
||||
|
||||
if (state.currentStudyTarget.type === 'skill') {
|
||||
set({
|
||||
currentStudyTarget: null,
|
||||
skillProgress: {
|
||||
...state.skillProgress,
|
||||
[state.currentStudyTarget.id]: savedProgress,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
set({ currentStudyTarget: null });
|
||||
}
|
||||
},
|
||||
|
||||
setStudyTarget: (target: StudyTarget | null) => {
|
||||
set({ currentStudyTarget: target });
|
||||
},
|
||||
|
||||
setCurrentStudyTarget: (target: StudyTarget | null) => {
|
||||
set({ currentStudyTarget: target });
|
||||
},
|
||||
|
||||
selectSkillUpgrade: (skillId: string, upgradeId: string) => {
|
||||
set((state) => {
|
||||
const current = state.skillUpgrades?.[skillId] || [];
|
||||
if (current.includes(upgradeId)) return state;
|
||||
if (current.length >= 2) return state; // Max 2 upgrades per milestone
|
||||
return {
|
||||
skillUpgrades: {
|
||||
...state.skillUpgrades,
|
||||
[skillId]: [...current, upgradeId],
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
deselectSkillUpgrade: (skillId: string, upgradeId: string) => {
|
||||
set((state) => {
|
||||
const current = state.skillUpgrades?.[skillId] || [];
|
||||
return {
|
||||
skillUpgrades: {
|
||||
...state.skillUpgrades,
|
||||
[skillId]: current.filter(id => id !== upgradeId),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => {
|
||||
set((state) => {
|
||||
// Determine which milestone we're committing
|
||||
const isL5 = upgradeIds.some(id => id.includes('_l5'));
|
||||
const isL10 = upgradeIds.some(id => id.includes('_l10'));
|
||||
|
||||
const existingUpgrades = state.skillUpgrades?.[skillId] || [];
|
||||
|
||||
let preservedUpgrades: string[];
|
||||
if (isL5) {
|
||||
preservedUpgrades = existingUpgrades.filter(id => id.includes('_l10'));
|
||||
} else if (isL10) {
|
||||
preservedUpgrades = existingUpgrades.filter(id => id.includes('_l5'));
|
||||
} else {
|
||||
preservedUpgrades = [];
|
||||
}
|
||||
|
||||
const mergedUpgrades = [...preservedUpgrades, ...upgradeIds];
|
||||
|
||||
return {
|
||||
skillUpgrades: {
|
||||
...state.skillUpgrades,
|
||||
[skillId]: mergedUpgrades,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
tierUpSkill: (skillId: string) => {
|
||||
const state = get();
|
||||
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
|
||||
const currentTier = state.skillTiers?.[baseSkillId] || 1;
|
||||
const nextTier = currentTier + 1;
|
||||
|
||||
if (nextTier > 5) return;
|
||||
|
||||
const nextTierSkillId = `${baseSkillId}_t${nextTier}`;
|
||||
const currentLevel = state.skills[skillId] || 0;
|
||||
|
||||
set({
|
||||
skillTiers: {
|
||||
...state.skillTiers,
|
||||
[baseSkillId]: nextTier,
|
||||
},
|
||||
skills: {
|
||||
...state.skills,
|
||||
[nextTierSkillId]: currentLevel,
|
||||
[skillId]: 0,
|
||||
},
|
||||
skillUpgrades: {
|
||||
...state.skillUpgrades,
|
||||
[nextTierSkillId]: [],
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => {
|
||||
const state = get();
|
||||
const baseSkillId = getBaseSkillId(skillId);
|
||||
const tier = state.skillTiers?.[baseSkillId] || 1;
|
||||
|
||||
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
|
||||
if (!path) return { available: [], selected: [] };
|
||||
|
||||
const tierDef = path.tiers.find(t => t.tier === tier);
|
||||
if (!tierDef) return { available: [], selected: [] };
|
||||
|
||||
const available = tierDef.upgrades.filter(u => u.milestone === milestone);
|
||||
const selected = state.skillUpgrades?.[skillId]?.filter(id =>
|
||||
available.some(u => u.id === id)
|
||||
) || [];
|
||||
|
||||
return { available, selected };
|
||||
},
|
||||
|
||||
resetSkills: (
|
||||
skills: Record<string, number> = {},
|
||||
skillUpgrades: Record<string, string[]> = {},
|
||||
skillTiers: Record<string, number> = {}
|
||||
) => {
|
||||
set({
|
||||
skills,
|
||||
skillProgress: {},
|
||||
skillUpgrades,
|
||||
skillTiers,
|
||||
paidStudySkills: {},
|
||||
currentStudyTarget: null,
|
||||
parallelStudyTarget: null,
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'mana-loop-skills',
|
||||
partialize: (state) => ({
|
||||
skills: state.skills,
|
||||
skillProgress: state.skillProgress,
|
||||
skillUpgrades: state.skillUpgrades,
|
||||
skillTiers: state.skillTiers,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
// ─── UI Store ────────────────────────────────────────────────────────────────
|
||||
// Handles logs, pause state, and UI-specific state
|
||||
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface LogEntry {
|
||||
message: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface UIState {
|
||||
logs: string[];
|
||||
paused: boolean;
|
||||
gameOver: boolean;
|
||||
victory: boolean;
|
||||
|
||||
// Actions
|
||||
addLog: (message: string) => void;
|
||||
clearLogs: () => void;
|
||||
togglePause: () => void;
|
||||
setPaused: (paused: boolean) => void;
|
||||
setGameOver: (gameOver: boolean, victory?: boolean) => void;
|
||||
reset: () => void;
|
||||
resetUI: () => void;
|
||||
}
|
||||
|
||||
const MAX_LOGS = 50;
|
||||
|
||||
export const useUIStore = create<UIState>((set) => ({
|
||||
logs: ['✨ The loop begins. You start with Mana Bolt. Gather your strength, mage.'],
|
||||
paused: false,
|
||||
gameOver: false,
|
||||
victory: false,
|
||||
|
||||
addLog: (message: string) => {
|
||||
set((state) => ({
|
||||
logs: [message, ...state.logs.slice(0, MAX_LOGS - 1)],
|
||||
}));
|
||||
},
|
||||
|
||||
clearLogs: () => {
|
||||
set({ logs: [] });
|
||||
},
|
||||
|
||||
togglePause: () => {
|
||||
set((state) => ({ paused: !state.paused }));
|
||||
},
|
||||
|
||||
setPaused: (paused: boolean) => {
|
||||
set({ paused });
|
||||
},
|
||||
|
||||
setGameOver: (gameOver: boolean, victory: boolean = false) => {
|
||||
set({ gameOver, victory });
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set({
|
||||
logs: ['✨ The loop begins. You start with Mana Bolt. Gather your strength, mage.'],
|
||||
paused: false,
|
||||
gameOver: false,
|
||||
victory: false,
|
||||
});
|
||||
},
|
||||
|
||||
resetUI: () => {
|
||||
set({
|
||||
logs: ['✨ The loop begins. You start with Mana Bolt. Gather your strength, mage.'],
|
||||
paused: false,
|
||||
gameOver: false,
|
||||
victory: false,
|
||||
});
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user