From e20216bda5b1d33d92eee8e3716b95d00bb3db23 Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Thu, 28 May 2026 21:24:06 +0200 Subject: [PATCH] feat: redesign Elemental subtab in DisciplinesTab to group by mana type --- docs/circular-deps.txt | 2 +- docs/dependency-graph.json | 2 +- docs/project-structure.txt | 2 + src/components/game/tabs/DisciplineCard.tsx | 168 ++++++++++++++ src/components/game/tabs/DisciplinesTab.tsx | 228 ++++--------------- src/components/game/tabs/ElementalSubtab.tsx | 166 ++++++++++++++ 6 files changed, 385 insertions(+), 183 deletions(-) create mode 100644 src/components/game/tabs/DisciplineCard.tsx create mode 100644 src/components/game/tabs/ElementalSubtab.tsx diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 66f88ce..273b465 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,4 +1,4 @@ # Circular Dependencies -Generated: 2026-05-28T17:49:52.546Z +Generated: 2026-05-28T19:01:33.787Z No circular dependencies found. ✅ diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index 0e2750e..ef02588 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_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.", "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." }, diff --git a/docs/project-structure.txt b/docs/project-structure.txt index b0238f6..3c6b571 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -130,7 +130,9 @@ Mana-Loop/ │ │ │ │ ├── CraftingTab.tsx │ │ │ │ ├── DebugTab.test.ts │ │ │ │ ├── DebugTab.tsx +│ │ │ │ ├── DisciplineCard.tsx │ │ │ │ ├── DisciplinesTab.tsx +│ │ │ │ ├── ElementalSubtab.tsx │ │ │ │ ├── EquipmentTab.test.ts │ │ │ │ ├── EquipmentTab.tsx │ │ │ │ ├── GolemancyTab.test.ts diff --git a/src/components/game/tabs/DisciplineCard.tsx b/src/components/game/tabs/DisciplineCard.tsx new file mode 100644 index 0000000..19160ab --- /dev/null +++ b/src/components/game/tabs/DisciplineCard.tsx @@ -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 = ({ + 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 ( +
+
+

{name}

+ + {manaIcon} + {manaName} + +
+

{description}

+ + {/* XP Progress */} +
+ {Math.round(progressPercent)}% +
+
0 ? 'bg-green-500' : 'bg-red-500'}`} + style={{ width: `${Math.round(progressPercent)}%` }} + /> +
+
+ + {/* Stats Row */} +
+ Drain: {drainPerSecond.toFixed(1)}/sec + XP: {displayXp} +
+ + {/* Conversion Info */} + {conversionRate != null && sourceManaTypes && ( +
+ Converts: {sourceManaTypes.map(s => s === 'raw' ? 'raw' : ELEMENTS[s]?.name ?? s).join(' + ')} → {manaName} +
+ )} + + {/* 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 ( +
+ Stat Bonus: {safeActive.toFixed(2)}{rateSuffix} on {statBonusLabel} + {safePerk > 0 && ( + + ({safeTotal.toFixed(2)}{rateSuffix} with perks) + + )} +
+ ); + })()} + + {/* Perks */} +
+ Perks: +
    + {computedPerks.length > 0 ? ( + computedPerks.map((p) => ( +
  • + {p.description} + — {p.currentEffect} +
  • + )) + ) : ( +
  • — none —
  • + )} +
+
+ + {/* Lock Reasons */} + {effectiveIsLocked && (missingPrereqs.length > 0 || missingSourceMana.length > 0) && ( +
+ Requires: {[...missingPrereqs, ...missingSourceMana].join(', ')} +
+ )} + + {/* Action Button */} +
+ +
+
+ ); +}; diff --git a/src/components/game/tabs/DisciplinesTab.tsx b/src/components/game/tabs/DisciplinesTab.tsx index 5176418..00eee79 100644 --- a/src/components/game/tabs/DisciplinesTab.tsx +++ b/src/components/game/tabs/DisciplinesTab.tsx @@ -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 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'; @@ -13,14 +11,14 @@ import { enchanterSpellDisciplines } from '@/lib/game/data/disciplines/enchanter import { enchanterSpecialDisciplines } from '@/lib/game/data/disciplines/enchanter-special'; 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 { 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'; import { DebugName } from '@/components/game/debug/debug-context'; -import { TICKS_PER_SECOND, computePerkCurrentEffect, computeTotalPerkBonusForStat, isRateStat } from './disciplines-utils'; -import type { ComputedPerkEffect } from './disciplines-utils'; +import { DisciplineCard } from './DisciplineCard'; +import { ElementalSubtab } from './ElementalSubtab'; // ─── Attunement Tabs ───────────────────────────────────────────────────────── @@ -38,168 +36,37 @@ const ATTUNEMENT_TABS: AttunementTab[] = [ { key: 'invoker', label: 'Invoker', items: invokerDisciplines }, ]; -// ─── Discipline Card Props ─────────────────────────────────────────────────── +// ─── Discipline Card Wrapper (for flat grid) ───────────────────────────────── -interface DisciplineCardProps { - definition: DisciplineDefinition; - xp: number; - paused: boolean; +interface CardWrapperProps { + disc: DisciplineDefinition; + disciplines: Record; concurrentLimit: number; - isLocked: boolean; - missingPrereqs: string[]; - missingSourceMana: string[]; + elements: ReturnType['elements']; + signedPacts: ReturnType['signedPacts']; onToggle: (id: string, paused: boolean) => void; } -// ─── Discipline Card Component ─────────────────────────────────────────────── - -const DisciplineCard: React.FC = ({ - definition, xp, paused: isPaused, concurrentLimit, - isLocked, missingPrereqs, missingSourceMana, onToggle, +const CardWrapper: React.FC = ({ + disc, disciplines, concurrentLimit, elements, 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); - // 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; - + const discState = disciplines[disc.id] ?? { xp: 0, paused: true }; + const prereqCheck = checkDisciplinePrerequisites(disc, disciplines, ALL_DISCIPLINES, elements); return ( -
-
-

{name}

- - {manaIcon} - {manaName} - -
-

{description}

- - {/* XP Progress */} -
- {Math.round(progressPercent)}% -
-
0 ? 'bg-green-500' : 'bg-red-500'}`} - style={{ width: `${Math.round(progressPercent)}%` }} - /> -
-
- - {/* Stats Row */} -
- Drain: {drainPerSecond.toFixed(1)}/sec - XP: {displayXp} -
- - {/* Conversion Info */} - {conversionRate != null && sourceManaTypes && ( -
- Converts: {sourceManaTypes.map(s => s === 'raw' ? 'raw' : ELEMENTS[s]?.name ?? s).join(' + ')} → {manaName} -
- )} - - {/* 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 ( -
- Stat Bonus: {safeActive.toFixed(2)}{rateSuffix} on {statBonusLabel} - {safePerk > 0 && ( - - ({safeTotal.toFixed(2)}{rateSuffix} with perks) - - )} -
- ); - })()} - - {/* Perks */} -
- Perks: -
    - {computedPerks.length > 0 ? ( - computedPerks.map((p) => ( -
  • - {p.description} - — {p.currentEffect} -
  • - )) - ) : ( -
  • — none —
  • - )} -
