refactor: Major codebase refactoring for maintainability
All checks were successful
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 3m4s

Store refactoring (2138 → 1651 lines, 23% reduction):
- Extract computed-stats.ts with 18 utility functions
- Extract navigation-slice.ts for floor navigation actions
- Extract study-slice.ts for study-related actions
- Move fmt/fmtDec to computed-stats, re-export from formatting

Page refactoring (2554 → 1695 lines, 34% reduction):
- Use existing SpireTab component instead of inline render
- Extract ActionButtons component
- Extract CalendarDisplay component
- Extract CraftingProgress component
- Extract StudyProgress component
- Extract ManaDisplay component
- Extract TimeDisplay component
- Create tabs/index.ts for cleaner exports

This improves code organization and makes the codebase more maintainable.
This commit is contained in:
2026-03-26 12:00:30 +00:00
parent 1d2dce75cc
commit 2ca5d8b7f8
16 changed files with 1497 additions and 1527 deletions

View File

@@ -2,7 +2,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { GameState, GameAction, StudyTarget, SpellCost, SkillUpgradeChoice, EquipmentSlot, EquipmentInstance, EnchantmentDesign, DesignEffect, LootInventory } from './types';
import type { GameState, GameAction, StudyTarget, SkillUpgradeChoice, EquipmentSlot, EnchantmentDesign, DesignEffect, LootInventory } from './types';
import {
ELEMENTS,
GUARDIANS,
@@ -17,19 +17,13 @@ import {
INCURSION_START_DAY,
MANA_PER_ELEMENT,
getStudySpeedMultiplier,
getStudyCostMultiplier,
ELEMENT_OPPOSITES,
EFFECT_RESEARCH_MAPPING,
BASE_UNLOCKED_EFFECTS,
ENCHANTING_UNLOCK_EFFECTS,
} from './constants';
import { computeEffects, hasSpecial, SPECIAL_EFFECTS, type ComputedEffects } from './upgrade-effects';
import {
computeAllEffects,
getUnifiedEffects,
computeEquipmentEffects,
type UnifiedEffects
} from './effects';
import { hasSpecial, SPECIAL_EFFECTS } from './upgrade-effects';
import { getUnifiedEffects } from './effects';
import { SKILL_EVOLUTION_PATHS } from './skill-evolution';
import {
createStartingEquipment,
@@ -37,7 +31,6 @@ import {
getSpellsFromEquipment,
type CraftingActions
} from './crafting-slice';
import { EQUIPMENT_TYPES } from './data/equipment';
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from './data/enchantment-effects';
import {
createFamiliarSlice,
@@ -47,228 +40,56 @@ import {
type FamiliarBonuses,
DEFAULT_FAMILIAR_BONUSES,
} from './familiar-slice';
import {
createNavigationSlice,
type NavigationActions,
} from './navigation-slice';
import {
createStudySlice,
type StudyActions,
} from './study-slice';
import { rollLootDrops, LOOT_DROPS } from './data/loot-drops';
import { CRAFTING_RECIPES, canCraftRecipe } from './data/crafting-recipes';
import { EQUIPMENT_TYPES } from './data/equipment';
import type { EquipmentInstance } from './types';
// Import computed stats and utility functions from computed-stats.ts
import {
DEFAULT_EFFECTS,
fmt,
fmtDec,
getFloorMaxHP,
getFloorElement,
getActiveEquipmentSpells,
getEffectiveSkillLevel,
computeMaxMana,
computeElementMax,
computeRegen,
computeEffectiveRegen,
computeClickMana,
calcDamage,
calcInsight,
getMeditationBonus,
getIncursionStrength,
canAffordSpellCost,
deductSpellCost,
} from './computed-stats';
// Default empty effects for when effects aren't provided
const DEFAULT_EFFECTS: ComputedEffects = {
maxManaMultiplier: 1,
maxManaBonus: 0,
regenMultiplier: 1,
regenBonus: 0,
clickManaMultiplier: 1,
clickManaBonus: 0,
meditationEfficiency: 1,
spellCostMultiplier: 1,
conversionEfficiency: 1,
baseDamageMultiplier: 1,
baseDamageBonus: 0,
attackSpeedMultiplier: 1,
critChanceBonus: 0,
critDamageMultiplier: 1.5,
elementalDamageMultiplier: 1,
studySpeedMultiplier: 1,
studyCostMultiplier: 1,
progressRetention: 0,
instantStudyChance: 0,
freeStudyChance: 0,
elementCapMultiplier: 1,
elementCapBonus: 0,
conversionCostMultiplier: 1,
doubleCraftChance: 0,
permanentRegenBonus: 0,
specials: new Set(),
activeUpgrades: [],
// Re-export formatting functions and computed stats for backward compatibility
export {
fmt,
fmtDec,
getFloorElement,
computeMaxMana,
computeRegen,
computeClickMana,
calcDamage,
getMeditationBonus,
getIncursionStrength,
canAffordSpellCost,
getFloorMaxHP,
};
// ─── Helper Functions ─────────────────────────────────────────────────────────
export function fmt(n: number): string {
if (!isFinite(n) || isNaN(n)) return '0';
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
return Math.floor(n).toString();
}
export function fmtDec(n: number, d: number = 1): string {
return isFinite(n) ? n.toFixed(d) : '0';
}
export function getFloorMaxHP(floor: number): number {
if (GUARDIANS[floor]) return GUARDIANS[floor].hp;
// Improved scaling: slower early game, faster late game
const baseHP = 100;
const floorScaling = floor * 50;
const exponentialScaling = Math.pow(floor, 1.7);
return Math.floor(baseHP + floorScaling + exponentialScaling);
}
export function getFloorElement(floor: number): string {
return FLOOR_ELEM_CYCLE[(floor - 1) % 8];
}
// Get all spells from equipped caster weapons (staves, wands, etc.)
// Returns array of { spellId, equipmentInstanceId }
function getActiveEquipmentSpells(
equippedInstances: Record<string, string | null>,
equipmentInstances: Record<string, EquipmentInstance>
): Array<{ spellId: string; equipmentId: string }> {
const spells: Array<{ spellId: string; equipmentId: string }> = [];
// Check main hand and off hand for caster equipment
const weaponSlots = ['mainHand', 'offHand'] as const;
for (const slot of weaponSlots) {
const instanceId = equippedInstances[slot];
if (!instanceId) continue;
const instance = equipmentInstances[instanceId];
if (!instance) continue;
// Check if this is a caster-type equipment
const equipType = EQUIPMENT_TYPES[instance.typeId];
if (!equipType || equipType.category !== 'caster') continue;
// Get spells from enchantments
for (const ench of instance.enchantments) {
const effectDef = ENCHANTMENT_EFFECTS[ench.effectId];
if (effectDef?.effect.type === 'spell' && effectDef.effect.spellId) {
spells.push({
spellId: effectDef.effect.spellId,
equipmentId: instanceId,
});
}
}
}
return spells;
}
// ─── Computed Stats Functions ─────────────────────────────────────────────────
// Helper to get effective skill level accounting for tiers
function getEffectiveSkillLevel(
skills: Record<string, number>,
baseSkillId: string,
skillTiers: Record<string, number> = {}
): { level: number; tier: number; tierMultiplier: number } {
// Find the highest tier the player has for this base skill
const currentTier = skillTiers[baseSkillId] || 1;
// Look for the tiered skill ID (e.g., manaFlow_t2)
const tieredSkillId = currentTier > 1 ? `${baseSkillId}_t${currentTier}` : baseSkillId;
const level = skills[tieredSkillId] || skills[baseSkillId] || 0;
// Tier multiplier: each tier is 10x more powerful
const tierMultiplier = Math.pow(10, currentTier - 1);
return { level, tier: currentTier, tierMultiplier };
}
export function computeMaxMana(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
effects?: ComputedEffects | UnifiedEffects
): number {
const pu = state.prestigeUpgrades;
const base =
100 +
(state.skills.manaWell || 0) * 100 +
(pu.manaWell || 0) * 500;
// If effects not provided, compute unified effects (includes equipment)
if (!effects && state.equipmentInstances && state.equippedInstances) {
effects = getUnifiedEffects(state as any);
}
// Apply effects if available (now includes equipment bonuses)
if (effects) {
return Math.floor((base + effects.maxManaBonus) * effects.maxManaMultiplier);
}
return base;
}
export function computeElementMax(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers'>,
effects?: ComputedEffects
): number {
const pu = state.prestigeUpgrades;
const base = 10 + (state.skills.elemAttune || 0) * 50 + (pu.elementalAttune || 0) * 25;
// Apply upgrade effects if provided
if (effects) {
return Math.floor((base + effects.elementCapBonus) * effects.elementCapMultiplier);
}
return base;
}
export function computeRegen(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
effects?: ComputedEffects | UnifiedEffects
): number {
const pu = state.prestigeUpgrades;
const temporalBonus = 1 + (pu.temporalEcho || 0) * 0.1;
const base =
2 +
(state.skills.manaFlow || 0) * 1 +
(state.skills.manaSpring || 0) * 2 +
(pu.manaFlow || 0) * 0.5;
let regen = base * temporalBonus;
// If effects not provided, compute unified effects (includes equipment)
if (!effects && state.equipmentInstances && state.equippedInstances) {
effects = getUnifiedEffects(state as any);
}
// Apply effects if available (now includes equipment bonuses)
if (effects) {
regen = (regen + effects.regenBonus + effects.permanentRegenBonus) * effects.regenMultiplier;
}
return regen;
}
/**
* Compute regen with dynamic special effects (needs current mana, max mana, incursion)
*/
export function computeEffectiveRegen(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'rawMana' | 'incursionStrength' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
effects?: ComputedEffects | UnifiedEffects
): number {
// Base regen from existing function
let regen = computeRegen(state, effects);
const maxMana = computeMaxMana(state, effects);
const currentMana = state.rawMana;
const incursionStrength = state.incursionStrength || 0;
// Apply incursion penalty
regen *= (1 - incursionStrength);
return regen;
}
export function computeClickMana(
state: Pick<GameState, 'skills' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
effects?: ComputedEffects | UnifiedEffects
): number {
const base =
1 +
(state.skills.manaTap || 0) * 1 +
(state.skills.manaSurge || 0) * 3;
// If effects not provided, compute unified effects (includes equipment)
if (!effects && state.equipmentInstances && state.equippedInstances) {
effects = getUnifiedEffects(state as any);
}
// Apply effects if available (now includes equipment bonuses)
if (effects) {
return Math.floor((base + effects.clickManaBonus) * effects.clickManaMultiplier);
}
return base;
}
// ─── Local Helper Functions ────────────────────────────────────────────────────
// Elemental damage bonus: +50% if spell element opposes floor element (super effective)
// -25% if spell element matches its own opposite (weak)
@@ -288,129 +109,6 @@ function getElementalBonus(spellElem: string, floorElem: string): number {
return 1.0; // Neutral
}
export function calcDamage(
state: Pick<GameState, 'skills' | 'signedPacts'>,
spellId: string,
floorElem?: string
): number {
const sp = SPELLS_DEF[spellId];
if (!sp) return 5;
const skills = state.skills;
const baseDmg = sp.dmg + (skills.combatTrain || 0) * 5;
const pct = 1 + (skills.arcaneFury || 0) * 0.1;
// Elemental mastery bonus
const elemMasteryBonus = 1 + (skills.elementalMastery || 0) * 0.15;
// Guardian bane bonus
const guardianBonus = floorElem && GUARDIANS[Object.values(GUARDIANS).find(g => g.element === floorElem)?.hp ? 0 : 0]
? 1 + (skills.guardianBane || 0) * 0.2
: 1;
const critChance = (skills.precision || 0) * 0.05;
const pactMult = state.signedPacts.reduce(
(m, f) => m * (GUARDIANS[f]?.pact || 1),
1
);
let damage = baseDmg * pct * pactMult * elemMasteryBonus;
// Apply elemental bonus if floor element provided
if (floorElem) {
damage *= getElementalBonus(sp.elem, floorElem);
}
// Apply crit
if (Math.random() < critChance) {
damage *= 1.5;
}
return damage;
}
export function calcInsight(state: Pick<GameState, 'maxFloorReached' | 'totalManaGathered' | 'signedPacts' | 'prestigeUpgrades' | 'skills'>): number {
const pu = state.prestigeUpgrades;
const skillBonus = 1 + (state.skills.insightHarvest || 0) * 0.1;
const mult = (1 + (pu.insightAmp || 0) * 0.25) * skillBonus;
return Math.floor(
(state.maxFloorReached * 15 + state.totalManaGathered / 500 + state.signedPacts.length * 150) * mult
);
}
// Meditation bonus now affects regen rate directly
export function getMeditationBonus(meditateTicks: number, skills: Record<string, number>, meditationEfficiency: number = 1): number {
const hasMeditation = skills.meditation === 1;
const hasDeepTrance = skills.deepTrance === 1;
const hasVoidMeditation = skills.voidMeditation === 1;
const hours = meditateTicks * HOURS_PER_TICK;
// Base meditation: ramps up over 4 hours to 1.5x
let bonus = 1 + Math.min(hours / 4, 0.5);
// With Meditation Focus: up to 2.5x after 4 hours
if (hasMeditation && hours >= 4) {
bonus = 2.5;
}
// With Deep Trance: up to 3.0x after 6 hours
if (hasDeepTrance && hours >= 6) {
bonus = 3.0;
}
// With Void Meditation: up to 5.0x after 8 hours
if (hasVoidMeditation && hours >= 8) {
bonus = 5.0;
}
// Apply meditation efficiency from upgrades (Deep Wellspring, etc.)
bonus *= meditationEfficiency;
return bonus;
}
export function getIncursionStrength(day: number, hour: number): number {
if (day < INCURSION_START_DAY) return 0;
const totalHours = (day - INCURSION_START_DAY) * 24 + hour;
const maxHours = (MAX_DAY - INCURSION_START_DAY) * 24;
return Math.min(0.95, (totalHours / maxHours) * 0.95);
}
// Check if player can afford spell cost
export function canAffordSpellCost(
cost: SpellCost,
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>
): boolean {
if (cost.type === 'raw') {
return rawMana >= cost.amount;
} else {
const elem = elements[cost.element || ''];
return elem && elem.unlocked && elem.current >= cost.amount;
}
}
// Deduct spell cost from appropriate mana pool
function deductSpellCost(
cost: SpellCost,
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>
): { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> } {
const newElements = { ...elements };
if (cost.type === 'raw') {
return { rawMana: rawMana - cost.amount, elements: newElements };
} else if (cost.element && newElements[cost.element]) {
newElements[cost.element] = {
...newElements[cost.element],
current: newElements[cost.element].current - cost.amount
};
return { rawMana, elements: newElements };
}
return { rawMana, elements: newElements };
}
// ─── Initial State Factory ────────────────────────────────────────────────────
function makeInitial(overrides: Partial<GameState> = {}): GameState {
@@ -587,17 +285,12 @@ function makeInitial(overrides: Partial<GameState> = {}): GameState {
// ─── Game Store ───────────────────────────────────────────────────────────────
interface GameStore extends GameState, CraftingActions, FamiliarActions {
interface GameStore extends GameState, CraftingActions, FamiliarActions, NavigationActions, StudyActions {
// Actions
tick: () => void;
gatherMana: () => void;
setAction: (action: GameAction) => void;
setSpell: (spellId: string) => void;
startStudyingSkill: (skillId: string) => void;
startStudyingSpell: (spellId: string) => void;
startParallelStudySkill: (skillId: string) => void;
cancelStudy: () => void;
cancelParallelStudy: () => void;
convertMana: (element: string, amount: number) => void;
unlockElement: (element: string) => void;
craftComposite: (target: string) => void;
@@ -611,10 +304,6 @@ interface GameStore extends GameState, CraftingActions, FamiliarActions {
commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => void;
tierUpSkill: (skillId: string) => void;
// Floor Navigation
setClimbDirection: (direction: 'up' | 'down') => void;
changeFloor: (direction: 'up' | 'down') => void;
// Inventory Management
updateLootInventory: (inventory: LootInventory) => void;
@@ -633,6 +322,8 @@ export const useGameStore = create<GameStore>()(
(set, get) => ({
...makeInitial(),
...createFamiliarSlice(set, get),
...createNavigationSlice(set, get),
...createStudySlice(set, get),
getMaxMana: () => computeMaxMana(get()),
getRegen: () => computeRegen(get()),
@@ -1221,108 +912,6 @@ export const useGameStore = create<GameStore>()(
}
},
startStudyingSkill: (skillId: string) => {
const state = get();
const sk = SKILLS_DEF[skillId];
if (!sk) return;
const currentLevel = state.skills[skillId] || 0;
if (currentLevel >= sk.max) return;
// Check prerequisites
if (sk.req) {
for (const [r, rl] of Object.entries(sk.req)) {
if ((state.skills[r] || 0) < rl) return;
}
}
// Check mana cost (with focused mind reduction)
const costMult = getStudyCostMultiplier(state.skills);
const cost = Math.floor(sk.base * (currentLevel + 1) * costMult);
if (state.rawMana < cost) return;
// Start studying
set({
rawMana: state.rawMana - cost,
currentAction: 'study',
currentStudyTarget: {
type: 'skill',
id: skillId,
progress: state.skillProgress[skillId] || 0,
required: sk.studyTime,
},
log: [`📚 Started studying ${sk.name}...`, ...state.log.slice(0, 49)],
});
},
startStudyingSpell: (spellId: string) => {
const state = get();
const sp = SPELLS_DEF[spellId];
if (!sp || state.spells[spellId]?.learned) return;
// Check mana cost (with focused mind reduction)
const costMult = getStudyCostMultiplier(state.skills);
const cost = Math.floor(sp.unlock * costMult);
if (state.rawMana < cost) return;
const studyTime = sp.studyTime || (sp.tier * 4); // Default study time based on tier
// Start studying
set({
rawMana: state.rawMana - cost,
currentAction: 'study',
currentStudyTarget: {
type: 'spell',
id: spellId,
progress: state.spells[spellId]?.studyProgress || 0,
required: studyTime,
},
spells: {
...state.spells,
[spellId]: { ...(state.spells[spellId] || { learned: false, level: 0 }), studyProgress: state.spells[spellId]?.studyProgress || 0 },
},
log: [`📚 Started studying ${sp.name}...`, ...state.log.slice(0, 49)],
});
},
cancelStudy: () => {
const state = get();
if (!state.currentStudyTarget) return;
// Knowledge retention bonus
const retentionBonus = 1 + (state.skills.knowledgeRetention || 0) * 0.2;
const savedProgress = Math.min(
state.currentStudyTarget.progress,
state.currentStudyTarget.required * retentionBonus
);
// Save progress
if (state.currentStudyTarget.type === 'skill') {
set({
currentStudyTarget: null,
currentAction: 'meditate',
skillProgress: {
...state.skillProgress,
[state.currentStudyTarget.id]: savedProgress,
},
log: [`📖 Study interrupted. Progress saved.`, ...state.log.slice(0, 49)],
});
} else if (state.currentStudyTarget.type === 'spell') {
set({
currentStudyTarget: null,
currentAction: 'meditate',
spells: {
...state.spells,
[state.currentStudyTarget.id]: {
...(state.spells[state.currentStudyTarget.id] || { learned: false, level: 0 }),
studyProgress: savedProgress,
},
},
log: [`📖 Study interrupted. Progress saved.`, ...state.log.slice(0, 49)],
});
}
},
convertMana: (element: string, amount: number = 1) => {
const state = get();
const e = state.elements[element];
@@ -1527,42 +1116,7 @@ export const useGameStore = create<GameStore>()(
log: [`🌟 ${SKILLS_DEF[baseSkillId]?.name || baseSkillId} evolved to Tier ${nextTier}!`, ...state.log.slice(0, 49)],
});
},
startParallelStudySkill: (skillId: string) => {
const state = get();
if (state.parallelStudyTarget) return; // Already have parallel study
if (!state.currentStudyTarget) return; // Need primary study
const sk = SKILLS_DEF[skillId];
if (!sk) return;
const currentLevel = state.skills[skillId] || 0;
if (currentLevel >= sk.max) return;
// Can't study same thing in parallel
if (state.currentStudyTarget.id === skillId) return;
set({
parallelStudyTarget: {
type: 'skill',
id: skillId,
progress: state.skillProgress[skillId] || 0,
required: sk.studyTime,
},
log: [`📚 Started parallel study of ${sk.name}... (50% speed)`, ...state.log.slice(0, 49)],
});
},
cancelParallelStudy: () => {
set((state) => {
if (!state.parallelStudyTarget) return state;
return {
parallelStudyTarget: null,
log: ['📖 Parallel study cancelled.', ...state.log.slice(0, 49)],
};
});
},
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => {
const state = get();
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
@@ -2007,47 +1561,6 @@ export const useGameStore = create<GameStore>()(
};
});
},
// ─── Floor Navigation ────────────────────────────────────────────────────────
setClimbDirection: (direction: 'up' | 'down') => {
set({ climbDirection: direction });
},
changeFloor: (direction: 'up' | 'down') => {
const state = get();
const currentFloor = state.currentFloor;
// Calculate next floor
const nextFloor = direction === 'up'
? Math.min(currentFloor + 1, 100)
: Math.max(currentFloor - 1, 1);
// Can't stay on same floor
if (nextFloor === currentFloor) return;
// Mark current floor as cleared (it will respawn when we come back)
const clearedFloors = { ...state.clearedFloors };
clearedFloors[currentFloor] = true;
// Check if next floor was cleared (needs respawn)
const nextFloorCleared = clearedFloors[nextFloor];
if (nextFloorCleared) {
// Respawn the floor
delete clearedFloors[nextFloor];
}
set({
currentFloor: nextFloor,
floorMaxHP: getFloorMaxHP(nextFloor),
floorHP: getFloorMaxHP(nextFloor),
maxFloorReached: Math.max(state.maxFloorReached, nextFloor),
clearedFloors,
climbDirection: direction,
equipmentSpellStates: state.equipmentSpellStates.map(s => ({ ...s, castProgress: 0 })),
log: [`🚶 Moved to floor ${nextFloor}${nextFloorCleared ? ' (respawned)' : ''}.`, ...state.log.slice(0, 49)],
});
},
}),
{
name: 'mana-loop-storage',