diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 0fe9630..53ebdb8 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,4 +1,4 @@ # Circular Dependencies -Generated: 2026-05-28T10:41:19.223Z +Generated: 2026-05-28T11:15:20.183Z No circular dependencies found. ✅ diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index 4e82886..64c97d6 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-05-28T10:41:17.557Z", + "generated": "2026-05-28T11:15:18.333Z", "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 ca9aed5..faead81 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -143,6 +143,7 @@ Mana-Loop/ │ │ │ │ ├── SpireSummaryTab.test.ts │ │ │ │ ├── SpireSummaryTab.tsx │ │ │ │ ├── StatsTab.tsx +│ │ │ │ ├── disciplines-utils.ts │ │ │ │ ├── guardian-pacts-components.tsx │ │ │ │ └── index.ts │ │ │ ├── ActionButtons.tsx diff --git a/src/components/game/tabs/DisciplinesTab.tsx b/src/components/game/tabs/DisciplinesTab.tsx index b730b61..f1d24f5 100644 --- a/src/components/game/tabs/DisciplinesTab.tsx +++ b/src/components/game/tabs/DisciplinesTab.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useMemo } 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'; @@ -16,6 +16,8 @@ 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 } from './disciplines-utils'; +import type { ComputedPerkEffect } from './disciplines-utils'; // ─── Attunement Tabs ───────────────────────────────────────────────────────── @@ -33,91 +35,63 @@ const ATTUNEMENT_TABS: AttunementTab[] = [ { key: 'invoker', label: 'Invoker', items: invokerDisciplines }, ]; -// ─── Discipline Card Props (split from monolithic 15-field interface) ──────── +// ─── Discipline Card Props ─────────────────────────────────────────────────── -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 { +interface DisciplineCardProps { + definition: DisciplineDefinition; 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 = ({ definition, runtime, callbacks }) => { +const DisciplineCard: React.FC = ({ + definition, xp, paused: isPaused, concurrentLimit, + isLocked, missingPrereqs, missingSourceMana, onToggle, +}) => { const { - id, name, description, manaType, baseCost, perkThresholds, perkValues, perkTypes, perkDescriptions, - statBonusLabel, baseValue, drainBase, difficultyFactor, scalingFactor, + id, name, description, manaType, perks, + statBonus, baseValue, drainBase, difficultyFactor, scalingFactor, + conversionRate, sourceManaTypes, } = 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 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 unlockedPerks = perkTypes?.reduce((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 statBonusLabel = statBonus.label; - const toggleAction = () => { - onToggle(id, isPaused); - }; + // 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 ( -
+

{name}

