refactor: cleanup codebase — remove hydration guards, extract constants, fix bugs
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m20s

This commit is contained in:
2026-05-26 11:20:36 +02:00
parent 5c64bb00fa
commit b402b8f56e
23 changed files with 579 additions and 979 deletions
+241 -260
View File
@@ -82,277 +82,258 @@ export const useGameStore = create<GameCoordinatorStore>()(
if (ctx.ui.gameOver || ctx.ui.paused) return;
// ── 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,
);
// Time progression
let hour = ctx.game.hour + HOURS_PER_TICK;
let day = ctx.game.day;
if (hour >= 24) {
hour -= 24;
day += 1;
}
// Check for loop end
if (day > MAX_DAY) {
const insightGained = calcInsight({
maxFloorReached: ctx.combat.maxFloorReached,
totalManaGathered: ctx.mana.totalManaGathered,
signedPacts: ctx.prestige.signedPacts,
prestigeUpgrades: ctx.prestige.prestigeUpgrades,
skills: {},
}, 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, {
// Shared setters object — used by every applyTickWrites call below
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const storeSetters = {
setGame: set,
setUI: (w) => useUIStore.setState(w),
setPrestige: (w) => usePrestigeStore.setState(w),
setMana: (w) => useManaStore.setState(w),
setCombat: (w) => useCombatStore.setState(w),
setCrafting: (w) => useCraftingStore.setState(w),
setAttunement: (w) => useAttunementStore.setState(w),
setDiscipline: (w) => useDisciplineStore.setState(w),
addLogs: (msgs) => msgs.forEach((m) => useUIStore.getState().addLog(m)),
});
return;
}
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)),
};
// Check for victory
if (ctx.combat.maxFloorReached >= 100 && ctx.prestige.signedPacts.includes(100)) {
const insightGained = calcInsight({
maxFloorReached: ctx.combat.maxFloorReached,
totalManaGathered: ctx.mana.totalManaGathered,
signedPacts: ctx.prestige.signedPacts,
prestigeUpgrades: ctx.prestige.prestigeUpgrades,
skills: {},
}, disciplineEffects) * 3;
// ── Phase 2: Compute — derive all updates ───────────────────────────
const writes: TickWrites = { logs: [] };
const addLog = (msg: string) => writes.logs.push(msg);
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, {
setGame: set,
setUI: (w) => useUIStore.setState(w),
setPrestige: (w) => usePrestigeStore.setState(w),
setMana: (w) => useManaStore.setState(w),
setCombat: (w) => useCombatStore.setState(w),
setCrafting: (w) => useCraftingStore.setState(w),
setAttunement: (w) => useAttunementStore.setState(w),
setDiscipline: (w) => useDisciplineStore.setState(w),
addLogs: (msgs) => msgs.forEach((m) => useUIStore.getState().addLog(m)),
});
return;
}
// 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;
// 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);
} 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 pactAffinityBonus = 1 - (ctx.prestige.prestigeUpgrades.pactAffinity || 0) * 0.1;
const requiredTime = guardian.pactTime * pactAffinityBonus;
const newProgress = ctx.prestige.pactRitualProgress + HOURS_PER_TICK;
if (newProgress >= requiredTime) {
addLog(`📜 Pact signed with ${guardian.name}! You have gained their boons.`);
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: newProgress,
};
}
}
}
// Discipline tick — process active disciplines (XP accrual + mana drain)
const disciplineResult = useDisciplineStore.getState().processTick({
rawMana,
elements,
});
rawMana = disciplineResult.rawMana;
elements = disciplineResult.elements;
// Apply per-element regen from discipline effects (regen_{element})
for (const [key, value] of Object.entries(disciplineEffects.bonuses)) {
if (key.startsWith('regen_') && key !== 'regenBonus') {
const element = key.replace('regen_', '');
if (elements[element]) {
elements[element] = {
...elements[element],
current: Math.min(
elements[element].max,
elements[element].current + value * HOURS_PER_TICK,
),
};
}
}
}
// 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,
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,
);
rawMana = combatResult.rawMana;
elements = combatResult.elements;
totalManaGathered += combatResult.totalManaGathered || 0;
if (combatResult.logMessages) {
combatResult.logMessages.forEach(msg => addLog(msg));
// Time progression
let hour = ctx.game.hour + HOURS_PER_TICK;
let day = ctx.game.day;
if (hour >= 24) {
hour -= 24;
day += 1;
}
writes.combat = {
...(writes.combat || {}),
currentFloor: combatResult.currentFloor,
floorHP: combatResult.floorHP,
floorMaxHP: combatResult.floorMaxHP,
maxFloorReached: combatResult.maxFloorReached,
castProgress: combatResult.castProgress,
equipmentSpellStates: combatResult.equipmentSpellStates,
// 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>,
};
}
// ── Phase 3: Write — batch all state updates ─────────────────────────
writes.game = { day, hour, incursionStrength };
writes.mana = {
rawMana,
meditateTicks,
totalManaGathered,
elements,
};
// Check for loop end
if (day > MAX_DAY) {
const insightGained = calcInsight(insightParams, disciplineEffects);
applyTickWrites(writes, {
setGame: set,
setUI: (w) => useUIStore.setState(w),
setPrestige: (w) => usePrestigeStore.setState(w),
setMana: (w) => useManaStore.setState(w),
setCombat: (w) => useCombatStore.setState(w),
setCrafting: (w) => useCraftingStore.setState(w),
setAttunement: (w) => useAttunementStore.setState(w),
setDiscipline: (w) => useDisciplineStore.setState(w),
addLogs: (msgs) => msgs.forEach((m) => useUIStore.getState().addLog(m)),
});
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);
} 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 pactAffinityBonus = 1 - (ctx.prestige.prestigeUpgrades.pactAffinity || 0) * 0.1;
const requiredTime = guardian.pactTime * pactAffinityBonus;
const newProgress = ctx.prestige.pactRitualProgress + HOURS_PER_TICK;
if (newProgress >= requiredTime) {
addLog(`📜 Pact signed with ${guardian.name}! You have gained their boons.`);
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: newProgress,
};
}
}
}
// Discipline tick — process active disciplines (XP accrual + mana drain)
const disciplineResult = useDisciplineStore.getState().processTick({
rawMana,
elements,
});
rawMana = disciplineResult.rawMana;
elements = disciplineResult.elements;
// Apply per-element regen from discipline effects (regen_{element})
for (const [key, value] of Object.entries(disciplineEffects.bonuses)) {
if (key.startsWith('regen_') && key !== 'regenBonus') {
const element = key.replace('regen_', '');
if (elements[element]) {
elements[element] = {
...elements[element],
current: Math.min(
elements[element].max,
elements[element].current + value * HOURS_PER_TICK,
),
};
}
}
}
// 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 {
+31 -132
View File
@@ -86,7 +86,6 @@ export const useManaStore = create<ManaStore>()(
spendRawMana: (amount: number) => {
const state = get();
if (state.rawMana < amount) return false;
set({ rawMana: state.rawMana - amount });
return true;
},
@@ -98,71 +97,35 @@ export const useManaStore = create<ManaStore>()(
}));
},
setMeditateTicks: (ticks: number) => {
set({ meditateTicks: ticks });
},
incrementMeditateTicks: () => {
set((state) => ({ meditateTicks: state.meditateTicks + 1 }));
},
resetMeditateTicks: () => {
set({ meditateTicks: 0 });
},
setMeditateTicks: (ticks: number) => set({ meditateTicks: ticks }),
incrementMeditateTicks: () => set((s) => ({ meditateTicks: s.meditateTicks + 1 })),
resetMeditateTicks: () => set({ meditateTicks: 0 }),
convertMana: (element: string, amount: number) => {
const state = get();
const elem = state.elements[element];
if (!elem?.unlocked) {
return fail(ErrorCode.ELEMENT_NOT_UNLOCKED, `Element ${element} is not unlocked`);
}
if (!elem?.unlocked) return fail(ErrorCode.ELEMENT_NOT_UNLOCKED, `Element ${element} is not unlocked`);
const cost = MANA_PER_ELEMENT * amount;
if (state.rawMana < cost) {
return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${cost} raw mana, have ${state.rawMana}`);
}
if (elem.current >= elem.max) {
return fail(ErrorCode.ELEMENT_MAX_CAPACITY, `Element ${element} is at max capacity`);
}
if (state.rawMana < cost) return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${cost} raw mana, have ${state.rawMana}`);
if (elem.current >= elem.max) return fail(ErrorCode.ELEMENT_MAX_CAPACITY, `Element ${element} is at max capacity`);
const canConvert = Math.min(
amount,
Math.floor(state.rawMana / MANA_PER_ELEMENT),
elem.max - elem.current
);
if (canConvert <= 0) {
return fail(ErrorCode.INVALID_INPUT, 'Cannot convert 0 or negative amount');
}
const canConvert = Math.min(amount, Math.floor(state.rawMana / MANA_PER_ELEMENT), elem.max - elem.current);
if (canConvert <= 0) return fail(ErrorCode.INVALID_INPUT, 'Cannot convert 0 or negative amount');
set({
rawMana: state.rawMana - canConvert * MANA_PER_ELEMENT,
elements: {
...state.elements,
[element]: { ...elem, current: elem.current + canConvert },
},
elements: { ...state.elements, [element]: { ...elem, current: elem.current + canConvert } },
});
return ok({ converted: canConvert });
},
unlockElement: (element: string, cost: number) => {
const state = get();
if (state.elements[element]?.unlocked) {
return fail(ErrorCode.INVALID_INPUT, `Element ${element} is already unlocked`);
}
if (state.rawMana < cost) {
return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${cost} raw mana, have ${state.rawMana}`);
}
set({
rawMana: state.rawMana - cost,
elements: {
...state.elements,
[element]: { ...state.elements[element], unlocked: true },
},
});
if (state.elements[element]?.unlocked) return fail(ErrorCode.INVALID_INPUT, `Element ${element} is already unlocked`);
if (state.rawMana < cost) return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${cost} raw mana, have ${state.rawMana}`);
set({ rawMana: state.rawMana - cost, elements: { ...state.elements, [element]: { ...state.elements[element], unlocked: true } } });
return okVoid();
},
@@ -170,15 +133,8 @@ export const useManaStore = create<ManaStore>()(
set((state) => {
const elem = state.elements[element];
if (!elem) return state;
return {
elements: {
...state.elements,
[element]: {
...elem,
current: Math.min(elem.current + amount, max),
},
},
elements: { ...state.elements, [element]: { ...elem, current: Math.min(elem.current + amount, max) } },
};
});
},
@@ -186,64 +142,35 @@ export const useManaStore = create<ManaStore>()(
spendElementMana: (element: string, amount: number) => {
const state = get();
const elem = state.elements[element];
if (!elem) {
return fail(ErrorCode.INVALID_ELEMENT, `Element ${element} does not exist`);
}
if (elem.current < amount) {
return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${amount} ${element} mana, have ${elem.current}`);
}
set({
elements: {
...state.elements,
[element]: { ...elem, current: elem.current - amount },
},
});
if (!elem) return fail(ErrorCode.INVALID_ELEMENT, `Element ${element} does not exist`);
if (elem.current < amount) return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${amount} ${element} mana, have ${elem.current}`);
set({ elements: { ...state.elements, [element]: { ...elem, current: elem.current - amount } } });
return okVoid();
},
setElementMax: (max: number) => {
set((state) => ({
elements: Object.fromEntries(
Object.entries(state.elements).map(([k, v]) => [k, { ...v, max }])
) as Record<string, ElementState>,
elements: Object.fromEntries(Object.entries(state.elements).map(([k, v]) => [k, { ...v, max }])) as Record<string, ElementState>,
}));
},
craftComposite: (target: string, recipe: string[]) => {
const state = get();
// Count required ingredients
const costs: Record<string, number> = {};
recipe.forEach(r => {
costs[r] = (costs[r] || 0) + 1;
});
recipe.forEach(r => { costs[r] = (costs[r] || 0) + 1; });
// Check if we have all ingredients
for (const [r, amt] of Object.entries(costs)) {
if ((state.elements[r]?.current || 0) < amt) {
return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${amt} ${r} mana, have ${state.elements[r]?.current || 0}`);
}
if ((state.elements[r]?.current || 0) < amt) return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${amt} ${r} mana, have ${state.elements[r]?.current || 0}`);
}
// Deduct ingredients
const newElems = { ...state.elements };
for (const [r, amt] of Object.entries(costs)) {
newElems[r] = {
...newElems[r],
current: newElems[r].current - amt,
};
newElems[r] = { ...newElems[r], current: newElems[r].current - amt };
}
// Add crafted element
const targetElem = newElems[target];
newElems[target] = {
...(targetElem || { current: 0, max: 10, unlocked: false }),
current: (targetElem?.current || 0) + 1,
unlocked: true,
};
newElems[target] = { ...(targetElem || { current: 0, max: 10, unlocked: false }), current: (targetElem?.current || 0) + 1, unlocked: true };
set({ elements: newElems });
return okVoid();
},
@@ -252,27 +179,16 @@ export const useManaStore = create<ManaStore>()(
const state = get();
const elements = { ...state.elements };
const unlockedElements = Object.entries(elements)
.filter(([, e]) => e.unlocked && e.current < e.max);
if (unlockedElements.length === 0 || rawMana < 100) return null;
const unlockedElements = Object.entries(elements).filter(([, e]) => e.unlocked && e.current < e.max);
if (unlockedElements.length === 0 || rawMana < MANA_PER_ELEMENT) return null;
unlockedElements.sort((a, b) => (b[1].max - b[1].current) - (a[1].max - a[1].current));
const [targetId, targetState] = unlockedElements[0];
const canConvert = Math.min(
Math.floor(rawMana / 100),
targetState.max - targetState.current
);
const canConvert = Math.min(Math.floor(rawMana / MANA_PER_ELEMENT), targetState.max - targetState.current);
if (canConvert <= 0) return null;
rawMana -= canConvert * 100;
const updatedElements = {
...elements,
[targetId]: { ...targetState, current: targetState.current + canConvert }
};
return { rawMana, elements: updatedElements };
rawMana -= canConvert * MANA_PER_ELEMENT;
return { rawMana, elements: { ...elements, [targetId]: { ...targetState, current: targetState.current + canConvert } } };
},
resetMana: (
@@ -283,24 +199,13 @@ export const useManaStore = create<ManaStore>()(
) => {
const elementMax = 10 + (prestigeUpgrades.elemMax || 0) * 5;
const startingMana = 10 + (prestigeUpgrades.manaStart || 0) * 10;
const elements = makeInitialElements(elementMax, prestigeUpgrades);
set({
rawMana: startingMana,
meditateTicks: 0,
totalManaGathered: 0,
elements,
});
set({ rawMana: startingMana, meditateTicks: 0, totalManaGathered: 0, elements: makeInitialElements(elementMax, prestigeUpgrades) });
},
}),
{
storage: createSafeStorage(),
name: 'mana-loop-mana',
partialize: (state) => ({
rawMana: state.rawMana,
totalManaGathered: state.totalManaGathered,
elements: state.elements,
}),
partialize: (state) => ({ rawMana: state.rawMana, totalManaGathered: state.totalManaGathered, elements: state.elements }),
}
)
);
@@ -311,16 +216,10 @@ export function makeInitialElements(
prestigeUpgrades: Record<string, number> = {}
): Record<string, ElementState> {
const elemStart = (prestigeUpgrades.elemStart || 0) * 5;
const elements: Record<string, ElementState> = {};
Object.keys(ELEMENTS).forEach(k => {
for (const k of Object.keys(ELEMENTS)) {
const isUnlocked = BASE_UNLOCKED_ELEMENTS.includes(k);
elements[k] = {
current: isUnlocked ? elemStart : 0,
max: elementMax,
unlocked: isUnlocked,
};
});
elements[k] = { current: isUnlocked ? elemStart : 0, max: elementMax, unlocked: isUnlocked };
}
return elements;
}