430 lines
15 KiB
TypeScript
Executable File
430 lines
15 KiB
TypeScript
Executable File
// ─── 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, getStudySpeedMultiplier } from '../constants';
|
|
import { computeEffects, hasSpecial, SPECIAL_EFFECTS } from '../upgrade-effects';
|
|
import {
|
|
computeMaxMana,
|
|
computeRegen,
|
|
getFloorElement,
|
|
getFloorMaxHP,
|
|
getMeditationBonus,
|
|
getIncursionStrength,
|
|
calcInsight,
|
|
calcDamage,
|
|
deductSpellCost,
|
|
canAffordSpellCost,
|
|
} from '../utils';
|
|
import { useUIStore } from './uiStore';
|
|
import { usePrestigeStore } from './prestigeStore';
|
|
import { useManaStore } from './manaStore';
|
|
import { useSkillStore } from './skillStore';
|
|
import { useCombatStore, makeInitialSpells } from './combatStore';
|
|
|
|
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;
|
|
initGame: () => void;
|
|
}
|
|
|
|
const initialState: GameCoordinatorState = {
|
|
day: 1,
|
|
hour: 0,
|
|
incursionStrength: 0,
|
|
containmentWards: 0,
|
|
initialized: 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 - delegate to manaStore
|
|
if (combatState.currentAction === 'convert') {
|
|
const convertResult = useManaStore.getState().processConvertAction(rawMana);
|
|
if (convertResult) {
|
|
rawMana = convertResult.rawMana;
|
|
elements = convertResult.elements;
|
|
}
|
|
}
|
|
|
|
// 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 - delegate to combatStore
|
|
if (combatState.currentAction === 'climb') {
|
|
const combatResult = useCombatStore.getState().processCombatTick(
|
|
skillState.skills,
|
|
rawMana,
|
|
elements,
|
|
maxMana,
|
|
effects.attackSpeedMultiplier,
|
|
(floor, wasGuardian) => {
|
|
if (wasGuardian) {
|
|
addLog(`⚔️ ${GUARDIANS[floor]?.name || 'Guardian'} defeated! Visit the Grimoire to sign a pact.`);
|
|
} else if (floor % 5 === 0) {
|
|
addLog(`🏰 Floor ${floor} cleared!`);
|
|
}
|
|
},
|
|
(damage) => {
|
|
// Apply upgrade damage multipliers and bonuses
|
|
let dmg = damage * effects.baseDamageMultiplier + effects.baseDamageBonus;
|
|
|
|
// Executioner: +100% damage to enemies below 25% HP
|
|
if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && combatState.floorHP / combatState.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!`);
|
|
}
|
|
|
|
return { rawMana, elements, modifiedDamage: dmg };
|
|
}
|
|
);
|
|
|
|
rawMana = combatResult.rawMana;
|
|
elements = combatResult.elements;
|
|
totalManaGathered += combatResult.totalManaGathered || 0;
|
|
|
|
// Log any messages from combat
|
|
if (combatResult.logMessages) {
|
|
combatResult.logMessages.forEach(msg => addLog(msg));
|
|
}
|
|
}
|
|
|
|
// Update all stores with new state
|
|
useManaStore.setState({
|
|
rawMana,
|
|
meditateTicks,
|
|
totalManaGathered,
|
|
elements,
|
|
});
|
|
|
|
set({
|
|
day,
|
|
hour,
|
|
incursionStrength,
|
|
});
|
|
},
|
|
|
|
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;
|
|
|
|
useUIStore.getState().resetUI();
|
|
usePrestigeStore.getState().resetPrestige();
|
|
useManaStore.getState().resetMana({}, {}, {}, {});
|
|
useSkillStore.getState().resetSkills();
|
|
useCombatStore.getState().resetCombat(startFloor);
|
|
|
|
set({
|
|
...initialState,
|
|
initialized: true,
|
|
});
|
|
},
|
|
|
|
togglePause: () => {
|
|
useUIStore.getState().togglePause();
|
|
},
|
|
|
|
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.getState().gatherMana(cm, max);
|
|
},
|
|
|
|
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);
|
|
},
|
|
};
|
|
}
|