fix: improve Discipline tab UX - remove confusing base cost label, convert drain to /sec, show computed perk effects and perk-augmented stat totals, fix /tick label suffixes in data
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m23s

This commit is contained in:
2026-05-28 13:45:22 +02:00
parent 26639746e9
commit 9671078fea
6 changed files with 181 additions and 133 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# Circular Dependencies # Circular Dependencies
Generated: 2026-05-28T10:41:19.223Z Generated: 2026-05-28T11:15:20.183Z
No circular dependencies found. ✅ No circular dependencies found. ✅
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"_meta": { "_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.", "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."
}, },
+1
View File
@@ -143,6 +143,7 @@ Mana-Loop/
│ │ │ │ ├── SpireSummaryTab.test.ts │ │ │ │ ├── SpireSummaryTab.test.ts
│ │ │ │ ├── SpireSummaryTab.tsx │ │ │ │ ├── SpireSummaryTab.tsx
│ │ │ │ ├── StatsTab.tsx │ │ │ │ ├── StatsTab.tsx
│ │ │ │ ├── disciplines-utils.ts
│ │ │ │ ├── guardian-pacts-components.tsx │ │ │ │ ├── guardian-pacts-components.tsx
│ │ │ │ └── index.ts │ │ │ │ └── index.ts
│ │ │ ├── ActionButtons.tsx │ │ │ ├── ActionButtons.tsx
+78 -120
View File
@@ -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 { 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 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 { 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 } from './disciplines-utils';
import type { ComputedPerkEffect } from './disciplines-utils';
// ─── Attunement Tabs ───────────────────────────────────────────────────────── // ─── Attunement Tabs ─────────────────────────────────────────────────────────
@@ -33,91 +35,63 @@ const ATTUNEMENT_TABS: AttunementTab[] = [
{ key: 'invoker', label: 'Invoker', items: invokerDisciplines }, { key: 'invoker', label: 'Invoker', items: invokerDisciplines },
]; ];
// ─── Discipline Card Props (split from monolithic 15-field interface) ──────── // ─── Discipline Card Props ───────────────────────────────────────────────────
export interface DisciplineCardDefinition { interface DisciplineCardProps {
id: string; definition: DisciplineDefinition;
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; xp: number;
paused: boolean; paused: boolean;
concurrentLimit: number; concurrentLimit: number;
isLocked: boolean; isLocked: boolean;
missingPrereqs: string[]; missingPrereqs: string[];
missingSourceMana: string[]; missingSourceMana: string[];
}
export interface DisciplineCardCallbacks {
onToggle: (id: string, paused: boolean) => void; onToggle: (id: string, paused: boolean) => void;
} }
interface DisciplineCardProps {
definition: DisciplineCardDefinition;
runtime: DisciplineCardRuntime;
callbacks: DisciplineCardCallbacks;
}
// ─── Discipline Card Component ─────────────────────────────────────────────── // ─── Discipline Card Component ───────────────────────────────────────────────
const DisciplineCard: React.FC<DisciplineCardProps> = ({ definition, runtime, callbacks }) => { const DisciplineCard: React.FC<DisciplineCardProps> = ({
definition, xp, paused: isPaused, concurrentLimit,
isLocked, missingPrereqs, missingSourceMana, onToggle,
}) => {
const { const {
id, name, description, manaType, baseCost, perkThresholds, perkValues, perkTypes, perkDescriptions, id, name, description, manaType, perks,
statBonusLabel, baseValue, drainBase, difficultyFactor, scalingFactor, statBonus, baseValue, drainBase, difficultyFactor, scalingFactor,
conversionRate, sourceManaTypes,
} = definition; } = definition;
const { xp, paused: isPaused, concurrentLimit, isLocked, missingPrereqs, missingSourceMana } = runtime;
const { onToggle } = callbacks;
const displayXp = xp; const displayXp = xp;
const progressPercent = Math.min(displayXp / Math.max(1, concurrentLimit * 100), 100); const progressPercent = Math.min(displayXp / Math.max(1, concurrentLimit * 100), 100);
const activeStatBonus = calculateStatBonus(baseValue, displayXp, scalingFactor); 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 elementDef = ELEMENTS[manaType];
const manaColor = elementDef?.color ?? '#888888'; const manaColor = elementDef?.color ?? '#888888';
const manaIcon = elementDef?.sym ?? '✦'; const manaIcon = elementDef?.sym ?? '✦';
const manaName = elementDef?.name ?? manaType; const manaName = elementDef?.name ?? manaType;
const effectiveIsLocked = isLocked || missingSourceMana.length > 0; const effectiveIsLocked = isLocked || missingSourceMana.length > 0;
const unlockedPerks = perkTypes?.reduce<string[]>((acc, typ, idx) => { const statBonusLabel = statBonus.label;
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 = () => { // Compute perk effects with current values
onToggle(id, isPaused); 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 key={id} className={clsx('border rounded-lg p-4 shadow-sm space-y-3', effectiveIsLocked && 'opacity-60 border-gray-600')}> <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"> <div className="flex items-center justify-between gap-2">
<h3 className="text-lg font-medium">{name}</h3> <h3 className="text-lg font-medium">{name}</h3>
<span <span
@@ -135,6 +109,7 @@ const DisciplineCard: React.FC<DisciplineCardProps> = ({ definition, runtime, ca
</div> </div>
<p className="text-sm text-gray-400">{description}</p> <p className="text-sm text-gray-400">{description}</p>
{/* XP Progress */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs font-mono whitespace-nowrap">{Math.round(progressPercent)}%</span> <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="flex-1 bg-gray-200 rounded-full overflow-hidden h-3">
@@ -145,54 +120,63 @@ const DisciplineCard: React.FC<DisciplineCardProps> = ({ definition, runtime, ca
</div> </div>
</div> </div>
{/* Stats Row */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-gray-400"> <div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-gray-400">
<span> <span><strong>Drain:</strong> {drainPerSecond.toFixed(1)}/sec</span>
<strong>Drain:</strong> {estimatedDrain.toFixed(1)}/tick <span><strong>XP:</strong> {displayXp}</span>
</span>
<span>
<strong>Base Cost:</strong> {baseCost}
</span>
<span>
<strong>XP:</strong> {displayXp}
</span>
</div> </div>
{definition.conversionRate != null && definition.sourceManaTypes && ( {/* Conversion Info */}
{conversionRate != null && sourceManaTypes && (
<div className="mt-2 text-xs text-gray-400"> <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} <strong>Converts:</strong> {sourceManaTypes.map(s => s === 'raw' ? 'raw' : ELEMENTS[s]?.name ?? s).join(' + ')} {manaName}
</div> </div>
)} )}
{/* Stat Bonus with Perk Total */}
<div className="mt-2 text-sm"> <div className="mt-2 text-sm">
<strong>Stat Bonus:</strong> {activeStatBonus.toFixed(2)} on {statBonusLabel} <strong>Stat Bonus:</strong> {activeStatBonus.toFixed(2)}/sec on {statBonusLabel}
{perkBonusTotal > 0 && (
<span className="text-green-400 ml-1">
({statBonusTotal.toFixed(2)}/sec with perks)
</span>
)}
</div> </div>
{/* Perks */}
<div className="mt-2"> <div className="mt-2">
<strong>Perks:</strong> <strong>Perks:</strong>
<ul className="mt-1 list-disc list-inside space-y-1 text-xs"> <ul className="mt-1 list-disc list-inside space-y-1 text-xs">
{unlockedPerks && unlockedPerks.length > 0 ? ( {computedPerks.length > 0 ? (
unlockedPerks.map((p) => ( computedPerks.map((p) => (
<li key={p} className="text-green-500">{p}</li> <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">locked</li> <li className="text-gray-400"> none </li>
)} )}
</ul> </ul>
</div> </div>
{/* Lock Reasons */}
{effectiveIsLocked && (missingPrereqs.length > 0 || missingSourceMana.length > 0) && ( {effectiveIsLocked && (missingPrereqs.length > 0 || missingSourceMana.length > 0) && (
<div className="mt-2 text-xs text-red-400"> <div className="mt-2 text-xs text-red-400">
<strong>Requires:</strong> {[...missingPrereqs, ...missingSourceMana].join(', ')} <strong>Requires:</strong> {[...missingPrereqs, ...missingSourceMana].join(', ')}
</div> </div>
)} )}
{/* Action Button */}
<div className="mt-4 flex justify-end"> <div className="mt-4 flex justify-end">
<button <button
onClick={toggleAction} onClick={() => onToggle(id, isPaused)}
disabled={effectiveIsLocked} disabled={effectiveIsLocked}
className={clsx( className={clsx(
'rounded px-3 py-1 text-sm font-medium', 'rounded px-3 py-1 text-sm font-medium',
isLocked effectiveIsLocked
? 'bg-gray-600 text-gray-400 cursor-not-allowed' ? 'bg-gray-600 text-gray-400 cursor-not-allowed'
: isPaused : isPaused
? 'bg-yellow-600 text-white hover:bg-yellow-500' ? 'bg-yellow-600 text-white hover:bg-yellow-500'
@@ -236,24 +220,21 @@ export const DisciplinesTab: React.FC = () => {
<div className="mt-6"> <div className="mt-6">
{/* Tab bar */} {/* Tab bar */}
<div className="flex gap-2 mb-4"> <div className="flex gap-2 mb-4">
{ATTUNEMENT_TABS.map((tab) => { {ATTUNEMENT_TABS.map((tab) => (
const isActiveTab = activeAttunement === tab.key;
return (
<button <button
key={tab.key} key={tab.key}
onClick={() => setActiveAttunement(tab.key)} onClick={() => setActiveAttunement(tab.key)}
className={clsx('rounded px-3 py-1', { className={clsx('rounded px-3 py-1', {
'bg-blue-600 text-white': isActiveTab, 'bg-blue-600 text-white': activeAttunement === tab.key,
'text-gray-600': !isActiveTab, 'text-gray-600': activeAttunement !== tab.key,
})} })}
> >
{tab.label} {tab.label}
</button> </button>
); ))}
})}
</div> </div>
{/* Discipline cards — only render active tab */} {/* Discipline cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{activeTab?.items.map((disc) => { {activeTab?.items.map((disc) => {
const discState = disciplines[disc.id] ?? { xp: 0, paused: true }; const discState = disciplines[disc.id] ?? { xp: 0, paused: true };
@@ -261,47 +242,24 @@ export const DisciplinesTab: React.FC = () => {
return ( return (
<DisciplineCard <DisciplineCard
key={disc.id} key={disc.id}
definition={{ definition={disc}
id: disc.id, xp={discState.xp}
name: disc.name, paused={discState.paused}
description: disc.description, concurrentLimit={concurrentLimit}
perkThresholds: disc.perks?.map((p) => p.threshold), isLocked={!prereqCheck.canProceed}
perkValues: disc.perks?.map((p) => p.value), missingPrereqs={prereqCheck.missingPrereqs}
perkTypes: disc.perks?.map((p) => p.type), missingSourceMana={disc.sourceManaTypes
perkDescriptions: disc.perks?.map((p) => p.description), ? disc.sourceManaTypes
manaType: disc.manaType, .filter((src) => src !== 'raw' && (!elements[src] || !elements[src].unlocked))
baseCost: disc.baseCost, .map((src) => `${src} mana`)
statBonus: disc.statBonus.stat, : []}
statBonusLabel: disc.statBonus.label, onToggle={handleToggle}
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> </div>
{/* Summary info */} {/* 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">
<div>Active Disciple{activeIds.length}{activeIds.length === 1 ? '' : 's'} / {concurrentLimit}</div> <div>Active Disciple{activeIds.length}{activeIds.length === 1 ? '' : 's'} / {concurrentLimit}</div>
<div>Concurrent Limit: {concurrentLimit}</div> <div>Concurrent Limit: {concurrentLimit}</div>
@@ -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;
}
+3 -3
View File
@@ -84,7 +84,7 @@ export const baseDisciplines: DisciplineDefinition[] = [
baseCost: 5, baseCost: 5,
description: description:
'Deepen your meditation practice to accelerate discipline XP gain. The more you master yourself, the faster all disciplines grow.', '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, difficultyFactor: 120,
scalingFactor: 60, scalingFactor: 60,
drainBase: 1, drainBase: 1,
@@ -94,7 +94,7 @@ export const baseDisciplines: DisciplineDefinition[] = [
type: 'once', type: 'once',
threshold: 100, threshold: 100,
value: 0, value: 0,
description: '+0.5 Discipline XP per tick', description: '+0.5 Discipline XP per sec',
bonus: { stat: 'disciplineXpBonus', amount: 0.5 }, bonus: { stat: 'disciplineXpBonus', amount: 0.5 },
}, },
{ {
@@ -102,7 +102,7 @@ export const baseDisciplines: DisciplineDefinition[] = [
type: 'infinite', type: 'infinite',
threshold: 200, threshold: 200,
value: 100, 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 }, bonus: { stat: 'disciplineXpBonus', amount: 0.25 },
}, },
], ],