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
@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { BookOpen, Plus, Pause, Play } from 'lucide-react';
import { useDisciplineStore } from '@/lib/game/stores/discipline-slice';
import { ALL_DISCIPLINES } from '@/lib/game/data/disciplines';
import { useManaStore } from '@/lib/game/stores/manaStore';
export function DisciplineDebugSection() {
const disciplines = useDisciplineStore((s) => s.disciplines);
@@ -12,6 +13,7 @@ export function DisciplineDebugSection() {
const concurrentLimit = useDisciplineStore((s) => s.concurrentLimit);
const activate = useDisciplineStore((s) => s.activate);
const deactivate = useDisciplineStore((s) => s.deactivate);
const elements = useManaStore((s) => s.elements);
const _handleTogglePause = (id: string) => {
const disc = disciplines[id];
@@ -41,7 +43,7 @@ export function DisciplineDebugSection() {
const handleActivateAll = () => {
ALL_DISCIPLINES.forEach((d) => {
if (!activeIds.includes(d.id)) {
activate(d.id);
activate(d.id, { elements });
}
});
};
@@ -111,7 +113,7 @@ export function DisciplineDebugSection() {
if (isActive) {
deactivate(def.id);
} 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 { calculateStatBonus, calculateManaDrain, checkDisciplinePrerequisites } from '@/lib/game/utils/discipline-math';
import { ALL_DISCIPLINES } from '@/lib/game/data/disciplines';
import { useManaStore } from '@/lib/game/stores/manaStore';
import clsx from 'clsx';
// ─── Attunement Tabs ─────────────────────────────────────────────────────────
@@ -25,8 +26,8 @@ interface AttunementTab {
const ATTUNEMENT_TABS: AttunementTab[] = [
{ key: 'base', label: 'Base', items: baseDisciplines },
{ key: 'elements', label: 'Mana Types', items: elementalAttunementDisciplines },
{ key: 'elemental-regen', label: 'Elemental Regen', items: elementalRegenDisciplines },
{ key: 'elemental-regen-advanced', label: 'Advanced Regen', items: elementalRegenAdvancedDisciplines },
{ key: 'elemental-regen', label: 'Elemental Flow', items: elementalRegenDisciplines },
{ key: 'elemental-regen-advanced', label: 'Advanced Flow', items: elementalRegenAdvancedDisciplines },
{ key: 'enchanter', label: 'Enchanter', items: enchanterDisciplines },
{ key: 'fabricator', label: 'Fabricator', items: fabricatorDisciplines },
{ key: 'invoker', label: 'Invoker', items: invokerDisciplines },
@@ -50,6 +51,8 @@ export interface DisciplineCardDefinition {
drainBase: number;
difficultyFactor: number;
scalingFactor: number;
sourceManaTypes?: ManaType[];
conversionRate?: number;
}
export interface DisciplineCardRuntime {
@@ -58,6 +61,7 @@ export interface DisciplineCardRuntime {
concurrentLimit: number;
isLocked: boolean;
missingPrereqs: string[];
missingSourceMana: string[];
}
export interface DisciplineCardCallbacks {
@@ -77,7 +81,7 @@ const DisciplineCard: React.FC<DisciplineCardProps> = ({ definition, runtime, ca
id, name, description, manaType, baseCost, perkThresholds, perkValues, perkTypes,
statBonusLabel, baseValue, drainBase, difficultyFactor, scalingFactor,
} = definition;
const { xp, paused: isPaused, concurrentLimit, isLocked, missingPrereqs } = runtime;
const { xp, paused: isPaused, concurrentLimit, isLocked, missingPrereqs, missingSourceMana } = runtime;
const { onToggle } = callbacks;
const displayXp = xp;
@@ -91,6 +95,8 @@ const DisciplineCard: React.FC<DisciplineCardProps> = ({ definition, runtime, ca
const manaIcon = elementDef?.sym ?? '✦';
const manaName = elementDef?.name ?? manaType;
const effectiveIsLocked = isLocked || missingSourceMana.length > 0;
const unlockedPerks = perkTypes?.reduce<string[]>((acc, typ, idx) => {
const threshold = perkThresholds?.[idx];
if (threshold === undefined) return acc;
@@ -109,7 +115,7 @@ const DisciplineCard: React.FC<DisciplineCardProps> = ({ definition, runtime, ca
};
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">
<h3 className="text-lg font-medium">{name}</h3>
<span
@@ -149,6 +155,12 @@ const DisciplineCard: React.FC<DisciplineCardProps> = ({ definition, runtime, ca
</span>
</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">
<strong>Stat Bonus:</strong> {activeStatBonus.toFixed(2)} on {statBonusLabel}
</div>
@@ -166,16 +178,16 @@ const DisciplineCard: React.FC<DisciplineCardProps> = ({ definition, runtime, ca
</ul>
</div>
{isLocked && missingPrereqs.length > 0 && (
{effectiveIsLocked && (missingPrereqs.length > 0 || missingSourceMana.length > 0) && (
<div className="mt-2 text-xs text-red-400">
<strong>Requires:</strong> {missingPrereqs.join(', ')}
<strong>Requires:</strong> {[...missingPrereqs, ...missingSourceMana].join(', ')}
</div>
)}
<div className="mt-4 flex justify-end">
<button
onClick={toggleAction}
disabled={isLocked}
disabled={effectiveIsLocked}
className={clsx(
'rounded px-3 py-1 text-sm font-medium',
isLocked
@@ -185,7 +197,7 @@ const DisciplineCard: React.FC<DisciplineCardProps> = ({ definition, runtime, ca
: 'bg-blue-600 text-white hover:bg-blue-500',
)}
>
{isLocked ? 'Locked' : isPaused ? 'Start Practicing' : 'Stop Practicing'}
{effectiveIsLocked ? 'Locked' : isPaused ? 'Start Practicing' : 'Stop Practicing'}
</button>
</div>
</div>
@@ -203,13 +215,15 @@ export const DisciplinesTab: React.FC = () => {
const [activeAttunement, setActiveAttunement] = useState<string>('base');
const elements = useManaStore((s) => s.elements);
const handleToggle = useCallback((id: string, paused: boolean) => {
if (paused) {
activate(id);
activate(id, { elements });
} else {
deactivate(id);
}
}, [activate, deactivate]);
}, [activate, deactivate, elements]);
const activeTab = ATTUNEMENT_TABS.find((t) => t.key === activeAttunement);
@@ -254,6 +268,8 @@ export const DisciplinesTab: React.FC = () => {
statBonus: disc.statBonus.stat,
statBonusLabel: disc.statBonus.label,
requires: disc.requires,
sourceManaTypes: disc.sourceManaTypes,
conversionRate: disc.conversionRate,
baseValue: disc.statBonus.baseValue,
drainBase: disc.drainBase,
difficultyFactor: disc.difficultyFactor,
@@ -265,6 +281,11 @@ export const DisciplinesTab: React.FC = () => {
concurrentLimit,
isLocked: !prereqCheck.canProceed,
missingPrereqs: prereqCheck.missingPrereqs,
missingSourceMana: disc.sourceManaTypes
? disc.sourceManaTypes.filter(
(src) => src !== 'raw' && (!elements[src] || !elements[src].unlocked),
).map((src) => `${src} mana`)
: [],
}}
callbacks={{
onToggle: handleToggle,
@@ -1,197 +1,233 @@
// ─── Elemental Regen Disciplines (Composite + Exotic) ─────────────────────────
// Regen disciplines for composite and exotic mana types.
// ─── Elemental Conversion Disciplines (Composite + Exotic) ──────────────────────
// Conversion disciplines for composite and exotic mana types.
// All are BASE attunement so they are available to every role once the element is unlocked.
import { DisciplinesAttunementType } from '../../types/disciplines';
import type { DisciplineDefinition } from '../../types/disciplines';
// ── 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',
name: 'Metal Mana Flow',
attunement: DisciplinesAttunementType.BASE,
manaType: 'metal',
baseCost: 12,
description: 'Convert raw mana + fire mana + earth mana into metal mana over time.',
statBonus: { stat: 'conversion_metal', baseValue: COMP_CONVERSION, label: 'Metal Conversion/tick' },
difficultyFactor: COMP_DIFF,
scalingFactor: COMP_SCALE,
drainBase: COMP_DRAIN,
conversionRate: COMP_CONVERSION,
sourceManaTypes: ['raw', 'fire', 'earth'],
requires: ['metal'],
perks: [
{
id: 'regen-metal-1',
type: 'once',
threshold: 150,
value: 0,
description: '+0.35 Metal Conversion/tick',
bonus: { stat: 'conversion_metal', amount: COMP_CONVERSION },
},
{
id: 'regen-metal-inf',
type: 'infinite',
threshold: 400,
value: 100,
description: 'Every 100 XP: +0.15 Metal Conversion/tick',
bonus: { stat: 'conversion_metal', amount: 0.15 },
},
],
};
const sandDiscipline: DisciplineDefinition = {
id: 'regen-sand',
name: 'Sand Mana Flow',
attunement: DisciplinesAttunementType.BASE,
manaType: 'sand',
baseCost: 12,
description: 'Convert raw mana + earth mana + water mana into sand mana over time.',
statBonus: { stat: 'conversion_sand', baseValue: COMP_CONVERSION, label: 'Sand Conversion/tick' },
difficultyFactor: COMP_DIFF,
scalingFactor: COMP_SCALE,
drainBase: COMP_DRAIN,
conversionRate: COMP_CONVERSION,
sourceManaTypes: ['raw', 'earth', 'water'],
requires: ['sand'],
perks: [
{
id: 'regen-sand-1',
type: 'once',
threshold: 150,
value: 0,
description: '+0.35 Sand Conversion/tick',
bonus: { stat: 'conversion_sand', amount: COMP_CONVERSION },
},
{
id: 'regen-sand-inf',
type: 'infinite',
threshold: 400,
value: 100,
description: 'Every 100 XP: +0.15 Sand Conversion/tick',
bonus: { stat: 'conversion_sand', amount: 0.15 },
},
],
};
const lightningDiscipline: DisciplineDefinition = {
id: 'regen-lightning',
name: 'Lightning Mana Flow',
attunement: DisciplinesAttunementType.BASE,
manaType: 'lightning',
baseCost: 12,
description: 'Convert raw mana + fire mana + air mana into lightning mana over time.',
statBonus: { stat: 'conversion_lightning', baseValue: COMP_CONVERSION, label: 'Lightning Conversion/tick' },
difficultyFactor: COMP_DIFF,
scalingFactor: COMP_SCALE,
drainBase: COMP_DRAIN,
conversionRate: COMP_CONVERSION,
sourceManaTypes: ['raw', 'fire', 'air'],
requires: ['lightning'],
perks: [
{
id: 'regen-lightning-1',
type: 'once',
threshold: 150,
value: 0,
description: '+0.35 Lightning Conversion/tick',
bonus: { stat: 'conversion_lightning', amount: COMP_CONVERSION },
},
{
id: 'regen-lightning-inf',
type: 'infinite',
threshold: 400,
value: 100,
description: 'Every 100 XP: +0.15 Lightning Conversion/tick',
bonus: { stat: 'conversion_lightning', amount: 0.15 },
},
],
};
// ── 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',
name: 'Crystal Mana Flow',
attunement: DisciplinesAttunementType.BASE,
manaType: 'crystal',
baseCost: 18,
description: 'Convert raw mana + sand mana + light mana into crystal mana over time.',
statBonus: { stat: 'conversion_crystal', baseValue: EXO_CONVERSION, label: 'Crystal Conversion/tick' },
difficultyFactor: EXO_DIFF,
scalingFactor: EXO_SCALE,
drainBase: EXO_DRAIN,
conversionRate: EXO_CONVERSION,
sourceManaTypes: ['raw', 'sand', 'light'],
requires: ['crystal'],
perks: [
{
id: 'regen-crystal-1',
type: 'once',
threshold: 200,
value: 0,
description: '+0.25 Crystal Conversion/tick',
bonus: { stat: 'conversion_crystal', amount: EXO_CONVERSION },
},
{
id: 'regen-crystal-inf',
type: 'infinite',
threshold: 500,
value: 100,
description: 'Every 100 XP: +0.1 Crystal Conversion/tick',
bonus: { stat: 'conversion_crystal', amount: 0.1 },
},
],
};
const stellarDiscipline: DisciplineDefinition = {
id: 'regen-stellar',
name: 'Stellar Mana Flow',
attunement: DisciplinesAttunementType.BASE,
manaType: 'stellar',
baseCost: 18,
description: 'Convert raw mana + fire mana + light mana into stellar mana over time.',
statBonus: { stat: 'conversion_stellar', baseValue: EXO_CONVERSION, label: 'Stellar Conversion/tick' },
difficultyFactor: EXO_DIFF,
scalingFactor: EXO_SCALE,
drainBase: EXO_DRAIN,
conversionRate: EXO_CONVERSION,
sourceManaTypes: ['raw', 'fire', 'light'],
requires: ['stellar'],
perks: [
{
id: 'regen-stellar-1',
type: 'once',
threshold: 200,
value: 0,
description: '+0.25 Stellar Conversion/tick',
bonus: { stat: 'conversion_stellar', amount: EXO_CONVERSION },
},
{
id: 'regen-stellar-inf',
type: 'infinite',
threshold: 500,
value: 100,
description: 'Every 100 XP: +0.1 Stellar Conversion/tick',
bonus: { stat: 'conversion_stellar', amount: 0.1 },
},
],
};
const voidDiscipline: DisciplineDefinition = {
id: 'regen-void',
name: 'Void Mana Flow',
attunement: DisciplinesAttunementType.BASE,
manaType: 'void',
baseCost: 18,
description: 'Convert raw mana + dark mana + death mana into void mana over time.',
statBonus: { stat: 'conversion_void', baseValue: EXO_CONVERSION, label: 'Void Conversion/tick' },
difficultyFactor: EXO_DIFF,
scalingFactor: EXO_SCALE,
drainBase: EXO_DRAIN,
conversionRate: EXO_CONVERSION,
sourceManaTypes: ['raw', 'dark', 'death'],
requires: ['void'],
perks: [
{
id: 'regen-void-1',
type: 'once',
threshold: 200,
value: 0,
description: '+0.25 Void Conversion/tick',
bonus: { stat: 'conversion_void', amount: EXO_CONVERSION },
},
{
id: 'regen-void-inf',
type: 'infinite',
threshold: 500,
value: 100,
description: 'Every 100 XP: +0.1 Void Conversion/tick',
bonus: { stat: 'conversion_void', amount: 0.1 },
},
],
};
export const elementalRegenAdvancedDisciplines: DisciplineDefinition[] = [
// ── Composite Elements ─────────────────────────────────────────────────────
{
id: 'regen-metal',
name: 'Metal Mana Flow',
attunement: DisciplinesAttunementType.BASE,
manaType: 'metal',
baseCost: 12,
description: 'Attune your metal mana to regenerate passively over time.',
statBonus: { stat: 'regen_metal', baseValue: 0.35, label: 'Metal Regen/tick' },
difficultyFactor: 160,
scalingFactor: 80,
drainBase: 2,
requires: ['metal'],
perks: [
{
id: 'regen-metal-1',
type: 'once',
threshold: 150,
value: 0,
description: '+0.35 Metal Regen/tick',
bonus: { stat: 'regen_metal', amount: 0.35 },
},
{
id: 'regen-metal-inf',
type: 'infinite',
threshold: 400,
value: 100,
description: 'Every 100 XP: +0.15 Metal Regen/tick',
bonus: { stat: 'regen_metal', amount: 0.15 },
},
],
},
{
id: 'regen-sand',
name: 'Sand Mana Flow',
attunement: DisciplinesAttunementType.BASE,
manaType: 'sand',
baseCost: 12,
description: 'Attune your sand mana to regenerate passively over time.',
statBonus: { stat: 'regen_sand', baseValue: 0.35, label: 'Sand Regen/tick' },
difficultyFactor: 160,
scalingFactor: 80,
drainBase: 2,
requires: ['sand'],
perks: [
{
id: 'regen-sand-1',
type: 'once',
threshold: 150,
value: 0,
description: '+0.35 Sand Regen/tick',
bonus: { stat: 'regen_sand', amount: 0.35 },
},
{
id: 'regen-sand-inf',
type: 'infinite',
threshold: 400,
value: 100,
description: 'Every 100 XP: +0.15 Sand Regen/tick',
bonus: { stat: 'regen_sand', amount: 0.15 },
},
],
},
{
id: 'regen-lightning',
name: 'Lightning Mana Flow',
attunement: DisciplinesAttunementType.BASE,
manaType: 'lightning',
baseCost: 12,
description: 'Attune your lightning mana to regenerate passively over time.',
statBonus: { stat: 'regen_lightning', baseValue: 0.35, label: 'Lightning Regen/tick' },
difficultyFactor: 160,
scalingFactor: 80,
drainBase: 2,
requires: ['lightning'],
perks: [
{
id: 'regen-lightning-1',
type: 'once',
threshold: 150,
value: 0,
description: '+0.35 Lightning Regen/tick',
bonus: { stat: 'regen_lightning', amount: 0.35 },
},
{
id: 'regen-lightning-inf',
type: 'infinite',
threshold: 400,
value: 100,
description: 'Every 100 XP: +0.15 Lightning Regen/tick',
bonus: { stat: 'regen_lightning', amount: 0.15 },
},
],
},
// ── Exotic Elements ────────────────────────────────────────────────────────
{
id: 'regen-crystal',
name: 'Crystal Mana Flow',
attunement: DisciplinesAttunementType.BASE,
manaType: 'crystal',
baseCost: 18,
description: 'Attune your crystal mana to regenerate passively over time.',
statBonus: { stat: 'regen_crystal', baseValue: 0.25, label: 'Crystal Regen/tick' },
difficultyFactor: 220,
scalingFactor: 110,
drainBase: 3,
requires: ['crystal'],
perks: [
{
id: 'regen-crystal-1',
type: 'once',
threshold: 200,
value: 0,
description: '+0.25 Crystal Regen/tick',
bonus: { stat: 'regen_crystal', amount: 0.25 },
},
{
id: 'regen-crystal-inf',
type: 'infinite',
threshold: 500,
value: 100,
description: 'Every 100 XP: +0.1 Crystal Regen/tick',
bonus: { stat: 'regen_crystal', amount: 0.1 },
},
],
},
{
id: 'regen-stellar',
name: 'Stellar Mana Flow',
attunement: DisciplinesAttunementType.BASE,
manaType: 'stellar',
baseCost: 18,
description: 'Attune your stellar mana to regenerate passively over time.',
statBonus: { stat: 'regen_stellar', baseValue: 0.25, label: 'Stellar Regen/tick' },
difficultyFactor: 220,
scalingFactor: 110,
drainBase: 3,
requires: ['stellar'],
perks: [
{
id: 'regen-stellar-1',
type: 'once',
threshold: 200,
value: 0,
description: '+0.25 Stellar Regen/tick',
bonus: { stat: 'regen_stellar', amount: 0.25 },
},
{
id: 'regen-stellar-inf',
type: 'infinite',
threshold: 500,
value: 100,
description: 'Every 100 XP: +0.1 Stellar Regen/tick',
bonus: { stat: 'regen_stellar', amount: 0.1 },
},
],
},
{
id: 'regen-void',
name: 'Void Mana Flow',
attunement: DisciplinesAttunementType.BASE,
manaType: 'void',
baseCost: 18,
description: 'Attune your void mana to regenerate passively over time.',
statBonus: { stat: 'regen_void', baseValue: 0.25, label: 'Void Regen/tick' },
difficultyFactor: 220,
scalingFactor: 110,
drainBase: 3,
requires: ['void'],
perks: [
{
id: 'regen-void-1',
type: 'once',
threshold: 200,
value: 0,
description: '+0.25 Void Regen/tick',
bonus: { stat: 'regen_void', amount: 0.25 },
},
{
id: 'regen-void-inf',
type: 'infinite',
threshold: 500,
value: 100,
description: 'Every 100 XP: +0.1 Void Regen/tick',
bonus: { stat: 'regen_void', amount: 0.1 },
},
],
},
metalDiscipline,
sandDiscipline,
lightningDiscipline,
crystalDiscipline,
stellarDiscipline,
voidDiscipline,
];
@@ -1,28 +1,38 @@
// ─── Elemental Regen Disciplines (Base + Utility) ─────────────────────────────
// One discipline per mana type that provides passive regen for that element.
// ─── Elemental Conversion Disciplines (Base + Utility) ─────────────────────────
// 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.
import { DisciplinesAttunementType } 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_DIFF = 120;
const BASE_SCALE = 60;
function makeBaseRegen(id: string, name: string, manaType: string, cost: number): DisciplineDefinition {
const shortId = id.replace('regen-', '');
function makeBaseConversion(
id: string,
name: string,
manaType: string,
cost: number,
): DisciplineDefinition {
return {
id,
name: `${name} Mana Flow`,
attunement: DisciplinesAttunementType.BASE,
manaType: manaType as DisciplineDefinition['manaType'],
baseCost: cost,
description: `Attune your ${name.toLowerCase()} mana to regenerate passively over time.`,
statBonus: { stat: `regen_${shortId}` as DisciplineDefinition['statBonus']['stat'], baseValue: BASE_REGEN, label: `${name} Regen/tick` },
description: `Convert raw mana into ${name.toLowerCase()} mana over time.`,
statBonus: {
stat: `conversion_${manaType}` as DisciplineDefinition['statBonus']['stat'],
baseValue: BASE_CONVERSION,
label: `${name} Conversion/tick`,
},
difficultyFactor: BASE_DIFF,
scalingFactor: BASE_SCALE,
drainBase: BASE_DRAIN,
conversionRate: BASE_CONVERSION,
sourceManaTypes: ['raw' as DisciplineDefinition['manaType']],
requires: [manaType],
perks: [
{
@@ -30,16 +40,16 @@ function makeBaseRegen(id: string, name: string, manaType: string, cost: number)
type: 'once',
threshold: 100,
value: 0,
description: `+${BASE_REGEN} ${name} Regen/tick`,
bonus: { stat: `regen_${shortId}`, amount: BASE_REGEN },
description: `+${BASE_CONVERSION} ${name} Conversion/tick`,
bonus: { stat: `conversion_${manaType}`, amount: BASE_CONVERSION },
},
{
id: `${id}-inf`,
type: 'infinite',
threshold: 300,
value: 100,
description: `Every 100 XP: +0.25 ${name} Regen/tick`,
bonus: { stat: `regen_${shortId}`, amount: 0.25 },
description: `Every 100 XP: +0.25 ${name} Conversion/tick`,
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[] = [
// ── Base Elements ──────────────────────────────────────────────────────────
makeBaseRegen('regen-fire', 'Fire', 'fire', 8),
makeBaseRegen('regen-water', 'Water', 'water', 8),
makeBaseRegen('regen-air', 'Air', 'air', 8),
makeBaseRegen('regen-earth', 'Earth', 'earth', 8),
makeBaseRegen('regen-light', 'Light', 'light', 8),
makeBaseRegen('regen-dark', 'Dark', 'dark', 8),
makeBaseRegen('regen-death', 'Death', 'death', 8),
makeBaseConversion('regen-fire', 'Fire', 'fire', 8),
makeBaseConversion('regen-water', 'Water', 'water', 8),
makeBaseConversion('regen-air', 'Air', 'air', 8),
makeBaseConversion('regen-earth', 'Earth', 'earth', 8),
makeBaseConversion('regen-light', 'Light', 'light', 8),
makeBaseConversion('regen-dark', 'Dark', 'dark', 8),
makeBaseConversion('regen-death', 'Death', 'death', 8),
// ── Utility Element ────────────────────────────────────────────────────────
{
@@ -62,11 +72,13 @@ export const elementalRegenDisciplines: DisciplineDefinition[] = [
attunement: DisciplinesAttunementType.BASE,
manaType: 'transference',
baseCost: 6,
description: 'Attune your transference mana to regenerate passively over time.',
statBonus: { stat: 'regen_transference', baseValue: 0.4, label: 'Transference Regen/tick' },
description: 'Convert raw mana into transference mana over time.',
statBonus: { stat: 'conversion_transference', baseValue: 0.4, label: 'Transference Conversion/tick' },
difficultyFactor: 100,
scalingFactor: 50,
drainBase: 1,
conversionRate: 0.4,
sourceManaTypes: ['transference'],
requires: ['transference'],
perks: [
{
@@ -74,16 +86,16 @@ export const elementalRegenDisciplines: DisciplineDefinition[] = [
type: 'once',
threshold: 100,
value: 0,
description: '+0.4 Transference Regen/tick',
bonus: { stat: 'regen_transference', amount: 0.4 },
description: '+0.4 Transference Conversion/tick',
bonus: { stat: 'conversion_transference', amount: 0.4 },
},
{
id: 'regen-transference-inf',
type: 'infinite',
threshold: 300,
value: 100,
description: 'Every 100 XP: +0.2 Transference Regen/tick',
bonus: { stat: 'regen_transference', amount: 0.2 },
description: 'Every 100 XP: +0.2 Transference Conversion/tick',
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 };
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})
for (const [key, value] of Object.entries(disciplineEffects.bonuses)) {
+22 -4
View File
@@ -23,11 +23,19 @@ const KNOWN_BONUS_STATS = new Set([
'elementCapBonus',
]);
export function computeDisciplineEffects(_state?: DisciplineStoreState): {
export interface DisciplineEffectsResult {
bonuses: Record<string, number>;
multipliers: Record<string, number>;
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 activeDiscs = Object.entries(disciplines)
.filter(([, disc]) => disc && disc.xp > 0)
@@ -37,6 +45,7 @@ export function computeDisciplineEffects(_state?: DisciplineStoreState): {
const bonuses: Record<string, number> = {};
const multipliers: Record<string, number> = {};
const specials = new Set<string>();
const conversions: Record<string, { rate: number; sourceManaTypes: string[] }> = {};
function addBonus(stat: string, amount: number) {
bonuses[stat] = (bonuses[stat] || 0) + amount;
@@ -49,6 +58,16 @@ export function computeDisciplineEffects(_state?: DisciplineStoreState): {
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
const perks = getUnlockedPerks(def, disc.xp);
for (const perk of perks) {
@@ -56,7 +75,6 @@ export function computeDisciplineEffects(_state?: DisciplineStoreState): {
if (perk.bonus) {
addBonus(perk.bonus.stat, perk.bonus.amount);
} else if (!perk.unlocksEffects) {
// Fallback: qualitative perk with no structured bonus — add as special flag
specials.add(perk.id);
}
// 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;
if (nonPaused >= s.concurrentLimit) return s;
if (!canProceedDiscipline(def, existing, gameState)) return s;
// Check discipline prerequisites (requires field → discipline XP)
const prereqCheck = checkDisciplinePrerequisites(def, s.disciplines, ALL_DISCIPLINES);
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 };
// Set currentAction to 'practicing' (only overrides 'meditate')
useCombatStore.getState().startPracticing();
+35 -12
View File
@@ -263,22 +263,45 @@ export const useGameStore = create<GameCoordinatorStore>()(
rawMana = disciplineResult.rawMana;
elements = disciplineResult.elements;
// Apply per-element regen from discipline effects (regen_{element})
for (const [key, value] of Object.entries(disciplineEffects.bonuses)) {
if (key.startsWith('regen_') && key !== 'regenBonus') {
const element = key.replace('regen_', '');
if (elements[element]) {
elements[element] = {
...elements[element],
current: Math.min(
elements[element].max,
elements[element].current + value * HOURS_PER_TICK,
),
// Apply discipline conversions: drain source mana, add to target element
for (const [targetElem, conv] of Object.entries(disciplineEffects.conversions)) {
const conversionAmount = conv.rate * HOURS_PER_TICK;
// Check that all source mana types are available (unlocked and have enough)
let canConvert = true;
for (const srcType of conv.sourceManaTypes) {
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(
elements[targetElem].max,
elements[targetElem].current + conversionAmount,
),
};
}
}
// Unlock enchantment effects from newly unlocked discipline perks
if (disciplineResult.unlockedEffects.length > 0) {
useCraftingStore.getState().unlockEffects(disciplineResult.unlockedEffects);
+12
View File
@@ -42,6 +42,18 @@ export interface DisciplineDefinition {
drainBase: number;
perks: DisciplinePerk[];
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 ─────────────────────────────────────────────────────────
+37 -6
View File
@@ -87,10 +87,31 @@ export function canProceedDiscipline(
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.
* For disciplines with sourceManaTypes (conversion disciplines), requires are
* mana type IDs that must be unlocked (e.g. fire, earth).
* 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(
discipline: DisciplineDefinition,
@@ -104,11 +125,21 @@ export function checkDisciplinePrerequisites(
const missingPrereqs: string[] = [];
for (const reqId of discipline.requires) {
const reqState = allDisciplines[reqId];
// 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);
missingPrereqs.push(reqDef?.name ?? reqId);
// Check if this is a discipline prerequisite (exists in definitions)
const reqDef = allDefinitions.find((d) => d.id === reqId);
if (reqDef) {
const reqState = allDisciplines[reqId];
if (!reqState || reqState.xp <= 0) {
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);
}
}
}