Files
Mana-Loop/src/lib/game/stores/gameStore.ts
T
n8n-gitea a11ea065eb
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m17s
fix: mana conversion attunement rate now uses flat base + linear multiplier per spec
2026-06-07 23:06:03 +02:00

377 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 } 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) };
}
// Compute per-element net regen: produced rate - drain from being used as component
const elementRegen: Record<string, number> = {};
for (const [elem, entry] of Object.entries(conversionResult.rates)) {
const produced = entry.finalRate;
const drained = conversionResult.elementDrain[elem] || 0;
elementRegen[elem] = produced - drained;
}
// 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') {
// Recovery room grants 10× regen/conversion multiplier.
// Normal regen was already applied above, so we apply only
// the delta (9× additional) to avoid double-counting.
const boostedRegen = baseRegen * 10;
const netBoostedRegen = Math.max(0, boostedRegen * (1 - incursionStrength) * meditationMultiplier - conversionResult.totalRawDrain);
const regenDelta = netBoostedRegen - netRawRegen;
rawMana = Math.min(rawMana + regenDelta * 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 };
// Normal conversion already applied above; add only the 9× delta
elements[elem] = { ...elements[elem], current: Math.min(elements[elem].max, elements[elem].current + entry.finalRate * 9 * 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, elementRegen };
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)
+ (def.conversionRate || 0);
}
}
return { pactElementMap, grossRegen };
}