8cebea9586
#172 - Grimoire tab: removed dead 'loaded' state guard that permanently showed loading #169 - Transference Mana Flow: added elements param to checkDisciplinePrerequisites so mana type unlocks are verified #168 - Perk descriptions: wired 4 broken perks (enchant-2, channel-1, golem-2, efficiency-1) with actual bonus effects; fixed enchant-1 interval (5→50); fixed study-mana-enchantments stat (maxMana→maxManaBonus) #171 - Shields: removed all shield equipment (4 types), recipes, category, slot mappings; added 'shields' to AGENTS.md banned list #166 - regenMultiplier: merged disciplineEffects.multipliers.regenMultiplier into computeAllEffects() #165 - Meditation cap: added meditationCap display to ManaStatsSection UI; updated perk description #167 - XP accumulation: added Meditative Mastery base discipline with disciplineXpBonus stat; wired into tick pipeline
312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
import React, { useState, useCallback } from 'react';
|
|
import { useDisciplineStore } from '@/lib/game/stores/discipline-slice';
|
|
import type { DisciplineDefinition } from '@/lib/game/types/disciplines';
|
|
import type { ManaType } from '@/lib/game/types/elements';
|
|
import { ELEMENTS } from '@/lib/game/constants/elements';
|
|
import { baseDisciplines } from '@/lib/game/data/disciplines/base';
|
|
import { elementalAttunementDisciplines } from '@/lib/game/data/disciplines/elemental';
|
|
import { elementalRegenDisciplines } from '@/lib/game/data/disciplines/elemental-regen';
|
|
import { elementalRegenAdvancedDisciplines } from '@/lib/game/data/disciplines/elemental-regen-advanced';
|
|
import { enchanterDisciplines } from '@/lib/game/data/disciplines/enchanter';
|
|
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 { usePrestigeStore } from '@/lib/game/stores/prestigeStore';
|
|
import clsx from 'clsx';
|
|
|
|
// ─── Attunement Tabs ─────────────────────────────────────────────────────────
|
|
|
|
interface AttunementTab {
|
|
key: string;
|
|
label: string;
|
|
items: DisciplineDefinition[];
|
|
}
|
|
|
|
const ATTUNEMENT_TABS: AttunementTab[] = [
|
|
{ key: 'base', label: 'Base', items: baseDisciplines },
|
|
{ key: 'elements', label: 'Mana Types', items: elementalAttunementDisciplines },
|
|
{ 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 },
|
|
];
|
|
|
|
// ─── Discipline Card Props (split from monolithic 15-field interface) ────────
|
|
|
|
export interface DisciplineCardDefinition {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
manaType: ManaType;
|
|
baseCost: number;
|
|
perkThresholds?: number[];
|
|
perkValues?: number[];
|
|
perkTypes?: string[];
|
|
perkDescriptions?: string[];
|
|
statBonus: string;
|
|
statBonusLabel: string;
|
|
requires?: string[];
|
|
baseValue: number;
|
|
drainBase: number;
|
|
difficultyFactor: number;
|
|
scalingFactor: number;
|
|
sourceManaTypes?: ManaType[];
|
|
conversionRate?: number;
|
|
}
|
|
|
|
export interface DisciplineCardRuntime {
|
|
xp: number;
|
|
paused: boolean;
|
|
concurrentLimit: number;
|
|
isLocked: boolean;
|
|
missingPrereqs: string[];
|
|
missingSourceMana: string[];
|
|
}
|
|
|
|
export interface DisciplineCardCallbacks {
|
|
onToggle: (id: string, paused: boolean) => void;
|
|
}
|
|
|
|
interface DisciplineCardProps {
|
|
definition: DisciplineCardDefinition;
|
|
runtime: DisciplineCardRuntime;
|
|
callbacks: DisciplineCardCallbacks;
|
|
}
|
|
|
|
// ─── Discipline Card Component ───────────────────────────────────────────────
|
|
|
|
const DisciplineCard: React.FC<DisciplineCardProps> = ({ definition, runtime, callbacks }) => {
|
|
const {
|
|
id, name, description, manaType, baseCost, perkThresholds, perkValues, perkTypes, perkDescriptions,
|
|
statBonusLabel, baseValue, drainBase, difficultyFactor, scalingFactor,
|
|
} = definition;
|
|
const { xp, paused: isPaused, concurrentLimit, isLocked, missingPrereqs, missingSourceMana } = runtime;
|
|
const { onToggle } = callbacks;
|
|
|
|
const displayXp = xp;
|
|
const progressPercent = Math.min(displayXp / Math.max(1, concurrentLimit * 100), 100);
|
|
|
|
const activeStatBonus = calculateStatBonus(baseValue, displayXp, scalingFactor);
|
|
const estimatedDrain = calculateManaDrain(drainBase, displayXp, difficultyFactor);
|
|
|
|
const elementDef = ELEMENTS[manaType];
|
|
const manaColor = elementDef?.color ?? '#888888';
|
|
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;
|
|
const desc = perkDescriptions?.[idx];
|
|
if (typ === 'once' || typ === 'infinite') {
|
|
if (displayXp >= threshold && desc) acc.push(desc);
|
|
} else if (typ === 'capped') {
|
|
const interval = perkValues?.[idx] ?? 1;
|
|
const tier = Math.max(0, Math.floor((displayXp - threshold) / interval) + 1);
|
|
if (tier > 0 && desc) acc.push(desc);
|
|
}
|
|
return acc;
|
|
}, []);
|
|
|
|
const toggleAction = () => {
|
|
onToggle(id, isPaused);
|
|
};
|
|
|
|
return (
|
|
<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
|
|
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
|
|
style={{
|
|
backgroundColor: `${manaColor}20`,
|
|
borderColor: `${manaColor}60`,
|
|
borderWidth: 1,
|
|
color: manaColor,
|
|
}}
|
|
>
|
|
<span>{manaIcon}</span>
|
|
<span>{manaName}</span>
|
|
</span>
|
|
</div>
|
|
<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={`transition-all duration-300 ${activeStatBonus > 0 ? 'bg-green-500' : 'bg-red-500'}`}
|
|
style={{ width: `${Math.round(progressPercent)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-gray-400">
|
|
<span>
|
|
<strong>Drain:</strong> {estimatedDrain.toFixed(1)}/tick
|
|
</span>
|
|
<span>
|
|
<strong>Base Cost:</strong> {baseCost}
|
|
</span>
|
|
<span>
|
|
<strong>XP:</strong> {displayXp}
|
|
</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>
|
|
|
|
<div className="mt-2">
|
|
<strong>Perks:</strong>
|
|
<ul className="mt-1 list-disc list-inside space-y-1 text-xs">
|
|
{unlockedPerks && unlockedPerks.length > 0 ? (
|
|
unlockedPerks.map((p) => (
|
|
<li key={p} className="text-green-500">{p}</li>
|
|
))
|
|
) : (
|
|
<li className="text-gray-400">—locked—</li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
|
|
{effectiveIsLocked && (missingPrereqs.length > 0 || missingSourceMana.length > 0) && (
|
|
<div className="mt-2 text-xs text-red-400">
|
|
<strong>Requires:</strong> {[...missingPrereqs, ...missingSourceMana].join(', ')}
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-4 flex justify-end">
|
|
<button
|
|
onClick={toggleAction}
|
|
disabled={effectiveIsLocked}
|
|
className={clsx(
|
|
'rounded px-3 py-1 text-sm font-medium',
|
|
isLocked
|
|
? 'bg-gray-600 text-gray-400 cursor-not-allowed'
|
|
: isPaused
|
|
? 'bg-yellow-600 text-white hover:bg-yellow-500'
|
|
: 'bg-blue-600 text-white hover:bg-blue-500',
|
|
)}
|
|
>
|
|
{effectiveIsLocked ? 'Locked' : isPaused ? 'Start Practicing' : 'Stop Practicing'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── Disciplines Tab ─────────────────────────────────────────────────────────
|
|
|
|
export const DisciplinesTab: React.FC = () => {
|
|
const activeIds = useDisciplineStore((s) => s.activeIds);
|
|
const concurrentLimit = useDisciplineStore((s) => s.concurrentLimit);
|
|
const disciplines = useDisciplineStore((s) => s.disciplines);
|
|
const activate = useDisciplineStore((s) => s.activate);
|
|
const deactivate = useDisciplineStore((s) => s.deactivate);
|
|
|
|
const [activeAttunement, setActiveAttunement] = useState<string>('base');
|
|
|
|
const rawMana = useManaStore((s) => s.rawMana);
|
|
const elements = useManaStore((s) => s.elements);
|
|
const signedPacts = usePrestigeStore((s) => s.signedPacts);
|
|
|
|
const handleToggle = useCallback((id: string, paused: boolean) => {
|
|
if (paused) {
|
|
activate(id, { rawMana, elements, signedPacts });
|
|
} else {
|
|
deactivate(id);
|
|
}
|
|
}, [activate, deactivate, rawMana, elements, signedPacts]);
|
|
|
|
const activeTab = ATTUNEMENT_TABS.find((t) => t.key === activeAttunement);
|
|
|
|
return (
|
|
<div className="mt-6">
|
|
{/* Tab bar */}
|
|
<div className="flex gap-2 mb-4">
|
|
{ATTUNEMENT_TABS.map((tab) => {
|
|
const isActiveTab = activeAttunement === tab.key;
|
|
return (
|
|
<button
|
|
key={tab.key}
|
|
onClick={() => setActiveAttunement(tab.key)}
|
|
className={clsx('rounded px-3 py-1', {
|
|
'bg-blue-600 text-white': isActiveTab,
|
|
'text-gray-600': !isActiveTab,
|
|
})}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Discipline cards — only render active tab */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{activeTab?.items.map((disc) => {
|
|
const discState = disciplines[disc.id] ?? { xp: 0, paused: true };
|
|
const prereqCheck = checkDisciplinePrerequisites(disc, disciplines, ALL_DISCIPLINES, elements);
|
|
return (
|
|
<DisciplineCard
|
|
key={disc.id}
|
|
definition={{
|
|
id: disc.id,
|
|
name: disc.name,
|
|
description: disc.description,
|
|
perkThresholds: disc.perks?.map((p) => p.threshold),
|
|
perkValues: disc.perks?.map((p) => p.value),
|
|
perkTypes: disc.perks?.map((p) => p.type),
|
|
perkDescriptions: disc.perks?.map((p) => p.description),
|
|
manaType: disc.manaType,
|
|
baseCost: disc.baseCost,
|
|
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,
|
|
scalingFactor: disc.scalingFactor,
|
|
}}
|
|
runtime={{
|
|
xp: discState.xp,
|
|
paused: discState.paused,
|
|
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,
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</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>
|
|
);
|
|
};
|