-
- - {/* Lock Reasons */} - {effectiveIsLocked && (missingPrereqs.length > 0 || missingSourceMana.length > 0) && ( -
- Requires: {[...missingPrereqs, ...missingSourceMana].join(', ')} -
- )} - - {/* Action Button */} -
- -
-
+ src !== 'raw' && (!elements[src] || !elements[src].unlocked)) + .map((src) => `${src} mana`) + : []} + onToggle={onToggle} + /> ); }; @@ -247,30 +114,29 @@ export const DisciplinesTab: React.FC = () => { ))}
- {/* Discipline cards */} -
- {activeTab?.items.map((disc) => { - const discState = disciplines[disc.id] ?? { xp: 0, paused: true }; - const prereqCheck = checkDisciplinePrerequisites(disc, disciplines, ALL_DISCIPLINES, elements); - return ( - + ) : ( +
+ {activeTab?.items.map((disc) => ( + src !== 'raw' && (!elements[src] || !elements[src].unlocked)) - .map((src) => `${src} mana`) - : []} + elements={elements} + signedPacts={signedPacts} onToggle={handleToggle} /> - ); - })} -
+ ))} +
+ )} {/* Summary */}
diff --git a/src/components/game/tabs/ElementalSubtab.tsx b/src/components/game/tabs/ElementalSubtab.tsx new file mode 100644 index 0000000..9df7c9f --- /dev/null +++ b/src/components/game/tabs/ElementalSubtab.tsx @@ -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; + +function buildElementalDisciplineMap( + capacityDisciplines: DisciplineDefinition[], + conversionDisciplines: DisciplineDefinition[], +): DisciplineMap { + const map = new Map(); + 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; + concurrentLimit: number; + elements: DisciplineCardProps['missingSourceMana'] extends readonly string[] + ? Record + : never; + onToggle: (id: string, paused: boolean) => void; +} + +// ─── Elemental Discipline Group ────────────────────────────────────────────── + +interface GroupProps extends SharedRenderProps { + manaType: string; + capacity?: DisciplineDefinition; + conversion?: DisciplineDefinition; +} + +const ElementalDisciplineGroup: React.FC = ({ + 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 ( +
+
+ {manaIcon} +

{manaName}

+
+
+ {entries.map(({ def }) => { + const discState = disciplines[def.id] ?? { xp: 0, paused: true }; + const prereqCheck = checkDisciplinePrerequisites(def, disciplines, ALL_DISCIPLINES, elements as any); + return ( + src !== 'raw' && (!elements[src] || !elements[src].unlocked)) + .map((src) => `${src} mana`) + : []} + onToggle={onToggle} + /> + ); + })} +
+
+ ); +}; + +// ─── Elemental Subtab ──────────────────────────────────────────────────────── + +interface ElementalSubtabProps extends SharedRenderProps {} + +export const ElementalSubtab: React.FC = ({ + disciplines, concurrentLimit, elements, onToggle, +}) => { + const disciplineMap = useMemo( + () => buildElementalDisciplineMap( + elementalAttunementDisciplines, + [...elementalRegenDisciplines, ...elementalRegenAdvancedDisciplines], + ), + [], + ); + + return ( +
+ {ELEMENT_GROUPS.map((group) => { + const hasAny = group.manaTypes.some( + (mt) => disciplineMap.get(mt)?.capacity || disciplineMap.get(mt)?.conversion, + ); + if (!hasAny) return null; + + return ( +
+

+ {group.categoryLabel} +

+
+ {group.manaTypes.map((manaType) => { + const entry = disciplineMap.get(manaType); + if (!entry?.capacity && !entry?.conversion) return null; + return ( + + ); + })} +
+
+ ); + })} +
+ ); +};