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
+12 -7
View File
@@ -1,5 +1,6 @@
'use client';
import { useMemo } from 'react';
import { useAttunementStore } from '@/lib/game/stores';
import { ATTUNEMENTS_DEF } from '@/lib/game/data/attunements';
import { Separator } from '@/components/ui/separator';
@@ -18,13 +19,17 @@ const SLOT_LABELS: Record<string, string> = {
export function AttunementStatus() {
const attunements = useAttunementStore((s) => s.attunements);
const activeAttunements = Object.entries(attunements)
.filter(([, state]) => state.active)
.sort(([, a], [, b]) => {
const orderA = Object.values(ATTUNEMENTS_DEF).findIndex(d => d.id === a.id);
const orderB = Object.values(ATTUNEMENTS_DEF).findIndex(d => d.id === b.id);
return orderA - orderB;
});
const attunementOrder = useMemo(() => {
const map = new Map<string, number>();
Object.values(ATTUNEMENTS_DEF).forEach((d, i) => map.set(d.id, i));
return map;
}, []);
const activeAttunements = useMemo(() => {
return Object.entries(attunements)
.filter(([, state]) => state.active)
.sort(([, a], [, b]) => (attunementOrder.get(a.id) ?? 0) - (attunementOrder.get(b.id) ?? 0));
}, [attunements, attunementOrder]);
const xpForNext = (level: number) => {
if (level <= 1) return 0;
+1 -1
View File
@@ -67,7 +67,7 @@ export function ManaDisplay({
style={{
background: 'var(--mana-raw)',
border: '1px solid var(--border-accent)',
color: '#0C1020',
color: 'var(--bg-gather-btn)',
fontWeight: 600,
}}
onMouseDown={onGatherStart}
+1 -15
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useMemo, useEffect } from 'react';
import { useState, useMemo } from 'react';
import { useCombatStore } from '@/lib/game/stores';
import {
ACHIEVEMENTS,
@@ -164,14 +164,8 @@ function CategorySection({
export function AchievementsTab() {
const achievements = useCombatStore((s) => s.achievements);
const [mounted, setMounted] = useState(false);
const [collapsedCategories, setCollapsedCategories] = useState<Record<string, boolean>>({});
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);
const byCategory = useMemo(() => getAchievementsByCategory(), []);
const categories = useMemo(
() => Object.keys(byCategory).sort(),
@@ -188,14 +182,6 @@ export function AchievementsTab() {
}));
};
if (!mounted) {
return (
<div className="flex items-center justify-center p-8 text-gray-500">
Loading achievements
</div>
);
}
return (
<DebugName name="AchievementsTab">
<div className="space-y-4">
+1 -15
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { useAttunementStore } from '@/lib/game/stores';
import { ATTUNEMENTS_DEF, ATTUNEMENT_SLOT_NAMES, getAttunementXPForLevel, MAX_ATTUNEMENT_LEVEL } from '@/lib/game/data/attunements';
import type { AttunementDef, AttunementState } from '@/lib/game/types';
@@ -157,24 +157,10 @@ function AttunementCard({ def, state }: AttunementCardProps) {
export function AttunementsTab() {
const attunements = useAttunementStore((s) => s.attunements);
const [mounted, setMounted] = useState(false);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);
const allDefs = Object.values(ATTUNEMENTS_DEF);
const unlockedCount = allDefs.filter((d) => isAttunementUnlocked(d.id, attunements)).length;
if (!mounted) {
return (
<div className="flex items-center justify-center p-8 text-gray-500">
Loading attunements
</div>
);
}
return (
<DebugName name="AttunementsTab">
<div className="space-y-4">
+1 -15
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback } from 'react';
import React, { useState, useCallback } from 'react';
import { useDisciplineStore } from '@/lib/game/stores/discipline-slice';
import type { DisciplineDefinition } from '@/lib/game/types/disciplines';
import type { ManaType } from '@/lib/game/types/elements';
@@ -201,14 +201,8 @@ export const DisciplinesTab: React.FC = () => {
const activate = useDisciplineStore((s) => s.activate);
const deactivate = useDisciplineStore((s) => s.deactivate);
const [mounted, setMounted] = useState(false);
const [activeAttunement, setActiveAttunement] = useState<string>('base');
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);
const handleToggle = useCallback((id: string, paused: boolean) => {
if (paused) {
activate(id);
@@ -217,14 +211,6 @@ export const DisciplinesTab: React.FC = () => {
}
}, [activate, deactivate]);
if (!mounted) {
return (
<div className="flex items-center justify-center p-8 text-gray-500">
Loading disciplines
</div>
);
}
const activeTab = ATTUNEMENT_TABS.find((t) => t.key === activeAttunement);
return (
+1 -16
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useState, useCallback, useMemo } from 'react';
import { useCraftingStore } from '@/lib/game/stores/craftingStore';
import type { EquipmentSlot } from '@/lib/game/types';
import { DebugName } from '@/components/game/debug/debug-context';
@@ -9,19 +9,12 @@ import { InventoryList } from './EquipmentTab/InventoryList';
import { EquipmentEffectsSummary } from './EquipmentTab/EquipmentEffectsSummary';
export function EquipmentTab() {
const [mounted, setMounted] = useState(false);
const equippedInstances = useCraftingStore((s) => s.equippedInstances);
const equipmentInstances = useCraftingStore((s) => s.equipmentInstances);
const storeEquipItem = useCraftingStore((s) => s.equipItem);
const storeUnequipItem = useCraftingStore((s) => s.unequipItem);
const storeDeleteEquipment = useCraftingStore((s) => s.deleteEquipmentInstance);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);
const handleEquip = useCallback(
(instanceId: string, slot: EquipmentSlot): boolean => {
return storeEquipItem(instanceId, slot);
@@ -51,14 +44,6 @@ export function EquipmentTab() {
[equipmentInstances, equippedInstances]
);
if (!mounted) {
return (
<div className="flex items-center justify-center p-8 text-[var(--text-muted)]">
Loading equipment
</div>
);
}
return (
<DebugName name="EquipmentTab">
<div className="space-y-6">
+1 -15
View File
@@ -1,6 +1,6 @@
'use client';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import React, { useState, useCallback, useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useCombatStore } from '@/lib/game/stores/combatStore';
import { useAttunementStore } from '@/lib/game/stores/attunementStore';
@@ -197,7 +197,6 @@ GolemCard.displayName = 'GolemCard';
// ─── Main Tab ────────────────────────────────────────────────────────────────
export const GolemancyTab: React.FC = () => {
const [mounted, setMounted] = useState(false);
const [activeTier, setActiveTier] = useState<string>('base');
const { golemancy, toggleGolem } = useCombatStore(useShallow(s => ({
@@ -210,11 +209,6 @@ export const GolemancyTab: React.FC = () => {
elements: s.elements,
})));
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);
// Build attunement lookup for isGolemUnlocked
const attunementLookup = useMemo(() => {
const lookup: Record<string, { active: boolean; level: number }> = {};
@@ -254,14 +248,6 @@ export const GolemancyTab: React.FC = () => {
const golemSlots = getGolemSlots(fabricatorLevel);
const enabledCount = golemancy.enabledGolems.length;
if (!mounted) {
return (
<div className="flex items-center justify-center p-8 text-gray-500">
Loading golemancy
</div>
);
}
const activeTierGolems = golemsByTier[activeTier] ?? [];
return (
+1 -15
View File
@@ -1,6 +1,6 @@
'use client';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import React, { useState, useMemo, useCallback } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { usePrestigeStore } from '@/lib/game/stores/prestigeStore';
import { useManaStore } from '@/lib/game/stores/manaStore';
@@ -53,7 +53,6 @@ function groupFloorsByTier(floors: number[]): FloorTier[] {
// ─── Main Tab ────────────────────────────────────────────────────────────────
export const GuardianPactsTab: React.FC = () => {
const [mounted, setMounted] = useState(false);
const [activeTier, setActiveTier] = useState<string>('all');
const {
@@ -75,11 +74,6 @@ export const GuardianPactsTab: React.FC = () => {
const rawMana = useManaStore(s => s.rawMana);
const addLog = useUIStore(s => s.addLog);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);
const guardianFloors = useMemo(
() => getAllGuardianFloors(),
[],
@@ -126,14 +120,6 @@ export const GuardianPactsTab: React.FC = () => {
return boonMap;
}, [signedPacts]);
if (!mounted) {
return (
<div className="flex items-center justify-center p-8 text-gray-500">
Loading guardian pacts
</div>
);
}
return (
<DebugName name="GuardianPactsTab">
<div className="space-y-4">
+1 -16
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useCallback } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { usePrestigeStore, useGameStore } from '@/lib/game/stores';
import { PRESTIGE_DEF } from '@/lib/game/constants/prestige';
@@ -186,8 +186,6 @@ function ResetLoopSection({ loopInsight, onReset }: { loopInsight: number; onRes
// ─── Main Component ───────────────────────────────────────────────────────────
export function PrestigeTab() {
const [mounted, setMounted] = useState(false);
const {
insight,
totalInsight,
@@ -212,11 +210,6 @@ export function PrestigeTab() {
const startNewLoop = useGameStore((s) => s.startNewLoop);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);
const handlePurchase = useCallback((id: string) => {
doPrestige(id);
}, [doPrestige]);
@@ -225,14 +218,6 @@ export function PrestigeTab() {
startNewLoop();
}, [startNewLoop]);
if (!mounted) {
return (
<div className="flex items-center justify-center p-8 text-gray-500">
Loading prestige
</div>
);
}
const upgradeEntries = Object.entries(PRESTIGE_DEF);
return (
@@ -71,7 +71,7 @@ function EnemyRow({ enemy }: { enemy: EnemyState }) {
);
}
export function RoomDisplay({ floorState, _floor }: RoomDisplayProps) {
export function RoomDisplay({ floorState }: RoomDisplayProps) {
// Guard against null/undefined/stale floorState
if (!floorState || !floorState.roomType) {
return (
@@ -47,7 +47,6 @@ function useSpireStats(prestigeUpgrades: Record<string, number>, equippedInstanc
// ─── Main Component ───────────────────────────────────────────────────────────
export function SpireCombatPage() {
const [mounted, setMounted] = useState(false);
const [roomsCleared, setRoomsCleared] = useState(0);
const {
@@ -104,8 +103,6 @@ export function SpireCombatPage() {
const totalRooms = useMemo(() => getRoomsForFloor(currentFloor), [currentFloor]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
setRoomsCleared(0);
const newRoom = generateSpireFloorState(currentFloor, 0, totalRooms);
setCurrentRoom(newRoom);
@@ -166,14 +163,6 @@ export function SpireCombatPage() {
addActivityLog('floor_transition', '🚪 Exited the Spire.');
};
if (!mounted) {
return (
<div className="flex items-center justify-center min-h-screen text-gray-500">
Loading spire...
</div>
);
}
return (
<div className="min-h-screen bg-gray-950 flex flex-col">
<header className="sticky top-0 z-50 bg-gray-900/95 border-b border-gray-800 px-4 py-2">
+1 -16
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { useState, useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useCombatStore, usePrestigeStore, fmt } from '@/lib/game/stores';
import { ELEMENT_OPPOSITES, FLOOR_ELEM_CYCLE } from '@/lib/game/constants';
@@ -311,8 +311,6 @@ function GuardianRosterItem({ floor, guardian, isDefeated }: { floor: number; gu
// ─── Main Component ───────────────────────────────────────────────────────────
export function SpireSummaryTab() {
const [mounted, setMounted] = useState(false);
const {
maxFloorReached,
clearedFloors,
@@ -327,11 +325,6 @@ export function SpireSummaryTab() {
insight: s.insight,
})));
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);
const defeatedGuardians = useMemo(() => {
return GUARDIAN_FLOORS.filter((floor) => clearedFloors[floor]);
}, [clearedFloors]);
@@ -346,14 +339,6 @@ export function SpireSummaryTab() {
return Object.values(clearedFloors).filter(Boolean).length;
}, [clearedFloors]);
if (!mounted) {
return (
<div className="flex items-center justify-center p-8 text-gray-500">
Loading spire data
</div>
);
}
return (
<DebugName name="SpireSummaryTab">
<div className="space-y-4">
+28 -68
View File
@@ -3,6 +3,7 @@
import type { EquipmentInstance, AppliedEnchantment, EnchantmentDesign, ApplicationProgress } from './types';
import { calculateApplicationTime, calculateApplicationManaPerHour } from './crafting-utils';
import { HOURS_PER_TICK } from './constants';
import { hasSpecial, SPECIAL_EFFECTS } from './effects/special-effects';
import type { ComputedEffects } from './effects/upgrade-effects.types';
import type { AttunementState } from './types';
@@ -11,32 +12,16 @@ import { ENCHANTMENT_EFFECTS } from './data/enchantment-effects';
// ─── Application Validation ─────────────────────────────────────────────────
// Check if enchantment application can start
export function canApplyEnchantment(
instance: EquipmentInstance | undefined,
design: EnchantmentDesign | undefined,
currentAction: string
): { canApply: boolean; reason?: string } {
if (!instance) {
return { canApply: false, reason: 'Equipment instance not found' };
}
if (!design) {
return { canApply: false, reason: 'Enchantment design not found' };
}
if (currentAction !== 'meditate') {
return { canApply: false, reason: 'Must be in meditate state' };
}
if (!instance.tags?.includes('Ready for Enchantment')) {
return { canApply: false, reason: 'Equipment must be prepared for enchanting' };
}
if (instance.usedCapacity + design.totalCapacityUsed > instance.totalCapacity) {
return { canApply: false, reason: 'Not enough capacity on equipment' };
}
if (!instance) return { canApply: false, reason: 'Equipment instance not found' };
if (!design) return { canApply: false, reason: 'Enchantment design not found' };
if (currentAction !== 'meditate') return { canApply: false, reason: 'Must be in meditate state' };
if (!instance.tags?.includes('Ready for Enchantment')) return { canApply: false, reason: 'Equipment must be prepared for enchanting' };
if (instance.usedCapacity + design.totalCapacityUsed > instance.totalCapacity) return { canApply: false, reason: 'Not enough capacity on equipment' };
return { canApply: true };
}
@@ -51,21 +36,18 @@ export interface ApplicationCosts {
export function calculateApplicationCosts(design: EnchantmentDesign): ApplicationCosts {
const time = calculateApplicationTime(design);
const manaPerHour = calculateApplicationManaPerHour(design);
const manaPerTick = manaPerHour * 0.04; // HOURS_PER_TICK
const manaPerTick = manaPerHour * HOURS_PER_TICK;
return { time, manaPerHour, manaPerTick };
}
// ─── Application Progress ───────────────────────────────────────────────────
// Initialize application progress
export function initializeApplicationProgress(
equipmentInstanceId: string,
designId: string,
design: EnchantmentDesign
): ApplicationProgress {
const costs = calculateApplicationCosts(design);
return {
equipmentInstanceId,
designId,
@@ -77,7 +59,13 @@ export function initializeApplicationProgress(
};
}
// Calculate application progress after a tick
// Free enchant chance per special effect
const FREE_ENCHANT_CHANCES: Record<string, number> = {
[SPECIAL_EFFECTS.ENCHANT_PRESERVATION]: 0.25,
[SPECIAL_EFFECTS.THRIFTY_ENCHANTER]: 0.10,
[SPECIAL_EFFECTS.OPTIMIZED_ENCHANTING]: 0.25,
};
export interface ApplicationTickResult {
progress: number;
manaSpent: number;
@@ -93,20 +81,14 @@ export function calculateApplicationTick(
manaPerTick: number,
computedEffects: ComputedEffects
): ApplicationTickResult {
let progress = currentProgress + 0.04;
let progress = currentProgress + HOURS_PER_TICK;
let manaSpent = currentManaSpent + manaPerTick;
let manaConsumed = manaPerTick;
let triggeredFreeEnchant = false;
let freeEnchantChance = 0;
if (hasSpecial(computedEffects, SPECIAL_EFFECTS.ENCHANT_PRESERVATION)) {
freeEnchantChance += 0.25;
}
if (hasSpecial(computedEffects, SPECIAL_EFFECTS.THRIFTY_ENCHANTER)) {
freeEnchantChance += 0.10;
}
if (hasSpecial(computedEffects, SPECIAL_EFFECTS.OPTIMIZED_ENCHANTING)) {
freeEnchantChance += 0.25;
for (const [special, chance] of Object.entries(FREE_ENCHANT_CHANCES)) {
if (hasSpecial(computedEffects, special)) freeEnchantChance += chance;
}
if (freeEnchantChance > 0 && Math.random() < freeEnchantChance) {
@@ -116,47 +98,32 @@ export function calculateApplicationTick(
triggeredFreeEnchant = true;
}
return {
progress,
manaSpent,
manaConsumed,
isComplete: progress >= required,
triggeredFreeEnchant,
};
return { progress, manaSpent, manaConsumed, isComplete: progress >= required, triggeredFreeEnchant };
}
// ─── Enchantment Application ────────────────────────────────────────────────
// Apply enchantments to equipment instance
const PURE_ESSENCE_STACK_BONUS = 1.25;
const PURE_ESSENCE_COST_CAP = 100;
export function applyEnchantments(
instance: EquipmentInstance,
design: EnchantmentDesign,
computedEffects: ComputedEffects
): {
updatedInstance: EquipmentInstance;
xpGained: number;
logMessage: string;
} {
): { updatedInstance: EquipmentInstance; xpGained: number; logMessage: string } {
const isPureEssenceActive = hasSpecial(computedEffects, SPECIAL_EFFECTS.PURE_ESSENCE);
const newEnchantments: AppliedEnchantment[] = design.effects.map(eff => {
let stacks = eff.stacks;
let actualCost = eff.capacityCost;
const effectDef = ENCHANTMENT_EFFECTS[eff.effectId];
if (isPureEssenceActive && effectDef && effectDef.baseCapacityCost < 100) {
stacks = Math.ceil(stacks * 1.25);
}
const bonusStacks = isPureEssenceActive && effectDef && effectDef.baseCapacityCost < PURE_ESSENCE_COST_CAP;
return {
effectId: eff.effectId,
stacks,
actualCost,
stacks: bonusStacks ? Math.ceil(eff.stacks * PURE_ESSENCE_STACK_BONUS) : eff.stacks,
actualCost: eff.capacityCost,
};
});
const xpGained = calculateEnchantingXP(design.totalCapacityUsed);
const updatedInstance: EquipmentInstance = {
...instance,
enchantments: [...instance.enchantments, ...newEnchantments],
@@ -176,15 +143,12 @@ export function updateEnchanterAttunement(
attunements: Record<string, AttunementState>,
xpGained: number
): Record<string, AttunementState> {
if (!attunements?.enchanter?.active || xpGained <= 0) {
return attunements;
}
if (!attunements?.enchanter?.active || xpGained <= 0) return attunements;
const enchanterState = attunements.enchanter;
let newXP = enchanterState.experience + xpGained;
let newLevel = enchanterState.level;
while (newLevel < MAX_ATTUNEMENT_LEVEL) {
const xpNeeded = getAttunementXPForLevel(newLevel + 1);
if (newXP >= xpNeeded) {
@@ -197,11 +161,7 @@ export function updateEnchanterAttunement(
return {
...attunements,
enchanter: {
...enchanterState,
level: newLevel,
experience: newXP,
},
enchanter: { ...enchanterState, level: newLevel, experience: newXP },
};
}
@@ -222,7 +182,7 @@ export function resumeApplication() {
// ─── Progress Calculations ──────────────────────────────────────────────────
export function getApplicationManaCostForTick(manaPerHour: number): number {
return manaPerHour * 0.04;
return manaPerHour * HOURS_PER_TICK;
}
export function getApplicationRemainingTime(currentProgress: number, required: number): number {
+44 -66
View File
@@ -6,67 +6,55 @@ import type { ComputedEffects } from './effects/upgrade-effects.types';
import { calculateEnchantingXP } from './data/attunements';
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from './data/enchantment-effects';
import { hasSpecial, SPECIAL_EFFECTS } from './effects/special-effects';
import { HOURS_PER_TICK } from './constants';
import { EQUIPMENT_TYPES } from './data/equipment';
// Progress per tick expressed as a fraction of HOURS_PER_TICK
const DESIGN_PROGRESS_PER_TICK = HOURS_PER_TICK;
const HASTY_ENCHANTER_BONUS_MULTIPLIER = 0.25;
// ─── Design Creation & Calculation ──────────────────────────────────────────
// Validate effects for a design against equipment category
export function validateDesignEffects(
effects: DesignEffect[],
equipmentTypeId: string,
enchantingLevel: number
): { valid: boolean; reason?: string } {
if (enchantingLevel < 1) {
return { valid: false, reason: 'Requires enchanting skill level 1' };
}
if (enchantingLevel < 1) return { valid: false, reason: 'Requires enchanting skill level 1' };
const equipType = EQUIPMENT_TYPES[equipmentTypeId];
if (!equipType) {
return { valid: false, reason: 'Invalid equipment type' };
}
const category = equipType.category;
if (!category) {
return { valid: false, reason: 'Invalid equipment category' };
}
if (!equipType || !equipType.category) return { valid: false, reason: 'Invalid equipment type or category' };
for (const eff of effects) {
const effectDef = ENCHANTMENT_EFFECTS[eff.effectId];
if (!effectDef) {
return { valid: false, reason: `Unknown effect: ${eff.effectId}` };
}
if (!effectDef.allowedEquipmentCategories.includes(category)) {
return { valid: false, reason: `Effect ${eff.effectId} not allowed on ${category}` };
}
if (eff.stacks > effectDef.maxStacks) {
return { valid: false, reason: `Stacks exceed maximum for ${eff.effectId}` };
if (!effectDef) return { valid: false, reason: `Unknown effect: ${eff.effectId}` };
if (!effectDef.allowedEquipmentCategories.includes(equipType.category)) {
return { valid: false, reason: `Effect ${eff.effectId} not allowed on ${equipType.category}` };
}
if (eff.stacks > effectDef.maxStacks) return { valid: false, reason: `Stacks exceed maximum for ${eff.effectId}` };
}
return { valid: true };
}
// Create an enchantment design from validated inputs
export function createEnchantmentDesign(
name: string,
equipmentType: string,
effects: DesignEffect[],
efficiencyBonus: number = 0
): EnchantmentDesign {
const totalCapacityUsed = calculateDesignCapacityCost(effects, efficiencyBonus);
const designTime = calculateDesignTime(effects);
return {
id: `design_${Date.now()}`,
name,
equipmentType,
effects,
totalCapacityUsed,
designTime,
totalCapacityUsed: calculateDesignCapacityCost(effects, efficiencyBonus),
designTime: calculateDesignTime(effects),
created: Date.now(),
};
}
// ─── Capacity Cost Calculation ──────────────────────────────────────────────
// ─── Capacity & Time Calculations ───────────────────────────────────────────
export function calculateDesignCapacityCost(effects: DesignEffect[], efficiencyBonus: number = 0): number {
return effects.reduce((total, eff) => total + calculateEffectCapacityCost(eff.effectId, eff.stacks, efficiencyBonus), 0);
@@ -76,6 +64,25 @@ export function calculateTotalCapacityCost(design: EnchantmentDesign): number {
return design.totalCapacityUsed;
}
export function calculateDesignTime(effects: DesignEffect[]): number {
let time = 1;
for (const eff of effects) {
if (ENCHANTMENT_EFFECTS[eff.effectId]) time += 0.5 * eff.stacks;
}
return time;
}
export function getDesignTimeWithHaste(
effects: DesignEffect[],
isRepeatDesign: boolean,
computedEffects: ComputedEffects
): number {
const time = calculateDesignTime(effects);
return isRepeatDesign && hasSpecial(computedEffects, SPECIAL_EFFECTS.HASTY_ENCHANTER)
? time * 0.75
: time;
}
// ─── XP & Progression ───────────────────────────────────────────────────────
export function calculateEnchantingXpFromDesign(design: EnchantmentDesign): number {
@@ -88,37 +95,11 @@ export function calculateXpFromInstanceEnchantments(
let totalXp = 0;
for (const ench of instance.enchantments) {
const effectDef = ENCHANTMENT_EFFECTS[ench.effectId];
const baseCost = effectDef?.baseCapacityCost || 0;
totalXp += calculateEnchantingXP(baseCost * ench.stacks);
totalXp += calculateEnchantingXP((effectDef?.baseCapacityCost || 0) * ench.stacks);
}
return totalXp;
}
// ─── Design Time Calculations ──────────────────────────────────────────────
export function calculateDesignTime(effects: DesignEffect[]): number {
let time = 1;
for (const eff of effects) {
const effectDef = ENCHANTMENT_EFFECTS[eff.effectId];
if (effectDef) {
time += 0.5 * eff.stacks;
}
}
return time;
}
export function getDesignTimeWithHaste(
effects: DesignEffect[],
isRepeatDesign: boolean,
computedEffects: ComputedEffects
): number {
let time = calculateDesignTime(effects);
if (isRepeatDesign && hasSpecial(computedEffects, SPECIAL_EFFECTS.HASTY_ENCHANTER)) {
time *= 0.75;
}
return time;
}
// ─── Progress Calculations ──────────────────────────────────────────────────
export interface DesignProgressUpdate {
@@ -128,21 +109,23 @@ export interface DesignProgressUpdate {
timeBonus: number;
}
const INSTANT_DESIGN_CHANCE = 0.10;
export function calculateDesignProgress(
currentProgress: number,
required: number,
computedEffects: ComputedEffects,
isRepeatDesign: boolean
): DesignProgressUpdate {
let progress = currentProgress + 0.04;
let progress = currentProgress + DESIGN_PROGRESS_PER_TICK;
let timeBonus = 0;
if (isRepeatDesign && hasSpecial(computedEffects, SPECIAL_EFFECTS.HASTY_ENCHANTER)) {
timeBonus = 0.04 * 0.25;
timeBonus = DESIGN_PROGRESS_PER_TICK * HASTY_ENCHANTER_BONUS_MULTIPLIER;
progress += timeBonus;
}
if (hasSpecial(computedEffects, SPECIAL_EFFECTS.INSTANT_DESIGNS) && Math.random() < 0.10) {
if (hasSpecial(computedEffects, SPECIAL_EFFECTS.INSTANT_DESIGNS) && Math.random() < INSTANT_DESIGN_CHANCE) {
progress = required;
}
@@ -165,8 +148,7 @@ export function isSecondDesignSlotAvailable(
): boolean {
if (!designProgress && !designProgress2) return true;
if (!designProgress && designProgress2) return false;
if (designProgress && !designProgress2 && hasEnchantMastery) return true;
return false;
return !!(designProgress && !designProgress2 && hasEnchantMastery);
}
// ─── Auto-save Completed Design ────────────────────────────────────────────
@@ -181,13 +163,12 @@ export function createCompletedDesignFromProgress(
},
efficiencyBonus: number = 0
): EnchantmentDesign {
const totalCapacityCost = calculateDesignCapacityCost(progressData.effects, efficiencyBonus);
return {
id: progressData.designId,
name: progressData.name,
equipmentType: progressData.equipmentType,
effects: progressData.effects,
totalCapacityUsed: totalCapacityCost,
totalCapacityUsed: calculateDesignCapacityCost(progressData.effects, efficiencyBonus),
designTime: progressData.required,
created: Date.now(),
};
@@ -206,13 +187,10 @@ export function filterDesignsByEquipment(
equipment: { instanceId: string; totalCapacity: number; usedCapacity: number } | null
): DesignWithCapacityInfo[] {
if (!equipment) return [];
const availableCapacity = equipment.totalCapacity - equipment.usedCapacity;
return designs.map(design => ({
design,
fitsInEquipment: designFitsInEquipment(design, equipment),
availableCapacity: equipment.totalCapacity - equipment.usedCapacity,
fitsInEquipment: (equipment.usedCapacity || 0) + design.totalCapacityUsed <= equipment.totalCapacity,
availableCapacity,
}));
}
function designFitsInEquipment(design: EnchantmentDesign, instance: { usedCapacity: number; totalCapacity: number }): boolean {
return (instance.usedCapacity || 0) + design.totalCapacityUsed <= instance.totalCapacity;
}
+46 -97
View File
@@ -6,10 +6,12 @@ import { CRAFTING_RECIPES, canCraftRecipe, type CraftingRecipe } from './data/cr
import { EQUIPMENT_TYPES } from './data/equipment';
import { ok, fail, ErrorCode } from './utils/result';
import type { Result } from './utils/result';
import { HOURS_PER_TICK } from './constants';
const MANA_REFUND_RATE = 0.5;
// ─── Equipment Crafting Validation ──────────────────────────────────────────
// Check if equipment crafting can start
export function canStartEquipmentCrafting(
blueprintId: string,
hasBlueprint: boolean,
@@ -17,38 +19,27 @@ export function canStartEquipmentCrafting(
currentMana: number,
currentAction: string
): { canCraft: boolean; reason?: string; recipe?: CraftingRecipe; missingMaterials?: Record<string, number>; missingMana?: number } {
if (currentAction !== 'meditate') {
return { canCraft: false, reason: 'Must be in meditate state' };
}
if (currentAction !== 'meditate') return { canCraft: false, reason: 'Must be in meditate state' };
const recipe = CRAFTING_RECIPES[blueprintId];
if (!recipe) {
return { canCraft: false, reason: 'Invalid blueprint' };
}
if (!hasBlueprint) {
return { canCraft: false, reason: 'Blueprint not acquired' };
}
if (!recipe) return { canCraft: false, reason: 'Invalid blueprint' };
if (!hasBlueprint) return { canCraft: false, reason: 'Blueprint not acquired' };
const { canCraft, missingMaterials } = canCraftRecipe(recipe, materials, currentMana);
if (canCraft) return { canCraft: true, recipe };
if (!canCraft) {
const missingMana = Math.max(0, recipe.manaCost - currentMana);
return {
canCraft: false,
reason: missingMana > 0 ? 'Insufficient mana' : 'Missing materials',
recipe,
missingMaterials,
missingMana: missingMana > 0 ? missingMana : undefined,
};
}
return { canCraft: true, recipe };
const missingManaAmount = Math.max(0, recipe.manaCost - currentMana);
return {
canCraft: false,
reason: missingManaAmount > 0 ? 'Insufficient mana' : 'Missing materials',
recipe,
missingMaterials,
missingMana: missingManaAmount > 0 ? missingManaAmount : undefined,
};
}
// ─── Equipment Crafting Execution ───────────────────────────────────────────
// Deduct crafting costs and initialize progress
export interface CraftingInitResult {
recipe: CraftingRecipe;
newMaterials: Record<string, number>;
@@ -63,108 +54,80 @@ export function initializeEquipmentCrafting(
): CraftingInitResult {
const recipe = CRAFTING_RECIPES[blueprintId];
// Deduct materials
const newMaterials = { ...materials };
for (const [matId, amount] of Object.entries(recipe.materials)) {
newMaterials[matId] = (newMaterials[matId] || 0) - amount;
if (newMaterials[matId] <= 0) {
delete newMaterials[matId];
}
if (newMaterials[matId] <= 0) delete newMaterials[matId];
}
// Create progress
const progress: EquipmentCraftingProgress = {
blueprintId,
equipmentTypeId: recipe.equipmentTypeId,
progress: 0,
required: recipe.craftTime,
manaSpent: recipe.manaCost,
};
return {
recipe,
newMaterials,
manaCost: recipe.manaCost,
progress,
progress: {
blueprintId,
equipmentTypeId: recipe.equipmentTypeId,
progress: 0,
required: recipe.craftTime,
manaSpent: recipe.manaCost,
},
};
}
// ─── Crafting Progress ──────────────────────────────────────────────────────
// Calculate crafting progress after a tick
export interface CraftingTickResult {
progress: number;
isComplete: boolean;
}
export function calculateCraftingTick(currentProgress: number, required: number): CraftingTickResult {
const progress = currentProgress + 0.04; // HOURS_PER_TICK
return {
progress,
isComplete: progress >= required,
};
const progress = currentProgress + HOURS_PER_TICK;
return { progress, isComplete: progress >= required };
}
// ─── Crafting Completion ───────────────────────────────────────────────────
// Create equipment instance from completed crafting
const BASE_EQUIPMENT_QUALITY = 100;
export function completeEquipmentCrafting(
blueprintId: string,
recipe: CraftingRecipe
): Result<{
instanceId: string;
instance: EquipmentInstance;
logMessage: string;
}> {
): Result<{ instanceId: string; instance: EquipmentInstance; logMessage: string }> {
const equipType = EQUIPMENT_TYPES[recipe.equipmentTypeId];
if (!equipType) {
return fail(ErrorCode.INVALID_EQUIPMENT_TYPE, `Invalid equipment type: ${recipe.equipmentTypeId}`);
}
if (!equipType) return fail(ErrorCode.INVALID_EQUIPMENT_TYPE, `Invalid equipment type: ${recipe.equipmentTypeId}`);
const instanceId = `equip_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const newInstance: EquipmentInstance = {
instanceId,
typeId: recipe.equipmentTypeId,
name: recipe.name,
enchantments: [],
usedCapacity: 0,
totalCapacity: equipType.baseCapacity,
rarity: recipe.rarity,
quality: 100,
tags: [],
};
return ok({
instanceId,
instance: newInstance,
instance: {
instanceId,
typeId: recipe.equipmentTypeId,
name: recipe.name,
enchantments: [],
usedCapacity: 0,
totalCapacity: equipType.baseCapacity,
rarity: recipe.rarity,
quality: BASE_EQUIPMENT_QUALITY,
tags: [],
},
logMessage: `🔨 Crafted ${recipe.name}!`,
});
}
// ─── Crafting Cancellation ──────────────────────────────────────────────────
// Cancel active crafting and refund partial resources
export interface CraftingCancelResult {
manaRefund: number;
logMessage: string;
}
export function cancelEquipmentCrafting(_blueprintId: string, manaSpent: number): CraftingCancelResult {
export function cancelEquipmentCrafting(blueprintId: string, manaSpent: number): CraftingCancelResult {
const recipe = CRAFTING_RECIPES[blueprintId];
if (!recipe) {
return {
manaRefund: 0,
logMessage: 'Invalid crafting recipe.',
};
}
if (!recipe) return { manaRefund: 0, logMessage: 'Invalid crafting recipe.' };
// Refund 50% of mana
const manaRefund = Math.floor(manaSpent * 0.5);
return {
manaRefund,
logMessage: `🚫 Equipment crafting cancelled. Refunded ${manaRefund} mana.`,
};
const manaRefund = Math.floor(manaSpent * MANA_REFUND_RATE);
return { manaRefund, logMessage: `🚫 Equipment crafting cancelled. Refunded ${manaRefund} mana.` };
}
// ─── Recipe Information ─────────────────────────────────────────────────────
@@ -179,23 +142,16 @@ export function getCraftableRecipes(
currentMana: number
): CraftingRecipe[] {
const craftable: CraftingRecipe[] = [];
for (const blueprintId of blueprints) {
const recipe = CRAFTING_RECIPES[blueprintId];
if (!recipe) continue;
const { canCraft } = canCraftRecipe(recipe, materials, currentMana);
if (canCraft) {
craftable.push(recipe);
}
if (canCraftRecipe(recipe, materials, currentMana).canCraft) craftable.push(recipe);
}
return craftable;
}
// ─── Material Management ────────────────────────────────────────────────────
// Delete materials from inventory
export function deleteMaterials(materialId: string, amount: number, materials: Record<string, number>): {
newMaterials: Record<string, number>;
deleted: number;
@@ -204,25 +160,18 @@ export function deleteMaterials(materialId: string, amount: number, materials: R
const deleted = Math.min(amount, currentAmount);
const remaining = Math.max(0, currentAmount - amount);
const newMaterials = { ...materials };
if (remaining <= 0) {
delete newMaterials[materialId];
} else {
newMaterials[materialId] = remaining;
}
return {
newMaterials,
deleted,
};
return { newMaterials, deleted };
}
// Get total material count
export function getMaterialCount(materials: Record<string, number>, materialId: string): number {
return materials[materialId] || 0;
}
// Add materials to inventory
export function addMaterials(materials: Record<string, number>, materialId: string, amount: number): Record<string, number> {
const newMaterials = { ...materials };
newMaterials[materialId] = (newMaterials[materialId] || 0) + amount;
+27 -54
View File
@@ -3,26 +3,21 @@
import type { EquipmentInstance, PreparationProgress } from './types';
import { calculatePrepTime, calculatePrepManaCost, calculateManaPerHourForPrep } from './crafting-utils';
import { HOURS_PER_TICK } from './constants';
// ─── Preparation Validation ─────────────────────────────────────────────────
// Check if an equipment instance can be prepared
export function canPrepareEquipment(
instance: EquipmentInstance | undefined,
currentTags: string[]
): { canPrepare: boolean; reason?: string } {
if (!instance) {
return { canPrepare: false, reason: 'Equipment instance not found' };
}
if (currentTags.includes('Ready for Enchantment')) {
return { canPrepare: false, reason: 'Equipment is already prepared for enchanting' };
}
if (!instance) return { canPrepare: false, reason: 'Equipment instance not found' };
if (currentTags.includes('Ready for Enchantment')) return { canPrepare: false, reason: 'Equipment is already prepared for enchanting' };
return { canPrepare: true };
}
// Calculate preparation resource costs
// ─── Preparation Costs ──────────────────────────────────────────────────────
export interface PreparationCosts {
time: number;
manaTotal: number;
@@ -34,30 +29,20 @@ export function calculatePreparationCosts(totalCapacity: number): PreparationCos
const time = calculatePrepTime(totalCapacity);
const manaTotal = calculatePrepManaCost(totalCapacity);
const manaPerHour = calculateManaPerHourForPrep(totalCapacity, time);
const manaPerTick = manaPerHour * 0.04; // HOURS_PER_TICK
return { time, manaTotal, manaPerHour, manaPerTick };
return { time, manaTotal, manaPerHour, manaPerTick: manaPerHour * HOURS_PER_TICK };
}
// ─── Preparation Progress ───────────────────────────────────────────────────
// Initialize preparation progress
export function initializePreparationProgress(
equipmentInstanceId: string,
totalCapacity: number,
manaCostPaid: number = 0
): PreparationProgress {
const costs = calculatePreparationCosts(totalCapacity);
return {
equipmentInstanceId,
progress: 0,
required: costs.time,
manaCostPaid,
};
return { equipmentInstanceId, progress: 0, required: costs.time, manaCostPaid };
}
// Calculate updated preparation progress after a tick
// ─── Preparation Tick ───────────────────────────────────────────────────────
export interface PreparationTickResult {
progress: number;
manaCostPaid: number;
@@ -68,15 +53,14 @@ export interface PreparationTickResult {
export function calculatePreparationTick(
currentProgress: number,
required: number,
currentManaCostPaid: number,
manaPerTick: number
): PreparationTickResult {
const progress = currentProgress + 0.04; // HOURS_PER_TICK
const progress = currentProgress + HOURS_PER_TICK;
const manaConsumed = manaPerTick;
const manaCostPaid = manaPerTick; // Accumulated
return {
progress,
manaCostPaid,
manaCostPaid: currentManaCostPaid + manaConsumed,
manaConsumed,
isComplete: progress >= required,
};
@@ -84,51 +68,40 @@ export function calculatePreparationTick(
// ─── Preparation Completion ─────────────────────────────────────────────────
// Apply preparation completion to equipment instance
const BASE_DISENCHANT_RECOVERY_RATE = 0.1;
const DISENCHANT_RECOVERY_PER_LEVEL = 0.2;
export function completePreparation(
instance: EquipmentInstance,
_manaSpent: number
): {
updatedInstance: EquipmentInstance;
manaRecovered: number;
logMessage: string;
} {
// Calculate mana recovery from disenchanting (disenchanting skill removed - Bug 13)
const disenchantLevel = 0;
const recoveryRate = 0.1 + disenchantLevel * 0.2; // 10% base + 20% per level
disenchantLevel: number = 0
): { updatedInstance: EquipmentInstance; manaRecovered: number; logMessage: string } {
const recoveryRate = BASE_DISENCHANT_RECOVERY_RATE + disenchantLevel * DISENCHANT_RECOVERY_PER_LEVEL;
let totalRecovered = 0;
for (const ench of instance.enchantments) {
totalRecovered += Math.floor(ench.actualCost * recoveryRate);
}
const updatedInstance: EquipmentInstance = {
...instance,
enchantments: [],
usedCapacity: 0,
rarity: 'common',
tags: [...(instance.tags || []), 'Ready for Enchantment'],
};
return {
updatedInstance,
updatedInstance: {
...instance,
enchantments: [],
usedCapacity: 0,
rarity: 'common',
tags: [...(instance.tags || []), 'Ready for Enchantment'],
},
manaRecovered: totalRecovered,
logMessage: `✅ Equipment prepared for enchanting! Recovered ${totalRecovered} mana.`,
};
}
// Cancel preparation (no resource recovery for preparation itself)
export function cancelPreparation() {
return {
logMessage: 'Preparation cancelled.',
};
return { logMessage: 'Preparation cancelled.' };
}
// ─── Preparation State Calculations ─────────────────────────────────────────
export function getPreparationManaCostForTick(instance: EquipmentInstance): number {
const costs = calculatePreparationCosts(instance.totalCapacity);
return costs.manaPerTick;
return calculatePreparationCosts(instance.totalCapacity).manaPerTick;
}
export function getPreparationRemainingTime(currentProgress: number, required: number): number {
+44 -78
View File
@@ -2,8 +2,22 @@
// Dynamic computation functions that depend on special effects
import type { ComputedEffects } from './upgrade-effects.types';
import { SPECIAL_EFFECTS, hasSpecial } from './special-effects';
// Threshold ratios for mana-dependent effects (currentMana / maxMana)
const MANA_HIGH_THRESHOLD = 0.75;
const MANA_OVERPOWER_THRESHOLD = 0.8;
const MANA_BERSERKER_THRESHOLD = 0.5;
const MANA_LOW_THRESHOLD = 0.25;
const MANA_CRITICAL_THRESHOLD = 0.1;
// Regen multipliers for mana-dependent thresholds
const MANA_TORRENT_MULTIPLIER = 1.5;
const MANA_CRISIS_MULTIPLIER = 1.5; // Desperate / Despair Wells
const PANIC_RESERVE_MULTIPLIER = 2.0;
const REGEN_BOOST_PER_100_MANA = 0.1;
/**
* Compute regen with special effects that depend on dynamic values
*/
@@ -15,60 +29,51 @@ export function computeDynamicRegen(
incursionStrength: number
): number {
let regen = baseRegen;
// Mana Cascade: +0.1 regen per 100 max mana
// Per-100-max-mana regen bonuses
const manaHundreds = Math.floor(maxMana / 100);
if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_CASCADE)) {
regen += Math.floor(maxMana / 100) * 0.1;
regen += manaHundreds * REGEN_BOOST_PER_100_MANA;
}
// Mana Waterfall: +0.25 regen per 100 max mana (upgraded cascade)
if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_WATERFALL)) {
regen += Math.floor(maxMana / 100) * 0.25;
regen += manaHundreds * 0.25;
}
// Mana Torrent: +50% regen when above 75% mana
if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_TORRENT) && currentMana > maxMana * 0.75) {
regen *= 1.5;
}
// Desperate Wells / Despair Wells: +50% regen when below 25% mana
if ((hasSpecial(effects, SPECIAL_EFFECTS.DESPERATE_WELLS) || hasSpecial(effects, SPECIAL_EFFECTS.DESPAIR_WELLS)) && currentMana < maxMana * 0.25) {
regen *= 1.5;
}
// Panic Reserve: +100% regen when below 10% mana
if (hasSpecial(effects, SPECIAL_EFFECTS.PANIC_RESERVE) && currentMana < maxMana * 0.1) {
regen *= 2.0;
}
// Deep Reserve: +0.5 regen per 100 max mana
if (hasSpecial(effects, SPECIAL_EFFECTS.DEEP_RESERVE)) {
regen += Math.floor(maxMana / 100) * 0.5;
regen += manaHundreds * 0.5;
}
// Mana Core: 0.5% of max mana added as regen
// Fractional max-mana regen
if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_CORE)) {
regen += maxMana * 0.005;
}
// Mana Tide: Regen pulses ±50% (sinusoidal based on time)
// Mana Tide: sinusoidal pulse
if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_TIDE)) {
regen *= (1.0 + 0.5 * Math.sin(Date.now() / 10000));
}
// Eternal Flow: Regen immune to ALL penalties
// Mana-ratio-dependent multipliers
const manaRatio = currentMana / maxMana;
if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_TORRENT) && manaRatio > MANA_HIGH_THRESHOLD) {
regen *= MANA_TORRENT_MULTIPLIER;
}
if ((hasSpecial(effects, SPECIAL_EFFECTS.DESPERATE_WELLS) || hasSpecial(effects, SPECIAL_EFFECTS.DESPAIR_WELLS)) && manaRatio < MANA_LOW_THRESHOLD) {
regen *= MANA_CRISIS_MULTIPLIER;
}
if (hasSpecial(effects, SPECIAL_EFFECTS.PANIC_RESERVE) && manaRatio < MANA_CRITICAL_THRESHOLD) {
regen *= PANIC_RESERVE_MULTIPLIER;
}
// Eternal Flow: skip incursion + multiplier below
if (hasSpecial(effects, SPECIAL_EFFECTS.ETERNAL_FLOW)) {
return regen * effects.regenMultiplier;
}
// Steady Stream: Regen immune to incursion (skip incursion penalty only)
if (hasSpecial(effects, SPECIAL_EFFECTS.STEADY_STREAM)) {
// incursion penalty is skipped, but regenMultiplier still applies below
} else {
// Apply incursion penalty
// Steady Stream: skip incursion only
if (!hasSpecial(effects, SPECIAL_EFFECTS.STEADY_STREAM)) {
regen *= (1 - incursionStrength);
}
return regen * effects.regenMultiplier;
}
@@ -79,46 +84,7 @@ export function computeDynamicClickMana(
effects: ComputedEffects,
baseClickMana: number
): number {
let clickMana = baseClickMana;
// Mana Echo: 10% chance to gain double mana from clicks
// Note: The chance is handled in the click handler, this just returns the base
// The click handler should check hasSpecial and apply the 10% chance
// Mana Genesis: Generate 1% of max mana per hour passively
// This is handled in the game loop (store.ts), not here
// Mana Heart: +10% max mana per loop (permanent)
// This is applied during loop reset in store.ts
return Math.floor((clickMana + effects.clickManaBonus) * effects.clickManaMultiplier);
return Math.floor((baseClickMana + effects.clickManaBonus) * effects.clickManaMultiplier);
}
/**
* Compute damage with special effects
*/
export function computeDynamicDamage(
effects: ComputedEffects,
baseDamage: number,
_floorHPPct: number,
currentMana: number,
maxMana: number
): number {
let damage = baseDamage * effects.baseDamageMultiplier;
// Overpower: +50% damage when mana above 80%
if (hasSpecial(effects, SPECIAL_EFFECTS.OVERPOWER) && currentMana >= maxMana * 0.8) {
damage *= 1.5;
}
// Berserker: +50% damage when below 50% mana
if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && currentMana < maxMana * 0.5) {
damage *= 1.5;
}
// Combo Master: Every 5th attack deals 3x damage
// Note: The hit counter is tracked in game state, this just returns the multiplier
// The combat handler should check hasSpecial and the hit count
return damage + effects.baseDamageBonus;
}
+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;
}