feat: redesign Elemental subtab in DisciplinesTab to group by mana type
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m20s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m20s
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
# Circular Dependencies
|
# Circular Dependencies
|
||||||
Generated: 2026-05-28T17:49:52.546Z
|
Generated: 2026-05-28T19:01:33.787Z
|
||||||
|
|
||||||
No circular dependencies found. ✅
|
No circular dependencies found. ✅
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"generated": "2026-05-28T17:49:50.766Z",
|
"generated": "2026-05-28T19:01:32.036Z",
|
||||||
"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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -130,7 +130,9 @@ Mana-Loop/
|
|||||||
│ │ │ │ ├── CraftingTab.tsx
|
│ │ │ │ ├── CraftingTab.tsx
|
||||||
│ │ │ │ ├── DebugTab.test.ts
|
│ │ │ │ ├── DebugTab.test.ts
|
||||||
│ │ │ │ ├── DebugTab.tsx
|
│ │ │ │ ├── DebugTab.tsx
|
||||||
|
│ │ │ │ ├── DisciplineCard.tsx
|
||||||
│ │ │ │ ├── DisciplinesTab.tsx
|
│ │ │ │ ├── DisciplinesTab.tsx
|
||||||
|
│ │ │ │ ├── ElementalSubtab.tsx
|
||||||
│ │ │ │ ├── EquipmentTab.test.ts
|
│ │ │ │ ├── EquipmentTab.test.ts
|
||||||
│ │ │ │ ├── EquipmentTab.tsx
|
│ │ │ │ ├── EquipmentTab.tsx
|
||||||
│ │ │ │ ├── GolemancyTab.test.ts
|
│ │ │ │ ├── GolemancyTab.test.ts
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import type { DisciplineDefinition } from '@/lib/game/types/disciplines';
|
||||||
|
import type { ManaType } from '@/lib/game/types/elements';
|
||||||
|
import { ELEMENTS } from '@/lib/game/constants/elements';
|
||||||
|
import { calculateStatBonus, calculateManaDrain } from '@/lib/game/utils/discipline-math';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { TICKS_PER_SECOND, computePerkCurrentEffect, computeTotalPerkBonusForStat, isRateStat } from './disciplines-utils';
|
||||||
|
import type { ComputedPerkEffect } from './disciplines-utils';
|
||||||
|
|
||||||
|
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DisciplineCardProps {
|
||||||
|
definition: DisciplineDefinition;
|
||||||
|
xp: number;
|
||||||
|
paused: boolean;
|
||||||
|
concurrentLimit: number;
|
||||||
|
isLocked: boolean;
|
||||||
|
missingPrereqs: string[];
|
||||||
|
missingSourceMana: string[];
|
||||||
|
onToggle: (id: string, paused: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const DisciplineCard: React.FC<DisciplineCardProps> = ({
|
||||||
|
definition, xp, paused: isPaused, concurrentLimit,
|
||||||
|
isLocked, missingPrereqs, missingSourceMana, onToggle,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
id, name, description, manaType, perks,
|
||||||
|
statBonus, drainBase, difficultyFactor, scalingFactor,
|
||||||
|
conversionRate, sourceManaTypes,
|
||||||
|
} = definition;
|
||||||
|
|
||||||
|
const displayXp = xp;
|
||||||
|
const progressPercent = Math.min(displayXp / Math.max(1, concurrentLimit * 100), 100);
|
||||||
|
const activeStatBonus = calculateStatBonus(statBonus.baseValue, displayXp, scalingFactor);
|
||||||
|
const drainPerSecond = calculateManaDrain(drainBase, displayXp, difficultyFactor) * TICKS_PER_SECOND;
|
||||||
|
|
||||||
|
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 statBonusLabel = statBonus.label;
|
||||||
|
|
||||||
|
const computedPerks = useMemo((): ComputedPerkEffect[] => {
|
||||||
|
if (!perks) return [];
|
||||||
|
return perks.map((perk) => ({
|
||||||
|
description: perk.description,
|
||||||
|
currentEffect: computePerkCurrentEffect(perk, displayXp),
|
||||||
|
}));
|
||||||
|
}, [perks, displayXp]);
|
||||||
|
|
||||||
|
const perkBonusTotal = useMemo(() => {
|
||||||
|
if (!perks || perks.length === 0) return 0;
|
||||||
|
return computeTotalPerkBonusForStat(perks, displayXp, statBonus.stat);
|
||||||
|
}, [perks, displayXp, statBonus.stat]);
|
||||||
|
|
||||||
|
const statBonusTotal = activeStatBonus + perkBonusTotal;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div 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>
|
||||||
|
|
||||||
|
{/* XP Progress */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Stats Row */}
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-gray-400">
|
||||||
|
<span><strong>Drain:</strong> {drainPerSecond.toFixed(1)}/sec</span>
|
||||||
|
<span><strong>XP:</strong> {displayXp}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conversion Info */}
|
||||||
|
{conversionRate != null && sourceManaTypes && (
|
||||||
|
<div className="mt-2 text-xs text-gray-400">
|
||||||
|
<strong>Converts:</strong> {sourceManaTypes.map(s => s === 'raw' ? 'raw' : ELEMENTS[s]?.name ?? s).join(' + ')} → {manaName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stat Bonus with Perk Total */}
|
||||||
|
{(() => {
|
||||||
|
const safeActive = Number.isFinite(activeStatBonus) ? activeStatBonus : 0;
|
||||||
|
const safePerk = Number.isFinite(perkBonusTotal) ? perkBonusTotal : 0;
|
||||||
|
const safeTotal = Number.isFinite(statBonusTotal) ? statBonusTotal : 0;
|
||||||
|
const rateSuffix = isRateStat(statBonus.stat) ? '/sec' : '';
|
||||||
|
return (
|
||||||
|
<div className="mt-2 text-sm">
|
||||||
|
<strong>Stat Bonus:</strong> {safeActive.toFixed(2)}{rateSuffix} on {statBonusLabel}
|
||||||
|
{safePerk > 0 && (
|
||||||
|
<span className="text-green-400 ml-1">
|
||||||
|
({safeTotal.toFixed(2)}{rateSuffix} with perks)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Perks */}
|
||||||
|
<div className="mt-2">
|
||||||
|
<strong>Perks:</strong>
|
||||||
|
<ul className="mt-1 list-disc list-inside space-y-1 text-xs">
|
||||||
|
{computedPerks.length > 0 ? (
|
||||||
|
computedPerks.map((p) => (
|
||||||
|
<li key={p.description} className={clsx(
|
||||||
|
p.currentEffect.startsWith('at ') ? 'text-gray-400' : 'text-green-500',
|
||||||
|
)}>
|
||||||
|
{p.description}
|
||||||
|
<span className="text-gray-300 ml-1">— {p.currentEffect}</span>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<li className="text-gray-400">— none —</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lock Reasons */}
|
||||||
|
{effectiveIsLocked && (missingPrereqs.length > 0 || missingSourceMana.length > 0) && (
|
||||||
|
<div className="mt-2 text-xs text-red-400">
|
||||||
|
<strong>Requires:</strong> {[...missingPrereqs, ...missingSourceMana].join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Button */}
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => onToggle(id, isPaused)}
|
||||||
|
disabled={effectiveIsLocked}
|
||||||
|
className={clsx(
|
||||||
|
'rounded px-3 py-1 text-sm font-medium',
|
||||||
|
effectiveIsLocked
|
||||||
|
? '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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import React, { useState, useCallback, useMemo } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { useDisciplineStore } from '@/lib/game/stores/discipline-slice';
|
import { useDisciplineStore } from '@/lib/game/stores/discipline-slice';
|
||||||
import type { DisciplineDefinition } from '@/lib/game/types/disciplines';
|
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 { baseDisciplines } from '@/lib/game/data/disciplines/base';
|
||||||
import { elementalAttunementDisciplines } from '@/lib/game/data/disciplines/elemental';
|
import { elementalAttunementDisciplines } from '@/lib/game/data/disciplines/elemental';
|
||||||
import { elementalRegenDisciplines } from '@/lib/game/data/disciplines/elemental-regen';
|
import { elementalRegenDisciplines } from '@/lib/game/data/disciplines/elemental-regen';
|
||||||
@@ -13,14 +11,14 @@ import { enchanterSpellDisciplines } from '@/lib/game/data/disciplines/enchanter
|
|||||||
import { enchanterSpecialDisciplines } from '@/lib/game/data/disciplines/enchanter-special';
|
import { enchanterSpecialDisciplines } from '@/lib/game/data/disciplines/enchanter-special';
|
||||||
import { fabricatorDisciplines } from '@/lib/game/data/disciplines/fabricator';
|
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 { 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 { useManaStore } from '@/lib/game/stores/manaStore';
|
||||||
import { usePrestigeStore } from '@/lib/game/stores/prestigeStore';
|
import { usePrestigeStore } from '@/lib/game/stores/prestigeStore';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { DebugName } from '@/components/game/debug/debug-context';
|
import { DebugName } from '@/components/game/debug/debug-context';
|
||||||
import { TICKS_PER_SECOND, computePerkCurrentEffect, computeTotalPerkBonusForStat, isRateStat } from './disciplines-utils';
|
import { DisciplineCard } from './DisciplineCard';
|
||||||
import type { ComputedPerkEffect } from './disciplines-utils';
|
import { ElementalSubtab } from './ElementalSubtab';
|
||||||
|
|
||||||
// ─── Attunement Tabs ─────────────────────────────────────────────────────────
|
// ─── Attunement Tabs ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -38,168 +36,37 @@ const ATTUNEMENT_TABS: AttunementTab[] = [
|
|||||||
{ key: 'invoker', label: 'Invoker', items: invokerDisciplines },
|
{ key: 'invoker', label: 'Invoker', items: invokerDisciplines },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── Discipline Card Props ───────────────────────────────────────────────────
|
// ─── Discipline Card Wrapper (for flat grid) ─────────────────────────────────
|
||||||
|
|
||||||
interface DisciplineCardProps {
|
interface CardWrapperProps {
|
||||||
definition: DisciplineDefinition;
|
disc: DisciplineDefinition;
|
||||||
xp: number;
|
disciplines: Record<string, { xp: number; paused: boolean }>;
|
||||||
paused: boolean;
|
|
||||||
concurrentLimit: number;
|
concurrentLimit: number;
|
||||||
isLocked: boolean;
|
elements: ReturnType<typeof useManaStore.getState>['elements'];
|
||||||
missingPrereqs: string[];
|
signedPacts: ReturnType<typeof usePrestigeStore.getState>['signedPacts'];
|
||||||
missingSourceMana: string[];
|
|
||||||
onToggle: (id: string, paused: boolean) => void;
|
onToggle: (id: string, paused: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Discipline Card Component ───────────────────────────────────────────────
|
const CardWrapper: React.FC<CardWrapperProps> = ({
|
||||||
|
disc, disciplines, concurrentLimit, elements, onToggle,
|
||||||
const DisciplineCard: React.FC<DisciplineCardProps> = ({
|
|
||||||
definition, xp, paused: isPaused, concurrentLimit,
|
|
||||||
isLocked, missingPrereqs, missingSourceMana, onToggle,
|
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const discState = disciplines[disc.id] ?? { xp: 0, paused: true };
|
||||||
id, name, description, manaType, perks,
|
const prereqCheck = checkDisciplinePrerequisites(disc, disciplines, ALL_DISCIPLINES, elements);
|
||||||
statBonus, drainBase, difficultyFactor, scalingFactor,
|
|
||||||
conversionRate, sourceManaTypes,
|
|
||||||
} = definition;
|
|
||||||
|
|
||||||
const displayXp = xp;
|
|
||||||
const progressPercent = Math.min(displayXp / Math.max(1, concurrentLimit * 100), 100);
|
|
||||||
// statBonus.baseValue is the correct field — not a top-level baseValue
|
|
||||||
const activeStatBonus = calculateStatBonus(statBonus.baseValue, displayXp, scalingFactor);
|
|
||||||
const drainPerSecond = calculateManaDrain(drainBase, displayXp, difficultyFactor) * TICKS_PER_SECOND;
|
|
||||||
|
|
||||||
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 statBonusLabel = statBonus.label;
|
|
||||||
|
|
||||||
// Compute perk effects with current values
|
|
||||||
const computedPerks = useMemo((): ComputedPerkEffect[] => {
|
|
||||||
if (!perks) return [];
|
|
||||||
return perks.map((perk) => ({
|
|
||||||
description: perk.description,
|
|
||||||
currentEffect: computePerkCurrentEffect(perk, displayXp),
|
|
||||||
}));
|
|
||||||
}, [perks, displayXp]);
|
|
||||||
|
|
||||||
// Perk-augmented total for the stat bonus
|
|
||||||
const perkBonusTotal = useMemo(() => {
|
|
||||||
if (!perks || perks.length === 0) return 0;
|
|
||||||
return computeTotalPerkBonusForStat(perks, displayXp, statBonus.stat);
|
|
||||||
}, [perks, displayXp, statBonus.stat]);
|
|
||||||
|
|
||||||
const statBonusTotal = activeStatBonus + perkBonusTotal;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx('border rounded-lg p-4 shadow-sm space-y-3', effectiveIsLocked && 'opacity-60 border-gray-600')}>
|
<DisciplineCard
|
||||||
<div className="flex items-center justify-between gap-2">
|
definition={disc}
|
||||||
<h3 className="text-lg font-medium">{name}</h3>
|
xp={discState.xp}
|
||||||
<span
|
paused={discState.paused}
|
||||||
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
|
concurrentLimit={concurrentLimit}
|
||||||
style={{
|
isLocked={!prereqCheck.canProceed}
|
||||||
backgroundColor: `${manaColor}20`,
|
missingPrereqs={prereqCheck.missingPrereqs}
|
||||||
borderColor: `${manaColor}60`,
|
missingSourceMana={disc.sourceManaTypes
|
||||||
borderWidth: 1,
|
? disc.sourceManaTypes
|
||||||
color: manaColor,
|
.filter((src) => src !== 'raw' && (!elements[src] || !elements[src].unlocked))
|
||||||
}}
|
.map((src) => `${src} mana`)
|
||||||
>
|
: []}
|
||||||
<span>{manaIcon}</span>
|
onToggle={onToggle}
|
||||||
<span>{manaName}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-400">{description}</p>
|
|
||||||
|
|
||||||
{/* XP Progress */}
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Stats Row */}
|
|
||||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-gray-400">
|
|
||||||
<span><strong>Drain:</strong> {drainPerSecond.toFixed(1)}/sec</span>
|
|
||||||
<span><strong>XP:</strong> {displayXp}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Conversion Info */}
|
|
||||||
{conversionRate != null && sourceManaTypes && (
|
|
||||||
<div className="mt-2 text-xs text-gray-400">
|
|
||||||
<strong>Converts:</strong> {sourceManaTypes.map(s => s === 'raw' ? 'raw' : ELEMENTS[s]?.name ?? s).join(' + ')} → {manaName}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stat Bonus with Perk Total */}
|
|
||||||
{(() => {
|
|
||||||
// NaN guard — if the calculation produced NaN, show a safe fallback
|
|
||||||
const safeActive = Number.isFinite(activeStatBonus) ? activeStatBonus : 0;
|
|
||||||
const safePerk = Number.isFinite(perkBonusTotal) ? perkBonusTotal : 0;
|
|
||||||
const safeTotal = Number.isFinite(statBonusTotal) ? statBonusTotal : 0;
|
|
||||||
const rateSuffix = isRateStat(statBonus.stat) ? '/sec' : '';
|
|
||||||
return (
|
|
||||||
<div className="mt-2 text-sm">
|
|
||||||
<strong>Stat Bonus:</strong> {safeActive.toFixed(2)}{rateSuffix} on {statBonusLabel}
|
|
||||||
{safePerk > 0 && (
|
|
||||||
<span className="text-green-400 ml-1">
|
|
||||||
({safeTotal.toFixed(2)}{rateSuffix} with perks)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Perks */}
|
|
||||||
<div className="mt-2">
|
|
||||||
<strong>Perks:</strong>
|
|
||||||
<ul className="mt-1 list-disc list-inside space-y-1 text-xs">
|
|
||||||
{computedPerks.length > 0 ? (
|
|
||||||
computedPerks.map((p) => (
|
|
||||||
<li key={p.description} className={clsx(
|
|
||||||
p.currentEffect.startsWith('at ') ? 'text-gray-400' : 'text-green-500',
|
|
||||||
)}>
|
|
||||||
{p.description}
|
|
||||||
<span className="text-gray-300 ml-1">— {p.currentEffect}</span>
|
|
||||||
</li>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<li className="text-gray-400">— none —</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Lock Reasons */}
|
|
||||||
{effectiveIsLocked && (missingPrereqs.length > 0 || missingSourceMana.length > 0) && (
|
|
||||||
<div className="mt-2 text-xs text-red-400">
|
|
||||||
<strong>Requires:</strong> {[...missingPrereqs, ...missingSourceMana].join(', ')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action Button */}
|
|
||||||
<div className="mt-4 flex justify-end">
|
|
||||||
<button
|
|
||||||
onClick={() => onToggle(id, isPaused)}
|
|
||||||
disabled={effectiveIsLocked}
|
|
||||||
className={clsx(
|
|
||||||
'rounded px-3 py-1 text-sm font-medium',
|
|
||||||
effectiveIsLocked
|
|
||||||
? '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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -247,30 +114,29 @@ export const DisciplinesTab: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Discipline cards */}
|
{/* Grouped layout for elemental tab, grid for others */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
{activeAttunement === 'elemental' ? (
|
||||||
{activeTab?.items.map((disc) => {
|
<ElementalSubtab
|
||||||
const discState = disciplines[disc.id] ?? { xp: 0, paused: true };
|
disciplines={disciplines}
|
||||||
const prereqCheck = checkDisciplinePrerequisites(disc, disciplines, ALL_DISCIPLINES, elements);
|
|
||||||
return (
|
|
||||||
<DisciplineCard
|
|
||||||
key={disc.id}
|
|
||||||
definition={disc}
|
|
||||||
xp={discState.xp}
|
|
||||||
paused={discState.paused}
|
|
||||||
concurrentLimit={concurrentLimit}
|
concurrentLimit={concurrentLimit}
|
||||||
isLocked={!prereqCheck.canProceed}
|
elements={elements}
|
||||||
missingPrereqs={prereqCheck.missingPrereqs}
|
|
||||||
missingSourceMana={disc.sourceManaTypes
|
|
||||||
? disc.sourceManaTypes
|
|
||||||
.filter((src) => src !== 'raw' && (!elements[src] || !elements[src].unlocked))
|
|
||||||
.map((src) => `${src} mana`)
|
|
||||||
: []}
|
|
||||||
onToggle={handleToggle}
|
onToggle={handleToggle}
|
||||||
/>
|
/>
|
||||||
);
|
) : (
|
||||||
})}
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{activeTab?.items.map((disc) => (
|
||||||
|
<CardWrapper
|
||||||
|
key={disc.id}
|
||||||
|
disc={disc}
|
||||||
|
disciplines={disciplines}
|
||||||
|
concurrentLimit={concurrentLimit}
|
||||||
|
elements={elements}
|
||||||
|
signedPacts={signedPacts}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
<div className="mt-4 flex flex-col sm:flex-row gap-2 text-sm text-gray-500">
|
<div className="mt-4 flex flex-col sm:flex-row gap-2 text-sm text-gray-500">
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import type { DisciplineDefinition } from '@/lib/game/types/disciplines';
|
||||||
|
import { ELEMENTS } from '@/lib/game/constants/elements';
|
||||||
|
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 { ALL_DISCIPLINES } from '@/lib/game/data/disciplines';
|
||||||
|
import { checkDisciplinePrerequisites } from '@/lib/game/utils/discipline-math';
|
||||||
|
import { DisciplineCard } from './DisciplineCard';
|
||||||
|
import type { DisciplineCardProps } from './DisciplineCard';
|
||||||
|
|
||||||
|
// ─── Element Ordering ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ElementGroup {
|
||||||
|
category: 'base' | 'utility' | 'composite' | 'exotic';
|
||||||
|
categoryLabel: string;
|
||||||
|
manaTypes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ELEMENT_GROUPS: ElementGroup[] = [
|
||||||
|
{ category: 'base', categoryLabel: 'Base Elements', manaTypes: ['fire', 'water', 'air', 'earth', 'light', 'dark', 'death'] },
|
||||||
|
{ category: 'utility', categoryLabel: 'Utility', manaTypes: ['transference'] },
|
||||||
|
{ category: 'composite', categoryLabel: 'Composite', manaTypes: ['metal', 'sand', 'lightning'] },
|
||||||
|
{ category: 'exotic', categoryLabel: 'Exotic', manaTypes: ['crystal', 'stellar', 'void'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Discipline Map Builder ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type DisciplineMap = Map<string, { capacity?: DisciplineDefinition; conversion?: DisciplineDefinition }>;
|
||||||
|
|
||||||
|
function buildElementalDisciplineMap(
|
||||||
|
capacityDisciplines: DisciplineDefinition[],
|
||||||
|
conversionDisciplines: DisciplineDefinition[],
|
||||||
|
): DisciplineMap {
|
||||||
|
const map = new Map<string, { capacity?: DisciplineDefinition; conversion?: DisciplineDefinition }>();
|
||||||
|
for (const d of capacityDisciplines) {
|
||||||
|
const entry = map.get(d.manaType) ?? {};
|
||||||
|
entry.capacity = d;
|
||||||
|
map.set(d.manaType, entry);
|
||||||
|
}
|
||||||
|
for (const d of conversionDisciplines) {
|
||||||
|
const entry = map.get(d.manaType) ?? {};
|
||||||
|
entry.conversion = d;
|
||||||
|
map.set(d.manaType, entry);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Shared Props ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface SharedRenderProps {
|
||||||
|
disciplines: Record<string, { xp: number; paused: boolean }>;
|
||||||
|
concurrentLimit: number;
|
||||||
|
elements: DisciplineCardProps['missingSourceMana'] extends readonly string[]
|
||||||
|
? Record<string, { unlocked: boolean }>
|
||||||
|
: never;
|
||||||
|
onToggle: (id: string, paused: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Elemental Discipline Group ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface GroupProps extends SharedRenderProps {
|
||||||
|
manaType: string;
|
||||||
|
capacity?: DisciplineDefinition;
|
||||||
|
conversion?: DisciplineDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ElementalDisciplineGroup: React.FC<GroupProps> = ({
|
||||||
|
manaType, capacity, conversion, disciplines, concurrentLimit, elements, onToggle,
|
||||||
|
}) => {
|
||||||
|
const elementDef = ELEMENTS[manaType];
|
||||||
|
const manaColor = elementDef?.color ?? '#888888';
|
||||||
|
const manaIcon = elementDef?.sym ?? '✦';
|
||||||
|
const manaName = elementDef?.name ?? manaType;
|
||||||
|
|
||||||
|
if (!capacity && !conversion) return null;
|
||||||
|
|
||||||
|
const entries: { def: DisciplineDefinition }[] = [];
|
||||||
|
if (capacity) entries.push({ def: capacity });
|
||||||
|
if (conversion) entries.push({ def: conversion });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg p-4 space-y-3" style={{ borderColor: `${manaColor}40` }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xl">{manaIcon}</span>
|
||||||
|
<h3 className="text-lg font-semibold" style={{ color: manaColor }}>{manaName}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{entries.map(({ def }) => {
|
||||||
|
const discState = disciplines[def.id] ?? { xp: 0, paused: true };
|
||||||
|
const prereqCheck = checkDisciplinePrerequisites(def, disciplines, ALL_DISCIPLINES, elements as any);
|
||||||
|
return (
|
||||||
|
<DisciplineCard
|
||||||
|
key={def.id}
|
||||||
|
definition={def}
|
||||||
|
xp={discState.xp}
|
||||||
|
paused={discState.paused}
|
||||||
|
concurrentLimit={concurrentLimit}
|
||||||
|
isLocked={!prereqCheck.canProceed}
|
||||||
|
missingPrereqs={prereqCheck.missingPrereqs}
|
||||||
|
missingSourceMana={def.sourceManaTypes
|
||||||
|
? def.sourceManaTypes
|
||||||
|
.filter((src) => src !== 'raw' && (!elements[src] || !elements[src].unlocked))
|
||||||
|
.map((src) => `${src} mana`)
|
||||||
|
: []}
|
||||||
|
onToggle={onToggle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Elemental Subtab ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ElementalSubtabProps extends SharedRenderProps {}
|
||||||
|
|
||||||
|
export const ElementalSubtab: React.FC<ElementalSubtabProps> = ({
|
||||||
|
disciplines, concurrentLimit, elements, onToggle,
|
||||||
|
}) => {
|
||||||
|
const disciplineMap = useMemo(
|
||||||
|
() => buildElementalDisciplineMap(
|
||||||
|
elementalAttunementDisciplines,
|
||||||
|
[...elementalRegenDisciplines, ...elementalRegenAdvancedDisciplines],
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{ELEMENT_GROUPS.map((group) => {
|
||||||
|
const hasAny = group.manaTypes.some(
|
||||||
|
(mt) => disciplineMap.get(mt)?.capacity || disciplineMap.get(mt)?.conversion,
|
||||||
|
);
|
||||||
|
if (!hasAny) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.category}>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
|
||||||
|
{group.categoryLabel}
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{group.manaTypes.map((manaType) => {
|
||||||
|
const entry = disciplineMap.get(manaType);
|
||||||
|
if (!entry?.capacity && !entry?.conversion) return null;
|
||||||
|
return (
|
||||||
|
<ElementalDisciplineGroup
|
||||||
|
key={manaType}
|
||||||
|
manaType={manaType}
|
||||||
|
capacity={entry.capacity}
|
||||||
|
conversion={entry.conversion}
|
||||||
|
disciplines={disciplines}
|
||||||
|
concurrentLimit={concurrentLimit}
|
||||||
|
elements={elements}
|
||||||
|
onToggle={onToggle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user