feat: add prestige system and skill upgrades with comprehensive documentation
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 5m57s

This commit is contained in:
Refactoring Agent
2026-05-01 15:18:09 +02:00
parent 3691aa4acc
commit 03815f27ee
52 changed files with 4056 additions and 873 deletions
+11 -26
View File
@@ -1,7 +1,5 @@
'use client';
import { GameCard, ElementBadge } from '@/components/ui';
import { Badge } from '@/components/ui/badge';
import type { GameStore } from '@/lib/game/store';
import { AchievementsDisplay } from '@/components/game/AchievementsDisplay';
@@ -11,33 +9,20 @@ export interface AchievementsTabProps {
export function AchievementsTab({ store }: AchievementsTabProps) {
const achievements = store.achievements;
const unlockedCount = achievements.unlocked.length;
return (
<div className="space-y-4">
<GameCard>
<div className="pb-2">
<h2 className="text-lg font-[var(--font-heading)] font-semibold flex items-center gap-2 text-[var(--color-warning)]">
Achievements
<Badge className="ml-auto bg-[var(--bg-elevated)] text-[var(--color-warning)] border border-[var(--color-warning)]/30">
{unlockedCount} unlocked
</Badge>
</h2>
</div>
<div>
<AchievementsDisplay
achievements={achievements}
gameState={{
maxFloorReached: store.maxFloorReached,
totalManaGathered: store.totalManaGathered,
signedPacts: store.signedPacts,
totalSpellsCast: store.totalSpellsCast,
totalDamageDealt: store.totalDamageDealt,
totalCraftsCompleted: store.totalCraftsCompleted,
}}
/>
</div>
</GameCard>
<AchievementsDisplay
achievements={achievements}
gameState={{
maxFloorReached: store.maxFloorReached,
totalManaGathered: store.totalManaGathered,
signedPacts: store.signedPacts,
totalSpellsCast: store.totalSpellsCast,
totalDamageDealt: store.totalDamageDealt,
totalCraftsCompleted: store.totalCraftsCompleted,
}}
/>
</div>
);
}
+18 -9
View File
@@ -9,6 +9,7 @@ import {
type EquipmentType,
} from '@/lib/game/data/equipment';
import { ENCHANTMENT_EFFECTS } from '@/lib/game/data/enchantment-effects';
import { getUnifiedEffects } from '@/lib/game/effects';
import { fmt } from '@/lib/game/store';
import { Button } from '@/components/ui/button';
import { GameCard } from '@/components/ui/game-card';
@@ -513,7 +514,7 @@ showToast('success', 'Item Unequipped', `${instance?.name || 'Item'} removed fro
</div>
</div>
{/* Enchantment Power (placeholder for Task 5) */}
{/* Enchantment Power */}
<GameCard className="mt-4">
<div className="pb-2">
<h3 className="text-sm font-semibold text-[var(--text-primary)] flex items-center gap-2">
@@ -521,14 +522,22 @@ showToast('success', 'Item Unequipped', `${instance?.name || 'Item'} removed fro
</h3>
</div>
<div>
<StatRow
label="Enchantment Power:"
value="1.0×"
highlight="info"
/>
<p className="text-xs text-[var(--text-muted)] mt-2">
Increases the power of all enchantments. Will be wired from Task 5 implementation.
</p>
{(() => {
const unifiedEffects = getUnifiedEffects(store);
const enchantPower = unifiedEffects.enchantmentPowerMultiplier || 1;
return (
<>
<StatRow
label="Enchantment Power:"
value={`${enchantPower.toFixed(2)}×`}
highlight={enchantPower > 1 ? "success" : "default"}
/>
<p className="text-xs text-[var(--text-muted)] mt-2">
Increases the power of all enchantments by {(enchantPower - 1) * 100}%. Multiplier applied to all enchantment effects.
</p>
</>
);
})()}
</div>
</GameCard>
+253
View File
@@ -0,0 +1,253 @@
// ─── Prestige/Grimoire Tab ──────────────────────────
'use client';
import { useState } from 'react';
import { useGameStore, useGameLoop } from '@/lib/game/store';
import { getUnifiedEffects } from '@/lib/game/effects';
import {
ELEMENTS,
GUARDIANS,
PRESTIGE_DEF,
getStudySpeedMultiplier,
} from '@/lib/game/constants';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { fmt } from '@/lib/game/computed-stats';
export function PrestigeTab() {
const [selectedManaType, setSelectedManaType] = useState<string>('');
const store = useGameStore();
const gameLoop = useGameLoop();
const upgradeEffects = getUnifiedEffects(store);
// Get unlocked elements for mana type selector
const unlockedElements = Object.entries(ELEMENTS)
.filter(([id]) => store.elements[id]?.unlocked)
.map(([id, elem]) => ({
id,
name: elem.name,
sym: elem.sym,
color: elem.color,
}));
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Loop Stats */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">
Loop Stats
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-gray-800/50 rounded">
<div className="text-2xl font-bold text-amber-400 game-mono">
{store.loopCount}
</div>
<div className="text-xs text-gray-400">Loops Completed</div>
</div>
<div className="p-3 bg-gray-800/50 rounded">
<div className="text-2xl font-bold text-purple-400 game-mono">
{fmt(store.insight)}
</div>
<div className="text-xs text-gray-400">Current Insight</div>
</div>
<div className="p-3 bg-gray-800/50 rounded">
<div className="text-2xl font-bold text-blue-400 game-mono">
{fmt(store.totalInsight)}
</div>
<div className="text-xs text-gray-400">Total Insight</div>
</div>
<div className="p-3 bg-gray-800/50 rounded">
<div className="text-2xl font-bold text-green-400 game-mono">
{store.memorySlots}
</div>
<div className="text-xs text-gray-400">Memory Slots</div>
</div>
</div>
</CardContent>
</Card>
{/* Signed Pacts */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">
Signed Pacts
</CardTitle>
</CardHeader>
<CardContent>
{store.signedPacts.length === 0 ? (
<div className="text-gray-500 text-sm">
No pacts signed yet. Defeat guardians to earn pacts.
</div>
) : (
<div className="space-y-2">
{store.signedPacts.map((floor) => {
const guardian = GUARDIANS[floor];
if (!guardian) return null;
return (
<div
key={floor}
className="flex items-center justify-between p-2 rounded border"
style={{
borderColor: guardian.color,
backgroundColor: `${guardian.color}15`,
}}
>
<div>
<div
className="font-semibold text-sm"
style={{ color: guardian.color }}
>
{guardian.name}
</div>
<div className="text-xs text-gray-400">
Floor {floor}
</div>
</div>
<Badge className="bg-amber-900/50 text-amber-300">
{guardian.pact}x multiplier
</Badge>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Prestige Upgrades */}
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">
Insight Upgrades (Permanent)
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{Object.entries(PRESTIGE_DEF).map(([id, def]) => {
const level = store.prestigeUpgrades[id] || 0;
const maxed = level >= def.max;
const canBuy = !maxed && store.insight >= def.cost;
const isUnlockedManaTypeCapacity =
id === 'unlockedManaTypeCapacity';
return (
<div
key={id}
className="p-3 rounded border border-gray-700 bg-gray-800/50"
>
<div className="flex items-center justify-between mb-2">
<div className="font-semibold text-amber-400 text-sm">
{def.name}
</div>
<Badge variant="outline" className="text-xs">
{level}/{def.max}
</Badge>
</div>
<div className="text-xs text-gray-400 italic mb-2">
{def.desc}
</div>
{/* Mana type selector for unlockedManaTypeCapacity */}
{isUnlockedManaTypeCapacity && !maxed && (
<div className="mb-2">
<div className="text-xs text-gray-400 mb-1">
Select mana type:
</div>
<div className="grid grid-cols-3 gap-1">
{unlockedElements.map(
({ id: elemId, name, sym, color }) => (
<Button
key={elemId}
size="sm"
variant={
selectedManaType === elemId
? 'default'
: 'outline'
}
className="text-xs h-7"
style={{
borderColor:
selectedManaType === elemId
? color
: undefined,
backgroundColor:
selectedManaType === elemId
? color + '40'
: undefined,
}}
onClick={() => setSelectedManaType(elemId)}
>
{sym} {name}
</Button>
)
)}
</div>
</div>
)}
<Button
size="sm"
variant={canBuy ? 'default' : 'outline'}
className="w-full"
disabled={
!canBuy ||
(isUnlockedManaTypeCapacity && !selectedManaType)
}
onClick={() =>
store.doPrestige(id, selectedManaType)
}
>
{maxed
? 'Maxed'
: `Upgrade (${fmt(def.cost)} insight)`}
</Button>
</div>
);
})}
</div>
{/* Reset Game Button */}
<div className="mt-4 pt-4 border-t border-gray-700">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400">
Reset All Progress
</div>
<div className="text-xs text-gray-500">
Clear all data and start fresh
</div>
</div>
<Button
size="sm"
variant="outline"
className="border-red-600/50 text-red-400 hover:bg-red-900/20"
onClick={() => {
if (
confirm(
'Are you sure you want to reset ALL progress? This cannot be undone!'
)
) {
store.resetGame();
}
}}
>
<RotateCcw className="w-4 h-4 mr-1" />
Reset
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</ScrollArea>
);
}
+241
View File
@@ -0,0 +1,241 @@
// ─── Skill Row Component ───────────────────────────────────────────────────
// Individual skill row for the Skills tab - extracted from SkillsTab for modularity
'use client';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { ChevronRight } from 'lucide-react';
import type { SkillUpgradeChoice } from '@/lib/game/types';
import { ELEMENTS } from '@/lib/game/constants';
import { hasMilestoneUpgrade } from '@/lib/game/hooks/useSkillUpgradeSelection';
import { formatStudyTime } from '@/lib/game/formatting';
import { getUpgradesForSkillAtMilestone, getNextTierSkill, getTierMultiplier, SKILL_EVOLUTION_PATHS } from '@/lib/game/skill-evolution';
import type { ComputedEffects } from '@/lib/game/upgrade-effects.types';
import { SPECIAL_EFFECTS, hasSpecial } from '@/lib/game/special-effects';
type StudyTarget = { type: 'skill' | 'spell'; id: string; progress: number; required: number; } | null;
interface SkillRowProps {
skillId: string;
def: {
name: string;
desc: string;
cat: string;
max: number;
studyTime: number;
base: number;
cost?: { type: 'element'; element: string; amount: number } | { type: 'raw'; amount: number };
req?: Record<string, number>;
};
level: number;
maxed: boolean;
isStudying: boolean;
tierMultiplier: number;
skillDisplayName: string;
selectedUpgrades: string[];
selectedL5: string[];
selectedL10: string[];
prereqMet: boolean;
canStudy: boolean;
isParallelStudy: boolean;
canParallelStudy: boolean;
canTierUp: boolean;
hasInsufficientMana: boolean;
currentStudyTarget: StudyTarget;
milestoneInfo: { milestone: 5 | 10; hasUpgrades: boolean; selectedCount: number } | null;
upgradeEffects: ComputedEffects;
// Costs and times
cost: number;
additionalCost?: { type: 'element'; element: string; amount: number };
effectiveStudyTime: number;
costMult: number;
speedMult: number;
// Callbacks
onStudy: (skillId: string) => void;
onParallelStudy: (skillId: string) => void;
onCancelStudy: (skillId: string) => void;
onUpgradeDialogOpen: (skillId: string, milestone: 5 | 10) => void;
onTierUp: (skillId: string) => void;
onShowToast: (type: 'info' | 'error', title: string, description: string) => void;
tierUpLabel?: string;
}
export function SkillRow(props: SkillRowProps) {
const {
skillId,
def,
level,
maxed,
isStudying,
tierMultiplier,
skillDisplayName,
selectedUpgrades,
selectedL5,
selectedL10,
prereqMet,
canStudy,
isParallelStudy,
canParallelStudy,
canTierUp,
hasInsufficientMana,
currentStudyTarget,
milestoneInfo,
upgradeEffects,
cost,
additionalCost,
effectiveStudyTime,
costMult,
speedMult,
onStudy,
onParallelStudy,
onCancelStudy,
onUpgradeDialogOpen,
onTierUp,
} = props;
return (
<div
key={skillId}
className={`flex flex-col sm:flex-row sm:items-center justify-between p-3 rounded border gap-2 ${
isStudying ? 'border-purple-500 bg-purple-900/20' :
milestoneInfo ? 'border-amber-500/50 bg-amber-900/10' :
'border-gray-700 bg-gray-800/30'
}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-sm">{skillDisplayName}</span>
{level > 0 && <span className="text-purple-400 text-sm">Lv.{level}</span>}
{selectedUpgrades.length > 0 && (
<div className="flex gap-1">
{selectedL5.length > 0 && (
<Badge className="bg-amber-700/50 text-amber-200 text-xs">L5: {selectedL5.length}</Badge>
)}
{selectedL10.length > 0 && (
<Badge className="bg-purple-700/50 text-purple-200 text-xs">L10: {selectedL10.length}</Badge>
)}
</div>
)}
</div>
<div className="text-xs text-gray-400 italic">{def.desc}{level > 0 && tierMultiplier !== 1 && ` (Tier ${tierMultiplier}x effect)`}</div>
{!prereqMet && def.req && (
<div className="text-xs text-red-400 mt-1">
Requires: {Object.entries(def.req).map(([r, rl]) => `${r} Lv.${rl}`).join(', ')}
</div>
)}
<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>
{hasInsufficientMana && (
<div className="text-xs text-red-400 mt-1">
Insufficient mana! Need {cost} mana to study.
</div>
)}
{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 className="flex items-center gap-3 flex-wrap sm:flex-nowrap">
{/* Level dots */}
<div className="flex gap-1 shrink-0">
{Array.from({ length: def.max }).map((_, i) => (
<div
key={i}
className={`w-2 h-2 rounded-full border ${
i < level ? 'bg-purple-500 border-purple-400' :
i === 4 || i === 9 ? 'border-amber-500' :
'border-gray-600'
}`}
/>
))}
</div>
{isStudying ? (
<div className="text-xs text-purple-400">
{formatStudyTime(currentStudyTarget?.progress || 0)}/{formatStudyTime(def.studyTime * (level > 1 ? level : 1))}
</div>
) : milestoneInfo ? (
<Button
size="sm"
className="bg-amber-600 hover:bg-amber-700"
onClick={() => onUpgradeDialogOpen(skillId, milestoneInfo.milestone)}
>
Choose Upgrades
</Button>
) : canTierUp ? (
<Button
size="sm"
className="bg-purple-600 hover:bg-purple-700"
onClick={() => onTierUp(skillId)}
>
Tier Up
</Button>
) : maxed ? (
<Badge className="bg-green-900/50 text-green-300">Maxed</Badge>
) : (
<div className="flex gap-1">
<Button
size="sm"
variant={canStudy ? 'default' : 'outline'}
disabled={!canStudy}
className={canStudy ? 'bg-purple-600 hover:bg-purple-700' : 'opacity-50'}
onClick={() => {
if (cost > 0) {
onStudy(skillId);
}
}}
>
Study ({cost}{additionalCost && additionalCost.type === 'element' && ` + ${additionalCost.amount} ${ELEMENTS[additionalCost.element]?.sym}`}
</Button>
{hasSpecial(upgradeEffects, SPECIAL_EFFECTS.PARALLEL_STUDY) &&
currentStudyTarget &&
!isParallelStudy &&
canParallelStudy &&
canStudy && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="border-cyan-500 text-cyan-400 hover:bg-cyan-900/30"
onClick={() => {
if (cost > 0) {
onParallelStudy(skillId);
}
}}
>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Study in parallel (50% speed)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)}
</div>
</div>
);
}
+246 -308
View File
@@ -1,23 +1,43 @@
// ─── Skills Tab ────────────────────────────
// SkillsTab - Displays all skills organized by category
// Refactored: uses SkillRow component for per-skill rendering (reduced from 469 lines)
'use client';
import { useState } from 'react';
import { SKILLS_DEF, SKILL_CATEGORIES, getStudySpeedMultiplier, getStudyCostMultiplier } from '@/lib/game/constants';
import { SKILL_EVOLUTION_PATHS, getUpgradesForSkillAtMilestone, getNextTierSkill, getTierMultiplier } from '@/lib/game/skill-evolution';
import { getUnifiedEffects, hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/effects';
import {
SKILLS_DEF,
SKILL_CATEGORIES,
getStudySpeedMultiplier,
getStudyCostMultiplier,
} from '@/lib/game/constants';
import {
SKILL_EVOLUTION_PATHS,
getUpgradesForSkillAtMilestone,
getNextTierSkill,
getTierMultiplier,
} from '@/lib/game/skill-evolution';
import { getUnifiedEffects } from '@/lib/game/effects';
import { getAvailableSkillCategories } from '@/lib/game/data/attunements';
import { fmt, fmtDec } from '@/lib/game/store';
import { formatStudyTime } from '@/lib/game/formatting';
import type { SkillUpgradeChoice, GameStore } from '@/lib/game/types';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { StudyProgress } from './StudyProgress';
import { UpgradeDialog } from './UpgradeDialog';
import { ConfirmDialog } from '@/components/game/ConfirmDialog';
import { useGameToast } from '@/components/game/GameToast';
import { ELEMENTS } from '@/lib/game/constants';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { SkillRow } from './SkillRow';
import { useSkillUpgradeSelection } from '@/lib/game/hooks/useSkillUpgradeSelection';
export interface SkillsTabProps {
store: GameStore;
@@ -25,33 +45,33 @@ export interface SkillsTabProps {
// Check if skill has milestone available
function hasMilestoneUpgrade(
skillId: string,
level: number,
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'));
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'));
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;
}
@@ -59,16 +79,27 @@ export function SkillsTab({ store }: SkillsTabProps) {
const showToast = useGameToast();
const [upgradeDialogSkill, setUpgradeDialogSkill] = useState<string | null>(null);
const [upgradeDialogMilestone, setUpgradeDialogMilestone] = useState<5 | 10>(5);
const [pendingUpgradeSelections, setPendingUpgradeSelections] = useState<string[]>([]);
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
const [cancelStudyConfirm, setCancelStudyConfirm] = useState<{ skillId: string; skillName: string } | null>(null);
const [cancelStudyConfirm, setCancelStudyConfirm] = useState<{
skillId: string;
skillName: string;
} | null>(null);
const studySpeedMult = getStudySpeedMultiplier(store.skills);
const upgradeEffects = getUnifiedEffects(store);
// Upgrade selection hook
const {
pendingSelections,
setPendingSelections,
toggleUpgrade,
handleConfirm: hookHandleConfirm,
handleCancel: hookHandleCancel,
} = useSkillUpgradeSelection();
// Toggle category collapse
const toggleCategory = (categoryId: string) => {
setCollapsedCategories(prev => {
setCollapsedCategories((prev) => {
const newSet = new Set(prev);
if (newSet.has(categoryId)) {
newSet.delete(categoryId);
@@ -78,39 +109,30 @@ export function SkillsTab({ store }: SkillsTabProps) {
return newSet;
});
};
// Get upgrade choices for dialog
const getUpgradeChoices = () => {
if (!upgradeDialogSkill) return { available: [] as SkillUpgradeChoice[], selected: [] as string[] };
if (!upgradeDialogSkill)
return { available: [] as SkillUpgradeChoice[], selected: [] as string[] };
return store.getSkillUpgradeChoices(upgradeDialogSkill, upgradeDialogMilestone);
};
const { available, selected: alreadySelected } = getUpgradeChoices();
// Toggle selection
const toggleUpgrade = (upgradeId: string) => {
const currentSelections = pendingUpgradeSelections.length > 0 ? pendingUpgradeSelections : alreadySelected;
if (currentSelections.includes(upgradeId)) {
setPendingUpgradeSelections(currentSelections.filter(id => id !== upgradeId));
} else if (currentSelections.length < 2) {
setPendingUpgradeSelections([...currentSelections, upgradeId]);
}
};
// Commit selections and close
// Handle upgrade dialog confirm
const handleConfirm = () => {
const currentSelections = pendingUpgradeSelections.length > 0 ? pendingUpgradeSelections : alreadySelected;
if (currentSelections.length === 2 && upgradeDialogSkill) {
store.commitSkillUpgrades(upgradeDialogSkill, currentSelections, upgradeDialogMilestone);
}
setPendingUpgradeSelections([]);
setUpgradeDialogSkill(null);
hookHandleConfirm(
upgradeDialogSkill,
upgradeDialogMilestone,
(skillId, selections, milestone) =>
store.commitSkillUpgrades(skillId, selections, milestone),
() => setUpgradeDialogSkill(null)
);
};
// Cancel and close
// Handle upgrade dialog cancel
const handleCancel = () => {
setPendingUpgradeSelections([]);
setUpgradeDialogSkill(null);
hookHandleCancel(() => setUpgradeDialogSkill(null));
};
// Handle study start with toast
@@ -132,9 +154,9 @@ export function SkillsTab({ store }: SkillsTabProps) {
const currentTarget = store.currentStudyTarget;
if (currentTarget?.type === 'skill') {
const skillDef = SKILLS_DEF[currentTarget.id.includes('_t') ? currentTarget.id.split('_t')[0] : currentTarget.id];
setCancelStudyConfirm({
skillId: currentTarget.id,
skillName: skillDef?.name || 'Unknown Skill'
setCancelStudyConfirm({
skillId: currentTarget.id,
skillName: skillDef?.name || 'Unknown Skill',
});
}
};
@@ -142,11 +164,15 @@ export function SkillsTab({ store }: SkillsTabProps) {
const confirmCancelStudy = () => {
if (cancelStudyConfirm) {
store.cancelStudy();
showToast('warning', 'Study Cancelled', `${cancelStudyConfirm.skillName} study cancelled. Progress will be partially saved based on your Knowledge Retention skill.`);
showToast(
'warning',
'Study Cancelled',
`${cancelStudyConfirm.skillName} study cancelled. Progress will be partially saved based on your Knowledge Retention skill.`
);
setCancelStudyConfirm(null);
}
};
return (
<div className="space-y-4">
{/* Upgrade Selection Dialog */}
@@ -154,7 +180,7 @@ export function SkillsTab({ store }: SkillsTabProps) {
open={!!upgradeDialogSkill}
skillId={upgradeDialogSkill}
milestone={upgradeDialogMilestone}
pendingSelections={pendingUpgradeSelections}
pendingSelections={pendingSelections.length > 0 ? pendingSelections : alreadySelected}
available={available}
alreadySelected={alreadySelected}
onToggle={toggleUpgrade}
@@ -162,12 +188,12 @@ export function SkillsTab({ store }: SkillsTabProps) {
onCancel={handleCancel}
onOpenChange={(open) => {
if (!open) {
setPendingUpgradeSelections([]);
setPendingSelections([]);
setUpgradeDialogSkill(null);
}
}}
/>
{/* Cancel Study Confirmation Dialog */}
{cancelStudyConfirm && (
<ConfirmDialog
@@ -180,7 +206,7 @@ export function SkillsTab({ store }: SkillsTabProps) {
onConfirm={confirmCancelStudy}
/>
)}
{/* Current Study Progress */}
{store.currentStudyTarget && store.currentStudyTarget.type === 'skill' && (
<Card className="bg-gray-900/80 border-purple-600/50">
@@ -194,269 +220,181 @@ export function SkillsTab({ store }: SkillsTabProps) {
</CardContent>
</Card>
)}
{/* Get available skill categories based on attunements */}
{(() => {
const availableCategories = getAvailableSkillCategories(store.attunements || {});
return SKILL_CATEGORIES
.filter(cat => availableCategories.includes(cat.id))
.map((cat) => {
const skillsInCat = Object.entries(SKILLS_DEF).filter(([, def]) => def.cat === cat.id);
if (skillsInCat.length === 0) return null;
const isCollapsed = collapsedCategories.has(cat.id);
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]) => {
// 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;
return SKILL_CATEGORIES.filter((cat) => availableCategories.includes(cat.id)).map((cat) => {
const skillsInCat = Object.entries(SKILLS_DEF).filter(([, def]) => def.cat === cat.id);
if (skillsInCat.length === 0) return null;
const isCollapsed = collapsedCategories.has(cat.id);
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
}
}
}
}
// 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;
return (
<div
key={id}
className={`flex flex-col sm:flex-row sm:items-center justify-between p-3 rounded border gap-2 ${
isStudying ? 'border-purple-500 bg-purple-900/20' :
milestoneInfo ? 'border-amber-500/50 bg-amber-900/10' :
'border-gray-700 bg-gray-800/30'
}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-sm">{skillDisplayName}</span>
{currentTier > 1 && (
<Badge className="bg-purple-600/50 text-purple-200 text-xs">Tier {currentTier} ({fmtDec(tierMultiplier, 0)}x)</Badge>
)}
{level > 0 && <span className="text-purple-400 text-sm">Lv.{level}</span>}
{selectedUpgrades.length > 0 && (
<div className="flex gap-1">
{selectedL5.length > 0 && (
<Badge className="bg-amber-700/50 text-amber-200 text-xs">L5: {selectedL5.length}</Badge>
)}
{selectedL10.length > 0 && (
<Badge className="bg-purple-700/50 text-purple-200 text-xs">L10: {selectedL10.length}</Badge>
)}
</div>
)}
</div>
<div className="text-xs text-gray-400 italic">{def.desc}{currentTier > 1 && ` (Tier ${currentTier}: ${fmtDec(tierMultiplier, 0)}x effect)`}</div>
{!prereqMet && def.req && (
<div className="text-xs text-red-400 mt-1">
Requires: {Object.entries(def.req).map(([r, rl]) => `${SKILLS_DEF[r]?.name} Lv.${rl}`).join(', ')}
</div>
)}
<div className="text-xs text-gray-500 mt-1">
<span className={effectiveSpeedMult > 1 ? 'text-green-400' : ''}>
Study: {formatStudyTime(effectiveStudyTime)}{effectiveSpeedMult > 1 && <span className="text-xs ml-1">({Math.round(effectiveSpeedMult * 100)}% speed)</span>}
</span>
{' • '}
<span className={costMult < 1 ? 'text-green-400' : ''}>
Cost: {fmt(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 && (
<div className="text-xs text-red-400 mt-1">
Insufficient mana! Need {fmt(cost)} mana to study.
</div>
)}
{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 className="flex items-center gap-3 flex-wrap sm:flex-nowrap">
{/* Level dots */}
<div className="flex gap-1 shrink-0">
{Array.from({ length: def.max }).map((_, i) => (
<div
key={i}
className={`w-2 h-2 rounded-full border ${
i < level ? 'bg-purple-500 border-purple-400' :
i === 4 || i === 9 ? 'border-amber-500' :
'border-gray-600'
}`}
/>
))}
</div>
{isStudying ? (
<div className="text-xs text-purple-400">
{formatStudyTime(store.currentStudyTarget?.progress || 0)}/{formatStudyTime(tierStudyTime)}
</div>
) : milestoneInfo ? (
<Button
size="sm"
className="bg-amber-600 hover:bg-amber-700"
onClick={() => {
setUpgradeDialogSkill(tieredSkillId);
setUpgradeDialogMilestone(milestoneInfo.milestone);
}}
>
Choose Upgrades
</Button>
) : canTierUp ? (
<Button
size="sm"
className="bg-purple-600 hover:bg-purple-700"
onClick={() => store.tierUpSkill(tieredSkillId)}
>
Tier Up
</Button>
) : maxed ? (
<Badge className="bg-green-900/50 text-green-300">Maxed</Badge>
) : (
<div className="flex gap-1">
<Button
size="sm"
variant={canStudy ? 'default' : 'outline'}
disabled={!canStudy}
className={canStudy ? 'bg-purple-600 hover:bg-purple-700' : 'opacity-50'}
onClick={() => {
if (store.rawMana < cost) {
const deficit = cost - store.rawMana;
showToast('error', 'Insufficient Mana', `Need ${fmt(deficit)} more mana to study ${skillDisplayName}`);
return;
}
handleStartStudying(tieredSkillId);
}}
>
Study ({fmt(cost)}{additionalCost && additionalCost.type === 'element' && ` + ${additionalCost.amount} ${ELEMENTS[additionalCost.element]?.sym}`}
</Button>
{/* Parallel Study button */}
{hasSpecial(upgradeEffects, SPECIAL_EFFECTS.PARALLEL_STUDY) &&
store.currentStudyTarget &&
!store.parallelStudyTarget &&
store.currentStudyTarget.id !== tieredSkillId &&
canStudy && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="border-cyan-500 text-cyan-400 hover:bg-cyan-900/30"
onClick={() => {
if (store.rawMana < cost) {
const deficit = cost - store.rawMana;
showToast('error', 'Insufficient Mana', `Need ${fmt(deficit)} more mana for parallel study`);
return;
}
handleParallelStudy(tieredSkillId);
}}
>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Study in parallel (50% speed)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</CardContent>
)}
</Card>
);
});
// 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}
cost={cost}
additionalCost={additionalCost}
effectiveStudyTime={effectiveStudyTime}
costMult={costMult}
speedMult={speedMult}
onStudy={handleStartStudying}
onParallelStudy={handleParallelStudy}
onCancelStudy={() => {
if (store.currentStudyTarget?.id === tieredSkillId) {
handleCancelStudy();
}
}}
onUpgradeDialogOpen={(skillId, milestone) => {
setUpgradeDialogSkill(skillId);
setUpgradeDialogMilestone(milestone);
setPendingSelections([]);
}}
onTierUp={(skillId) => store.tierUpSkill(skillId)}
/>
);
})}
</div>
</CardContent>
)}
</Card>
);
});
})()}
</div>
);
}
SkillsTab.displayName = "SkillsTab";
+48 -1
View File
@@ -7,7 +7,7 @@ import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { TooltipProvider, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { BookOpen, ChevronUp, ChevronDown, RotateCcw, X, Mountain, Skull, Zap, Wind, Shield } from 'lucide-react';
import { BookOpen, ChevronUp, ChevronDown, RotateCcw, X, Mountain, Skull, Zap, Wind, Shield, Heart, ShieldCheck } from 'lucide-react';
import type { ActivityLogEntry } from '@/lib/game/types';
import type { GameStore } from '@/lib/game/store';
import { ELEMENTS, GUARDIANS, SPELLS_DEF, SKILLS_DEF, ROOM_TYPE_LABELS } from '@/lib/game/constants';
@@ -232,6 +232,32 @@ export function SpireTab({ store, simpleMode = false }: SpireTabProps) {
</TooltipContent>
</Tooltip>
)}
{primaryEnemy.healthRegen > 0 && (
<Tooltip>
<TooltipTrigger>
<Badge variant="outline" className="text-xs py-0">
<Heart className="w-3 h-3 mr-1 text-green-400" />
{(primaryEnemy.healthRegen * 100).toFixed(1)}% Regen
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>Regenerates {(primaryEnemy.healthRegen * 100).toFixed(1)}% of max HP per tick</p>
</TooltipContent>
</Tooltip>
)}
{primaryEnemy.barrier > 0 && (
<Tooltip>
<TooltipTrigger>
<Badge variant="outline" className="text-xs py-0">
<ShieldCheck className="w-3 h-3 mr-1 text-blue-400" />
{(primaryEnemy.barrier * 100).toFixed(0)}% Barrier
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>Has a barrier absorbing {(primaryEnemy.barrier * 100).toFixed(0)}% of max HP before HP takes damage</p>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
)}
@@ -264,6 +290,27 @@ export function SpireTab({ store, simpleMode = false }: SpireTabProps) {
}}
/>
</div>
{/* Show enemy properties for swarm enemies */}
<div className="flex flex-wrap gap-1 mt-1">
{enemy.armor > 0 && (
<Badge variant="outline" className="text-xs py-0">
<Shield className="w-2 h-2 mr-1" />
{(enemy.armor * 100).toFixed(0)}% Armor
</Badge>
)}
{enemy.healthRegen > 0 && (
<Badge variant="outline" className="text-xs py-0">
<Heart className="w-2 h-2 mr-1 text-green-400" />
{(enemy.healthRegen * 100).toFixed(1)}% Regen
</Badge>
)}
{enemy.barrier > 0 && (
<Badge variant="outline" className="text-xs py-0">
<ShieldCheck className="w-2 h-2 mr-1 text-blue-400" />
{(enemy.barrier * 100).toFixed(0)}% Barrier
</Badge>
)}
</div>
</div>
))}
</div>
+2 -4
View File
@@ -169,13 +169,11 @@ export function StatsTab({
<div className="flex justify-between text-sm">
<span className="text-gray-400">Enchantment Power:</span>
<span className="text-blue-300 font-[var(--font-mono)]">
{upgradeEffects && 'enchantPower' in upgradeEffects
? `${(upgradeEffects as Record<string, number>).enchantPower.toFixed(2)}×`
: '1.0×'}
{upgradeEffects?.enchantmentPowerMultiplier?.toFixed(2) || '1.0'}×
</span>
</div>
<p className="text-xs text-gray-500 mt-2">
Increases the power of all enchantments. Wired from Task 5 implementation.
Increases the power of all enchantments by {((upgradeEffects?.enchantmentPowerMultiplier || 1) - 1) * 100}%. Multiplier applied to all enchantment effects.
</p>
</CardContent>
</Card>