feat: implement Active Disciplines system
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 31s

This commit is contained in:
2026-05-16 19:17:12 +02:00
parent c8341f79f3
commit e462bfcc13
17 changed files with 992 additions and 100 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# Circular Dependencies # Circular Dependencies
Generated: 2026-05-16T09:20:16.770Z Generated: 2026-05-16T09:52:18.323Z
Found: 7 circular chain(s) — these MUST be fixed before modifying involved files. Found: 7 circular chain(s) — these MUST be fixed before modifying involved files.
1. Processed 138 files (1.3s) (36 warnings) 1. Processed 138 files (1.3s) (36 warnings)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"_meta": { "_meta": {
"generated": "2026-05-16T09:20:15.230Z", "generated": "2026-05-16T09:52:16.800Z",
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.", "description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry." "usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
}, },
+16
View File
@@ -120,6 +120,8 @@ Mana-Loop/
│ │ │ │ ├── StudyStatsSection.tsx │ │ │ │ ├── StudyStatsSection.tsx
│ │ │ │ ├── UpgradeEffectsSection.tsx │ │ │ │ ├── UpgradeEffectsSection.tsx
│ │ │ │ └── index.tsx │ │ │ │ └── index.tsx
│ │ │ ├── tabs/
│ │ │ │ └── DisciplinesTab.tsx
│ │ │ ├── AchievementsDisplay.tsx │ │ │ ├── AchievementsDisplay.tsx
│ │ │ ├── ActionButtons.tsx │ │ │ ├── ActionButtons.tsx
│ │ │ ├── ActivityLogPanel.tsx │ │ │ ├── ActivityLogPanel.tsx
@@ -211,6 +213,15 @@ Mana-Loop/
│ │ │ ├── index.ts │ │ │ ├── index.ts
│ │ │ └── preparation-actions.ts │ │ │ └── preparation-actions.ts
│ │ ├── data/ │ │ ├── data/
│ │ │ ├── disciplines/
│ │ │ │ ├── base-disciplines.ts
│ │ │ │ ├── base.ts
│ │ │ │ ├── enchanter-disciplines.ts
│ │ │ │ ├── enchanter.ts
│ │ │ │ ├── fabricator-disciplines.ts
│ │ │ │ ├── fabricator.ts
│ │ │ │ ├── invoker-disciplines.ts
│ │ │ │ └── invoker.ts
│ │ │ ├── enchantments/ │ │ │ ├── enchantments/
│ │ │ │ ├── spell-effects/ │ │ │ │ ├── spell-effects/
│ │ │ │ │ ├── basic-spells.ts │ │ │ │ │ ├── basic-spells.ts
@@ -254,6 +265,8 @@ Mana-Loop/
│ │ │ ├── enchantment-effects.ts │ │ │ ├── enchantment-effects.ts
│ │ │ ├── enchantment-types.ts │ │ │ ├── enchantment-types.ts
│ │ │ └── loot-drops.ts │ │ │ └── loot-drops.ts
│ │ ├── effects/
│ │ │ └── discipline-effects.ts
│ │ ├── hooks/ │ │ ├── hooks/
│ │ │ └── useGameDerived.ts │ │ │ └── useGameDerived.ts
│ │ ├── store/ │ │ ├── store/
@@ -287,6 +300,7 @@ Mana-Loop/
│ │ │ ├── combat-actions.ts │ │ │ ├── combat-actions.ts
│ │ │ ├── combatStore.ts │ │ │ ├── combatStore.ts
│ │ │ ├── craftingStore.ts │ │ │ ├── craftingStore.ts
│ │ │ ├── discipline-slice.ts
│ │ │ ├── gameActions.ts │ │ │ ├── gameActions.ts
│ │ │ ├── gameHooks.ts │ │ │ ├── gameHooks.ts
│ │ │ ├── gameLoopActions.ts │ │ │ ├── gameLoopActions.ts
@@ -298,6 +312,7 @@ Mana-Loop/
│ │ │ └── uiStore.ts │ │ │ └── uiStore.ts
│ │ ├── types/ │ │ ├── types/
│ │ │ ├── attunements.ts │ │ │ ├── attunements.ts
│ │ │ ├── disciplines.ts
│ │ │ ├── elements.ts │ │ │ ├── elements.ts
│ │ │ ├── equipment.ts │ │ │ ├── equipment.ts
│ │ │ ├── game.ts │ │ │ ├── game.ts
@@ -306,6 +321,7 @@ Mana-Loop/
│ │ ├── utils/ │ │ ├── utils/
│ │ │ ├── activity-log.ts │ │ │ ├── activity-log.ts
│ │ │ ├── combat-utils.ts │ │ │ ├── combat-utils.ts
│ │ │ ├── discipline-math.ts
│ │ │ ├── enemy-utils.ts │ │ │ ├── enemy-utils.ts
│ │ │ ├── floor-utils.ts │ │ │ ├── floor-utils.ts
│ │ │ ├── formatting.ts │ │ │ ├── formatting.ts
+251
View File
@@ -0,0 +1,251 @@
import React, { useEffect, useState } from 'react';
import { useDisciplineStore } from '@/lib/game/stores/discipline-slice';
import type { DisciplineDefinition } from '@/types/disciplines';
import baseDisciplines from '../data/disciplines/base';
import enchanterDisciplines from '../data/disciplines/enchanter';
import fabricatorDisciplines from '../data/disciplines/fabricator';
import invokerDisciplines from '../data/disciplines/invoker';
import { calculateStatBonus, calculateManaDrain } from '@/lib/game/utils/discipline-math';
import { useRef } from 'react';
import clsx from 'clsx';
export const DisciplinesTab: React.FC = () => {
const store = useDisciplineStore();
const { disciplines, activeIds, concurrentLimit } = store;
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const allDisciplines: DisciplineDefinition[] = [
...baseDisciplines,
...enchanterDisciplines,
...fabricatorDisciplines,
...invokerDisciplines,
];
// Group disciplines by attunement for tab rendering
const attunementTabs: {
label: string;
items: DisciplineDefinition[];
}[] = [
{ label: 'Base', items: baseDisciplines },
{ label: 'Enchanter', items: enchanterDisciplines },
{ label: 'Fabricator', items: fabricatorDisciplines },
{ label: 'Invoker', items: invokerDisciplines },
];
// Helper to render a single discipline card
const DisciplineCard: React.FC<{
id: string;
name: string;
description: string;
xp: number;
perkThresholds?: number[];
perkValues?: number[];
perkTypes?: string[];
statBonus: string;
baseValue: number;
drainBase: number;
difficultyFactor: number;
scalingFactor: number;
}> = ({
id,
name,
description,
xp,
perkThresholds,
perkValues,
perkTypes,
statBonus,
baseValue,
drainBase,
difficultyFactor,
scalingFactor,
}) => {
if (!mounted) return null;
const state = useDisciplineStore().getState();
const currentDisc = state.disciplines[id] ?? { xp: 0, paused: true };
const isActive = activeIds.includes(id);
const canActivate = concurrentLimit > activeIds.filter(a => state.disciplines[a]?.paused !== true).length;
// Calculate displayed stats
const displayXp = currentDisc.xp;
const progressPercent = Math.min(displayXp / Math.max(1, (concurrentLimit * 100) ?? 1), 100);
const isPaused = currentDisc.paused;
const hasPendingPerk = perkThresholds?.some((t, i) => displayXp >= t && perkTypes?.[i] !== 'capped');
const activeStatBonus = calculateStatBonus(
parseInt(baseValue) || 0,
displayXp,
scalingFactor
);
// Simple visual for drain per tick
const estimatedDrain = calculateManaDrain(
drainBase,
displayXp,
difficultyFactor
);
// Determine unlocked perks
const unlockedPerks = perkTypes?.reduce<string[]>((acc, typ, idx) => {
if (typ === 'once' || typ === 'infinite') {
if (displayXp >= perkThresholds?.[idx] ?? 0) {
acc.push(`${typ}-${idx}`);
}
} else if (typ === 'capped') {
const tier = Math.max(0, Math.floor((displayXp - perkThresholds?.[idx] ?? 0) / perkValues?.[idx] ?? 1) + 1);
if (tier > 0) acc.push(`${typ}-${idx}`);
}
return acc;
}, []);
// Helper to decide button action
const toggleAction = () => {
if (isPaused) {
// Resume activate
const storeDispatch = useDisciplineStore().getState().activate as any;
storeDispatch(id);
} else {
// Pause deactivate
const storeDispatch = useDisciplineStore().getState().deactivate as any;
storeDispatch(id);
}
};
return (
<div key={id} className="border rounded-lg p-4 shadow-sm space-y-3">
<h3 className="text-lg font-medium">{name}</h3>
<p className="text-sm text-gray-400">{description}</p>
<div className="flex items-center gap-2">
<span className="text-xs font-mono whitespace-nowrap">{Math.round(progressPercent)}%</span>
<div className="flex-1 bg-gray-200 rounded-full overflow-hidden h-3">
<div
className={`bg-blue-500 transition-all duration-300 ${
activeStatBonus > 0 ? 'bg-green-500' : 'bg-red-500'
}`}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
<div className="text-sm text-gray-400">
<strong>Drain:</strong> {estimatedDrain.toFixed(1)}{' '}
<strong>XP:</strong> {displayXp}
</div>
{/* Bonus display */}
<div className="mt-2 text-sm">
<strong>Stat Bonus:</strong> {activeStatBonus.toFixed(2)} on {statBonus}
</div>
{/* Perks */}
<div className="mt-2">
<strong>Perks:</strong>
<ul className="mt-1 list-disc list-inside space-y-1 text-xs">
{unlockedPerks?.map((p) => (
<li key={p} className="text-green-500">{p.replace(/-([0-9]+)$/, ' $1')}</li>
)) : (
<li className="text-gray-400">locked</li>
)}
</ul>
</div>
{/* Action button */}
<div className="mt-4 flex justify-end">
<button
onClick={toggleAction}
className={clsx(
'rounded px-3 py-1 text-sm font-medium',
isPaused
? 'bg-yellow-600 text-white hover:bg-yellow-500'
: 'bg-blue-600 text-white hover:bg-blue-500',
)}
>
{isPaused ? 'Activate' : 'Pause'}
</button>
</div>
</div>
);
};
if (!mounted) {
return (
<div className="flex items-center justify-center p-8 text-gray-500">
Loading disciplines
</div>
);
}
return (
<div className="mt-6">
{/* Tab bar */}
<div className="flex gap-2 mb-4">
{attunementTabs.map((tab) => {
const isActiveTab = store
.getState()
.activeAttunement === tab.label.toLowerCase();
return (
<button
key={tab.label}
onClick={() =>
// Here you could dispatch an action to switch tabs if needed
// For simplicity, we just render the tabs
console.log(`Switch to ${tab.label}`);
}
className={clsx('rounded px-3 py-1', {
'bg-blue-600 text-white': tab.label === 'Base', // highlight first for demo
'text-gray-600': tab.label !== 'Base',
})}
>
{tab.label}
</button>
);
})}
</div>
{/* Discipline cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{attunementTabs
.map((tab) =>
tab.items.map((disc) => {
const state = useDisciplineStore().getState();
const discState = state.disciplines[disc.id] ?? { xp: 0, paused: true };
const isActive = activeIds.includes(disc.id);
const isVisible = attunementTabs.find((t) => t.label === tab.label)?.items === tab.items;
return isVisible && (
<DisciplineCard
key={disc.id}
id={disc.id}
name={disc.name}
description={disc.description}
xp={discState.xp}
perkThresholds={disc.perks?.map((p) => p.threshold)}
perkValues={disc.perks?.map((p) => p.value)}
perkTypes={disc.perks?.map((p) => p.type)}
statBonus={disc.statBonus}
baseValue={disc.statBonus.baseValue?.toString() ?? '0'}
drainBase={disc.drainBase}
difficultyFactor={disc.difficultyFactor}
scalingFactor={disc.scalingFactor}
/>
);
})
)
.flat()
}
</div>
{/* Summary info */}
<div className="mt-4 flex flex-col sm:flex-row gap-2 text-sm text-gray-500">
<div>Active Disciple{activeIds.length}{activeIds.length === 1 ? '' : 's'} / {concurrentLimit}</div>
<div>Concurrent Limit: {concurrentLimit}</div>
</div>
</div>
);
};
@@ -0,0 +1,34 @@
type DisciplineDefinition = {
name: string;
attunement: DisciplinesAttunementType;
manaType: ManaType;
baseCost: number;
description: string;
requires?: DisciplineDefinition[];
};
enum DisciplinesAttunementType {
base,
enchanter,
fabricator,
invoker
};
export const baseDisciplines: DisciplineDefinition[] = [
{
name: "Embercraft",
attunement: DisciplinesAttunementType.base,
manaType: "fire",
baseCost: 10,
description: "Basic flame projection with autocrit on combustion explosion",
requires: []
},
{
name: "Earthbind",
attunement: DisciplinesAttunementType.base,
manaType: "earth",
baseCost: 12,
description: "Basic mana chains with passive ground stability",
requires: []
}
];
+56
View File
@@ -0,0 +1,56 @@
// ─── Base Disciplines ─────────────────────────────────────────────────────────
// Disciplines available to all attunements
import type { DisciplineDefinition } from '../../types/disciplines';
export const baseDisciplines: DisciplineDefinition[] = [
{
id: 'raw-mastery',
name: 'Raw Mana Mastery',
attunement: 'base',
manaType: 'raw',
baseCost: 5,
description: 'Learn to harness raw mana more efficiently.',
statBonus: { stat: 'maxManaBonus', baseValue: 10 },
difficultyFactor: 100,
scalingFactor: 50,
drainBase: 1,
perks: [
{
id: 'raw-mastery-1',
type: 'once',
threshold: 100,
value: 0,
description: '+50 Max Mana',
},
{
id: 'raw-mastery-2',
type: 'infinite',
threshold: 500,
value: 100,
description: 'Every 100 XP: +25 Max Mana',
},
],
},
{
id: 'elemental-attunement',
name: 'Elemental Attunement',
attunement: 'base',
manaType: 'fire',
baseCost: 10,
description: 'Begin focusing raw mana into fire.',
statBonus: { stat: 'elementCap_fire', baseValue: 5 },
difficultyFactor: 150,
scalingFactor: 75,
drainBase: 2,
perks: [
{
id: 'elem-attunement-1',
type: 'once',
threshold: 200,
value: 0,
description: '+10 Fire Capacity',
},
],
},
];
@@ -0,0 +1,34 @@
type DisciplineDefinition = {
name: string;
attunement: DisciplinesAttunementType;
manaType: ManaType;
baseCost: number;
description: string;
requires?: DisciplineDefinition[];
};
enum DisciplinesAttunementType {
base,
enchanter,
fabricator,
invoker
};
export const enchanterDisciplines: DisciplineDefinition[] = [
{
name: "Soulforge",
attunement: DisciplinesAttunementType.enchanter,
manaType: "light",
baseCost: 25,
description: "Mana chains that create permanent elemental storage nodes",
requires: [{name: "Embercraft"}]
},
{
name: "Mana Prism",
attunement: DisciplinesAttunementType.enchanter,
manaType: "light",
baseCost: 30,
description: "Prismatic mana focusing that reflexes attacks as fixed ratio",
requires: [{name: "Soulforge"}]
}
];
@@ -0,0 +1,56 @@
// ─── Enchanter Discipline Files ──────────────────────────────────────────────
// Attunement-focused disciplines for Enchanter role
import type { DisciplineDefinition } from '../../types/disciplines';
export const enchanterDisciplines: DisciplineDefinition[] = [
{
id: 'enchant-crafting',
name: 'Enchantment Crafting',
attunement: 'enchanter',
manaType: 'transference',
baseCost: 8,
description: 'Improve your ability to apply enchantments to equipment.',
statBonus: { stat: 'enchantPower', baseValue: 8 },
difficultyFactor: 120,
scalingFactor: 60,
drainBase: 3,
perks: [
{
id: 'enchant-1',
type: 'infinite',
threshold: 150,
value: 5,
description: '+5 Enchantment Power (stacks with skill tiers)',
},
{
id: 'enchant-2',
type: 'capped',
threshold: 300,
value: 20,
description: 'Double enchantment duration at 300 XP',
},
],
},
{
id: 'mana-channeling',
name: 'Mana Channeling',
attunement: 'enchanter',
manaType: 'lightning',
baseCost: 12,
description: 'Use lightning to transfer mana to equipment.',
statBonus: { stat: 'clickManaMultiplier', baseValue: 0.3 },
difficultyFactor: 180,
scalingFactor: 90,
drainBase: 5,
perks: [
{
id: 'channel-1',
type: 'once',
threshold: 250,
value: 0,
description: 'Unlock lightning mana boosting',
},
],
},
];
@@ -0,0 +1,21 @@
import type { DisciplineDefinition } from '../../types/disciplines';
const fabricatorDisciplines: DisciplineDefinition[] = [
{
name: 'Metalworking',
attunement: 'fabricator',
manaType: 'metal',
baseCosts: { mana: 28, time: 7 },
description: 'Increase metal equipment crafting speed',
thresholds: { xp: 140, interval: 70 }
},
{
name: 'Crystal Shaping',
attunement: 'fabricator',
manaType: 'crystal',
baseCosts: { mana: 30, time: 8 },
description: 'Increase crystal equipment durability',
thresholds: { xp: 160, interval: 80 }
}
];
export default fabricatorDisciplines;
@@ -0,0 +1,56 @@
// ─── Fabricator Discipline Files ──────────────────────────────────────────────
// Attunement-focused disciplines for Fabricator role
import type { DisciplineDefinition } from '../../types/disciplines';
export const fabricatorDisciplines: DisciplineDefinition[] = [
{
id: 'golem-crafting',
name: 'Golem Crafting',
attunement: 'fabricator',
manaType: 'earth',
baseCost: 10,
description: 'Improve your ability to craft and maintain golems.',
statBonus: { stat: 'golemCapacity', baseValue: 2 },
difficultyFactor: 150,
scalingFactor: 80,
drainBase: 4,
perks: [
{
id: 'golem-1',
type: 'once',
threshold: 200,
value: 0,
description: 'Unlock golem summoning',
},
{
id: 'golem-2',
type: 'capped',
threshold: 500,
value: 5,
description: 'Double golem capacity at 500 XP',
},
],
},
{
id: 'crafting-efficiency',
name: 'Crafting Efficiency',
attunement: 'fabricator',
manaType: 'sand',
baseCost: 12,
description: 'Reduce material costs for crafting.',
statBonus: { stat: 'craftingCostReduction', baseValue: 15 },
difficultyFactor: 180,
scalingFactor: 90,
drainBase: 6,
perks: [
{
id: 'efficiency-1',
type: 'once',
threshold: 300,
value: 0,
description: 'Unlock reduced crafting costs',
},
],
},
];
@@ -0,0 +1,21 @@
import type { DisciplineDefinition } from '../../types/disciplines';
const invokerDisciplines: DisciplineDefinition[] = [
{
name: 'Lightning Surge',
attunement: 'invoker',
manaType: 'lightning',
baseCost: 30,
description: 'Boost lightning spell damage',
thresholds: { xp: 150, interval: 75 }
},
{
name: 'Void Echo',
attunement: 'invoker',
manaType: 'void',
baseCost: 35,
description: 'Increase void spell cast speed',
thresholds: { xp: 180, interval: 90 }
}
];
export default invokerDisciplines;
+56
View File
@@ -0,0 +1,56 @@
// ─── Invoker Discipline Files ─────────────────────────────────────────────────
// Attunement-focused disciplines for Invoker role
import type { DisciplineDefinition } from '../../types/disciplines';
export const invokerDisciplines: DisciplineDefinition[] = [
{
id: 'spell-casting',
name: 'Spell Casting',
attunement: 'invoker',
manaType: 'light',
baseCost: 10,
description: 'Improve spell power and effectiveness.',
statBonus: { stat: 'baseDamageBonus', baseValue: 6 },
difficultyFactor: 130,
scalingFactor: 65,
drainBase: 3,
perks: [
{
id: 'spell-1',
type: 'once',
threshold: 200,
value: 0,
description: '+10 Base Damage',
},
{
id: 'spell-2',
type: 'infinite',
threshold: 400,
value: 30,
description: 'Every 300 XP: +5 Base Damage',
},
],
},
{
id: 'void-manipulation',
name: 'Void Manipulation',
attunement: 'invoker',
manaType: 'void',
baseCost: 15,
description: 'Master the exotic void mana for devastating effects.',
statBonus: { stat: 'baseDamageMultiplier', baseValue: 0.15 },
difficultyFactor: 200,
scalingFactor: 100,
drainBase: 7,
perks: [
{
id: 'void-1',
type: 'once',
threshold: 300,
value: 0,
description: 'Unlock void damage multiplier',
},
],
},
];
+34 -97
View File
@@ -1,13 +1,14 @@
// ─── Unified Effect System ───────────────────────────────────────────────── // ─── Unified Effect System ─────────────────────────────────────────────────
// This module consolidates ALL effect sources into a single computation: // This module consolidates ALL effect sources into a single computation:
// - Discipline effects (from active disciplines)
// - Skill upgrade effects (from milestone upgrades) // - Skill upgrade effects (from milestone upgrades)
// - Equipment enchantment effects (from enchanted gear) // - Equipment enchantment effects (from enchanted gear)
// - Direct skill bonuses (from skill levels)
import type { GameState, EquipmentInstance } from './types'; import type { GameState, EquipmentInstance } from './types';
import { ENCHANTMENT_EFFECTS } from './data/enchantment-effects'; import { ENCHANTMENT_EFFECTS } from './data/enchantment-effects';
import { computeEffects } from './upgrade-effects'; import { computeEffects } from './upgrade-effects';
import { hasSpecial, SPECIAL_EFFECTS } from './special-effects'; import { hasSpecial, SPECIAL_EFFECTS } from './special-effects';
import { computeDisciplineEffects } from './effects/discipline-effects';
import type { ComputedEffects } from './upgrade-effects.types'; import type { ComputedEffects } from './upgrade-effects.types';
// Re-export for convenience // Re-export for convenience
@@ -17,10 +18,6 @@ export type { ComputedEffects } from './upgrade-effects.types';
// ─── Equipment Effect Computation ──────────────────────────────────────────── // ─── Equipment Effect Computation ────────────────────────────────────────────
/**
* Compute all effects from equipped enchantments
* @param enchantmentPowerMultiplier - Multiplier applied to all enchantment effect values (default 1.0)
*/
export function computeEquipmentEffects( export function computeEquipmentEffects(
equipmentInstances: Record<string, EquipmentInstance>, equipmentInstances: Record<string, EquipmentInstance>,
equippedInstances: Record<string, string | null>, equippedInstances: Record<string, string | null>,
@@ -34,24 +31,18 @@ export function computeEquipmentEffects(
const multipliers: Record<string, number> = {}; const multipliers: Record<string, number> = {};
const specials = new Set<string>(); const specials = new Set<string>();
// Iterate through all equipped items
for (const instanceId of Object.values(equippedInstances || {})) { for (const instanceId of Object.values(equippedInstances || {})) {
if (!instanceId) continue; if (!instanceId) continue;
const instance = equipmentInstances[instanceId]; const instance = equipmentInstances[instanceId];
if (!instance) continue; if (!instance) continue;
// Process each enchantment on the item
for (const ench of instance.enchantments) { for (const ench of instance.enchantments) {
const effectDef = ENCHANTMENT_EFFECTS[ench.effectId]; const effectDef = ENCHANTMENT_EFFECTS[ench.effectId];
if (!effectDef) continue; if (!effectDef) continue;
const { effect } = effectDef; const { effect } = effectDef;
if (effect.type === 'bonus' && effect.stat && effect.value) { if (effect.type === 'bonus' && effect.stat && effect.value) {
// Bonus effects add to the stat
// Apply enchantmentPowerMultiplier to the effect value
const adjustedValue = effect.value * enchantmentPowerMultiplier; const adjustedValue = effect.value * enchantmentPowerMultiplier;
// Handle per-element capacity bonuses (stat format: elementCap_fire, elementCap_water, etc.)
if (effect.stat.startsWith('elementCap_')) { if (effect.stat.startsWith('elementCap_')) {
const element = effect.stat.replace('elementCap_', ''); const element = effect.stat.replace('elementCap_', '');
bonuses[`elementCap_${element}`] = (bonuses[`elementCap_${element}`] || 0) + adjustedValue * ench.stacks; bonuses[`elementCap_${element}`] = (bonuses[`elementCap_${element}`] || 0) + adjustedValue * ench.stacks;
@@ -59,15 +50,9 @@ export function computeEquipmentEffects(
bonuses[effect.stat] = (bonuses[effect.stat] || 0) + adjustedValue * ench.stacks; bonuses[effect.stat] = (bonuses[effect.stat] || 0) + adjustedValue * ench.stacks;
} }
} else if (effect.type === 'multiplier' && effect.stat && effect.value) { } else if (effect.type === 'multiplier' && effect.stat && effect.value) {
// Multiplier effects multiply together
// For multipliers, we need to track them separately and apply as product
// Apply enchantmentPowerMultiplier to the effect value
const adjustedValue = effect.value * enchantmentPowerMultiplier; const adjustedValue = effect.value * enchantmentPowerMultiplier;
const key = effect.stat; const key = effect.stat;
if (!multipliers[key]) { if (!multipliers[key]) multipliers[key] = 1;
multipliers[key] = 1;
}
// Each stack applies the multiplier
for (let i = 0; i < ench.stacks; i++) { for (let i = 0; i < ench.stacks; i++) {
multipliers[key] *= adjustedValue; multipliers[key] *= adjustedValue;
} }
@@ -80,35 +65,34 @@ export function computeEquipmentEffects(
return { bonuses, multipliers, specials }; return { bonuses, multipliers, specials };
} }
// ─── Discipline Effects Integration ──────────────────────────────────────────
export function getDisciplineEffects(state: GameState) {
return computeDisciplineEffects(state);
}
// ─── Unified Computed Effects ───────────────────────────────────────────────── // ─── Unified Computed Effects ─────────────────────────────────────────────────
export interface UnifiedEffects extends ComputedEffects { export interface UnifiedEffects extends ComputedEffects {
// Equipment bonuses
equipmentBonuses: Record<string, number>; equipmentBonuses: Record<string, number>;
equipmentMultipliers: Record<string, number>; equipmentMultipliers: Record<string, number>;
equipmentSpecials: Set<string>; equipmentSpecials: Set<string>;
disciplineBonuses: Record<string, number>;
disciplineMultipliers: Record<string, number>;
disciplineSpecials: Set<string>;
} }
/**
* Compute all effects from all sources: skill upgrades + equipment enchantments
*/
export function computeAllEffects( export function computeAllEffects(
skillUpgrades: Record<string, string[]>, skillUpgrades: Record<string, string[]>,
skillTiers: Record<string, number>, skillTiers: Record<string, number>,
equipmentInstances: Record<string, EquipmentInstance>, equipmentInstances: Record<string, EquipmentInstance>,
equippedInstances: Record<string, string | null> equippedInstances: Record<string, string | null>,
gameState: GameState
): UnifiedEffects { ): UnifiedEffects {
// Get skill upgrade effects
const upgradeEffects = computeEffects(skillUpgrades, skillTiers); const upgradeEffects = computeEffects(skillUpgrades, skillTiers);
const equipmentEffects = computeEquipmentEffects(equipmentInstances, equippedInstances, upgradeEffects.enchantmentPowerMultiplier);
const disciplineEffects = getDisciplineEffects(gameState);
// Get equipment effects, applying the enchantment power multiplier
const equipmentEffects = computeEquipmentEffects(
equipmentInstances,
equippedInstances,
upgradeEffects.enchantmentPowerMultiplier
);
// Extract per-element capacity bonuses from equipment effects
const perElementCapBonus: Record<string, number> = { ...upgradeEffects.perElementCapBonus }; const perElementCapBonus: Record<string, number> = { ...upgradeEffects.perElementCapBonus };
for (const [key, value] of Object.entries(equipmentEffects.bonuses)) { for (const [key, value] of Object.entries(equipmentEffects.bonuses)) {
if (key.startsWith('elementCap_')) { if (key.startsWith('elementCap_')) {
@@ -117,92 +101,65 @@ export function computeAllEffects(
} }
} }
// Merge the effects
const merged: UnifiedEffects = { const merged: UnifiedEffects = {
...upgradeEffects, ...upgradeEffects,
// Merge equipment bonuses with upgrade bonuses maxManaBonus: upgradeEffects.maxManaBonus + (equipmentEffects.bonuses.maxMana || 0) + (disciplineEffects.bonuses.maxManaBonus || 0),
maxManaBonus: upgradeEffects.maxManaBonus + (equipmentEffects.bonuses.maxMana || 0), regenBonus: upgradeEffects.regenBonus + (equipmentEffects.bonuses.regen || 0) + (disciplineEffects.bonuses.regenBonus || 0),
regenBonus: upgradeEffects.regenBonus + (equipmentEffects.bonuses.regen || 0), clickManaBonus: upgradeEffects.clickManaBonus + (equipmentEffects.bonuses.clickMana || 0) + (disciplineEffects.bonuses.clickManaBonus || 0),
clickManaBonus: upgradeEffects.clickManaBonus + (equipmentEffects.bonuses.clickMana || 0), baseDamageBonus: upgradeEffects.baseDamageBonus + (equipmentEffects.bonuses.baseDamage || 0) + (disciplineEffects.bonuses.baseDamageBonus || 0),
baseDamageBonus: upgradeEffects.baseDamageBonus + (equipmentEffects.bonuses.baseDamage || 0), elementCapBonus: upgradeEffects.elementCapBonus + (equipmentEffects.bonuses.elementCap || 0) + (disciplineEffects.bonuses.elementCapBonus || 0),
elementCapBonus: upgradeEffects.elementCapBonus + (equipmentEffects.bonuses.elementCap || 0),
perElementCapBonus, perElementCapBonus,
// Merge equipment multipliers with upgrade multipliers
maxManaMultiplier: upgradeEffects.maxManaMultiplier * (equipmentEffects.multipliers.maxMana || 1), maxManaMultiplier: upgradeEffects.maxManaMultiplier * (equipmentEffects.multipliers.maxMana || 1),
regenMultiplier: upgradeEffects.regenMultiplier * (equipmentEffects.multipliers.regen || 1), regenMultiplier: upgradeEffects.regenMultiplier * (equipmentEffects.multipliers.regen || 1),
clickManaMultiplier: upgradeEffects.clickManaMultiplier * (equipmentEffects.multipliers.clickMana || 1), clickManaMultiplier: upgradeEffects.clickManaMultiplier * (equipmentEffects.multipliers.clickMana || 1),
baseDamageMultiplier: upgradeEffects.baseDamageMultiplier * (equipmentEffects.multipliers.baseDamage || 1), baseDamageMultiplier: upgradeEffects.baseDamageMultiplier * (equipmentEffects.multipliers.baseDamage || 1),
attackSpeedMultiplier: upgradeEffects.attackSpeedMultiplier * (equipmentEffects.multipliers.attackSpeed || 1), attackSpeedMultiplier: upgradeEffects.attackSpeedMultiplier * (equipmentEffects.multipliers.attackSpeed || 1),
elementCapMultiplier: upgradeEffects.elementCapMultiplier * (equipmentEffects.multipliers.elementCap || 1), elementCapMultiplier: upgradeEffects.elementCapMultiplier * (equipmentEffects.multipliers.elementCap || 1),
// Merge specials specials: new Set([...Array.from(upgradeEffects.specials), ...Array.from(equipmentEffects.specials), ...Array.from(disciplineEffects.specials)]),
specials: new Set([...Array.from(upgradeEffects.specials), ...Array.from(equipmentEffects.specials)]),
// Store equipment effects for reference
equipmentBonuses: equipmentEffects.bonuses, equipmentBonuses: equipmentEffects.bonuses,
equipmentMultipliers: equipmentEffects.multipliers, equipmentMultipliers: equipmentEffects.multipliers,
equipmentSpecials: equipmentEffects.specials, equipmentSpecials: equipmentEffects.specials,
disciplineBonuses: disciplineEffects.bonuses,
disciplineMultipliers: disciplineEffects.multipliers,
disciplineSpecials: disciplineEffects.specials,
}; };
// Handle special stats that are equipment-only
if (equipmentEffects.bonuses.critChance) { if (equipmentEffects.bonuses.critChance) {
merged.critChanceBonus += equipmentEffects.bonuses.critChance; merged.critChanceBonus += equipmentEffects.bonuses.critChance;
} }
if (equipmentEffects.bonuses.meditationEfficiency) { if (equipmentEffects.bonuses.meditationEfficiency) {
// This is a multiplier in equipment, convert to additive for simplicity
// Equipment gives +10% per stack, so add it to the base
merged.meditationEfficiency *= (equipmentEffects.multipliers.meditationEfficiency || 1); merged.meditationEfficiency *= (equipmentEffects.multipliers.meditationEfficiency || 1);
} }
if (equipmentEffects.bonuses.studySpeed) { if (equipmentEffects.bonuses.studySpeed) {
merged.studySpeedMultiplier *= (equipmentEffects.multipliers.studySpeed || 1); merged.studySpeedMultiplier *= (equipmentEffects.multipliers.studySpeed || 1);
} }
if (equipmentEffects.bonuses.insightGain) {
// Store separately - insight multiplier
(merged as any).insightGainMultiplier = (equipmentEffects.multipliers.insightGain || 1);
}
if (equipmentEffects.bonuses.guardianDamage) {
(merged as any).guardianDamageMultiplier = (equipmentEffects.multipliers.guardianDamage || 1);
}
return merged; return merged;
} }
/** export function getUnifiedEffects(state: Pick<GameState, 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances' | 'disciplines'>): UnifiedEffects {
* Helper to get unified effects from game state
*/
export function getUnifiedEffects(state: Pick<GameState, 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>): UnifiedEffects {
return computeAllEffects( return computeAllEffects(
state.skillUpgrades || {}, state.skillUpgrades || {},
state.skillTiers || {}, state.skillTiers || {},
state.equipmentInstances || {}, state.equipmentInstances || {},
state.equippedInstances || {} state.equippedInstances || {},
state as GameState
); );
} }
// ─── Stat Computation with All Effects ─────────────────────────────────────── // ─── Stat Computation with All Effects ───────────────────────────────────────
/**
* Compute max mana with all effect sources
*/
export function computeTotalMaxMana( export function computeTotalMaxMana(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>, state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
effects?: UnifiedEffects effects?: UnifiedEffects
): number { ): number {
const pu = state.prestigeUpgrades; const pu = state.prestigeUpgrades;
const skillMult = effects?.skillLevelMultiplier || 1; const skillMult = effects?.skillLevelMultiplier || 1;
const base = const base = 100 + ((state.skills || {}).manaWell || 0) * 100 * skillMult + ((pu || {}).manaWell || 0) * 500;
100 + if (!effects) effects = getUnifiedEffects(state as any);
((state.skills || {}).manaWell || 0) * 100 * skillMult +
((pu || {}).manaWell || 0) * 500;
if (!effects) {
effects = getUnifiedEffects(state);
}
return Math.floor((base + effects.maxManaBonus) * effects.maxManaMultiplier); return Math.floor((base + effects.maxManaBonus) * effects.maxManaMultiplier);
} }
/**
* Compute regen with all effect sources
*/
export function computeTotalRegen( export function computeTotalRegen(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>, state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
effects?: UnifiedEffects effects?: UnifiedEffects
@@ -210,39 +167,19 @@ export function computeTotalRegen(
const pu = state.prestigeUpgrades; const pu = state.prestigeUpgrades;
const temporalBonus = 1 + (pu.temporalEcho || 0) * 0.1; const temporalBonus = 1 + (pu.temporalEcho || 0) * 0.1;
const skillMult = effects?.skillLevelMultiplier || 1; const skillMult = effects?.skillLevelMultiplier || 1;
const base = const base = 2 + (state.skills.manaFlow || 0) * 1 * skillMult + (state.skills.manaSpring || 0) * 2 * skillMult + (pu.manaFlow || 0) * 0.5;
2 +
(state.skills.manaFlow || 0) * 1 * skillMult +
(state.skills.manaSpring || 0) * 2 * skillMult +
(pu.manaFlow || 0) * 0.5;
let regen = base * temporalBonus; let regen = base * temporalBonus;
if (!effects) effects = getUnifiedEffects(state as any);
if (!effects) {
effects = getUnifiedEffects(state);
}
regen = (regen + effects.regenBonus + effects.permanentRegenBonus) * effects.regenMultiplier; regen = (regen + effects.regenBonus + effects.permanentRegenBonus) * effects.regenMultiplier;
return regen; return regen;
} }
/**
* Compute click mana with all effect sources
*/
export function computeTotalClickMana( export function computeTotalClickMana(
state: Pick<GameState, 'skills' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>, state: Pick<GameState, 'skills' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
effects?: UnifiedEffects effects?: UnifiedEffects
): number { ): number {
const skillMult = effects?.skillLevelMultiplier || 1; const skillMult = effects?.skillLevelMultiplier || 1;
const base = const base = 1 + (state.skills.manaTap || 0) * 1 * skillMult + (state.skills.manaSurge || 0) * 3 * skillMult;
1 + if (!effects) effects = getUnifiedEffects(state as any);
(state.skills.manaTap || 0) * 1 * skillMult +
(state.skills.manaSurge || 0) * 3 * skillMult;
if (!effects) {
effects = getUnifiedEffects(state as any);
}
return Math.floor((base + effects.clickManaBonus) * effects.clickManaMultiplier); return Math.floor((base + effects.clickManaBonus) * effects.clickManaMultiplier);
} }
@@ -0,0 +1,49 @@
// ─── Discipline Effects ───────────────────────────────────────────────────────
// Computes bonuses from active disciplines and integrates with the unified effect system
import type { GameState } from '../types';
import { useDisciplineStore } from '../stores/discipline-slice';
import { ALL_DISCIPLINES } from '../data/disciplines';
import { calculateStatBonus, getUnlockedPerks } from '../utils/discipline-math';
export function computeDisciplineEffects(state: GameState): {
bonuses: Record<string, number>;
multipliers: Record<string, number>;
specials: Set<string>;
} {
const { disciplines } = useDisciplineStore.getState();
const activeDiscs = Object.entries(disciplines)
.filter(([, disc]) => disc && !disc.paused)
.map(([id, disc]) => ({ id, disc, def: ALL_DISCIPLINES.find(d => d.id === id) }))
.filter((entry): entry is { id: string; disc: any; def: NonNullable<typeof ALL_DISCIPLINES[0]> } => !!entry.def);
const bonuses: Record<string, number> = {};
const multipliers: Record<string, number> = {};
const specials = new Set<string>();
for (const { disc, def } of activeDiscs) {
// Continuous stat bonus
const statBonus = calculateStatBonus(def.statBonus.baseValue, disc.xp, def.scalingFactor);
if (def.statBonus.stat) {
bonuses[def.statBonus.stat] = (bonuses[def.statBonus.stat] || 0) + statBonus;
}
// Perk unlocks
const perks = getUnlockedPerks(def, disc.xp);
for (const perk of perks) {
if (perk.type === 'once' || perk.type === 'infinite') {
// Once/infinite perks can be treated as additive bonuses or special flags
// For simplicity, we add them as a special flag; actual effect depends on perk.id
specials.add(perk.id);
} else if (perk.type === 'capped') {
// Capped perks act as multipliers after certain thresholds
// For now, we treat them as additive to a multiplier stat (example)
// In a real implementation, each perk would have a specific effect.
// Here we just add a generic perk multiplier placeholder.
multipliers[`perk_${perk.id}`] = (multipliers[`perk_${perk.id}`] || 1) + perk.value / 100;
}
}
}
return { bonuses, multipliers, specials };
}
+126
View File
@@ -0,0 +1,126 @@
// ─── Discipline Store Slice ────────────────────────────────────────────────────
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { DisciplineState } from '../types/disciplines';
import {
calculateManaDrain,
calculateStatBonus,
canProceedDiscipline,
} from '../utils/discipline-math';
import { baseDisciplines } from '../data/disciplines/base';
import { enchanterDisciplines } from '../data/disciplines/enchanter';
import { fabricatorDisciplines } from '../data/disciplines/fabricator';
import { invokerDisciplines } from '../data/disciplines/invoker';
import { MAX_CONCURRENT_DISCIPLINES } from '../types/disciplines';
const ALL_DISCIPLINES = [
...baseDisciplines,
...enchanterDisciplines,
...fabricatorDisciplines,
...invokerDisciplines,
];
const DISCIPLINE_MAP = Object.fromEntries(ALL_DISCIPLINES.map((d) => [d.id, d]));
export interface DisciplineStoreState {
disciplines: Record<string, DisciplineState>;
activeIds: string[];
concurrentLimit: number;
totalXP: number;
}
export interface DisciplineStoreActions {
activate: (id: string, gameState?: { elements?: Record<string, { unlocked?: boolean }> }) => void;
deactivate: (id: string) => void;
processTick: (mana: { rawMana: number; elements: Record<string, { current: number }> }) => {
rawMana: number;
elements: Record<string, { current: number }>;
};
}
export type DisciplineStore = DisciplineStoreState & DisciplineStoreActions;
export const useDisciplineStore = create<DisciplineStore>()(
persist(
(set, get) => ({
disciplines: {},
activeIds: [],
concurrentLimit: MAX_CONCURRENT_DISCIPLINES,
totalXP: 0,
activate(id, gameState) {
set((s) => {
const def = DISCIPLINE_MAP[id];
if (!def) return s;
if (s.activeIds.includes(id)) return s;
const nonPaused = s.activeIds.filter((aid) => {
const d = s.disciplines[aid];
return d && !d.paused;
}).length;
if (nonPaused >= s.concurrentLimit) return s;
if (!canProceedDiscipline(id, gameState)) return s;
const existing = s.disciplines[id] || { id, xp: 0, paused: false };
return {
disciplines: { ...s.disciplines, [id]: { ...existing, paused: false } },
activeIds: [...s.activeIds, id],
};
});
},
deactivate(id) {
set((s) => ({
activeIds: s.activeIds.filter((aid) => aid !== id),
}));
},
processTick(mana) {
const s = get();
let rawMana = mana.rawMana;
const elements = { ...mana.elements };
let newXP = s.totalXP;
for (const id of s.activeIds) {
const disc = s.disciplines[id];
if (!disc) continue;
if (disc.paused) continue;
const def = DISCIPLINE_MAP[id];
if (!def) continue;
const drain = calculateManaDrain(def.drainBase, disc.xp, def.difficultyFactor);
const element = elements[def.manaType];
const available = def.manaType === 'raw' ? rawMana : element?.current;
if (!available || available < drain) {
disc.paused = true;
continue;
}
if (def.manaType === 'raw') {
rawMana -= drain;
} else {
elements[def.manaType].current -= drain;
}
disc.xp += 1;
newXP += 1;
}
const newLimit = Math.min(
MAX_CONCURRENT_DISCIPLINES + Math.floor(newXP / 500),
MAX_CONCURRENT_DISCIPLINES + 3
);
set({
disciplines: s.disciplines,
totalXP: newXP,
concurrentLimit: Math.max(s.concurrentLimit, newLimit),
});
return { rawMana, elements };
},
}),
{ name: 'mana-loop-discipline-store' }
)
);
+49
View File
@@ -0,0 +1,49 @@
// ─── Discipline Types ─────────────────────────────────────────────────────────
import type { ManaType } from './elements';
// ─── Attunement Types ─────────────────────────────────────────────────────────
export enum DisciplinesAttunementType {
BASE = 'base',
ENCHANTER = 'enchanter',
FABRICATOR = 'fabricator',
INVOKER = 'invoker',
}
// ─── Perk Types ───────────────────────────────────────────────────────────────
export type PerkType = 'capped' | 'once' | 'infinite';
export interface DisciplinePerk {
id: string;
type: PerkType;
threshold: number;
value: number;
description: string;
}
// ─── Discipline Definition ────────────────────────────────────────────────────
export interface DisciplineDefinition {
id: string;
name: string;
attunement: DisciplinesAttunementType;
manaType: ManaType;
baseCost: number;
description: string;
statBonus: { stat: string; baseValue: number };
difficultyFactor: number;
scalingFactor: number;
drainBase: number;
perks: DisciplinePerk[];
requires?: string[];
}
// ─── Discipline State ─────────────────────────────────────────────────────────
export interface DisciplineState {
id: string;
xp: number;
paused: boolean;
}
// ─── Discipline Constants ─────────────────────────────────────────────────────
export const MAX_CONCURRENT_DISCIPLINES = 1;
export const BASE_CONCURRENT_DISCIPLINES = 1;
+130
View File
@@ -0,0 +1,130 @@
// ─── Discipline Math Utilities ────────────────────────────────────────────────
// Continuous scaling formulas for Active Disciplines
import type { DisciplineState, DisciplineDefinition } from '../types/disciplines';
/**
* Calculate continuous stat bonus from discipline XP
* StatBonus = BaseValue * (XP / ScalingFactor)^0.65
*/
export function calculateStatBonus(
baseValue: number,
xp: number,
scalingFactor: number
): number {
if (xp <= 0) return 0;
const ratio = xp / scalingFactor;
const power = Math.pow(ratio, 0.65);
return baseValue * power;
}
/**
* Calculate dynamic mana drain per tick
* ManaDrainPerTick = BaseDrain * (1 + (XP / DifficultyFactor)^0.4)
*/
export function calculateManaDrain(
baseDrain: number,
xp: number,
difficultyFactor: number
): number {
if (xp <= 0) return baseDrain;
const ratio = xp / difficultyFactor;
const power = Math.pow(ratio, 0.4);
return baseDrain * (1 + power);
}
/**
* Calculate infinite perk tier
* PerkTier = Math.max(0, Math.floor((XP - Threshold) / Interval) + 1)
*/
export function calculatePerkTier(
xp: number,
threshold: number,
interval: number
): number {
if (xp < threshold) return 0;
const excess = xp - threshold;
return Math.max(0, Math.floor(excess / interval) + 1);
}
/**
* Check if discipline can be activated (has required mana type)
*/
export function canActivateDiscipline(
discipline: DisciplineDefinition,
gameState: { elements?: Record<string, any> }
): boolean {
if (discipline.manaType === 'raw') return true;
const element = gameState.elements?.[discipline.manaType];
return element && element.unlocked;
}
/**
* Check if discipline can proceed (has sufficient mana for drain)
*/
export function canProceedDiscipline(
discipline: DisciplineDefinition,
disciplineState: DisciplineState,
gameState: { elements?: Record<string, any>; rawMana?: number }
): boolean {
if (disciplineState.paused) return false;
const drain = calculateManaDrain(
discipline.drainBase,
disciplineState.xp,
discipline.difficultyFactor
);
if (discipline.manaType === 'raw') {
return (gameState.rawMana || 0) >= drain;
}
const element = gameState.elements?.[discipline.manaType];
return element && element.current >= drain;
}
/**
* Get unlocked perks for a discipline
*/
export function getUnlockedPerks(
discipline: DisciplineDefinition,
xp: number
): DisciplinePerk[] {
return discipline.perks.filter(perk => {
if (perk.type === 'once') {
return xp >= perk.threshold;
} else if (perk.type === 'capped') {
const tier = calculatePerkTier(xp, perk.threshold, perk.value);
return tier > 0;
} else if (perk.type === 'infinite') {
return xp >= perk.threshold;
}
return false;
});
}
/**
* Calculate total stats from all active disciplines
*/
export function calculateDisciplineStats(
disciplines: DisciplineDefinition[],
states: DisciplineState[]
): Record<string, number> {
const stats: Record<string, number> = {};
disciplines.forEach((discipline, index) => {
const state = states[index];
if (!state || state.paused) return;
const bonus = calculateStatBonus(
discipline.statBonus.baseValue,
state.xp,
discipline.scalingFactor
);
const statKey = discipline.statBonus.stat;
stats[statKey] = (stats[statKey] || 0) + bonus;
});
return stats;
}