Files
Mana-Loop/src/components/game/tabs/AttunementsTab.tsx
T
n8n-gitea 93ffa0768b
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m2s
fix: #328 fabricator golem-2 interval 250→500 + golem-1 desc
- Fix Fabricator golem-2 capped perk interval from 250 to 500 (spec match)
- Update golem-1 description to 'Unlock golem summoning' (spec match)
2026-06-09 11:47:35 +02:00

326 lines
11 KiB
TypeScript

'use client';
import { useAttunementStore, usePrestigeStore } from '@/lib/game/stores';
import { ATTUNEMENTS_DEF, ATTUNEMENT_SLOT_NAMES, getAttunementXPForLevel, MAX_ATTUNEMENT_LEVEL } from '@/lib/game/data/attunements';
import type { AttunementDef, AttunementState } from '@/lib/game/types';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DebugName } from '@/components/game/debug/debug-context';
import { fmt } from '@/lib/game/stores';
import { Unlock } from 'lucide-react';
// ─── Helpers ─────────────────────────────────────────────────────────────────
function getXpForNextLevel(level: number): number {
if (level >= MAX_ATTUNEMENT_LEVEL) return 0;
return getAttunementXPForLevel(level + 1);
}
function getXpProgress(state: AttunementState): number {
const nextXp = getXpForNextLevel(state.level);
if (nextXp <= 0) return 100;
return Math.min(100, Math.round((state.experience / nextXp) * 100));
}
function isAttunementUnlocked(id: string, attunements: Record<string, AttunementState>): boolean {
return id in attunements;
}
/**
* Check whether an attunement's unlock condition is met.
* Evaluates the condition based on current game state.
*/
function isUnlockConditionMet(id: string, defeatedGuardians: number[]): boolean {
switch (id) {
case 'invoker':
return defeatedGuardians.includes(10);
case 'fabricator':
return false; // No specific gating condition implemented
default:
return false;
}
}
// ─── Attunement Card ─────────────────────────────────────────────────────────
interface AttunementCardProps {
def: AttunementDef;
state?: AttunementState;
canUnlock?: boolean;
onUnlock?: (id: string) => void;
}
function AttunementCard({ def, state, canUnlock, onUnlock }: AttunementCardProps) {
const unlocked = !!state;
const isStarting = def.unlocked === true;
const xpProgress = state ? getXpProgress(state) : 0;
const nextXp = state ? getXpForNextLevel(state.level) : 0;
// Style tokens derived from def.color
const color = def.color;
return (
<Card
className={`relative overflow-hidden ${
unlocked
? 'bg-gray-900/60'
: 'bg-gray-950/80'
}`}
style={{
borderLeft: `3px solid ${unlocked ? color : `${color}33`}`,
borderColor: unlocked ? `${color}88` : `${color}22`,
opacity: unlocked ? 1 : 0.55,
}}
>
{/* Starting badge (top-right ribbon) */}
{isStarting && unlocked && (
<div
className="absolute top-3 right-3 text-[10px] font-semibold px-2 py-0.5 rounded-full"
style={{ backgroundColor: `${color}22`, color }}
>
Starting
</div>
)}
{/* Locked overlay pattern */}
{!unlocked && (
<div className="absolute inset-0 pointer-events-none" style={{ background: `repeating-linear-gradient(45deg, transparent, transparent 12px, ${color}08 12px, ${color}08 24px)` }} />
)}
<CardContent className="p-4 space-y-3 relative">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<span
className="text-xl p-1 rounded"
style={{ backgroundColor: `${color}18` }}
>
{def.icon}
</span>
<div>
<h3
className="font-semibold"
style={{ color: unlocked ? color : `${color}99` }}
>
{def.name}
</h3>
<p className="text-xs text-gray-500">
{ATTUNEMENT_SLOT_NAMES[def.slot] ?? def.slot}
</p>
</div>
</div>
{unlocked ? (
<Badge
className="text-xs font-bold"
style={{ backgroundColor: `${color}25`, color, border: `1px solid ${color}44` }}
>
Lv.{state.level}
</Badge>
) : (
<Badge
variant="outline"
className="text-xs"
style={{ borderColor: `${color}44`, color: `${color}88` }}
>
🔒 Locked
</Badge>
)}
</div>
{/* Description */}
<p className={`text-xs leading-relaxed ${unlocked ? 'text-gray-400' : 'text-gray-600'}`}>{def.desc}</p>
{/* XP Progress (unlocked only) */}
{unlocked && state && (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500">XP Progress</span>
<span className="text-gray-400 font-mono">
{fmt(state.experience)} / {fmt(nextXp)}
</span>
</div>
<div className="h-2 rounded-full overflow-hidden bg-gray-800">
<div
className="h-full rounded-full transition-all"
style={{ width: `${xpProgress}%`, backgroundColor: color }}
/>
</div>
{state.level >= MAX_ATTUNEMENT_LEVEL && (
<p className="text-xs text-amber-400 italic">Maximum level reached</p>
)}
</div>
)}
{/* Unlock condition (locked only) */}
{!unlocked && def.unlockCondition && (
<div
className="text-xs italic pt-2"
style={{ color: `${color}77`, borderTop: `1px solid ${color}15` }}
>
🔒 {def.unlockCondition}
</div>
)}
{/* Unlock button (locked + condition met) */}
{!unlocked && canUnlock && onUnlock && (
<div className="pt-2" style={{ borderTop: `1px solid ${color}15` }}>
<Button
size="sm"
variant="outline"
className="w-full text-xs"
style={{ borderColor: `${color}66`, color }}
onClick={() => onUnlock(def.id)}
>
<Unlock className="w-3 h-3 mr-1" /> Unlock {def.name}
</Button>
</div>
)}
{/* Details grid */}
<div
className="grid grid-cols-2 gap-2 text-xs pt-3"
style={{ borderTop: `1px solid ${unlocked ? `${color}22` : `${color}10`}` }}
>
<div>
<span className="text-gray-500">Mana Type</span>
<p className="text-gray-300 capitalize">
{def.primaryManaType ?? 'None (pact-based)'}
</p>
</div>
<div>
<span className="text-gray-500">Raw Regen</span>
<p className="text-gray-300">+{def.rawManaRegen}/hr</p>
</div>
{def.conversionRate > 0 && (
<div>
<span className="text-gray-500">Conversion</span>
<p className="text-gray-300">{def.conversionRate}/hr</p>
</div>
)}
<div>
<span className="text-gray-500">Status</span>
<p style={{ color: state?.active ? '#4ade80' : unlocked ? `${color}aa` : '#6b7280' }}>
{state?.active ? '● Active' : unlocked ? '○ Inactive' : 'Locked'}
</p>
</div>
{/* Invoker special: pact-based note */}
{def.primaryManaType === undefined && (
<div className="col-span-2">
<span className="text-gray-500">Special</span>
<p style={{ color: `${color}cc` }}>
Gains elemental mana from each guardian pact signed
</p>
</div>
)}
</div>
{/* Capabilities */}
<div style={{ borderTop: `1px solid ${unlocked ? `${color}22` : `${color}10`}` }} className="pt-3">
<span className="text-xs text-gray-500 block mb-1.5">Capabilities</span>
<div className="flex flex-wrap gap-1">
{def.capabilities.map((cap) => (
<Badge
key={cap}
variant="outline"
className="text-[10px]"
style={{
borderColor: `${color}44`,
color: unlocked ? `${color}cc` : `${color}66`,
backgroundColor: `${color}0a`,
}}
>
{cap}
</Badge>
))}
</div>
</div>
{/* Skill Categories */}
<div>
<span className="text-xs text-gray-500 block mb-1.5">Skill Categories</span>
<div className="flex flex-wrap gap-1">
{def.skillCategories.map((cat) => (
<Badge
key={cat}
variant="outline"
className="text-[10px]"
style={{
borderColor: `${color}33`,
color: unlocked ? `${color}aa` : `${color}55`,
backgroundColor: `${color}08`,
}}
>
{cat}
</Badge>
))}
</div>
</div>
</CardContent>
</Card>
);
}
// ─── Main Component ──────────────────────────────────────────────────────────
export function AttunementsTab() {
const attunements = useAttunementStore((s) => s.attunements);
const unlockAttunement = useAttunementStore((s) => s.unlockAttunement);
const defeatedGuardians = usePrestigeStore((s) => s.defeatedGuardians);
const allDefs = Object.values(ATTUNEMENTS_DEF);
const unlockedCount = allDefs.filter((d) => isAttunementUnlocked(d.id, attunements)).length;
const handleUnlock = (id: string) => {
const prestigeState = usePrestigeStore.getState();
const success = unlockAttunement(id, prestigeState.defeatedGuardians);
if (!success) {
console.warn(`Failed to unlock attunement: ${id}`);
}
};
return (
<DebugName name="AttunementsTab">
<div className="space-y-4">
{/* Summary header */}
<Card className="bg-gray-900/60 border-gray-700">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-100">Attunements</h2>
<p className="text-sm text-gray-400">
Class-like abilities tied to body locations
</p>
</div>
<div className="text-right">
<div className="text-2xl font-bold" style={{ color: '#1ABC9C' }}>
{unlockedCount}
<span className="text-sm text-gray-500 font-normal">
/{allDefs.length}
</span>
</div>
<p className="text-xs text-gray-500">Unlocked</p>
</div>
</div>
</CardContent>
</Card>
{/* Attunement cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{allDefs.map((def) => (
<AttunementCard
key={def.id}
def={def}
state={attunements[def.id]}
canUnlock={isUnlockConditionMet(def.id, defeatedGuardians)}
onUnlock={handleUnlock}
/>
))}
</div>
</div>
</DebugName>
);
}
AttunementsTab.displayName = 'AttunementsTab';