= ({ definition, runtime, ca

{description}

+ {/* XP Progress */}
{Math.round(progressPercent)}%
@@ -145,54 +120,63 @@ const DisciplineCard: React.FC = ({ definition, runtime, ca
+ {/* Stats Row */}
- - Drain: {estimatedDrain.toFixed(1)}/tick - - - Base Cost: {baseCost} - - - XP: {displayXp} - + Drain: {drainPerSecond.toFixed(1)}/sec + XP: {displayXp}
- {definition.conversionRate != null && definition.sourceManaTypes && ( + {/* Conversion Info */} + {conversionRate != null && sourceManaTypes && (
- Converts: {definition.sourceManaTypes.map(s => s === 'raw' ? 'raw' : ELEMENTS[s]?.name ?? s).join(' + ')} → {manaName} + Converts: {sourceManaTypes.map(s => s === 'raw' ? 'raw' : ELEMENTS[s]?.name ?? s).join(' + ')} → {manaName}
)} + {/* Stat Bonus with Perk Total */}
- Stat Bonus: {activeStatBonus.toFixed(2)} on {statBonusLabel} + Stat Bonus: {activeStatBonus.toFixed(2)}/sec on {statBonusLabel} + {perkBonusTotal > 0 && ( + + ({statBonusTotal.toFixed(2)}/sec with perks) + + )}
+ {/* Perks */}
Perks:
    - {unlockedPerks && unlockedPerks.length > 0 ? ( - unlockedPerks.map((p) => ( -
  • {p}
  • + {computedPerks.length > 0 ? ( + computedPerks.map((p) => ( +
  • + {p.description} + — {p.currentEffect} +
  • )) ) : ( -
  • —locked—
  • +
  • — none —
  • )}
+ {/* Lock Reasons */} {effectiveIsLocked && (missingPrereqs.length > 0 || missingSourceMana.length > 0) && (
Requires: {[...missingPrereqs, ...missingSourceMana].join(', ')}
)} + {/* Action Button */}
- ); - })} + {ATTUNEMENT_TABS.map((tab) => ( + + ))}
- {/* Discipline cards — only render active tab */} + {/* Discipline cards */}
{activeTab?.items.map((disc) => { const discState = disciplines[disc.id] ?? { xp: 0, paused: true }; @@ -261,47 +242,24 @@ export const DisciplinesTab: React.FC = () => { return ( 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, - }} + definition={disc} + xp={discState.xp} + paused={discState.paused} + concurrentLimit={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`) + : []} + onToggle={handleToggle} /> ); })}
- {/* Summary info */} + {/* Summary */}
Active Disciple{activeIds.length}{activeIds.length === 1 ? '' : 's'} / {concurrentLimit}
Concurrent Limit: {concurrentLimit}
diff --git a/src/components/game/tabs/disciplines-utils.ts b/src/components/game/tabs/disciplines-utils.ts new file mode 100644 index 0000000..e0fea7d --- /dev/null +++ b/src/components/game/tabs/disciplines-utils.ts @@ -0,0 +1,89 @@ +// ─── Discipline Card Utility Functions ───────────────────────────────────────── +// Shared helpers for computing perk effects and stat bonus totals in the UI. + +import type { DisciplinePerk } from '@/lib/game/types/disciplines'; +import { calculatePerkTier } from '@/lib/game/utils/discipline-math'; +import { TICK_MS } from '@/lib/game/constants/core'; + +// TICK_MS = 200, so 5 ticks per second +export const TICKS_PER_SECOND = 1000 / TICK_MS; + +export interface ComputedPerkEffect { + description: string; + currentEffect: string; +} + +/** + * Compute the current effect for a perk based on XP. + * Returns a human-readable string like "+5.25/sec" or "+50 (unlocked)". + */ +export function computePerkCurrentEffect( + perk: DisciplinePerk, + xp: number, +): string { + if (xp < perk.threshold) { + return `at ${perk.threshold} XP`; + } + + if (!perk.bonus) { + return 'unlocked'; + } + + const { amount } = perk.bonus; + + switch (perk.type) { + case 'once': + return `+${amount} (unlocked)`; + case 'infinite': { + const tier = calculatePerkTier(xp, perk.threshold, perk.value); + const total = tier * amount; + return `+${total.toFixed(2)}/sec`; + } + case 'capped': { + let tier = calculatePerkTier(xp, perk.threshold, perk.value); + if (perk.maxTier !== undefined) { + tier = Math.min(tier, perk.maxTier); + } + const total = tier * amount; + return `+${total.toFixed(2)}/sec`; + } + default: + return 'active'; + } +} + +/** + * Compute total perk bonus amount for a specific statKey from all unlocked perks. + * Mirrors the logic in computeDisciplineEffects() for 'once', 'infinite', and 'capped' perks. + */ +export function computeTotalPerkBonusForStat( + perks: DisciplinePerk[], + xp: number, + statKey: string, +): number { + let total = 0; + for (const perk of perks) { + if (xp < perk.threshold) continue; + if (!perk.bonus || perk.bonus.stat !== statKey) continue; + + switch (perk.type) { + case 'once': + total += perk.bonus.amount; + break; + case 'infinite': { + const tier = calculatePerkTier(xp, perk.threshold, perk.value); + if (tier > 0) total += tier * perk.bonus.amount; + break; + } + case 'capped': { + let tier = calculatePerkTier(xp, perk.threshold, perk.value); + if (tier > 0 && perk.maxTier !== undefined) { + tier = Math.min(tier, perk.maxTier); + } + if (tier > 0) total += tier * perk.bonus.amount; + break; + } + } + } + return total; +} diff --git a/src/lib/game/data/disciplines/base.ts b/src/lib/game/data/disciplines/base.ts index 9e3c70d..2016fcb 100644 --- a/src/lib/game/data/disciplines/base.ts +++ b/src/lib/game/data/disciplines/base.ts @@ -84,7 +84,7 @@ export const baseDisciplines: DisciplineDefinition[] = [ baseCost: 5, description: 'Deepen your meditation practice to accelerate discipline XP gain. The more you master yourself, the faster all disciplines grow.', - statBonus: { stat: 'disciplineXpBonus', baseValue: 0.5, label: 'Discipline XP Bonus/tick' }, + statBonus: { stat: 'disciplineXpBonus', baseValue: 0.5, label: 'Discipline XP Bonus/sec' }, difficultyFactor: 120, scalingFactor: 60, drainBase: 1, @@ -94,7 +94,7 @@ export const baseDisciplines: DisciplineDefinition[] = [ type: 'once', threshold: 100, value: 0, - description: '+0.5 Discipline XP per tick', + description: '+0.5 Discipline XP per sec', bonus: { stat: 'disciplineXpBonus', amount: 0.5 }, }, { @@ -102,7 +102,7 @@ export const baseDisciplines: DisciplineDefinition[] = [ type: 'infinite', threshold: 200, value: 100, - description: 'Every 100 XP: +0.25 Discipline XP per tick', + description: 'Every 100 XP: +0.25 Discipline XP per sec', bonus: { stat: 'disciplineXpBonus', amount: 0.25 }, }, ],