Files
Mana-Loop/src/lib/game/stores/gameStore.ts
T
n8n-gitea 06c3fe4380
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m25s
fix: resolve TS compilation errors and all 7 circular dependencies
TypeScript fixes:
- gameStore.ts: replace result.ok with result.success (Result<void> uses success not ok)
- gameStore.ts: fix undefined newProgress variable → ctx.prestige.pactRitualProgress + HOURS_PER_TICK
- prestigeStore.ts: replace result.ok with result.success

Circular dependency fixes:
- Extract GameCoordinatorState to stores/gameStore.types.ts to break gameStore↔tick-pipeline/gameActions/gameLoopActions cycle
- Remove getDodgeChance re-export from floor-utils.ts to break floor-utils↔room-utils↔enemy-utils cycle
- Replace direct combatStore import in discipline-slice.ts with callback pattern to break discipline-slice↔combatStore↔combat-actions↔discipline-effects cycle

Verification: tsc --noEmit clean, madge --circular clean (0 circular deps)
2026-05-26 21:55:55 +02:00

398 lines
16 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) ─────────────────────────────────────────────────
// Manages: day, hour, incursionStrength, containmentWards
// Orchestrates tick across all stores via a read → compute → write pipeline.
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { HOURS_PER_TICK, MAX_DAY } from '../constants';
import { getGuardianForFloor } from '../data/guardian-encounters';
import { hasSpecial, SPECIAL_EFFECTS } from '../effects/special-effects';
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 { 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: () => {
// Wire discipline store ↔ combat store callbacks (breaks circular dependency)
useDisciplineStore.getState().setPracticingCallbacks({
onStartPracticing: () => useCombatStore.getState().startPracticing(),
onStopPracticing: () => useCombatStore.getState().stopPracticing(),
});
set({ initialized: true });
},
tick: () => {
try {
// ── Phase 1: Read — snapshot all store states once ──────────────────
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;
// Shared setters object — used by every applyTickWrites call below
// 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)),
};
// ── Phase 2: Compute — derive all updates ───────────────────────────
const writes: TickWrites = { logs: [] };
const addLog = (msg: string) => writes.logs.push(msg);
// Compute equipment and discipline effects
const equipmentEffects = computeEquipmentEffects(
ctx.crafting.equipmentInstances || {},
ctx.crafting.equippedInstances || {}
);
const disciplineEffects = computeDisciplineEffects();
const allSpecials = new Set<string>([
...equipmentEffects.specials,
...disciplineEffects.specials,
]);
const effects = { specials: allSpecials } as ComputedEffects;
const maxMana = computeMaxMana(
{ skills: {}, prestigeUpgrades: ctx.prestige.prestigeUpgrades, skillUpgrades: {}, skillTiers: {} },
undefined,
disciplineEffects,
);
const baseRegen = computeRegen(
{ skills: {}, prestigeUpgrades: ctx.prestige.prestigeUpgrades, skillUpgrades: {}, skillTiers: {}, attunements: {} },
undefined,
disciplineEffects,
) * (1 + (disciplineEffects.multipliers.regenMultiplier || 0));
// Time progression
let hour = ctx.game.hour + HOURS_PER_TICK;
let day = ctx.game.day;
if (hour >= 24) {
hour -= 24;
day += 1;
}
// Shared insight params — reused for both loop-end and victory
const insightParams = {
maxFloorReached: ctx.combat.maxFloorReached,
totalManaGathered: ctx.mana.totalManaGathered,
signedPacts: ctx.prestige.signedPacts,
prestigeUpgrades: ctx.prestige.prestigeUpgrades,
skills: {} as Record<string, number>,
};
// Check for loop end
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;
}
// Check for victory (3× insight multiplier)
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;
}
// Incursion
const incursionStrength = getIncursionStrength(day, hour);
// Meditation bonus tracking
let meditateTicks = ctx.mana.meditateTicks;
let meditationMultiplier = 1;
if (ctx.combat.currentAction === 'meditate') {
meditateTicks++;
meditationMultiplier = getMeditationBonus(meditateTicks, {}, 1, disciplineEffects.meditationCapBonus);
} else {
meditateTicks = 0;
}
// Calculate total attunement conversion per tick
let totalConversionPerTick = 0;
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);
totalConversionPerTick += scaledRate * HOURS_PER_TICK;
});
// Calculate effective regen
const effectiveRegen = Math.max(0, baseRegen * (1 - incursionStrength) * meditationMultiplier - totalConversionPerTick);
// Mana regeneration
let rawMana = Math.min(ctx.mana.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana);
let elements = { ...ctx.mana.elements };
// Apply attunement conversion
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;
if (elements[def.primaryManaType]) {
elements[def.primaryManaType].current = Math.min(
elements[def.primaryManaType].max,
elements[def.primaryManaType].current + conversionThisTick
);
}
});
let totalManaGathered = ctx.mana.totalManaGathered;
// Convert action — delegate to manaStore
if (ctx.combat.currentAction === 'convert') {
const convertResult = useManaStore.getState().processConvertAction(rawMana);
if (convertResult) {
rawMana = convertResult.rawMana;
elements = convertResult.elements;
}
}
// Pact ritual progress
if (ctx.prestige.pactRitualFloor !== null) {
const guardian = getGuardianForFloor(ctx.prestige.pactRitualFloor);
if (guardian) {
const pactAffinity = Math.min(0.9,
(ctx.prestige.prestigeUpgrades.pactAffinity || 0) * 0.1 + (disciplineEffects.bonuses.pactAffinityBonus || 0));
const requiredTime = guardian.pactTime * (1 - pactAffinity);
if (ctx.prestige.pactRitualProgress + HOURS_PER_TICK >= requiredTime) {
addLog(`📜 Pact signed with ${guardian.name}! You have gained their boons.`);
// Unlock mana types granted by this guardian
const manaStore = useManaStore.getState();
for (const manaType of guardian.unlocksMana || []) {
const result = manaStore.unlockElement(manaType, 0);
if (result.success) {
addLog(`${manaType.charAt(0).toUpperCase() + manaType.slice(1)} mana unlocked!`);
}
}
writes.prestige = {
...(writes.prestige || {}),
signedPacts: [...ctx.prestige.signedPacts, ctx.prestige.pactRitualFloor],
defeatedGuardians: ctx.prestige.defeatedGuardians.filter(f => f !== ctx.prestige.pactRitualFloor),
pactRitualFloor: null,
pactRitualProgress: 0,
};
} else {
writes.prestige = {
...(writes.prestige || {}),
pactRitualProgress: ctx.prestige.pactRitualProgress + HOURS_PER_TICK,
};
}
}
}
// Discipline tick — process active disciplines (XP accrual + mana drain)
const disciplineResult = useDisciplineStore.getState().processTick({
rawMana,
elements,
});
rawMana = disciplineResult.rawMana;
elements = disciplineResult.elements;
// Apply discipline conversions: drain source mana, add to target element
for (const [targetElem, conv] of Object.entries(disciplineEffects.conversions)) {
const conversionAmount = conv.rate * HOURS_PER_TICK;
// Check that all source mana types are available (unlocked and have enough)
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;
// Drain source mana types
for (const srcType of conv.sourceManaTypes) {
if (srcType === 'raw') {
rawMana -= conversionAmount;
} else if (elements[srcType]) {
elements[srcType] = {
...elements[srcType],
current: elements[srcType].current - conversionAmount,
};
}
}
// Add to target element
if (elements[targetElem]) {
elements[targetElem] = {
...elements[targetElem],
current: Math.min(
elements[targetElem].max,
elements[targetElem].current + conversionAmount,
),
};
}
}
// Unlock enchantment effects from newly unlocked discipline perks
if (disciplineResult.unlockedEffects.length > 0) {
useCraftingStore.getState().unlockEffects(disciplineResult.unlockedEffects);
for (const effectId of disciplineResult.unlockedEffects) {
addLog(`✨ Discipline insight unlocked: ${effectId}`);
}
}
// Combat — delegate to combatStore
if (ctx.combat.currentAction === 'climb') {
const combatResult = useCombatStore.getState().processCombatTick(
rawMana,
elements,
maxMana,
1,
(floor, wasGuardian) => {
if (wasGuardian) {
const defeatedGuardian = getGuardianForFloor(floor);
addLog(`\u2694\ufe0f ${defeatedGuardian?.name || 'Guardian'} defeated! Visit the Grimoire to sign a pact.`);
} else if (floor % 5 === 0) {
addLog(`🏰 Floor ${floor} cleared!`);
}
},
(damage) => {
let dmg = damage;
if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && ctx.combat.floorHP / ctx.combat.floorMaxHP < 0.25) {
dmg *= 2;
}
if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) {
dmg *= 1.5;
}
return { rawMana, elements, modifiedDamage: dmg };
},
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,
};
}
// ── Phase 3: Write — batch all state updates ─────────────────────────
writes.game = { day, hour, incursionStrength };
writes.mana = {
rawMana,
meditateTicks,
totalManaGathered,
elements,
};
applyTickWrites(writes, storeSetters);
} catch (error: unknown) {
// Log error to UI store if available, otherwise console error
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',
partialize: (state) => ({
day: state.day,
hour: state.hour,
incursionStrength: state.incursionStrength,
containmentWards: state.containmentWards,
}),
}
)
);