refactor: extract components from SkillsTab.tsx to reduce below 400 lines
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m10s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m10s
This commit is contained in:
@@ -0,0 +1,221 @@
|
|||||||
|
// ─── Category Skills List ───────────────────────────────────────────
|
||||||
|
// Wraps all skills in a single category, handles category-level UI
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { SkillRow } from './SkillRow';
|
||||||
|
import { SkillCategoryHeader } from './SkillCategoryHeader';
|
||||||
|
import type { GameStore } from '@/lib/game/store';
|
||||||
|
import { SKILL_CATEGORIES, SKILLS_DEF, getStudyCostMultiplier, getStudySpeedMultiplier } from '@/lib/game/constants';
|
||||||
|
import { getUpgradesForSkillAtMilestone, getNextTierSkill, getTierMultiplier, SKILL_EVOLUTION_PATHS } from '@/lib/game/skill-evolution';
|
||||||
|
import { SPECIAL_EFFECTS, hasSpecial } from '@/lib/game/special-effects';
|
||||||
|
import { ELEMENTS } from '@/lib/game/constants';
|
||||||
|
import { getUnifiedEffects } from '@/lib/game/effects';
|
||||||
|
import { hasMilestoneUpgrade } from '@/lib/game/hooks/useSkillUpgradeSelection';
|
||||||
|
import type { ComputedEffects } from '@/lib/game/upgrade-effects.types';
|
||||||
|
import type { SkillCost } from '@/lib/game/types';
|
||||||
|
import { useGameToast } from '@/components/game/GameToast';
|
||||||
|
|
||||||
|
interface CategorySkillsListProps {
|
||||||
|
category: { id: string; name: string; icon: string };
|
||||||
|
availableCategories: string[];
|
||||||
|
isCollapsed: boolean;
|
||||||
|
onToggleCategory: (categoryId: string) => void;
|
||||||
|
store: GameStore;
|
||||||
|
studySpeedMult: number;
|
||||||
|
upgradeEffects: ComputedEffects;
|
||||||
|
currentStudyTarget: any;
|
||||||
|
onStartStudying: (skillId: string) => void;
|
||||||
|
onParallelStudy: (skillId: string) => void;
|
||||||
|
onCancelStudy: () => void;
|
||||||
|
onOpenUpgradeDialog: (skillId: string, milestone: 5 | 10) => void;
|
||||||
|
onTierUp: (skillId: string) => void;
|
||||||
|
pendingSelections: string[];
|
||||||
|
setPendingSelections: (selections: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type guard for element skill costs
|
||||||
|
function isElementCost(cost?: SkillCost | null): cost is SkillCost & { type: 'element'; element: string } {
|
||||||
|
return cost !== null && typeof cost !== 'undefined' && cost.type === 'element' && typeof cost.element === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategorySkillsList({
|
||||||
|
category,
|
||||||
|
availableCategories,
|
||||||
|
isCollapsed,
|
||||||
|
onToggleCategory,
|
||||||
|
store,
|
||||||
|
studySpeedMult,
|
||||||
|
upgradeEffects,
|
||||||
|
currentStudyTarget,
|
||||||
|
onStartStudying,
|
||||||
|
onParallelStudy,
|
||||||
|
onCancelStudy,
|
||||||
|
onOpenUpgradeDialog,
|
||||||
|
onTierUp,
|
||||||
|
pendingSelections,
|
||||||
|
setPendingSelections,
|
||||||
|
}: CategorySkillsListProps) {
|
||||||
|
const showToast = useGameToast();
|
||||||
|
|
||||||
|
// Skip if category not available
|
||||||
|
if (!availableCategories.includes(category.id)) return null;
|
||||||
|
|
||||||
|
const skillsInCat = Object.entries(SKILLS_DEF).filter(([, def]) => def.cat === category.id);
|
||||||
|
if (skillsInCat.length === 0) return null;
|
||||||
|
|
||||||
|
const handleCancelStudyInternal = () => {
|
||||||
|
onCancelStudy();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={category.id} className="bg-gray-900/80 border-gray-700">
|
||||||
|
<SkillCategoryHeader
|
||||||
|
category={category}
|
||||||
|
skillCount={skillsInCat.length}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
onToggle={() => onToggleCategory(category.id)}
|
||||||
|
/>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{skillsInCat.map(([id, def]) => {
|
||||||
|
// GATE MANA CAPACITY SKILLS BY UNLOCKED ELEMENT
|
||||||
|
if (isElementCost(def.cost)) {
|
||||||
|
const element = store.elements[def.cost.element];
|
||||||
|
if (!element?.unlocked) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get tier info
|
||||||
|
const currentTier = store.skillTiers?.[id] || 1;
|
||||||
|
const tieredSkillId = currentTier > 1 ? `${id}_t${currentTier}` : id;
|
||||||
|
const tierMultiplier = getTierMultiplier(tieredSkillId);
|
||||||
|
|
||||||
|
// Get the actual level from the tiered skill
|
||||||
|
const level = store.skills[tieredSkillId] || store.skills[id] || 0;
|
||||||
|
const maxed = level >= def.max;
|
||||||
|
|
||||||
|
// Check if studying this skill
|
||||||
|
const isStudying =
|
||||||
|
(store.currentStudyTarget?.id === id || store.currentStudyTarget?.id === tieredSkillId) &&
|
||||||
|
store.currentStudyTarget?.type === 'skill';
|
||||||
|
|
||||||
|
// Get tier name for display
|
||||||
|
const tierDef = SKILL_EVOLUTION_PATHS[id]?.tiers.find((t) => t.tier === currentTier);
|
||||||
|
const skillDisplayName = tierDef?.name || def.name;
|
||||||
|
|
||||||
|
// Check prerequisites
|
||||||
|
let prereqMet = true;
|
||||||
|
if (def.req) {
|
||||||
|
for (const [r, rl] of Object.entries(def.req)) {
|
||||||
|
if ((store.skills[r] || 0) < rl) {
|
||||||
|
prereqMet = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply skill modifiers
|
||||||
|
const costMult = getStudyCostMultiplier(store.skills);
|
||||||
|
const speedMult = getStudySpeedMultiplier(store.skills);
|
||||||
|
const studyEffects = getUnifiedEffects(store);
|
||||||
|
const effectiveSpeedMult = speedMult * studyEffects.studySpeedMultiplier;
|
||||||
|
|
||||||
|
// Study time scales with tier
|
||||||
|
const tierStudyTime = def.studyTime * currentTier;
|
||||||
|
const effectiveStudyTime = tierStudyTime / effectiveSpeedMult;
|
||||||
|
|
||||||
|
// Cost scales with tier
|
||||||
|
const baseCost = def.base * (level + 1) * currentTier;
|
||||||
|
const cost = Math.floor(baseCost * costMult);
|
||||||
|
|
||||||
|
// Additional cost (element mana) - only pass element costs
|
||||||
|
const additionalCost = isElementCost(def.cost)
|
||||||
|
? { type: 'element' as const, element: def.cost.element, amount: def.cost.amount }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Can start studying?
|
||||||
|
let canStudy = !maxed && prereqMet && store.rawMana >= cost && !isStudying;
|
||||||
|
|
||||||
|
// Check additional cost (element mana)
|
||||||
|
if (isElementCost(def.cost)) {
|
||||||
|
const element = store.elements[def.cost.element];
|
||||||
|
if (!element || element.current < def.cost.amount) {
|
||||||
|
canStudy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for milestone upgrades
|
||||||
|
const milestoneInfo = hasMilestoneUpgrade(
|
||||||
|
tieredSkillId,
|
||||||
|
level,
|
||||||
|
store.skillTiers || {},
|
||||||
|
store.skillUpgrades
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check for tier up
|
||||||
|
const nextTierSkill = getNextTierSkill(tieredSkillId);
|
||||||
|
const canTierUp = Boolean(maxed && nextTierSkill);
|
||||||
|
|
||||||
|
// Get selected upgrades
|
||||||
|
const selectedUpgrades = store.skillUpgrades[tieredSkillId] || [];
|
||||||
|
const selectedL5 = selectedUpgrades.filter((u) => u.includes('_l5'));
|
||||||
|
const selectedL10 = selectedUpgrades.filter((u) => u.includes('_l10'));
|
||||||
|
|
||||||
|
// Check if insufficient mana for toast
|
||||||
|
const hasInsufficientMana = !isStudying && !maxed && store.rawMana < cost;
|
||||||
|
|
||||||
|
// Check for parallel study eligibility
|
||||||
|
const isParallelStudy =
|
||||||
|
store.currentStudyTarget?.id === tieredSkillId &&
|
||||||
|
store.currentStudyTarget?.type === 'skill';
|
||||||
|
const canParallelStudy: boolean =
|
||||||
|
hasSpecial(studyEffects, SPECIAL_EFFECTS.PARALLEL_STUDY) &&
|
||||||
|
!!store.currentStudyTarget &&
|
||||||
|
store.currentStudyTarget.id !== tieredSkillId &&
|
||||||
|
!isStudying;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SkillRow
|
||||||
|
key={id}
|
||||||
|
skillId={tieredSkillId}
|
||||||
|
def={def as any}
|
||||||
|
level={level}
|
||||||
|
maxed={maxed}
|
||||||
|
isStudying={isStudying}
|
||||||
|
tierMultiplier={tierMultiplier}
|
||||||
|
skillDisplayName={skillDisplayName}
|
||||||
|
selectedUpgrades={selectedUpgrades}
|
||||||
|
selectedL5={selectedL5}
|
||||||
|
selectedL10={selectedL10}
|
||||||
|
prereqMet={prereqMet}
|
||||||
|
canStudy={canStudy}
|
||||||
|
isParallelStudy={isParallelStudy}
|
||||||
|
canParallelStudy={canParallelStudy}
|
||||||
|
canTierUp={canTierUp}
|
||||||
|
hasInsufficientMana={hasInsufficientMana}
|
||||||
|
currentStudyTarget={currentStudyTarget}
|
||||||
|
milestoneInfo={milestoneInfo}
|
||||||
|
upgradeEffects={upgradeEffects}
|
||||||
|
cost={cost}
|
||||||
|
additionalCost={additionalCost}
|
||||||
|
effectiveStudyTime={effectiveStudyTime}
|
||||||
|
costMult={costMult}
|
||||||
|
speedMult={speedMult}
|
||||||
|
onStudy={onStartStudying}
|
||||||
|
onParallelStudy={onParallelStudy}
|
||||||
|
onCancelStudy={handleCancelStudyInternal}
|
||||||
|
onUpgradeDialogOpen={onOpenUpgradeDialog}
|
||||||
|
onTierUp={onTierUp}
|
||||||
|
onShowToast={showToast}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// ─── Milestone Progress ───────────────────────────────────────────
|
||||||
|
// Milestone upgrade progress indicator for skill rows
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
interface MilestoneProgressProps {
|
||||||
|
milestoneInfo: {
|
||||||
|
milestone: 5 | 10;
|
||||||
|
hasUpgrades: boolean;
|
||||||
|
selectedCount: number;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MilestoneProgress({ milestoneInfo }: MilestoneProgressProps) {
|
||||||
|
if (!milestoneInfo) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-amber-400 mt-1 flex items-center gap-1">
|
||||||
|
⭐ Level {milestoneInfo.milestone} milestone: {milestoneInfo.selectedCount}/2 upgrades selected
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// ─── Skill Category Header ───────────────────────────────────────────
|
||||||
|
// Header for a skill category with collapse/expand toggle
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SkillCategoryHeaderProps {
|
||||||
|
category: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
};
|
||||||
|
skillCount: number;
|
||||||
|
isCollapsed: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkillCategoryHeader({
|
||||||
|
category,
|
||||||
|
skillCount,
|
||||||
|
isCollapsed,
|
||||||
|
onToggle,
|
||||||
|
}: SkillCategoryHeaderProps) {
|
||||||
|
return (
|
||||||
|
<CardHeader className="pb-2 cursor-pointer" onClick={onToggle}>
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center justify-between">
|
||||||
|
<span>
|
||||||
|
{category.icon} {category.name}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{skillCount} skills
|
||||||
|
</Badge>
|
||||||
|
{isCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local import of CardHeader and CardTitle to avoid circular deps
|
||||||
|
import { CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// ─── Skill Multipliers ───────────────────────────────────────────
|
||||||
|
// Study speed and cost multiplier display for skill rows
|
||||||
|
|
||||||
|
import { ELEMENTS } from '@/lib/game/constants';
|
||||||
|
|
||||||
|
type AdditionalCost = { type: 'element'; element: string; amount: number };
|
||||||
|
|
||||||
|
interface SkillMultipliersProps {
|
||||||
|
effectiveStudyTime: number;
|
||||||
|
speedMult: number;
|
||||||
|
costMult: number;
|
||||||
|
cost: number;
|
||||||
|
additionalCost?: AdditionalCost;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkillMultipliers({
|
||||||
|
effectiveStudyTime,
|
||||||
|
speedMult,
|
||||||
|
costMult,
|
||||||
|
cost,
|
||||||
|
additionalCost,
|
||||||
|
}: SkillMultipliersProps) {
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
|
<span className={speedMult > 1 ? 'text-green-400' : ''}>
|
||||||
|
Study: {formatStudyTime(effectiveStudyTime)}{speedMult > 1 && (
|
||||||
|
<span className="text-xs ml-1">({Math.round(speedMult * 100)}% speed)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{' • '}
|
||||||
|
<span className={costMult < 1 ? 'text-green-400' : ''}>
|
||||||
|
Cost: {cost} mana{costMult < 1 && (
|
||||||
|
<span className="text-xs ml-1">({Math.round(costMult * 100)}% cost)</span>
|
||||||
|
)}
|
||||||
|
{additionalCost && additionalCost.type === 'element' && (
|
||||||
|
<span className="ml-2" style={{ color: ELEMENTS[additionalCost.element]?.color }}>
|
||||||
|
+ {additionalCost.amount} {ELEMENTS[additionalCost.element]?.sym} {additionalCost.element}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStudyTime(ms: number): string {
|
||||||
|
if (ms < 60_000) return `${Math.floor(ms / 1000)}s`;
|
||||||
|
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ${Math.floor((ms % 60_000) / 1000)}s`;
|
||||||
|
if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h ${Math.floor((ms % 3_600_000) / 60_000)}m`;
|
||||||
|
return `${Math.floor(ms / 86_400_000)}d ${Math.floor((ms % 86_400_000) / 3_600_000)}h`;
|
||||||
|
}
|
||||||
@@ -10,11 +10,12 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
|||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
||||||
import { ELEMENTS } from '@/lib/game/constants';
|
import { ELEMENTS } from '@/lib/game/constants';
|
||||||
import { hasMilestoneUpgrade } from '@/lib/game/hooks/useSkillUpgradeSelection';
|
|
||||||
import { formatStudyTime } from '@/lib/game/formatting';
|
import { formatStudyTime } from '@/lib/game/formatting';
|
||||||
import { getUpgradesForSkillAtMilestone, getNextTierSkill, getTierMultiplier, SKILL_EVOLUTION_PATHS } from '@/lib/game/skill-evolution';
|
import { getUpgradesForSkillAtMilestone, getNextTierSkill, getTierMultiplier, SKILL_EVOLUTION_PATHS } from '@/lib/game/skill-evolution';
|
||||||
import type { ComputedEffects } from '@/lib/game/upgrade-effects.types';
|
import type { ComputedEffects } from '@/lib/game/upgrade-effects.types';
|
||||||
import { SPECIAL_EFFECTS, hasSpecial } from '@/lib/game/special-effects';
|
import { SPECIAL_EFFECTS, hasSpecial } from '@/lib/game/special-effects';
|
||||||
|
import { MilestoneProgress } from './MilestoneProgress';
|
||||||
|
import { SkillMultipliers } from './SkillMultipliers';
|
||||||
|
|
||||||
type StudyTarget = { type: 'skill' | 'spell'; id: string; progress: number; required: number; } | null;
|
type StudyTarget = { type: 'skill' | 'spell'; id: string; progress: number; required: number; } | null;
|
||||||
|
|
||||||
@@ -126,20 +127,13 @@ export function SkillRow(props: SkillRowProps) {
|
|||||||
Requires: {Object.entries(def.req).map(([r, rl]) => `${r} Lv.${rl}`).join(', ')}
|
Requires: {Object.entries(def.req).map(([r, rl]) => `${r} Lv.${rl}`).join(', ')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
<SkillMultipliers
|
||||||
<span className={speedMult > 1 ? 'text-green-400' : ''}>
|
effectiveStudyTime={effectiveStudyTime}
|
||||||
Study: {formatStudyTime(effectiveStudyTime)}{speedMult > 1 && <span className="text-xs ml-1">({Math.round(speedMult * 100)}% speed)</span>}
|
speedMult={speedMult}
|
||||||
</span>
|
costMult={costMult}
|
||||||
{' • '}
|
cost={cost}
|
||||||
<span className={costMult < 1 ? 'text-green-400' : ''}>
|
additionalCost={additionalCost}
|
||||||
Cost: {cost} mana{costMult < 1 && <span className="text-xs ml-1">({Math.round(costMult * 100)}% cost)</span>}
|
/>
|
||||||
{additionalCost && additionalCost.type === 'element' && (
|
|
||||||
<span className="ml-2" style={{ color: ELEMENTS[additionalCost.element]?.color }}>
|
|
||||||
+ {additionalCost.amount} {ELEMENTS[additionalCost.element]?.sym} {additionalCost.element}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasInsufficientMana && (
|
{hasInsufficientMana && (
|
||||||
<div className="text-xs text-red-400 mt-1">
|
<div className="text-xs text-red-400 mt-1">
|
||||||
@@ -147,11 +141,7 @@ export function SkillRow(props: SkillRowProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{milestoneInfo && (
|
<MilestoneProgress milestoneInfo={milestoneInfo} />
|
||||||
<div className="text-xs text-amber-400 mt-1 flex items-center gap-1">
|
|
||||||
⭐ Level {milestoneInfo.milestone} milestone: {milestoneInfo.selectedCount}/2 upgrades selected
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 flex-wrap sm:flex-nowrap">
|
<div className="flex items-center gap-3 flex-wrap sm:flex-nowrap">
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// ─── Skills Tab ────────────────────────────
|
// ─── Skills Tab ───────────────────────────────────────────────────
|
||||||
// SkillsTab - Displays all skills organized by category
|
// SkillsTab - Displays all skills organized by category
|
||||||
// Refactored: uses SkillRow component for per-skill rendering (reduced from 469 lines)
|
// Refactored: extracted components for better modularity (reduced from 400 lines)
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
SKILLS_DEF,
|
SKILLS_DEF,
|
||||||
SKILL_CATEGORIES,
|
SKILL_CATEGORIES,
|
||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
import { getUnifiedEffects } from '@/lib/game/effects';
|
import { getUnifiedEffects } from '@/lib/game/effects';
|
||||||
import { getAvailableSkillCategories } from '@/lib/game/data/attunements';
|
import { getAvailableSkillCategories } from '@/lib/game/data/attunements';
|
||||||
import { fmt, fmtDec } from '@/lib/game/store';
|
import { fmt, fmtDec } from '@/lib/game/store';
|
||||||
import type { SkillUpgradeChoice, GameStore } from '@/lib/game/types';
|
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -38,43 +38,13 @@ import { ELEMENTS } from '@/lib/game/constants';
|
|||||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
import { SkillRow } from './SkillRow';
|
import { SkillRow } from './SkillRow';
|
||||||
import { useSkillUpgradeSelection } from '@/lib/game/hooks/useSkillUpgradeSelection';
|
import { useSkillUpgradeSelection } from '@/lib/game/hooks/useSkillUpgradeSelection';
|
||||||
|
import { CategorySkillsList } from './CategorySkillsList';
|
||||||
|
import type { GameStore } from '@/lib/game/store';
|
||||||
|
|
||||||
export interface SkillsTabProps {
|
export interface SkillsTabProps {
|
||||||
store: GameStore;
|
store: GameStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if skill has milestone available
|
|
||||||
function hasMilestoneUpgrade(
|
|
||||||
skillId: string,
|
|
||||||
level: number,
|
|
||||||
skillTiers: Record<string, number>,
|
|
||||||
skillUpgrades: Record<string, string[]>
|
|
||||||
): { milestone: 5 | 10; hasUpgrades: boolean; selectedCount: number } | null {
|
|
||||||
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
|
|
||||||
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
|
|
||||||
if (!path) return null;
|
|
||||||
|
|
||||||
// Check level 5 milestone
|
|
||||||
if (level >= 5) {
|
|
||||||
const upgrades5 = getUpgradesForSkillAtMilestone(skillId, 5, skillTiers);
|
|
||||||
const selected5 = (skillUpgrades[skillId] || []).filter((id) => id.includes('_l5'));
|
|
||||||
if (upgrades5.length > 0 && selected5.length < 2) {
|
|
||||||
return { milestone: 5, hasUpgrades: true, selectedCount: selected5.length };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check level 10 milestone
|
|
||||||
if (level >= 10) {
|
|
||||||
const upgrades10 = getUpgradesForSkillAtMilestone(skillId, 10, skillTiers);
|
|
||||||
const selected10 = (skillUpgrades[skillId] || []).filter((id) => id.includes('_l10'));
|
|
||||||
if (upgrades10.length > 0 && selected10.length < 2) {
|
|
||||||
return { milestone: 10, hasUpgrades: true, selectedCount: selected10.length };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SkillsTab({ store }: SkillsTabProps) {
|
export function SkillsTab({ store }: SkillsTabProps) {
|
||||||
const showToast = useGameToast();
|
const showToast = useGameToast();
|
||||||
const [upgradeDialogSkill, setUpgradeDialogSkill] = useState<string | null>(null);
|
const [upgradeDialogSkill, setUpgradeDialogSkill] = useState<string | null>(null);
|
||||||
@@ -86,7 +56,7 @@ export function SkillsTab({ store }: SkillsTabProps) {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const studySpeedMult = getStudySpeedMultiplier(store.skills);
|
const studySpeedMult = getStudySpeedMultiplier(store.skills);
|
||||||
const upgradeEffects = getUnifiedEffects(store);
|
const upgradeEffects = getUnifiedEffects(store as any);
|
||||||
|
|
||||||
// Upgrade selection hook
|
// Upgrade selection hook
|
||||||
const {
|
const {
|
||||||
@@ -135,6 +105,14 @@ export function SkillsTab({ store }: SkillsTabProps) {
|
|||||||
hookHandleCancel(() => setUpgradeDialogSkill(null));
|
hookHandleCancel(() => setUpgradeDialogSkill(null));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Wrapper for upgrade toggle that matches UpgradeDialog's onToggle signature
|
||||||
|
const handleUpgradeToggle = useCallback(
|
||||||
|
(upgradeId: string) => {
|
||||||
|
toggleUpgrade(upgradeId, available, alreadySelected);
|
||||||
|
},
|
||||||
|
[toggleUpgrade, available, alreadySelected]
|
||||||
|
);
|
||||||
|
|
||||||
// Handle study start with toast
|
// Handle study start with toast
|
||||||
const handleStartStudying = (skillId: string) => {
|
const handleStartStudying = (skillId: string) => {
|
||||||
const skillDef = SKILLS_DEF[skillId.includes('_t') ? skillId.split('_t')[0] : skillId];
|
const skillDef = SKILLS_DEF[skillId.includes('_t') ? skillId.split('_t')[0] : skillId];
|
||||||
@@ -173,6 +151,9 @@ export function SkillsTab({ store }: SkillsTabProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Get available skill categories based on attunements
|
||||||
|
const availableCategories = getAvailableSkillCategories(store.attunements || {});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Upgrade Selection Dialog */}
|
{/* Upgrade Selection Dialog */}
|
||||||
@@ -183,7 +164,7 @@ export function SkillsTab({ store }: SkillsTabProps) {
|
|||||||
pendingSelections={pendingSelections.length > 0 ? pendingSelections : alreadySelected}
|
pendingSelections={pendingSelections.length > 0 ? pendingSelections : alreadySelected}
|
||||||
available={available}
|
available={available}
|
||||||
alreadySelected={alreadySelected}
|
alreadySelected={alreadySelected}
|
||||||
onToggle={toggleUpgrade}
|
onToggle={handleUpgradeToggle}
|
||||||
onConfirm={handleConfirm}
|
onConfirm={handleConfirm}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
@@ -221,179 +202,31 @@ export function SkillsTab({ store }: SkillsTabProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Get available skill categories based on attunements */}
|
{/* Skill Categories */}
|
||||||
{(() => {
|
{SKILL_CATEGORIES.filter((cat) => availableCategories.includes(cat.id)).map((cat) => (
|
||||||
const availableCategories = getAvailableSkillCategories(store.attunements || {});
|
<CategorySkillsList
|
||||||
|
key={cat.id}
|
||||||
return SKILL_CATEGORIES.filter((cat) => availableCategories.includes(cat.id)).map((cat) => {
|
category={cat}
|
||||||
const skillsInCat = Object.entries(SKILLS_DEF).filter(([, def]) => def.cat === cat.id);
|
availableCategories={availableCategories}
|
||||||
if (skillsInCat.length === 0) return null;
|
isCollapsed={collapsedCategories.has(cat.id)}
|
||||||
|
onToggleCategory={toggleCategory}
|
||||||
const isCollapsed = collapsedCategories.has(cat.id);
|
store={store}
|
||||||
|
studySpeedMult={studySpeedMult}
|
||||||
return (
|
|
||||||
<Card key={cat.id} className="bg-gray-900/80 border-gray-700">
|
|
||||||
<CardHeader className="pb-2 cursor-pointer" onClick={() => toggleCategory(cat.id)}>
|
|
||||||
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center justify-between">
|
|
||||||
<span>
|
|
||||||
{cat.icon} {cat.name}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{skillsInCat.length} skills
|
|
||||||
</Badge>
|
|
||||||
{isCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
|
||||||
</div>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
{!isCollapsed && (
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{skillsInCat.map(([id, def]) => {
|
|
||||||
// GATE MANA CAPACITY SKILLS BY UNLOCKED ELEMENT
|
|
||||||
if (def.cost?.type === 'element') {
|
|
||||||
const element = store.elements[def.cost.element];
|
|
||||||
if (!element?.unlocked) {
|
|
||||||
return null; // Don't render this skill
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Get tier info
|
|
||||||
const currentTier = store.skillTiers?.[id] || 1;
|
|
||||||
const tieredSkillId = currentTier > 1 ? `${id}_t${currentTier}` : id;
|
|
||||||
const tierMultiplier = getTierMultiplier(tieredSkillId);
|
|
||||||
|
|
||||||
// Get the actual level from the tiered skill
|
|
||||||
const level = store.skills[tieredSkillId] || store.skills[id] || 0;
|
|
||||||
const maxed = level >= def.max;
|
|
||||||
|
|
||||||
// Check if studying this skill
|
|
||||||
const isStudying =
|
|
||||||
(store.currentStudyTarget?.id === id || store.currentStudyTarget?.id === tieredSkillId) &&
|
|
||||||
store.currentStudyTarget?.type === 'skill';
|
|
||||||
|
|
||||||
// Get tier name for display
|
|
||||||
const tierDef = SKILL_EVOLUTION_PATHS[id]?.tiers.find((t) => t.tier === currentTier);
|
|
||||||
const skillDisplayName = tierDef?.name || def.name;
|
|
||||||
|
|
||||||
// Check prerequisites
|
|
||||||
let prereqMet = true;
|
|
||||||
if (def.req) {
|
|
||||||
for (const [r, rl] of Object.entries(def.req)) {
|
|
||||||
if ((store.skills[r] || 0) < rl) {
|
|
||||||
prereqMet = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply skill modifiers
|
|
||||||
const costMult = getStudyCostMultiplier(store.skills);
|
|
||||||
const speedMult = getStudySpeedMultiplier(store.skills);
|
|
||||||
const studyEffects = getUnifiedEffects(store);
|
|
||||||
const effectiveSpeedMult = speedMult * studyEffects.studySpeedMultiplier;
|
|
||||||
|
|
||||||
// Study time scales with tier
|
|
||||||
const tierStudyTime = def.studyTime * currentTier;
|
|
||||||
const effectiveStudyTime = tierStudyTime / effectiveSpeedMult;
|
|
||||||
|
|
||||||
// Cost scales with tier
|
|
||||||
const baseCost = def.base * (level + 1) * currentTier;
|
|
||||||
const cost = Math.floor(baseCost * costMult);
|
|
||||||
|
|
||||||
// Additional cost (element mana)
|
|
||||||
const additionalCost = def.cost;
|
|
||||||
|
|
||||||
// Can start studying?
|
|
||||||
let canStudy = !maxed && prereqMet && store.rawMana >= cost && !isStudying;
|
|
||||||
|
|
||||||
// Check additional cost (element mana)
|
|
||||||
if (def.cost && def.cost.type === 'element') {
|
|
||||||
const element = store.elements[def.cost.element];
|
|
||||||
if (!element || element.current < def.cost.amount) {
|
|
||||||
canStudy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for milestone upgrades
|
|
||||||
const milestoneInfo = hasMilestoneUpgrade(
|
|
||||||
tieredSkillId,
|
|
||||||
level,
|
|
||||||
store.skillTiers || {},
|
|
||||||
store.skillUpgrades
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check for tier up
|
|
||||||
const nextTierSkill = getNextTierSkill(tieredSkillId);
|
|
||||||
const canTierUp = maxed && nextTierSkill;
|
|
||||||
|
|
||||||
// Get selected upgrades
|
|
||||||
const selectedUpgrades = store.skillUpgrades[tieredSkillId] || [];
|
|
||||||
const selectedL5 = selectedUpgrades.filter((u) => u.includes('_l5'));
|
|
||||||
const selectedL10 = selectedUpgrades.filter((u) => u.includes('_l10'));
|
|
||||||
|
|
||||||
// Check if insufficient mana for toast
|
|
||||||
const hasInsufficientMana = !isStudying && !maxed && store.rawMana < cost;
|
|
||||||
|
|
||||||
// Check for parallel study eligibility
|
|
||||||
const isParallelStudy =
|
|
||||||
store.parallelStudyTarget?.id === tieredSkillId &&
|
|
||||||
store.parallelStudyTarget?.type === 'skill';
|
|
||||||
const canParallelStudy =
|
|
||||||
hasSpecial(studyEffects, SPECIAL_EFFECTS.PARALLEL_STUDY) &&
|
|
||||||
store.currentStudyTarget &&
|
|
||||||
store.currentStudyTarget.id !== tieredSkillId &&
|
|
||||||
!isStudying;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SkillRow
|
|
||||||
key={id}
|
|
||||||
skillId={tieredSkillId}
|
|
||||||
def={def}
|
|
||||||
level={level}
|
|
||||||
maxed={maxed}
|
|
||||||
isStudying={isStudying}
|
|
||||||
tierMultiplier={tierMultiplier}
|
|
||||||
skillDisplayName={skillDisplayName}
|
|
||||||
selectedUpgrades={selectedUpgrades}
|
|
||||||
selectedL5={selectedL5}
|
|
||||||
selectedL10={selectedL10}
|
|
||||||
prereqMet={prereqMet}
|
|
||||||
canStudy={canStudy}
|
|
||||||
isParallelStudy={isParallelStudy}
|
|
||||||
canParallelStudy={canParallelStudy}
|
|
||||||
canTierUp={canTierUp}
|
|
||||||
hasInsufficientMana={hasInsufficientMana}
|
|
||||||
currentStudyTarget={store.currentStudyTarget}
|
|
||||||
milestoneInfo={milestoneInfo}
|
|
||||||
upgradeEffects={upgradeEffects}
|
upgradeEffects={upgradeEffects}
|
||||||
cost={cost}
|
currentStudyTarget={store.currentStudyTarget}
|
||||||
additionalCost={additionalCost}
|
onStartStudying={handleStartStudying}
|
||||||
effectiveStudyTime={effectiveStudyTime}
|
|
||||||
costMult={costMult}
|
|
||||||
speedMult={speedMult}
|
|
||||||
onStudy={handleStartStudying}
|
|
||||||
onParallelStudy={handleParallelStudy}
|
onParallelStudy={handleParallelStudy}
|
||||||
onCancelStudy={() => {
|
onCancelStudy={handleCancelStudy}
|
||||||
if (store.currentStudyTarget?.id === tieredSkillId) {
|
onOpenUpgradeDialog={(skillId, milestone) => {
|
||||||
handleCancelStudy();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onUpgradeDialogOpen={(skillId, milestone) => {
|
|
||||||
setUpgradeDialogSkill(skillId);
|
setUpgradeDialogSkill(skillId);
|
||||||
setUpgradeDialogMilestone(milestone);
|
setUpgradeDialogMilestone(milestone);
|
||||||
setPendingSelections([]);
|
setPendingSelections([]);
|
||||||
}}
|
}}
|
||||||
onTierUp={(skillId) => store.tierUpSkill(skillId)}
|
onTierUp={(skillId) => store.tierUpSkill(skillId)}
|
||||||
|
pendingSelections={pendingSelections}
|
||||||
|
setPendingSelections={setPendingSelections}
|
||||||
/>
|
/>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
import { useState, useMemo, useCallback, SetStateAction, Dispatch } from 'react';
|
import { useState, useMemo, useCallback, SetStateAction, Dispatch } from 'react';
|
||||||
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
||||||
|
import { getUpgradesForSkillAtMilestone } from '@/lib/game/skill-evolution';
|
||||||
|
|
||||||
export interface UseSkillUpgradeSelectionResult {
|
export interface UseSkillUpgradeSelectionResult {
|
||||||
pendingSelections: string[];
|
pendingSelections: string[];
|
||||||
@@ -12,6 +13,36 @@ export interface UseSkillUpgradeSelectionResult {
|
|||||||
handleCancel: (onClose: () => void) => void;
|
handleCancel: (onClose: () => void) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if skill has milestone available (also exported for use in SkillRow/CategorySkillsList)
|
||||||
|
*/
|
||||||
|
export function hasMilestoneUpgrade(
|
||||||
|
skillId: string,
|
||||||
|
level: number,
|
||||||
|
skillTiers: Record<string, number>,
|
||||||
|
skillUpgrades: Record<string, string[]>
|
||||||
|
): { milestone: 5 | 10; hasUpgrades: boolean; selectedCount: number } | null {
|
||||||
|
// Check level 5 milestone
|
||||||
|
if (level >= 5) {
|
||||||
|
const upgrades5 = getUpgradesForSkillAtMilestone(skillId, 5, skillTiers);
|
||||||
|
const selected5 = (skillUpgrades[skillId] || []).filter((id) => id.includes('_l5'));
|
||||||
|
if (upgrades5.length > 0 && selected5.length < 2) {
|
||||||
|
return { milestone: 5, hasUpgrades: true, selectedCount: selected5.length };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check level 10 milestone
|
||||||
|
if (level >= 10) {
|
||||||
|
const upgrades10 = getUpgradesForSkillAtMilestone(skillId, 10, skillTiers);
|
||||||
|
const selected10 = (skillUpgrades[skillId] || []).filter((id) => id.includes('_l10'));
|
||||||
|
if (upgrades10.length > 0 && selected10.length < 2) {
|
||||||
|
return { milestone: 10, hasUpgrades: true, selectedCount: selected10.length };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for managing skill upgrade selection state in the SkillsTab milestone upgrade dialog.
|
* Hook for managing skill upgrade selection state in the SkillsTab milestone upgrade dialog.
|
||||||
* Manages pending selections across the dialog open/close cycle.
|
* Manages pending selections across the dialog open/close cycle.
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ export type {
|
|||||||
SkillTierDef,
|
SkillTierDef,
|
||||||
SkillPerkChoice,
|
SkillPerkChoice,
|
||||||
SkillUpgradeChoice,
|
SkillUpgradeChoice,
|
||||||
PrestigeDef
|
PrestigeDef,
|
||||||
|
SkillCost
|
||||||
} from './skills';
|
} from './skills';
|
||||||
|
|
||||||
// Equipment types
|
// Equipment types
|
||||||
|
|||||||
Reference in New Issue
Block a user