377 lines
18 KiB
TypeScript
377 lines
18 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 } 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 };
|
||
}
|