06c3fe4380
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m25s
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)
398 lines
16 KiB
TypeScript
398 lines
16 KiB
TypeScript
// ─── 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,
|
||
}),
|
||
}
|
||
)
|
||
);
|