Files
Mana-Loop/src/lib/game/stores/gameStore.ts
T
n8n-gitea 098ec86189
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
fix: spire combat 11 high-severity discrepancies (issue #333)
D-01: Implement per-weapon cast progress (weaponCastProgress record)
D-04: Bypass Executioner/Berserker discipline specials for golem attacks
D-09: Fix lightning counter direction (lightning→water, not lightning→earth)
D-10: Add full composite element counters (blackflame/radiantflames ↔ frost/water/light/dark)
D-15: Fix Executioner to check per-enemy HP < 25% instead of floorHP ratio
D-20: Fix dodge formula to match spec (min(0.55, floor × 0.003), starts at 0)
D-22: Fix shield modifier to use flat HP pool instead of percentage barrier
D-23: Wire up applyMageBarrierRecharge in the damage pipeline
D-25: Move guardian regen from per-damage-event to once-per-tick
D-26: Add guardian armor reduction to the guardian defensive pipeline
D-31: Fix armor_corrode to be temporary (restore armor on effect expiry)
D-38: Implement AoE damage distribution across enemies

All 1069 tests pass. No files exceed 400 lines.
2026-06-08 18:25:05 +02:00

385 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}`);
}
}
// 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;
}
// Apply net element regen to pools: produced - drained (component consumption)
for (const [elem, entry] of Object.entries(conversionResult.rates)) {
if (entry.paused || !elements[elem]) continue;
if (!elements[elem].unlocked) elements[elem] = { ...elements[elem], unlocked: true };
const netRate = elementRegen[elem];
if (netRate === 0) continue;
const delta = netRate * HOURS_PER_TICK;
const newCurrent = delta >= 0
? Math.min(elements[elem].max, elements[elem].current + delta)
: Math.max(0, elements[elem].current + delta);
elements[elem] = { ...elements[elem], current: newCurrent };
}
// 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, ctx.prestige.signedPactDetails, day, hour);
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 });
// Guardian regen once per tick (not per-damage-event, spec §5.3, D-25 fix)
combatCbs.applyGuardianRegen();
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, weaponCastProgress: cr.weaponCastProgress, equipmentSpellStates: cr.equipmentSpellStates, golemancy: { ...(writes.combat?.golemancy || ctx.combat.golemancy), activeGolems: cr.activeGolems }, meleeSwordProgress: cr.meleeSwordProgress, currentRoom: cr.currentRoom } as Partial<CombatState>;
}
// 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 };
}