354 lines
15 KiB
TypeScript
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,
|
|
}),
|
|
}
|
|
)
|
|
);
|