refactor: Replace natural-regen disciplines with mana conversion speed disciplines
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m25s

- Add conversionRate + sourceManaTypes fields to DisciplineDefinition
- Rewrite elemental-regen.ts: 8 base disciplines now convert raw→element
- Rewrite elemental-regen-advanced.ts: 6 composite/exotic disciplines with proper source recipes
- Update discipline-effects.ts: produce conversion entries instead of regen bonuses
- Update gameStore.ts tick: drain source mana types, add to target element
- Update discipline-slice.ts: gate activation on source mana type access
- Update discipline-math.ts: resolve mana type IDs to 'X mana' display names
- Update DisciplinesTab.tsx: show conversion info, source requirements, and lock state
- Update DisciplineDebugSection.tsx: pass elements to activate()
- Update effects.ts: remove regen_{element} merge (no longer produced)
This commit is contained in:
2026-05-26 20:40:11 +02:00
parent 1c1bbf8017
commit 46013a15c8
12 changed files with 430 additions and 263 deletions
+8 -7
View File
@@ -1,13 +1,14 @@
# Circular Dependencies # Circular Dependencies
Generated: 2026-05-26T16:28:29.780Z Generated: 2026-05-26T16:39:59.755Z
Found: 6 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 135 files (1.5s) (2 warnings) 1. Processed 135 files (1.5s) (2 warnings)
2. 1) utils/floor-utils.ts > utils/room-utils.ts > utils/enemy-utils.ts 2. 1) effects/discipline-effects.ts > stores/discipline-slice.ts > stores/combatStore.ts > stores/combat-actions.ts
3. 2) utils/floor-utils.ts > utils/room-utils.ts 3. 2) utils/floor-utils.ts > utils/room-utils.ts > utils/enemy-utils.ts
4. 3) stores/gameStore.ts > stores/gameActions.ts 4. 3) utils/floor-utils.ts > utils/room-utils.ts
5. 4) stores/gameStore.ts > stores/gameLoopActions.ts 5. 4) stores/gameStore.ts > stores/gameActions.ts
6. 5) stores/gameStore.ts > stores/tick-pipeline.ts 6. 5) stores/gameStore.ts > stores/gameLoopActions.ts
7. 6) stores/gameStore.ts > stores/tick-pipeline.ts
## How to fix ## How to fix
1. Identify which import in the chain can be extracted to a shared types/utils file. 1. Identify which import in the chain can be extracted to a shared types/utils file.
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"_meta": { "_meta": {
"generated": "2026-05-26T16:28:28.147Z", "generated": "2026-05-26T16:39:58.077Z",
"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."
}, },
@@ -488,6 +488,7 @@
"data/disciplines/enchanter.ts", "data/disciplines/enchanter.ts",
"data/disciplines/fabricator.ts", "data/disciplines/fabricator.ts",
"data/disciplines/invoker.ts", "data/disciplines/invoker.ts",
"stores/combatStore.ts",
"types.ts", "types.ts",
"types/disciplines.ts", "types/disciplines.ts",
"utils/discipline-math.ts", "utils/discipline-math.ts",
@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { BookOpen, Plus, Pause, Play } from 'lucide-react'; import { BookOpen, Plus, Pause, Play } from 'lucide-react';
import { useDisciplineStore } from '@/lib/game/stores/discipline-slice'; import { useDisciplineStore } from '@/lib/game/stores/discipline-slice';
import { ALL_DISCIPLINES } from '@/lib/game/data/disciplines'; import { ALL_DISCIPLINES } from '@/lib/game/data/disciplines';
import { useManaStore } from '@/lib/game/stores/manaStore';
export function DisciplineDebugSection() { export function DisciplineDebugSection() {
const disciplines = useDisciplineStore((s) => s.disciplines); const disciplines = useDisciplineStore((s) => s.disciplines);
@@ -12,6 +13,7 @@ export function DisciplineDebugSection() {
const concurrentLimit = useDisciplineStore((s) => s.concurrentLimit); const concurrentLimit = useDisciplineStore((s) => s.concurrentLimit);
const activate = useDisciplineStore((s) => s.activate); const activate = useDisciplineStore((s) => s.activate);
const deactivate = useDisciplineStore((s) => s.deactivate); const deactivate = useDisciplineStore((s) => s.deactivate);
const elements = useManaStore((s) => s.elements);
const _handleTogglePause = (id: string) => { const _handleTogglePause = (id: string) => {
const disc = disciplines[id]; const disc = disciplines[id];
@@ -41,7 +43,7 @@ export function DisciplineDebugSection() {
const handleActivateAll = () => { const handleActivateAll = () => {
ALL_DISCIPLINES.forEach((d) => { ALL_DISCIPLINES.forEach((d) => {
if (!activeIds.includes(d.id)) { if (!activeIds.includes(d.id)) {
activate(d.id); activate(d.id, { elements });
} }
}); });
}; };
@@ -111,7 +113,7 @@ export function DisciplineDebugSection() {
if (isActive) { if (isActive) {
deactivate(def.id); deactivate(def.id);
} else { } else {
activate(def.id); activate(def.id, { elements });
} }
}} }}
> >
+31 -10
View File
@@ -12,6 +12,7 @@ import { fabricatorDisciplines } from '@/lib/game/data/disciplines/fabricator';
import { invokerDisciplines } from '@/lib/game/data/disciplines/invoker'; import { invokerDisciplines } from '@/lib/game/data/disciplines/invoker';
import { calculateStatBonus, calculateManaDrain, checkDisciplinePrerequisites } from '@/lib/game/utils/discipline-math'; import { calculateStatBonus, calculateManaDrain, checkDisciplinePrerequisites } from '@/lib/game/utils/discipline-math';
import { ALL_DISCIPLINES } from '@/lib/game/data/disciplines'; import { ALL_DISCIPLINES } from '@/lib/game/data/disciplines';
import { useManaStore } from '@/lib/game/stores/manaStore';
import clsx from 'clsx'; import clsx from 'clsx';
// ─── Attunement Tabs ───────────────────────────────────────────────────────── // ─── Attunement Tabs ─────────────────────────────────────────────────────────
@@ -25,8 +26,8 @@ interface AttunementTab {
const ATTUNEMENT_TABS: AttunementTab[] = [ const ATTUNEMENT_TABS: AttunementTab[] = [
{ key: 'base', label: 'Base', items: baseDisciplines }, { key: 'base', label: 'Base', items: baseDisciplines },
{ key: 'elements', label: 'Mana Types', items: elementalAttunementDisciplines }, { key: 'elements', label: 'Mana Types', items: elementalAttunementDisciplines },
{ key: 'elemental-regen', label: 'Elemental Regen', items: elementalRegenDisciplines }, { key: 'elemental-regen', label: 'Elemental Flow', items: elementalRegenDisciplines },
{ key: 'elemental-regen-advanced', label: 'Advanced Regen', items: elementalRegenAdvancedDisciplines }, { key: 'elemental-regen-advanced', label: 'Advanced Flow', items: elementalRegenAdvancedDisciplines },
{ key: 'enchanter', label: 'Enchanter', items: enchanterDisciplines }, { key: 'enchanter', label: 'Enchanter', items: enchanterDisciplines },
{ key: 'fabricator', label: 'Fabricator', items: fabricatorDisciplines }, { key: 'fabricator', label: 'Fabricator', items: fabricatorDisciplines },
{ key: 'invoker', label: 'Invoker', items: invokerDisciplines }, { key: 'invoker', label: 'Invoker', items: invokerDisciplines },
@@ -50,6 +51,8 @@ export interface DisciplineCardDefinition {
drainBase: number; drainBase: number;
difficultyFactor: number; difficultyFactor: number;
scalingFactor: number; scalingFactor: number;
sourceManaTypes?: ManaType[];
conversionRate?: number;
} }
export interface DisciplineCardRuntime { export interface DisciplineCardRuntime {
@@ -58,6 +61,7 @@ export interface DisciplineCardRuntime {
concurrentLimit: number; concurrentLimit: number;
isLocked: boolean; isLocked: boolean;
missingPrereqs: string[]; missingPrereqs: string[];
missingSourceMana: string[];
} }
export interface DisciplineCardCallbacks { export interface DisciplineCardCallbacks {
@@ -77,7 +81,7 @@ const DisciplineCard: React.FC<DisciplineCardProps> = ({ definition, runtime, ca
id, name, description, manaType, baseCost, perkThresholds, perkValues, perkTypes, id, name, description, manaType, baseCost, perkThresholds, perkValues, perkTypes,
statBonusLabel, baseValue, drainBase, difficultyFactor, scalingFactor, statBonusLabel, baseValue, drainBase, difficultyFactor, scalingFactor,
} = definition; } = definition;
const { xp, paused: isPaused, concurrentLimit, isLocked, missingPrereqs } = runtime; const { xp, paused: isPaused, concurrentLimit, isLocked, missingPrereqs, missingSourceMana } = runtime;
const { onToggle } = callbacks; const { onToggle } = callbacks;
const displayXp = xp; const displayXp = xp;
@@ -91,6 +95,8 @@ const DisciplineCard: React.FC<DisciplineCardProps> = ({ definition, runtime, ca
const manaIcon = elementDef?.sym ?? '✦'; const manaIcon = elementDef?.sym ?? '✦';
const manaName = elementDef?.name ?? manaType; const manaName = elementDef?.name ?? manaType;
const effectiveIsLocked = isLocked || missingSourceMana.length > 0;
const unlockedPerks = perkTypes?.reduce<string[]>((acc, typ, idx) => { const unlockedPerks = perkTypes?.reduce<string[]>((acc, typ, idx) => {
const threshold = perkThresholds?.[idx]; const threshold = perkThresholds?.[idx];
if (threshold === undefined) return acc; if (threshold === undefined) return acc;
@@ -109,7 +115,7 @@ const DisciplineCard: React.FC<DisciplineCardProps> = ({ definition, runtime, ca
}; };
return ( return (
<div key={id} className={clsx('border rounded-lg p-4 shadow-sm space-y-3', isLocked && 'opacity-60 border-gray-600')}> <div key={id} className={clsx('border rounded-lg p-4 shadow-sm space-y-3', effectiveIsLocked && 'opacity-60 border-gray-600')}>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<h3 className="text-lg font-medium">{name}</h3> <h3 className="text-lg font-medium">{name}</h3>
<span <span
@@ -149,6 +155,12 @@ const DisciplineCard: React.FC<DisciplineCardProps> = ({ definition, runtime, ca
</span> </span>
</div> </div>
{definition.conversionRate != null && definition.sourceManaTypes && (
<div className="mt-2 text-xs text-gray-400">
<strong>Converts:</strong> {definition.sourceManaTypes.map(s => s === 'raw' ? 'raw' : ELEMENTS[s]?.name ?? s).join(' + ')} {manaName}
</div>
)}
<div className="mt-2 text-sm"> <div className="mt-2 text-sm">
<strong>Stat Bonus:</strong> {activeStatBonus.toFixed(2)} on {statBonusLabel} <strong>Stat Bonus:</strong> {activeStatBonus.toFixed(2)} on {statBonusLabel}
</div> </div>
@@ -166,16 +178,16 @@ const DisciplineCard: React.FC<DisciplineCardProps> = ({ definition, runtime, ca
</ul> </ul>
</div> </div>
{isLocked && missingPrereqs.length > 0 && ( {effectiveIsLocked && (missingPrereqs.length > 0 || missingSourceMana.length > 0) && (
<div className="mt-2 text-xs text-red-400"> <div className="mt-2 text-xs text-red-400">
<strong>Requires:</strong> {missingPrereqs.join(', ')} <strong>Requires:</strong> {[...missingPrereqs, ...missingSourceMana].join(', ')}
</div> </div>
)} )}
<div className="mt-4 flex justify-end"> <div className="mt-4 flex justify-end">
<button <button
onClick={toggleAction} onClick={toggleAction}
disabled={isLocked} disabled={effectiveIsLocked}
className={clsx( className={clsx(
'rounded px-3 py-1 text-sm font-medium', 'rounded px-3 py-1 text-sm font-medium',
isLocked isLocked
@@ -185,7 +197,7 @@ const DisciplineCard: React.FC<DisciplineCardProps> = ({ definition, runtime, ca
: 'bg-blue-600 text-white hover:bg-blue-500', : 'bg-blue-600 text-white hover:bg-blue-500',
)} )}
> >
{isLocked ? 'Locked' : isPaused ? 'Start Practicing' : 'Stop Practicing'} {effectiveIsLocked ? 'Locked' : isPaused ? 'Start Practicing' : 'Stop Practicing'}
</button> </button>
</div> </div>
</div> </div>
@@ -203,13 +215,15 @@ export const DisciplinesTab: React.FC = () => {
const [activeAttunement, setActiveAttunement] = useState<string>('base'); const [activeAttunement, setActiveAttunement] = useState<string>('base');
const elements = useManaStore((s) => s.elements);
const handleToggle = useCallback((id: string, paused: boolean) => { const handleToggle = useCallback((id: string, paused: boolean) => {
if (paused) { if (paused) {
activate(id); activate(id, { elements });
} else { } else {
deactivate(id); deactivate(id);
} }
}, [activate, deactivate]); }, [activate, deactivate, elements]);
const activeTab = ATTUNEMENT_TABS.find((t) => t.key === activeAttunement); const activeTab = ATTUNEMENT_TABS.find((t) => t.key === activeAttunement);
@@ -254,6 +268,8 @@ export const DisciplinesTab: React.FC = () => {
statBonus: disc.statBonus.stat, statBonus: disc.statBonus.stat,
statBonusLabel: disc.statBonus.label, statBonusLabel: disc.statBonus.label,
requires: disc.requires, requires: disc.requires,
sourceManaTypes: disc.sourceManaTypes,
conversionRate: disc.conversionRate,
baseValue: disc.statBonus.baseValue, baseValue: disc.statBonus.baseValue,
drainBase: disc.drainBase, drainBase: disc.drainBase,
difficultyFactor: disc.difficultyFactor, difficultyFactor: disc.difficultyFactor,
@@ -265,6 +281,11 @@ export const DisciplinesTab: React.FC = () => {
concurrentLimit, concurrentLimit,
isLocked: !prereqCheck.canProceed, isLocked: !prereqCheck.canProceed,
missingPrereqs: prereqCheck.missingPrereqs, missingPrereqs: prereqCheck.missingPrereqs,
missingSourceMana: disc.sourceManaTypes
? disc.sourceManaTypes.filter(
(src) => src !== 'raw' && (!elements[src] || !elements[src].unlocked),
).map((src) => `${src} mana`)
: [],
}} }}
callbacks={{ callbacks={{
onToggle: handleToggle, onToggle: handleToggle,
@@ -1,23 +1,30 @@
// ─── Elemental Regen Disciplines (Composite + Exotic) ───────────────────────── // ─── Elemental Conversion Disciplines (Composite + Exotic) ──────────────────────
// Regen disciplines for composite and exotic mana types. // Conversion disciplines for composite and exotic mana types.
// All are BASE attunement so they are available to every role once the element is unlocked. // All are BASE attunement so they are available to every role once the element is unlocked.
import { DisciplinesAttunementType } from '../../types/disciplines'; import { DisciplinesAttunementType } from '../../types/disciplines';
import type { DisciplineDefinition } from '../../types/disciplines'; import type { DisciplineDefinition } from '../../types/disciplines';
export const elementalRegenAdvancedDisciplines: DisciplineDefinition[] = [
// ── Composite Elements ───────────────────────────────────────────────────── // ── Composite Elements ─────────────────────────────────────────────────────
{
const COMP_CONVERSION = 0.35;
const COMP_DRAIN = 2;
const COMP_DIFF = 160;
const COMP_SCALE = 80;
const metalDiscipline: DisciplineDefinition = {
id: 'regen-metal', id: 'regen-metal',
name: 'Metal Mana Flow', name: 'Metal Mana Flow',
attunement: DisciplinesAttunementType.BASE, attunement: DisciplinesAttunementType.BASE,
manaType: 'metal', manaType: 'metal',
baseCost: 12, baseCost: 12,
description: 'Attune your metal mana to regenerate passively over time.', description: 'Convert raw mana + fire mana + earth mana into metal mana over time.',
statBonus: { stat: 'regen_metal', baseValue: 0.35, label: 'Metal Regen/tick' }, statBonus: { stat: 'conversion_metal', baseValue: COMP_CONVERSION, label: 'Metal Conversion/tick' },
difficultyFactor: 160, difficultyFactor: COMP_DIFF,
scalingFactor: 80, scalingFactor: COMP_SCALE,
drainBase: 2, drainBase: COMP_DRAIN,
conversionRate: COMP_CONVERSION,
sourceManaTypes: ['raw', 'fire', 'earth'],
requires: ['metal'], requires: ['metal'],
perks: [ perks: [
{ {
@@ -25,30 +32,33 @@ export const elementalRegenAdvancedDisciplines: DisciplineDefinition[] = [
type: 'once', type: 'once',
threshold: 150, threshold: 150,
value: 0, value: 0,
description: '+0.35 Metal Regen/tick', description: '+0.35 Metal Conversion/tick',
bonus: { stat: 'regen_metal', amount: 0.35 }, bonus: { stat: 'conversion_metal', amount: COMP_CONVERSION },
}, },
{ {
id: 'regen-metal-inf', id: 'regen-metal-inf',
type: 'infinite', type: 'infinite',
threshold: 400, threshold: 400,
value: 100, value: 100,
description: 'Every 100 XP: +0.15 Metal Regen/tick', description: 'Every 100 XP: +0.15 Metal Conversion/tick',
bonus: { stat: 'regen_metal', amount: 0.15 }, bonus: { stat: 'conversion_metal', amount: 0.15 },
}, },
], ],
}, };
{
const sandDiscipline: DisciplineDefinition = {
id: 'regen-sand', id: 'regen-sand',
name: 'Sand Mana Flow', name: 'Sand Mana Flow',
attunement: DisciplinesAttunementType.BASE, attunement: DisciplinesAttunementType.BASE,
manaType: 'sand', manaType: 'sand',
baseCost: 12, baseCost: 12,
description: 'Attune your sand mana to regenerate passively over time.', description: 'Convert raw mana + earth mana + water mana into sand mana over time.',
statBonus: { stat: 'regen_sand', baseValue: 0.35, label: 'Sand Regen/tick' }, statBonus: { stat: 'conversion_sand', baseValue: COMP_CONVERSION, label: 'Sand Conversion/tick' },
difficultyFactor: 160, difficultyFactor: COMP_DIFF,
scalingFactor: 80, scalingFactor: COMP_SCALE,
drainBase: 2, drainBase: COMP_DRAIN,
conversionRate: COMP_CONVERSION,
sourceManaTypes: ['raw', 'earth', 'water'],
requires: ['sand'], requires: ['sand'],
perks: [ perks: [
{ {
@@ -56,30 +66,33 @@ export const elementalRegenAdvancedDisciplines: DisciplineDefinition[] = [
type: 'once', type: 'once',
threshold: 150, threshold: 150,
value: 0, value: 0,
description: '+0.35 Sand Regen/tick', description: '+0.35 Sand Conversion/tick',
bonus: { stat: 'regen_sand', amount: 0.35 }, bonus: { stat: 'conversion_sand', amount: COMP_CONVERSION },
}, },
{ {
id: 'regen-sand-inf', id: 'regen-sand-inf',
type: 'infinite', type: 'infinite',
threshold: 400, threshold: 400,
value: 100, value: 100,
description: 'Every 100 XP: +0.15 Sand Regen/tick', description: 'Every 100 XP: +0.15 Sand Conversion/tick',
bonus: { stat: 'regen_sand', amount: 0.15 }, bonus: { stat: 'conversion_sand', amount: 0.15 },
}, },
], ],
}, };
{
const lightningDiscipline: DisciplineDefinition = {
id: 'regen-lightning', id: 'regen-lightning',
name: 'Lightning Mana Flow', name: 'Lightning Mana Flow',
attunement: DisciplinesAttunementType.BASE, attunement: DisciplinesAttunementType.BASE,
manaType: 'lightning', manaType: 'lightning',
baseCost: 12, baseCost: 12,
description: 'Attune your lightning mana to regenerate passively over time.', description: 'Convert raw mana + fire mana + air mana into lightning mana over time.',
statBonus: { stat: 'regen_lightning', baseValue: 0.35, label: 'Lightning Regen/tick' }, statBonus: { stat: 'conversion_lightning', baseValue: COMP_CONVERSION, label: 'Lightning Conversion/tick' },
difficultyFactor: 160, difficultyFactor: COMP_DIFF,
scalingFactor: 80, scalingFactor: COMP_SCALE,
drainBase: 2, drainBase: COMP_DRAIN,
conversionRate: COMP_CONVERSION,
sourceManaTypes: ['raw', 'fire', 'air'],
requires: ['lightning'], requires: ['lightning'],
perks: [ perks: [
{ {
@@ -87,31 +100,40 @@ export const elementalRegenAdvancedDisciplines: DisciplineDefinition[] = [
type: 'once', type: 'once',
threshold: 150, threshold: 150,
value: 0, value: 0,
description: '+0.35 Lightning Regen/tick', description: '+0.35 Lightning Conversion/tick',
bonus: { stat: 'regen_lightning', amount: 0.35 }, bonus: { stat: 'conversion_lightning', amount: COMP_CONVERSION },
}, },
{ {
id: 'regen-lightning-inf', id: 'regen-lightning-inf',
type: 'infinite', type: 'infinite',
threshold: 400, threshold: 400,
value: 100, value: 100,
description: 'Every 100 XP: +0.15 Lightning Regen/tick', description: 'Every 100 XP: +0.15 Lightning Conversion/tick',
bonus: { stat: 'regen_lightning', amount: 0.15 }, bonus: { stat: 'conversion_lightning', amount: 0.15 },
}, },
], ],
}, };
// ── Exotic Elements ──────────────────────────────────────────────────────── // ── Exotic Elements ────────────────────────────────────────────────────────
{
const EXO_CONVERSION = 0.25;
const EXO_DRAIN = 3;
const EXO_DIFF = 220;
const EXO_SCALE = 110;
const crystalDiscipline: DisciplineDefinition = {
id: 'regen-crystal', id: 'regen-crystal',
name: 'Crystal Mana Flow', name: 'Crystal Mana Flow',
attunement: DisciplinesAttunementType.BASE, attunement: DisciplinesAttunementType.BASE,
manaType: 'crystal', manaType: 'crystal',
baseCost: 18, baseCost: 18,
description: 'Attune your crystal mana to regenerate passively over time.', description: 'Convert raw mana + sand mana + light mana into crystal mana over time.',
statBonus: { stat: 'regen_crystal', baseValue: 0.25, label: 'Crystal Regen/tick' }, statBonus: { stat: 'conversion_crystal', baseValue: EXO_CONVERSION, label: 'Crystal Conversion/tick' },
difficultyFactor: 220, difficultyFactor: EXO_DIFF,
scalingFactor: 110, scalingFactor: EXO_SCALE,
drainBase: 3, drainBase: EXO_DRAIN,
conversionRate: EXO_CONVERSION,
sourceManaTypes: ['raw', 'sand', 'light'],
requires: ['crystal'], requires: ['crystal'],
perks: [ perks: [
{ {
@@ -119,30 +141,33 @@ export const elementalRegenAdvancedDisciplines: DisciplineDefinition[] = [
type: 'once', type: 'once',
threshold: 200, threshold: 200,
value: 0, value: 0,
description: '+0.25 Crystal Regen/tick', description: '+0.25 Crystal Conversion/tick',
bonus: { stat: 'regen_crystal', amount: 0.25 }, bonus: { stat: 'conversion_crystal', amount: EXO_CONVERSION },
}, },
{ {
id: 'regen-crystal-inf', id: 'regen-crystal-inf',
type: 'infinite', type: 'infinite',
threshold: 500, threshold: 500,
value: 100, value: 100,
description: 'Every 100 XP: +0.1 Crystal Regen/tick', description: 'Every 100 XP: +0.1 Crystal Conversion/tick',
bonus: { stat: 'regen_crystal', amount: 0.1 }, bonus: { stat: 'conversion_crystal', amount: 0.1 },
}, },
], ],
}, };
{
const stellarDiscipline: DisciplineDefinition = {
id: 'regen-stellar', id: 'regen-stellar',
name: 'Stellar Mana Flow', name: 'Stellar Mana Flow',
attunement: DisciplinesAttunementType.BASE, attunement: DisciplinesAttunementType.BASE,
manaType: 'stellar', manaType: 'stellar',
baseCost: 18, baseCost: 18,
description: 'Attune your stellar mana to regenerate passively over time.', description: 'Convert raw mana + fire mana + light mana into stellar mana over time.',
statBonus: { stat: 'regen_stellar', baseValue: 0.25, label: 'Stellar Regen/tick' }, statBonus: { stat: 'conversion_stellar', baseValue: EXO_CONVERSION, label: 'Stellar Conversion/tick' },
difficultyFactor: 220, difficultyFactor: EXO_DIFF,
scalingFactor: 110, scalingFactor: EXO_SCALE,
drainBase: 3, drainBase: EXO_DRAIN,
conversionRate: EXO_CONVERSION,
sourceManaTypes: ['raw', 'fire', 'light'],
requires: ['stellar'], requires: ['stellar'],
perks: [ perks: [
{ {
@@ -150,30 +175,33 @@ export const elementalRegenAdvancedDisciplines: DisciplineDefinition[] = [
type: 'once', type: 'once',
threshold: 200, threshold: 200,
value: 0, value: 0,
description: '+0.25 Stellar Regen/tick', description: '+0.25 Stellar Conversion/tick',
bonus: { stat: 'regen_stellar', amount: 0.25 }, bonus: { stat: 'conversion_stellar', amount: EXO_CONVERSION },
}, },
{ {
id: 'regen-stellar-inf', id: 'regen-stellar-inf',
type: 'infinite', type: 'infinite',
threshold: 500, threshold: 500,
value: 100, value: 100,
description: 'Every 100 XP: +0.1 Stellar Regen/tick', description: 'Every 100 XP: +0.1 Stellar Conversion/tick',
bonus: { stat: 'regen_stellar', amount: 0.1 }, bonus: { stat: 'conversion_stellar', amount: 0.1 },
}, },
], ],
}, };
{
const voidDiscipline: DisciplineDefinition = {
id: 'regen-void', id: 'regen-void',
name: 'Void Mana Flow', name: 'Void Mana Flow',
attunement: DisciplinesAttunementType.BASE, attunement: DisciplinesAttunementType.BASE,
manaType: 'void', manaType: 'void',
baseCost: 18, baseCost: 18,
description: 'Attune your void mana to regenerate passively over time.', description: 'Convert raw mana + dark mana + death mana into void mana over time.',
statBonus: { stat: 'regen_void', baseValue: 0.25, label: 'Void Regen/tick' }, statBonus: { stat: 'conversion_void', baseValue: EXO_CONVERSION, label: 'Void Conversion/tick' },
difficultyFactor: 220, difficultyFactor: EXO_DIFF,
scalingFactor: 110, scalingFactor: EXO_SCALE,
drainBase: 3, drainBase: EXO_DRAIN,
conversionRate: EXO_CONVERSION,
sourceManaTypes: ['raw', 'dark', 'death'],
requires: ['void'], requires: ['void'],
perks: [ perks: [
{ {
@@ -181,17 +209,25 @@ export const elementalRegenAdvancedDisciplines: DisciplineDefinition[] = [
type: 'once', type: 'once',
threshold: 200, threshold: 200,
value: 0, value: 0,
description: '+0.25 Void Regen/tick', description: '+0.25 Void Conversion/tick',
bonus: { stat: 'regen_void', amount: 0.25 }, bonus: { stat: 'conversion_void', amount: EXO_CONVERSION },
}, },
{ {
id: 'regen-void-inf', id: 'regen-void-inf',
type: 'infinite', type: 'infinite',
threshold: 500, threshold: 500,
value: 100, value: 100,
description: 'Every 100 XP: +0.1 Void Regen/tick', description: 'Every 100 XP: +0.1 Void Conversion/tick',
bonus: { stat: 'regen_void', amount: 0.1 }, bonus: { stat: 'conversion_void', amount: 0.1 },
}, },
], ],
}, };
export const elementalRegenAdvancedDisciplines: DisciplineDefinition[] = [
metalDiscipline,
sandDiscipline,
lightningDiscipline,
crystalDiscipline,
stellarDiscipline,
voidDiscipline,
]; ];
@@ -1,28 +1,38 @@
// ─── Elemental Regen Disciplines (Base + Utility) ───────────────────────────── // ─── Elemental Conversion Disciplines (Base + Utility) ─────────────────────────
// One discipline per mana type that provides passive regen for that element. // One discipline per mana type that converts raw mana into that element.
// All are BASE attunement so they are available to every role once the element is unlocked. // All are BASE attunement so they are available to every role once the element is unlocked.
import { DisciplinesAttunementType } from '../../types/disciplines'; import { DisciplinesAttunementType } from '../../types/disciplines';
import type { DisciplineDefinition } from '../../types/disciplines'; import type { DisciplineDefinition } from '../../types/disciplines';
const BASE_REGEN = 0.5; const BASE_CONVERSION = 0.5;
const BASE_DRAIN = 1.5; const BASE_DRAIN = 1.5;
const BASE_DIFF = 120; const BASE_DIFF = 120;
const BASE_SCALE = 60; const BASE_SCALE = 60;
function makeBaseRegen(id: string, name: string, manaType: string, cost: number): DisciplineDefinition { function makeBaseConversion(
const shortId = id.replace('regen-', ''); id: string,
name: string,
manaType: string,
cost: number,
): DisciplineDefinition {
return { return {
id, id,
name: `${name} Mana Flow`, name: `${name} Mana Flow`,
attunement: DisciplinesAttunementType.BASE, attunement: DisciplinesAttunementType.BASE,
manaType: manaType as DisciplineDefinition['manaType'], manaType: manaType as DisciplineDefinition['manaType'],
baseCost: cost, baseCost: cost,
description: `Attune your ${name.toLowerCase()} mana to regenerate passively over time.`, description: `Convert raw mana into ${name.toLowerCase()} mana over time.`,
statBonus: { stat: `regen_${shortId}` as DisciplineDefinition['statBonus']['stat'], baseValue: BASE_REGEN, label: `${name} Regen/tick` }, statBonus: {
stat: `conversion_${manaType}` as DisciplineDefinition['statBonus']['stat'],
baseValue: BASE_CONVERSION,
label: `${name} Conversion/tick`,
},
difficultyFactor: BASE_DIFF, difficultyFactor: BASE_DIFF,
scalingFactor: BASE_SCALE, scalingFactor: BASE_SCALE,
drainBase: BASE_DRAIN, drainBase: BASE_DRAIN,
conversionRate: BASE_CONVERSION,
sourceManaTypes: ['raw' as DisciplineDefinition['manaType']],
requires: [manaType], requires: [manaType],
perks: [ perks: [
{ {
@@ -30,16 +40,16 @@ function makeBaseRegen(id: string, name: string, manaType: string, cost: number)
type: 'once', type: 'once',
threshold: 100, threshold: 100,
value: 0, value: 0,
description: `+${BASE_REGEN} ${name} Regen/tick`, description: `+${BASE_CONVERSION} ${name} Conversion/tick`,
bonus: { stat: `regen_${shortId}`, amount: BASE_REGEN }, bonus: { stat: `conversion_${manaType}`, amount: BASE_CONVERSION },
}, },
{ {
id: `${id}-inf`, id: `${id}-inf`,
type: 'infinite', type: 'infinite',
threshold: 300, threshold: 300,
value: 100, value: 100,
description: `Every 100 XP: +0.25 ${name} Regen/tick`, description: `Every 100 XP: +0.25 ${name} Conversion/tick`,
bonus: { stat: `regen_${shortId}`, amount: 0.25 }, bonus: { stat: `conversion_${manaType}`, amount: 0.25 },
}, },
], ],
}; };
@@ -47,13 +57,13 @@ function makeBaseRegen(id: string, name: string, manaType: string, cost: number)
export const elementalRegenDisciplines: DisciplineDefinition[] = [ export const elementalRegenDisciplines: DisciplineDefinition[] = [
// ── Base Elements ────────────────────────────────────────────────────────── // ── Base Elements ──────────────────────────────────────────────────────────
makeBaseRegen('regen-fire', 'Fire', 'fire', 8), makeBaseConversion('regen-fire', 'Fire', 'fire', 8),
makeBaseRegen('regen-water', 'Water', 'water', 8), makeBaseConversion('regen-water', 'Water', 'water', 8),
makeBaseRegen('regen-air', 'Air', 'air', 8), makeBaseConversion('regen-air', 'Air', 'air', 8),
makeBaseRegen('regen-earth', 'Earth', 'earth', 8), makeBaseConversion('regen-earth', 'Earth', 'earth', 8),
makeBaseRegen('regen-light', 'Light', 'light', 8), makeBaseConversion('regen-light', 'Light', 'light', 8),
makeBaseRegen('regen-dark', 'Dark', 'dark', 8), makeBaseConversion('regen-dark', 'Dark', 'dark', 8),
makeBaseRegen('regen-death', 'Death', 'death', 8), makeBaseConversion('regen-death', 'Death', 'death', 8),
// ── Utility Element ──────────────────────────────────────────────────────── // ── Utility Element ────────────────────────────────────────────────────────
{ {
@@ -62,11 +72,13 @@ export const elementalRegenDisciplines: DisciplineDefinition[] = [
attunement: DisciplinesAttunementType.BASE, attunement: DisciplinesAttunementType.BASE,
manaType: 'transference', manaType: 'transference',
baseCost: 6, baseCost: 6,
description: 'Attune your transference mana to regenerate passively over time.', description: 'Convert raw mana into transference mana over time.',
statBonus: { stat: 'regen_transference', baseValue: 0.4, label: 'Transference Regen/tick' }, statBonus: { stat: 'conversion_transference', baseValue: 0.4, label: 'Transference Conversion/tick' },
difficultyFactor: 100, difficultyFactor: 100,
scalingFactor: 50, scalingFactor: 50,
drainBase: 1, drainBase: 1,
conversionRate: 0.4,
sourceManaTypes: ['transference'],
requires: ['transference'], requires: ['transference'],
perks: [ perks: [
{ {
@@ -74,16 +86,16 @@ export const elementalRegenDisciplines: DisciplineDefinition[] = [
type: 'once', type: 'once',
threshold: 100, threshold: 100,
value: 0, value: 0,
description: '+0.4 Transference Regen/tick', description: '+0.4 Transference Conversion/tick',
bonus: { stat: 'regen_transference', amount: 0.4 }, bonus: { stat: 'conversion_transference', amount: 0.4 },
}, },
{ {
id: 'regen-transference-inf', id: 'regen-transference-inf',
type: 'infinite', type: 'infinite',
threshold: 300, threshold: 300,
value: 100, value: 100,
description: 'Every 100 XP: +0.2 Transference Regen/tick', description: 'Every 100 XP: +0.2 Transference Conversion/tick',
bonus: { stat: 'regen_transference', amount: 0.2 }, bonus: { stat: 'conversion_transference', amount: 0.2 },
}, },
], ],
}, },
+3 -7
View File
@@ -93,14 +93,10 @@ export function computeAllEffects(
} }
} }
// Merge per-element regen from discipline effects (regen_{element}) // Per-element regen from discipline effects (regen_{element}) — no longer produced
// by elemental disciplines (they now use conversion instead).
// Kept for backward compatibility with any upgrade effects that may provide per-element regen.
const perElementRegenBonus: Record<string, number> = { ...upgradeEffects.perElementRegenBonus }; const perElementRegenBonus: Record<string, number> = { ...upgradeEffects.perElementRegenBonus };
for (const [key, value] of Object.entries(disciplineEffects.bonuses)) {
if (key.startsWith('regen_') && key !== 'regenBonus') {
const element = key.replace('regen_', '');
perElementRegenBonus[element] = (perElementRegenBonus[element] || 0) + value;
}
}
// Merge per-element cap bonuses from discipline effects (elementCap_{element}) // Merge per-element cap bonuses from discipline effects (elementCap_{element})
for (const [key, value] of Object.entries(disciplineEffects.bonuses)) { for (const [key, value] of Object.entries(disciplineEffects.bonuses)) {
+22 -4
View File
@@ -23,11 +23,19 @@ const KNOWN_BONUS_STATS = new Set([
'elementCapBonus', 'elementCapBonus',
]); ]);
export function computeDisciplineEffects(_state?: DisciplineStoreState): { export interface DisciplineEffectsResult {
bonuses: Record<string, number>; bonuses: Record<string, number>;
multipliers: Record<string, number>; multipliers: Record<string, number>;
specials: Set<string>; specials: Set<string>;
} { /**
* Conversion entries: for each active discipline with a conversionRate,
* maps target mana type → { rate, sourceManaTypes }.
* The tick pipeline drains source mana types and adds to the target.
*/
conversions: Record<string, { rate: number; sourceManaTypes: string[] }>;
}
export function computeDisciplineEffects(_state?: DisciplineStoreState): DisciplineEffectsResult {
const { disciplines } = useDisciplineStore.getState(); const { disciplines } = useDisciplineStore.getState();
const activeDiscs = Object.entries(disciplines) const activeDiscs = Object.entries(disciplines)
.filter(([, disc]) => disc && disc.xp > 0) .filter(([, disc]) => disc && disc.xp > 0)
@@ -37,6 +45,7 @@ export function computeDisciplineEffects(_state?: DisciplineStoreState): {
const bonuses: Record<string, number> = {}; const bonuses: Record<string, number> = {};
const multipliers: Record<string, number> = {}; const multipliers: Record<string, number> = {};
const specials = new Set<string>(); const specials = new Set<string>();
const conversions: Record<string, { rate: number; sourceManaTypes: string[] }> = {};
function addBonus(stat: string, amount: number) { function addBonus(stat: string, amount: number) {
bonuses[stat] = (bonuses[stat] || 0) + amount; bonuses[stat] = (bonuses[stat] || 0) + amount;
@@ -49,6 +58,16 @@ export function computeDisciplineEffects(_state?: DisciplineStoreState): {
addBonus(def.statBonus.stat, statBonus); addBonus(def.statBonus.stat, statBonus);
} }
// Conversion entry — if this discipline defines conversionRate
if (def.conversionRate && def.sourceManaTypes && def.sourceManaTypes.length > 0) {
// Scale the conversion rate by the stat bonus multiplier
const scaledRate = def.conversionRate + statBonus;
conversions[def.manaType] = {
rate: scaledRate,
sourceManaTypes: def.sourceManaTypes,
};
}
// Perk unlocks // Perk unlocks
const perks = getUnlockedPerks(def, disc.xp); const perks = getUnlockedPerks(def, disc.xp);
for (const perk of perks) { for (const perk of perks) {
@@ -56,7 +75,6 @@ export function computeDisciplineEffects(_state?: DisciplineStoreState): {
if (perk.bonus) { if (perk.bonus) {
addBonus(perk.bonus.stat, perk.bonus.amount); addBonus(perk.bonus.stat, perk.bonus.amount);
} else if (!perk.unlocksEffects) { } else if (!perk.unlocksEffects) {
// Fallback: qualitative perk with no structured bonus — add as special flag
specials.add(perk.id); specials.add(perk.id);
} }
// Perks with unlocksEffects are handled by discipline-slice.ts processTick() // Perks with unlocksEffects are handled by discipline-slice.ts processTick()
@@ -83,5 +101,5 @@ export function computeDisciplineEffects(_state?: DisciplineStoreState): {
} }
} }
return { bonuses, multipliers, specials }; return { bonuses, multipliers, specials, conversions };
} }
+14
View File
@@ -89,9 +89,23 @@ export const useDisciplineStore = create<DisciplineStore>()(
}).length; }).length;
if (nonPaused >= s.concurrentLimit) return s; if (nonPaused >= s.concurrentLimit) return s;
if (!canProceedDiscipline(def, existing, gameState)) return s; if (!canProceedDiscipline(def, existing, gameState)) return s;
// Check discipline prerequisites (requires field → discipline XP)
const prereqCheck = checkDisciplinePrerequisites(def, s.disciplines, ALL_DISCIPLINES); const prereqCheck = checkDisciplinePrerequisites(def, s.disciplines, ALL_DISCIPLINES);
if (!prereqCheck.canProceed) return s; if (!prereqCheck.canProceed) return s;
// For conversion disciplines: gate on having all source mana types unlocked
if (def.sourceManaTypes && def.sourceManaTypes.length > 0) {
const elements = gameState?.elements;
if (elements) {
for (const srcType of def.sourceManaTypes) {
if (srcType === 'raw') continue; // raw is always available
const srcElem = elements[srcType];
if (!srcElem || !srcElem.unlocked) return s;
}
}
}
const discState = existing || { id, xp: 0, paused: false }; const discState = existing || { id, xp: 0, paused: false };
// Set currentAction to 'practicing' (only overrides 'meditate') // Set currentAction to 'practicing' (only overrides 'meditate')
useCombatStore.getState().startPracticing(); useCombatStore.getState().startPracticing();
+34 -11
View File
@@ -263,22 +263,45 @@ export const useGameStore = create<GameCoordinatorStore>()(
rawMana = disciplineResult.rawMana; rawMana = disciplineResult.rawMana;
elements = disciplineResult.elements; elements = disciplineResult.elements;
// Apply per-element regen from discipline effects (regen_{element}) // Apply discipline conversions: drain source mana, add to target element
for (const [key, value] of Object.entries(disciplineEffects.bonuses)) { for (const [targetElem, conv] of Object.entries(disciplineEffects.conversions)) {
if (key.startsWith('regen_') && key !== 'regenBonus') { const conversionAmount = conv.rate * HOURS_PER_TICK;
const element = key.replace('regen_', ''); // Check that all source mana types are available (unlocked and have enough)
if (elements[element]) { let canConvert = true;
elements[element] = { for (const srcType of conv.sourceManaTypes) {
...elements[element], if (srcType === 'raw') {
if (rawMana < conversionAmount) {
canConvert = false;
break;
}
} else if (!elements[srcType] || !elements[srcType].unlocked || elements[srcType].current < conversionAmount) {
canConvert = false;
break;
}
}
if (!canConvert) continue;
// Drain source mana types
for (const srcType of conv.sourceManaTypes) {
if (srcType === 'raw') {
rawMana -= conversionAmount;
} else if (elements[srcType]) {
elements[srcType] = {
...elements[srcType],
current: elements[srcType].current - conversionAmount,
};
}
}
// Add to target element
if (elements[targetElem]) {
elements[targetElem] = {
...elements[targetElem],
current: Math.min( current: Math.min(
elements[element].max, elements[targetElem].max,
elements[element].current + value * HOURS_PER_TICK, elements[targetElem].current + conversionAmount,
), ),
}; };
} }
} }
}
// Unlock enchantment effects from newly unlocked discipline perks // Unlock enchantment effects from newly unlocked discipline perks
if (disciplineResult.unlockedEffects.length > 0) { if (disciplineResult.unlockedEffects.length > 0) {
useCraftingStore.getState().unlockEffects(disciplineResult.unlockedEffects); useCraftingStore.getState().unlockEffects(disciplineResult.unlockedEffects);
+12
View File
@@ -42,6 +42,18 @@ export interface DisciplineDefinition {
drainBase: number; drainBase: number;
perks: DisciplinePerk[]; perks: DisciplinePerk[];
requires?: string[]; requires?: string[];
/**
* If set, this discipline converts source mana types into the target manaType
* instead of providing passive regen. The conversion rate is the amount of
* target mana produced per tick (before XP scaling).
*/
conversionRate?: number;
/**
* Mana types consumed as input for conversion. Always includes 'raw'.
* For composites/exotics, also includes constituent element(s).
* Example: ['raw'] for base, ['raw', 'fire', 'earth'] for metal.
*/
sourceManaTypes?: ManaType[];
} }
// ─── Discipline State ───────────────────────────────────────────────────────── // ─── Discipline State ─────────────────────────────────────────────────────────
+35 -4
View File
@@ -87,10 +87,31 @@ export function canProceedDiscipline(
return element && element.current >= drain; return element && element.current >= drain;
} }
// ─── Known mana type names for display ────────────────────────────────────────
const MANA_TYPE_NAMES: Record<string, string> = {
raw: 'raw',
fire: 'fire',
water: 'water',
air: 'air',
earth: 'earth',
light: 'light',
dark: 'dark',
death: 'death',
transference: 'transference',
metal: 'metal',
sand: 'sand',
lightning: 'lightning',
crystal: 'crystal',
stellar: 'stellar',
void: 'void',
};
/** /**
* Check if a discipline's prerequisites are met. * Check if a discipline's prerequisites are met.
* For disciplines with sourceManaTypes (conversion disciplines), requires are
* mana type IDs that must be unlocked (e.g. fire, earth).
* Returns { canProceed: boolean, missingPrereqs: string[] } * Returns { canProceed: boolean, missingPrereqs: string[] }
* where missingPrereqs is a list of prerequisite discipline names that are not yet unlocked. * where missingPrereqs is a list of human-readable prerequisite descriptions.
*/ */
export function checkDisciplinePrerequisites( export function checkDisciplinePrerequisites(
discipline: DisciplineDefinition, discipline: DisciplineDefinition,
@@ -104,12 +125,22 @@ export function checkDisciplinePrerequisites(
const missingPrereqs: string[] = []; const missingPrereqs: string[] = [];
for (const reqId of discipline.requires) { for (const reqId of discipline.requires) {
const reqState = allDisciplines[reqId]; // Check if this is a discipline prerequisite (exists in definitions)
// A prerequisite is met if the discipline has XP > 0 (has been practiced)
if (!reqState || reqState.xp <= 0) {
const reqDef = allDefinitions.find((d) => d.id === reqId); const reqDef = allDefinitions.find((d) => d.id === reqId);
if (reqDef) {
const reqState = allDisciplines[reqId];
if (!reqState || reqState.xp <= 0) {
missingPrereqs.push(reqDef?.name ?? reqId); missingPrereqs.push(reqDef?.name ?? reqId);
} }
} else {
// Treat as mana type requirement — display as "<name> mana"
const typeName = MANA_TYPE_NAMES[reqId];
if (typeName) {
missingPrereqs.push(`${typeName} mana`);
} else {
missingPrereqs.push(reqId);
}
}
} }
return { canProceed: missingPrereqs.length === 0, missingPrereqs }; return { canProceed: missingPrereqs.length === 0, missingPrereqs };