Files
Mana-Loop/src/lib/game/stores/gameStore.ts
T
2026-05-31 01:18:01 +02:00

354 lines
15 KiB
TypeScript

// Game Store — coordinator, tick pipeline, time/incursion
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { HOURS_PER_TICK, MAX_DAY } from '../constants';
import { computeEquipmentEffects } from '../effects';
import type { ComputedEffects } from '../effects/upgrade-effects.types';
import { computeDisciplineEffects } from '../effects/discipline-effects';
import { computeMaxMana, computeRegen, getMeditationBonus, getIncursionStrength, calcInsight } from '../utils';
import { mergePerElementCapBonuses } from '../utils/element-cap-bonus';
import { processPactRitual } from './pipelines/pact-ritual';
import { buildCombatCallbacks } from './pipelines/combat-tick';
import { useUIStore } from './uiStore';
import { usePrestigeStore } from './prestigeStore';
import { useManaStore } from './manaStore';
import { useCombatStore } from './combatStore';
import { useAttunementStore } from './attunementStore';
import { useCraftingStore } from './craftingStore';
import { useDisciplineStore } from './discipline-slice';
import { ATTUNEMENTS_DEF, getAttunementConversionRate } from '../data/attunements';
import { createResetGame, createGatherMana } from './gameActions';
import { createSafeStorage } from '../utils/safe-persist';
import { createStartNewLoop } from './gameLoopActions';
import { buildTickContext, applyTickWrites } from './tick-pipeline';
import type { TickContext, TickWrites } from './tick-pipeline';
import type { GameCoordinatorState } from './gameStore.types';
export interface GameCoordinatorStore extends GameCoordinatorState {
tick: () => void;
resetGame: () => void;
togglePause: () => void;
startNewLoop: () => void;
initGame: () => void;
gatherMana: () => void;
}
const initialState: GameCoordinatorState = {
day: 1,
hour: 0,
incursionStrength: 0,
containmentWards: 0,
initialized: false,
};
export const useGameStore = create<GameCoordinatorStore>()(
persist(
(set, get) => ({
...initialState,
initGame: () => {
useDisciplineStore.getState().setPracticingCallbacks({
onStartPracticing: () => useCombatStore.getState().startPracticing(),
onStopPracticing: () => useCombatStore.getState().stopPracticing(),
});
set({ initialized: true });
},
tick: () => {
try {
const ctx = buildTickContext({
game: get(),
ui: useUIStore.getState(),
prestige: usePrestigeStore.getState(),
mana: useManaStore.getState(),
combat: useCombatStore.getState(),
crafting: useCraftingStore.getState(),
attunement: useAttunementStore.getState(),
discipline: useDisciplineStore.getState(),
});
if (ctx.ui.gameOver || ctx.ui.paused) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const storeSetters = {
setGame: set,
setUI: (w: any) => useUIStore.setState(w),
setPrestige: (w: any) => usePrestigeStore.setState(w),
setMana: (w: any) => useManaStore.setState(w),
setCombat: (w: any) => useCombatStore.setState(w),
setCrafting: (w: any) => useCraftingStore.setState(w),
setAttunement: (w: any) => useAttunementStore.setState(w),
setDiscipline: (w: any) => useDisciplineStore.setState(w),
addLogs: (msgs: string[]) => msgs.forEach((m) => useUIStore.getState().addLog(m)),
};
const writes: TickWrites = { logs: [] };
const addLog = (msg: string) => writes.logs.push(msg);
const steadyHandLevel = ctx.prestige.prestigeUpgrades.steadyHand || 0;
const enchantmentPowerMultiplier = 1 + steadyHandLevel * 0.15;
const equipmentEffects = computeEquipmentEffects(
ctx.crafting.equipmentInstances || {},
ctx.crafting.equippedInstances || {},
enchantmentPowerMultiplier,
);
const disciplineEffects = computeDisciplineEffects();
const allSpecials = new Set<string>([
...equipmentEffects.specials,
...disciplineEffects.specials,
]);
const effects = { specials: allSpecials } as ComputedEffects;
const maxMana = computeMaxMana(
{ prestigeUpgrades: ctx.prestige.prestigeUpgrades },
undefined,
disciplineEffects,
);
const baseRegen = computeRegen(
{ prestigeUpgrades: ctx.prestige.prestigeUpgrades, attunements: {} },
undefined,
disciplineEffects,
) * (1 + (disciplineEffects.multipliers.regenMultiplier || 0));
let hour = ctx.game.hour + HOURS_PER_TICK;
let day = ctx.game.day;
if (hour >= 24) {
hour -= 24;
day += 1;
}
const insightParams = {
maxFloorReached: ctx.combat.maxFloorReached,
totalManaGathered: ctx.mana.totalManaGathered,
signedPacts: ctx.prestige.signedPacts,
prestigeUpgrades: ctx.prestige.prestigeUpgrades,
};
if (day > MAX_DAY) {
const insightGained = calcInsight(insightParams, disciplineEffects);
addLog('The loop ends. Gained ' + insightGained + ' Insight.');
writes.ui = { ...(writes.ui || {}), gameOver: true, victory: false };
writes.prestige = { ...(writes.prestige || {}), loopInsight: insightGained };
writes.game = { day, hour };
applyTickWrites(writes, storeSetters);
return;
}
if (ctx.combat.maxFloorReached >= 100 && ctx.prestige.signedPacts.includes(100)) {
const insightGained = calcInsight(insightParams, disciplineEffects) * 3;
addLog('VICTORY! The Awakened One falls! Gained ' + insightGained + ' Insight!');
writes.ui = { ...(writes.ui || {}), gameOver: true, victory: true };
writes.prestige = { ...(writes.prestige || {}), loopInsight: insightGained };
applyTickWrites(writes, storeSetters);
return;
}
const incursionStrength = getIncursionStrength(day, hour);
let meditateTicks = ctx.mana.meditateTicks;
let meditationMultiplier = 1;
if (ctx.combat.currentAction === 'meditate') {
meditateTicks++;
meditationMultiplier = getMeditationBonus(meditateTicks, 1, disciplineEffects.meditationCapBonus);
} else {
meditateTicks = 0;
}
let totalConversionPerTick = 0;
let rawManaDelta = 0;
let elements = { ...ctx.mana.elements };
Object.entries(ctx.attunement.attunements).forEach(([id, state]) => {
if (!state.active) return;
const def = ATTUNEMENTS_DEF[id];
if (!def || def.conversionRate <= 0 || !def.primaryManaType) return;
const scaledRate = getAttunementConversionRate(id, state.level || 1);
const conversionThisTick = scaledRate * HOURS_PER_TICK;
totalConversionPerTick += conversionThisTick;
// Deduct raw mana to pay for the conversion — without this, attunements produce free element mana
rawManaDelta -= conversionThisTick;
if (elements[def.primaryManaType]) {
if (!elements[def.primaryManaType].unlocked) {
elements[def.primaryManaType] = { ...elements[def.primaryManaType], unlocked: true };
}
elements[def.primaryManaType].current = Math.min(
elements[def.primaryManaType].max,
elements[def.primaryManaType].current + conversionThisTick,
);
}
});
const effectiveRegen = Math.max(0, baseRegen * (1 - incursionStrength) * meditationMultiplier - totalConversionPerTick);
const rawAfterConversion = ctx.mana.rawMana + rawManaDelta;
const regenFromMeditation = Math.max(0, effectiveRegen * HOURS_PER_TICK);
const roomLeft = Math.max(0, maxMana - Math.max(0, rawAfterConversion));
// Only count regen that actually fits below the cap (fix #224)
const actualRegenAdded = Math.floor(Math.min(regenFromMeditation, roomLeft) * 1000) / 1000;
let rawMana = Math.max(0, Math.min(rawAfterConversion + effectiveRegen * HOURS_PER_TICK, maxMana));
let totalManaGathered = ctx.mana.totalManaGathered + Math.max(0, actualRegenAdded);
if (ctx.combat.currentAction === 'convert') {
const convertResult = useManaStore.getState().processConvertAction(rawMana);
if (convertResult) {
rawMana = convertResult.rawMana;
elements = convertResult.elements;
}
}
const pactResult = processPactRitual(
ctx.prestige.pactRitualFloor,
ctx.prestige.pactRitualProgress,
ctx.prestige.signedPacts,
ctx.prestige.defeatedGuardians,
ctx.prestige.prestigeUpgrades.pactAffinity || 0,
disciplineEffects.bonuses.pactAffinityBonus || 0,
);
if (pactResult.writes) {
writes.prestige = { ...(writes.prestige || {}), ...pactResult.writes };
}
pactResult.logs.forEach(l => addLog(l));
const disciplineResult = useDisciplineStore.getState().processTick({
rawMana,
elements,
});
rawMana = disciplineResult.rawMana;
elements = disciplineResult.elements;
for (const [targetElem, conv] of Object.entries(disciplineEffects.conversions)) {
const conversionAmount = conv.rate * HOURS_PER_TICK;
let canConvert = true;
for (const srcType of conv.sourceManaTypes) {
if (srcType === 'raw') {
if (rawMana < conversionAmount) { canConvert = false; break; }
} else if (!elements[srcType] || !elements[srcType].unlocked || elements[srcType].current < conversionAmount) {
canConvert = false; break;
}
}
if (!canConvert) continue;
// Re-check against actual remaining mana to prevent negative values
// when multiple disciplines share the same source
for (const srcType of conv.sourceManaTypes) {
if (srcType === 'raw' && rawMana < conversionAmount) { canConvert = false; break; }
if (srcType !== 'raw' && elements[srcType] && elements[srcType].current < conversionAmount) { canConvert = false; break; }
}
if (!canConvert) continue;
for (const srcType of conv.sourceManaTypes) {
if (srcType === 'raw') {
rawMana -= conversionAmount;
} else if (elements[srcType]) {
elements[srcType] = { ...elements[srcType], current: Math.max(0, elements[srcType].current - conversionAmount) };
}
}
if (elements[targetElem]) {
elements[targetElem] = {
...elements[targetElem],
current: Math.min(elements[targetElem].max, elements[targetElem].current + conversionAmount),
};
}
}
if (disciplineResult.unlockedEffects.length > 0) {
useCraftingStore.getState().unlockEffects(disciplineResult.unlockedEffects);
for (const effectId of disciplineResult.unlockedEffects) {
addLog('Discipline insight unlocked: ' + effectId);
}
}
if (disciplineResult.unlockedRecipes.length > 0) {
useCraftingStore.getState().unlockRecipes(disciplineResult.unlockedRecipes);
for (const recipeId of disciplineResult.unlockedRecipes) {
addLog('Fabricator recipe unlocked: ' + recipeId);
}
}
const perElementCapBonuses = mergePerElementCapBonuses(
disciplineEffects.bonuses,
equipmentEffects.bonuses,
);
useManaStore.getState().computeElementMaxWithBonuses(perElementCapBonuses);
const manaStateAfter = useManaStore.getState();
for (const [ek, es] of Object.entries(manaStateAfter.elements)) {
if (elements[ek]) {
elements[ek] = { ...elements[ek], max: es.max, baseMax: es.baseMax };
}
}
// Combat — delegate to combatStore
if (ctx.combat.currentAction === 'climb') {
const combatCbs = buildCombatCallbacks({
ctx, effects, maxMana, addLog, useCombatStore, usePrestigeStore,
});
const combatResult = useCombatStore.getState().processCombatTick(
rawMana, elements, maxMana, 1,
combatCbs.onFloorCleared,
combatCbs.makeOnDamageDealt(() => rawMana, () => elements),
ctx.prestige.signedPacts,
);
rawMana = combatResult.rawMana;
elements = combatResult.elements;
totalManaGathered += combatResult.totalManaGathered || 0;
if (combatResult.logMessages) {
combatResult.logMessages.forEach(msg => addLog(msg));
}
writes.combat = {
...(writes.combat || {}),
currentFloor: combatResult.currentFloor,
floorHP: combatResult.floorHP,
floorMaxHP: combatResult.floorMaxHP,
maxFloorReached: combatResult.maxFloorReached,
castProgress: combatResult.castProgress,
equipmentSpellStates: combatResult.equipmentSpellStates,
};
}
if (ctx.combat.currentAction === 'craft') {
const craftingResult = useCraftingStore.getState().processEquipmentCraftingTick();
if (craftingResult.logMessage) {
addLog(craftingResult.logMessage);
}
}
// Phase 3: Write
writes.game = { day, hour, incursionStrength };
writes.mana = {
rawMana,
meditateTicks,
totalManaGathered,
elements,
};
applyTickWrites(writes, storeSetters);
} catch (error: unknown) {
try {
const msg = error instanceof Error ? error.message : String(error);
useUIStore.getState().addLog('Tick error: ' + msg);
} catch {
console.error('Tick error:', error);
}
}
},
resetGame: createResetGame(set, initialState),
togglePause: () => {
useUIStore.getState().togglePause();
},
startNewLoop: createStartNewLoop(set),
gatherMana: createGatherMana(),
}),
{
storage: createSafeStorage(),
name: 'mana-loop-game-storage',
version: 1,
partialize: (state) => ({
day: state.day,
hour: state.hour,
incursionStrength: state.incursionStrength,
containmentWards: state.containmentWards,
}),
}
)
);