365 lines
17 KiB
TypeScript
365 lines
17 KiB
TypeScript
// Game Store — coordinator, tick pipeline, time/incursion
|
|
import { create } from 'zustand';
|
|
import { persist } from 'zustand/middleware';
|
|
import { HOURS_PER_TICK, MAX_DAY, EQUIPMENT_TYPES } 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 { getElementDistance } from '../utils/element-distance';
|
|
import { computeConversionRates } from '../utils/conversion-rates';
|
|
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 { processEnchantingTicks } from './pipelines/enchanting-tick';
|
|
import { buildGolemCombatPipeline } from './pipelines/golem-combat';
|
|
import { getGuardianForFloor } from '../data/guardian-encounters';
|
|
|
|
import type { TickContext, TickWrites } from './tick-pipeline';
|
|
import type { GameCoordinatorState } from './gameStore.types';
|
|
import type { EnemyState } from '../types';
|
|
import { applyEnemyDefenses as applyEnemyDefensesFromPipeline } from './pipelines/combat-tick';
|
|
|
|
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;
|
|
}
|
|
|
|
// ── Unified Conversion System ─────────────────────────────────────
|
|
const { pactElementMap, grossRegen } = buildConversionParams(ctx.prestige.signedPacts, ctx.attunement.attunements);
|
|
const invokerLevel = ctx.attunement.attunements.invoker?.active ? (ctx.attunement.attunements.invoker.level || 1) : 0;
|
|
const conversionResult = computeConversionRates({
|
|
disciplineEffects, attunements: ctx.attunement.attunements,
|
|
signedPacts: ctx.prestige.signedPacts, pactElementMap, invokerLevel,
|
|
meditationMultiplier, grossRegen, rawGrossRegen: baseRegen,
|
|
});
|
|
|
|
// Apply conversion results: produce element mana from conversions
|
|
let rawMana = ctx.mana.rawMana;
|
|
let elements = { ...ctx.mana.elements };
|
|
|
|
// Log paused conversions
|
|
for (const [elem, entry] of Object.entries(conversionResult.rates)) {
|
|
if (entry.paused && entry.pauseReason) {
|
|
addLog(`⚠️ PAUSED: ${elem} conversion — ${entry.pauseReason}`);
|
|
}
|
|
}
|
|
|
|
// Apply produced element mana (from active conversions)
|
|
for (const [elem, entry] of Object.entries(conversionResult.rates)) {
|
|
if (entry.paused || entry.finalRate <= 0 || !elements[elem]) continue;
|
|
if (!elements[elem].unlocked) elements[elem] = { ...elements[elem], unlocked: true };
|
|
elements[elem] = { ...elements[elem], current: Math.min(elements[elem].max, elements[elem].current + entry.finalRate * HOURS_PER_TICK) };
|
|
}
|
|
// Net raw regen = gross regen - conversion drains - incursion
|
|
const netRawRegen = Math.max(0, baseRegen * (1 - incursionStrength) * meditationMultiplier - conversionResult.totalRawDrain);
|
|
const actualRegen = Math.floor(Math.min(netRawRegen * HOURS_PER_TICK, maxMana - rawMana) * 1000) / 1000;
|
|
rawMana = Math.max(0, Math.min(rawMana + netRawRegen * HOURS_PER_TICK, maxMana));
|
|
let totalManaGathered = ctx.mana.totalManaGathered + Math.max(0, actualRegen);
|
|
|
|
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 dr = useDisciplineStore.getState().processTick({ rawMana, elements });
|
|
rawMana = dr.rawMana; elements = dr.elements;
|
|
if (dr.autoPausedNames.length > 0) addLog('⏸️ Auto-paused (insufficient mana): ' + dr.autoPausedNames.join(', '));
|
|
rawMana = Math.min(rawMana, computeMaxMana({ prestigeUpgrades: ctx.prestige.prestigeUpgrades }, undefined, computeDisciplineEffects()));
|
|
|
|
if (dr.unlockedEffects.length > 0) {
|
|
useCraftingStore.getState().unlockEffects(dr.unlockedEffects);
|
|
for (const effectId of dr.unlockedEffects) {
|
|
addLog('Discipline insight unlocked: ' + effectId);
|
|
}
|
|
}
|
|
if (dr.unlockedRecipes.length > 0) {
|
|
useCraftingStore.getState().unlockRecipes(dr.unlockedRecipes);
|
|
for (const recipeId of dr.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
|
|
if (ctx.combat.currentAction === 'climb') {
|
|
const combatCbs = buildCombatCallbacks({ ctx, effects, maxMana, addLog, useCombatStore, usePrestigeStore });
|
|
const roomEnemies = ctx.combat.currentRoom?.enemies ?? [];
|
|
const primaryEnemy = roomEnemies[0] ?? null;
|
|
const activeEnemy = combatCbs.applyMageBarrierRecharge(primaryEnemy) ?? primaryEnemy;
|
|
const defCtx = { roomType: ctx.combat.currentRoom?.roomType ?? 'combat', enemy: activeEnemy };
|
|
const golemPipeline = buildGolemCombatPipeline(addLog);
|
|
const equippedSwords: Record<string, import('../types').EquipmentInstance> = {};
|
|
for (const [slot, iid] of Object.entries(ctx.crafting.equippedInstances || {})) {
|
|
if (!iid) continue;
|
|
const inst = ctx.crafting.equipmentInstances?.[iid];
|
|
if (inst && EQUIPMENT_TYPES[inst.typeId]?.category === 'sword') equippedSwords[iid] = inst;
|
|
}
|
|
const cr = useCombatStore.getState().processCombatTick(
|
|
rawMana, elements, maxMana, 1,
|
|
combatCbs.onFloorCleared,
|
|
combatCbs.makeOnDamageDealt(() => rawMana, () => elements, defCtx, addLog),
|
|
ctx.prestige.signedPacts,
|
|
{ activeGolems: golemPipeline.activeGolems },
|
|
golemPipeline.golemApplyDamageToRoom,
|
|
(dmg, enemy, roomType, addLogFn, bypassArmor, bypassBarrier) =>
|
|
applyEnemyDefensesFromPipeline(dmg, enemy, roomType, addLogFn, bypassArmor, bypassBarrier),
|
|
equippedSwords,
|
|
);
|
|
rawMana = cr.rawMana; elements = cr.elements;
|
|
totalManaGathered += cr.totalManaGathered || 0;
|
|
if (cr.logMessages) cr.logMessages.forEach(msg => addLog(msg));
|
|
writes.combat = { ...(writes.combat || {}), currentFloor: cr.currentFloor, floorHP: cr.floorHP, floorMaxHP: cr.floorMaxHP, maxFloorReached: cr.maxFloorReached, castProgress: cr.castProgress, equipmentSpellStates: cr.equipmentSpellStates, golemancy: { ...(writes.combat?.golemancy || ctx.combat.golemancy), activeGolems: cr.activeGolems }, meleeSwordProgress: cr.meleeSwordProgress, currentRoom: cr.currentRoom };
|
|
}
|
|
|
|
// Non-combat room tick (library, recovery, treasure, puzzle)
|
|
if (ctx.combat.currentAction === 'climb') {
|
|
const roomType = ctx.combat.currentRoom?.roomType;
|
|
if (roomType === 'library' || roomType === 'recovery' || roomType === 'treasure' || roomType === 'puzzle') {
|
|
if (roomType === 'recovery') {
|
|
const boostedRegen = baseRegen * 10;
|
|
const netBoostedRegen = Math.max(0, boostedRegen * (1 - incursionStrength) * meditationMultiplier - conversionResult.totalRawDrain);
|
|
rawMana = Math.min(rawMana + netBoostedRegen * HOURS_PER_TICK, maxMana);
|
|
for (const [elem, entry] of Object.entries(conversionResult.rates)) {
|
|
if (entry.paused || entry.finalRate <= 0 || !elements[elem]) continue;
|
|
if (!elements[elem].unlocked) elements[elem] = { ...elements[elem], unlocked: true };
|
|
elements[elem] = { ...elements[elem], current: Math.min(elements[elem].max, elements[elem].current + entry.finalRate * 10 * HOURS_PER_TICK) };
|
|
}
|
|
}
|
|
useCombatStore.getState().tickNonCombatRoom(HOURS_PER_TICK);
|
|
const updatedRoom = useCombatStore.getState().currentRoom;
|
|
writes.combat = { ...(writes.combat || {}), currentRoom: updatedRoom };
|
|
}
|
|
}
|
|
|
|
if (ctx.combat.currentAction === 'craft') {
|
|
const craftingResult = useCraftingStore.getState().processEquipmentCraftingTick();
|
|
if (craftingResult.logMessage) {
|
|
addLog(craftingResult.logMessage);
|
|
}
|
|
}
|
|
|
|
// Enchanting: Design / Prepare / Application ticks
|
|
const enchantingResult = processEnchantingTicks({
|
|
ctx, effects, rawMana, addLog,
|
|
});
|
|
rawMana = enchantingResult.rawMana;
|
|
if (enchantingResult.writes) {
|
|
if (enchantingResult.writes.combat) {
|
|
writes.combat = { ...(writes.combat || {}), ...enchantingResult.writes.combat };
|
|
}
|
|
if (enchantingResult.writes.attunement) {
|
|
writes.attunement = { ...(writes.attunement || {}), ...enchantingResult.writes.attunement };
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
}),
|
|
}
|
|
)
|
|
);
|
|
|
|
/** Build pact element map and gross regen for the unified conversion system */
|
|
function buildConversionParams(
|
|
signedPacts: number[],
|
|
attunements: Record<string, { active: boolean; level: number }>,
|
|
): { pactElementMap: Record<number, string>; grossRegen: Record<string, number> } {
|
|
const pactElementMap: Record<number, string> = {};
|
|
for (const floor of signedPacts) {
|
|
const guardian = getGuardianForFloor(floor);
|
|
if (guardian?.element?.length) {
|
|
pactElementMap[floor] = guardian.element[0];
|
|
}
|
|
}
|
|
const grossRegen: Record<string, number> = {};
|
|
for (const [id, state] of Object.entries(attunements)) {
|
|
if (!state.active) continue;
|
|
const def = ATTUNEMENTS_DEF[id];
|
|
if (def?.primaryManaType) {
|
|
grossRegen[def.primaryManaType] = (grossRegen[def.primaryManaType] || 0)
|
|
+ getAttunementConversionRate(id, state.level || 1);
|
|
}
|
|
}
|
|
return { pactElementMap, grossRegen };
|
|
}
|