3ad919a047
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m17s
DISC-2: Removed old pool-drain model from discipline-slice.ts processTick() - Disciplines no longer drain rawMana or element pools - canProceedDiscipline() no longer checks mana sufficiency - Removed auto-pause on insufficient mana from processTick() - Removed drain display and auto-paused message from DisciplineCard.tsx - Removed auto-paused log from gameStore.ts tick() DISC-4: Audited crafting pipeline — no composite crafting logic remains - craftComposite already removed from manaStore.ts (comment only) - No other composite crafting references found DISC-5: Added collapsible formula reference to Conversion Stats section - Shows unified formula, multipliers, cost formulas, and constraints DISC-6: Added per-element net regen summary line - Shows 'Net Fire Regen: +0.50/hr − 0.15/hr = +0.35/hr' per element DISC-7: Created dedicated 'Conversion Stats' section in Stats tab - Renamed from 'Conversion Breakdown' to dedicated section header DISC-8: Added detailed per-element regen breakdown to ManaDisplay - Each element card now expandable to show produced rate and downstream drains - New ElementRegenBreakdown type and elementRegenBreakdown prop Tests: Updated 4 test files to reflect new no-drain behavior - All 1090 tests pass
263 lines
10 KiB
TypeScript
263 lines
10 KiB
TypeScript
// ─── Discipline Store Slice ────────────────────────────────────────────────────
|
|
import { create } from 'zustand';
|
|
import { persist } from 'zustand/middleware';
|
|
import { createSafeStorage } from '../utils/safe-persist';
|
|
import type { DisciplineState } from '../types/disciplines';
|
|
import type { ElementState } from '../types';
|
|
import {
|
|
calculateStatBonus,
|
|
canProceedDiscipline,
|
|
checkDisciplinePrerequisites,
|
|
getUnlockedPerks
|
|
} from '../utils/discipline-math';
|
|
import { baseDisciplines } from '../data/disciplines/base';
|
|
import { elementalAttunementDisciplines } from '../data/disciplines/elemental';
|
|
import { elementalRegenDisciplines } from '../data/disciplines/elemental-regen';
|
|
import { elementalRegenAdvancedDisciplines } from '../data/disciplines/elemental-regen-advanced';
|
|
import { enchanterDisciplines } from '../data/disciplines/enchanter';
|
|
import { enchanterUtilityDisciplines } from '../data/disciplines/enchanter-utility';
|
|
import { enchanterSpellDisciplines } from '../data/disciplines/enchanter-spells';
|
|
import { enchanterSpecialDisciplines } from '../data/disciplines/enchanter-special';
|
|
import { fabricatorDisciplines } from '../data/disciplines/fabricator';
|
|
import { invokerDisciplines } from '../data/disciplines/invoker';
|
|
import { MAX_CONCURRENT_DISCIPLINES } from '../types/disciplines';
|
|
import { useManaStore } from './manaStore';
|
|
import { usePrestigeStore } from './prestigeStore';
|
|
|
|
|
|
const ALL_DISCIPLINES = [
|
|
...baseDisciplines,
|
|
...elementalAttunementDisciplines,
|
|
...elementalRegenDisciplines,
|
|
...elementalRegenAdvancedDisciplines,
|
|
...enchanterDisciplines,
|
|
...enchanterUtilityDisciplines,
|
|
...enchanterSpellDisciplines,
|
|
...enchanterSpecialDisciplines,
|
|
...fabricatorDisciplines,
|
|
...invokerDisciplines,
|
|
];
|
|
const DISCIPLINE_MAP = Object.fromEntries(ALL_DISCIPLINES.map((d) => [d.id, d]));
|
|
|
|
export interface DisciplineStoreState {
|
|
disciplines: Record<string, DisciplineState>;
|
|
activeIds: string[];
|
|
concurrentLimit: number;
|
|
totalXP: number;
|
|
processedPerks: string[];
|
|
practicingCallbacks: { onStartPracticing: () => void; onStopPracticing: () => void } | null;
|
|
}
|
|
|
|
export interface DisciplineStoreActions {
|
|
activate: (id: string, gameStateOverrides?: { elements?: Record<string, ElementState>; rawMana?: number; signedPacts?: number[] }) => void;
|
|
deactivate: (id: string) => void;
|
|
processTick: (mana: { rawMana: number; elements: Record<string, ElementState> }) => {
|
|
rawMana: number;
|
|
elements: Record<string, ElementState>;
|
|
unlockedEffects: string[];
|
|
unlockedRecipes: string[];
|
|
autoPausedNames: string[];
|
|
};
|
|
setPracticingCallbacks(callbacks: { onStartPracticing: () => void; onStopPracticing: () => void }): void;
|
|
resetDisciplines: () => void;
|
|
}
|
|
|
|
export type DisciplineStore = DisciplineStoreState & DisciplineStoreActions;
|
|
|
|
export const useDisciplineStore = create<DisciplineStore>()(
|
|
persist(
|
|
(set, get) => ({
|
|
disciplines: {},
|
|
activeIds: [],
|
|
concurrentLimit: MAX_CONCURRENT_DISCIPLINES,
|
|
totalXP: 0,
|
|
processedPerks: [],
|
|
practicingCallbacks: null,
|
|
|
|
activate(id, gameStateOverrides) {
|
|
set((s) => {
|
|
const def = DISCIPLINE_MAP[id];
|
|
if (!def) return s;
|
|
|
|
// Guard against corrupted persisted state
|
|
const activeIds = s.activeIds ?? [];
|
|
|
|
// Allow re-activation if discipline exists but is paused
|
|
const existing = s.disciplines[id];
|
|
if (activeIds.includes(id)) {
|
|
// If already active and paused (manually or auto), un-pause it
|
|
if (existing?.paused) {
|
|
return {
|
|
disciplines: { ...s.disciplines, [id]: { ...existing, paused: false, autoPaused: false } },
|
|
};
|
|
}
|
|
return s;
|
|
}
|
|
|
|
const nonPaused = activeIds.filter((aid) => {
|
|
const d = s.disciplines[aid];
|
|
return d && !d.paused;
|
|
}).length;
|
|
if (nonPaused >= s.concurrentLimit) return s;
|
|
|
|
// Resolve game state: use overrides if provided (for tests), otherwise read
|
|
// directly from the mana and prestige stores. This prevents the UI from needing
|
|
// to manually construct and pass a gameState bag — eliminating the class of bug
|
|
// where a missing field (e.g. rawMana) silently prevents reactivation.
|
|
const resolvedState = gameStateOverrides ?? {
|
|
rawMana: useManaStore.getState().rawMana,
|
|
elements: useManaStore.getState().elements,
|
|
signedPacts: usePrestigeStore.getState().signedPacts,
|
|
};
|
|
|
|
if (!canProceedDiscipline(def, existing, resolvedState)) return s;
|
|
|
|
// Check discipline prerequisites (requires field → discipline XP or mana type unlock)
|
|
const prereqCheck = checkDisciplinePrerequisites(def, s.disciplines, ALL_DISCIPLINES, resolvedState.elements, resolvedState.signedPacts);
|
|
if (!prereqCheck.canProceed) return s;
|
|
|
|
// For conversion disciplines: gate on having all source mana types unlocked
|
|
if (def.sourceManaTypes && def.sourceManaTypes.length > 0) {
|
|
const elements = resolvedState.elements;
|
|
if (elements) {
|
|
for (const srcType of def.sourceManaTypes) {
|
|
if (srcType === 'raw') continue; // raw is always available
|
|
const srcElem = elements[srcType];
|
|
if (!srcElem || !srcElem.unlocked) return s;
|
|
}
|
|
}
|
|
}
|
|
|
|
const discState = existing || { id, xp: 0, paused: false };
|
|
// Set currentAction to 'practicing' (only overrides 'meditate')
|
|
get().practicingCallbacks?.onStartPracticing?.();
|
|
return {
|
|
disciplines: { ...s.disciplines, [id]: { ...discState, paused: false } },
|
|
activeIds: [...activeIds, id],
|
|
};
|
|
});
|
|
},
|
|
|
|
deactivate(id) {
|
|
set((s) => {
|
|
const newActiveIds = s.activeIds.filter((aid) => aid !== id);
|
|
// If no more active disciplines, restore currentAction to 'meditate'
|
|
if (newActiveIds.length === 0) {
|
|
get().practicingCallbacks?.onStopPracticing?.();
|
|
}
|
|
return {
|
|
activeIds: newActiveIds,
|
|
disciplines: s.disciplines[id]
|
|
? { ...s.disciplines, [id]: { ...s.disciplines[id], paused: true } }
|
|
: s.disciplines,
|
|
};
|
|
});
|
|
},
|
|
|
|
setPracticingCallbacks(callbacks) {
|
|
set({ practicingCallbacks: callbacks });
|
|
},
|
|
|
|
resetDisciplines() {
|
|
set({
|
|
disciplines: {},
|
|
activeIds: [],
|
|
concurrentLimit: MAX_CONCURRENT_DISCIPLINES,
|
|
totalXP: 0,
|
|
processedPerks: [],
|
|
});
|
|
},
|
|
|
|
processTick(mana) {
|
|
const s = get();
|
|
const rawMana = mana.rawMana;
|
|
const elements = { ...mana.elements };
|
|
let newXP = s.totalXP;
|
|
const newDisciplines = { ...s.disciplines };
|
|
const newUnlockedEffects: string[] = [];
|
|
const newUnlockedRecipes: string[] = [];
|
|
const newProcessedPerks = [...(s.processedPerks ?? [])];
|
|
|
|
for (const id of s.activeIds ?? []) {
|
|
const disc = newDisciplines[id];
|
|
if (!disc) continue;
|
|
if (disc.paused) continue;
|
|
if (disc.autoPaused) continue; // already auto-paused, don't re-process
|
|
|
|
const def = DISCIPLINE_MAP[id];
|
|
if (!def) continue;
|
|
|
|
const oldXP = disc.xp;
|
|
// Compute discipline XP bonus directly to avoid circular import
|
|
let xpBonus = 0;
|
|
for (const [did, dState] of Object.entries(newDisciplines)) {
|
|
if (!dState || dState.xp <= 0) continue;
|
|
const dDef = DISCIPLINE_MAP[did];
|
|
if (!dDef) continue;
|
|
// Only disciplines with disciplineXpBonus stat contribute
|
|
if (dDef.statBonus.stat === 'disciplineXpBonus') {
|
|
xpBonus += calculateStatBonus(
|
|
dDef.statBonus.baseValue,
|
|
dState.xp,
|
|
dDef.scalingFactor
|
|
);
|
|
}
|
|
// Perk bonuses for disciplineXpBonus
|
|
const perks = getUnlockedPerks(dDef, dState.xp);
|
|
for (const perk of perks) {
|
|
if (perk.bonus && perk.bonus.stat === 'disciplineXpBonus') {
|
|
xpBonus += perk.bonus.amount;
|
|
}
|
|
}
|
|
}
|
|
const xpGain = 1 + xpBonus;
|
|
newDisciplines[id] = { ...disc, xp: disc.xp + xpGain };
|
|
newXP += xpGain;
|
|
|
|
// Check for newly unlocked perks that unlock effects
|
|
if (def.perks.length > 0) {
|
|
const oldPerks = getUnlockedPerks(def, oldXP);
|
|
const newPerks = getUnlockedPerks(def, disc.xp + 1);
|
|
const oldPerkIds = new Set(oldPerks.map(p => p.id));
|
|
for (const perk of newPerks) {
|
|
if (!oldPerkIds.has(perk.id)) {
|
|
const perkKey = `${id}:${perk.id}`;
|
|
if (!newProcessedPerks.includes(perkKey)) {
|
|
if (perk.unlocksEffects) {
|
|
newUnlockedEffects.push(...perk.unlocksEffects);
|
|
}
|
|
if (perk.unlocksRecipes) {
|
|
newUnlockedRecipes.push(...perk.unlocksRecipes);
|
|
}
|
|
newProcessedPerks.push(perkKey);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const newLimit = Math.min(
|
|
MAX_CONCURRENT_DISCIPLINES + Math.floor(newXP / 500),
|
|
MAX_CONCURRENT_DISCIPLINES + 3
|
|
);
|
|
|
|
// Check if no active disciplines remain to fire onStopPracticing
|
|
const keepActiveIds = s.activeIds ?? [];
|
|
if (keepActiveIds.length === 0 && s.activeIds.length > 0) {
|
|
get().practicingCallbacks?.onStopPracticing?.();
|
|
}
|
|
|
|
set({
|
|
disciplines: newDisciplines,
|
|
activeIds: keepActiveIds,
|
|
totalXP: newXP,
|
|
concurrentLimit: Math.max(s.concurrentLimit, newLimit),
|
|
processedPerks: newProcessedPerks,
|
|
});
|
|
|
|
return { rawMana, elements, unlockedEffects: newUnlockedEffects, unlockedRecipes: newUnlockedRecipes, autoPausedNames: [] };
|
|
},
|
|
}),
|
|
{ storage: createSafeStorage(), name: 'mana-loop-discipline-store', version: 1, partialize: (state) => ({ disciplines: state.disciplines, activeIds: state.activeIds, concurrentLimit: state.concurrentLimit, totalXP: state.totalXP, processedPerks: state.processedPerks }) }
|
|
)
|
|
); |