Files
Mana-Loop/src/lib/game/stores/gameStore.ts
T
n8n-gitea ee24227d62
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m4s
feat: implement non-combat room gameplay (Library, Recovery, Treasure, Puzzle)
2026-06-04 19:28:25 +02:00

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 };
}