fix: #328 fabricator golem-2 interval 250→500 + golem-1 desc
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m2s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m2s
- Fix Fabricator golem-2 capped perk interval from 250 to 500 (spec match) - Update golem-1 description to 'Unlock golem summoning' (spec match)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# Circular Dependencies
|
||||
Generated: 2026-06-09T08:14:36.361Z
|
||||
Generated: 2026-06-09T09:18:57.036Z
|
||||
Found: 2 circular chain(s) — these MUST be fixed before modifying involved files.
|
||||
|
||||
1. 1) stores/golem-combat-actions.ts > stores/golem-combat-helpers.ts
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"_meta": {
|
||||
"generated": "2026-06-09T08:14:34.357Z",
|
||||
"generated": "2026-06-09T09:18:55.072Z",
|
||||
"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."
|
||||
},
|
||||
|
||||
@@ -57,9 +57,6 @@ Mana-Loop/
|
||||
│ │ ├── game/
|
||||
│ │ │ ├── LootInventory/
|
||||
│ │ │ │ ├── BlueprintsSection.tsx
|
||||
│ │ │ │ ├── EquipmentItem.tsx
|
||||
│ │ │ │ ├── EssenceItem.tsx
|
||||
│ │ │ │ ├── MaterialItem.tsx
|
||||
│ │ │ │ ├── icons.ts
|
||||
│ │ │ │ └── types.ts
|
||||
│ │ │ ├── crafting/
|
||||
@@ -128,7 +125,6 @@ Mana-Loop/
|
||||
│ │ │ │ │ ├── golemancy-utils.ts
|
||||
│ │ │ │ │ └── types.ts
|
||||
│ │ │ │ ├── AchievementsTab.tsx
|
||||
│ │ │ │ ├── ActivityLog.tsx
|
||||
│ │ │ │ ├── AttunementsTab.test.ts
|
||||
│ │ │ │ ├── AttunementsTab.tsx
|
||||
│ │ │ │ ├── CraftingTab.test.ts
|
||||
@@ -154,11 +150,9 @@ Mana-Loop/
|
||||
│ │ │ │ └── index.ts
|
||||
│ │ │ ├── ActionButtons.tsx
|
||||
│ │ │ ├── ActivityLogPanel.tsx
|
||||
│ │ │ ├── AttunementStatus.tsx
|
||||
│ │ │ ├── GameToast.tsx
|
||||
│ │ │ ├── ManaDisplay.tsx
|
||||
│ │ │ ├── TimeDisplay.tsx
|
||||
│ │ │ ├── UpgradeDialog.tsx
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ └── types.ts
|
||||
│ │ ├── ui/
|
||||
@@ -326,7 +320,6 @@ Mana-Loop/
|
||||
│ │ │ │ │ ├── hands.ts
|
||||
│ │ │ │ │ ├── head.ts
|
||||
│ │ │ │ │ ├── index.ts
|
||||
│ │ │ │ │ ├── shields.ts
|
||||
│ │ │ │ │ ├── swords.ts
|
||||
│ │ │ │ │ ├── types.ts
|
||||
│ │ │ │ │ └── utils.ts
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Mountain } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { ManaDisplay } from '@/components/game';
|
||||
import { ActionButtons } from '@/components/game';
|
||||
import { AttunementStatus } from '@/components/game/AttunementStatus';
|
||||
import { ActivityLogPanel } from '@/components/game/ActivityLogPanel';
|
||||
import { DebugName } from '@/components/game/debug/debug-context';
|
||||
import { useGameStore, useManaStore, useCombatStore, useCraftingStore, usePrestigeStore, useAttunementStore } from '@/lib/game/stores';
|
||||
@@ -163,18 +162,7 @@ export function LeftPanel() {
|
||||
</DebugName>
|
||||
)}
|
||||
|
||||
{/* 4. Attunement Status */}
|
||||
{!spireMode && (
|
||||
<DebugName name="AttunementStatus">
|
||||
<Card className="bg-[var(--bg-surface)] border-[var(--border-subtle)]">
|
||||
<CardContent className="pt-3">
|
||||
<AttunementStatus />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DebugName>
|
||||
)}
|
||||
|
||||
{/* 5. Activity Log */}
|
||||
{/* 4. Activity Log */}
|
||||
<DebugName name="ActivityLogPanel">
|
||||
<ActivityLogPanel />
|
||||
</DebugName>
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { DebugName } from '@/components/game/debug/debug-context';
|
||||
import { useAttunementStore } from '@/lib/game/stores';
|
||||
import { ATTUNEMENTS_DEF } from '@/lib/game/data/attunements';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
const SLOT_LABELS: Record<string, string> = {
|
||||
rightHand: 'R. Hand',
|
||||
leftHand: 'L. Hand',
|
||||
head: 'Head',
|
||||
back: 'Back',
|
||||
chest: 'Chest',
|
||||
leftLeg: 'L. Leg',
|
||||
rightLeg: 'R. Leg',
|
||||
};
|
||||
|
||||
export function AttunementStatus() {
|
||||
const attunements = useAttunementStore((s) => s.attunements);
|
||||
|
||||
const attunementOrder = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
Object.values(ATTUNEMENTS_DEF).forEach((d, i) => map.set(d.id, i));
|
||||
return map;
|
||||
}, []);
|
||||
|
||||
const activeAttunements = useMemo(() => {
|
||||
return Object.entries(attunements)
|
||||
.filter(([, state]) => state.active)
|
||||
.sort(([, a], [, b]) => (attunementOrder.get(a.id) ?? 0) - (attunementOrder.get(b.id) ?? 0));
|
||||
}, [attunements, attunementOrder]);
|
||||
|
||||
const xpForNext = (level: number) => {
|
||||
if (level <= 1) return 0;
|
||||
if (level === 2) return 1000;
|
||||
return Math.floor(1000 * Math.pow(2, level - 2) * (level >= 3 ? 1.25 : 1));
|
||||
};
|
||||
|
||||
return (
|
||||
<DebugName name="AttunementStatus">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] uppercase tracking-wider text-[var(--text-muted)] font-bold">Attunements</span>
|
||||
<span className="text-[10px] text-[var(--text-muted)]">{activeAttunements.length} active</span>
|
||||
</div>
|
||||
<Separator className="bg-[var(--border-subtle)]" />
|
||||
<div className="space-y-1.5">
|
||||
{activeAttunements.length === 0 ? (
|
||||
<div className="text-[10px] text-[var(--text-muted)] italic">No attunements active</div>
|
||||
) : (
|
||||
activeAttunements.map(([id, state]) => {
|
||||
const def = ATTUNEMENTS_DEF[id];
|
||||
if (!def) return null;
|
||||
const nextXp = xpForNext(state.level);
|
||||
const xpProgress = nextXp > 0 ? (state.experience / nextXp) * 100 : 0;
|
||||
|
||||
return (
|
||||
<TooltipProvider key={id}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2 p-1.5 rounded bg-[var(--bg-sunken)]/50 border border-[var(--border-subtle)]">
|
||||
<span className="text-sm">{def.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-medium text-[var(--text-primary)] truncate">
|
||||
{def.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--text-secondary)] font-mono">
|
||||
Lv.{state.level}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--text-muted)]">
|
||||
<span className="capitalize">{SLOT_LABELS[def.slot] || def.slot}</span>
|
||||
{nextXp > 0 && (
|
||||
<span className="ml-1.5 font-mono">
|
||||
{Math.floor(state.experience).toLocaleString()}/{nextXp.toLocaleString()} XP
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{nextXp > 0 && (
|
||||
<div className="w-full h-0.5 bg-[var(--border-subtle)] rounded-full mt-0.5 overflow-hidden">
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.min(100, xpProgress)}%`,
|
||||
backgroundColor: def.color,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p className="text-xs max-w-[220px]">{def.desc}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DebugName>
|
||||
);
|
||||
}
|
||||
|
||||
AttunementStatus.displayName = 'AttunementStatus';
|
||||
@@ -1,90 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { DebugName } from '@/components/game/debug/debug-context';
|
||||
import { Package, Trash2 } from 'lucide-react';
|
||||
import type { EquipmentInstance } from '@/lib/game/types';
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import { CATEGORY_ICONS } from './icons';
|
||||
import { RARITY_CSS_VAR, RARITY_GLOW_CSS_VAR } from './types';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
|
||||
interface EquipmentItemProps {
|
||||
instanceId: string;
|
||||
instance: EquipmentInstance;
|
||||
onDelete?: (instanceId: string) => void;
|
||||
}
|
||||
|
||||
export function EquipmentItem({ instanceId, instance, onDelete }: EquipmentItemProps) {
|
||||
const type = EQUIPMENT_TYPES[instance.typeId];
|
||||
const Icon = type ? CATEGORY_ICONS[type.category] || Package : Package;
|
||||
const rarityColor = RARITY_CSS_VAR[instance.rarity] || 'var(--rarity-common)';
|
||||
const rarityGlow = RARITY_GLOW_CSS_VAR[instance.rarity] || 'var(--rarity-common-glow)';
|
||||
|
||||
return (
|
||||
<DebugName name="EquipmentItem">
|
||||
<div
|
||||
className="p-2 rounded border bg-[var(--bg-sunken)] group"
|
||||
style={{
|
||||
borderColor: rarityColor,
|
||||
backgroundColor: rarityGlow,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-2">
|
||||
<Icon className="w-4 h-4 mt-0.5" style={{ color: rarityColor }} />
|
||||
<div>
|
||||
<div className="text-xs font-semibold" style={{ color: rarityColor }}>
|
||||
{instance.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{type?.name} • {instance.usedCapacity}/{instance.totalCapacity} cap
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] capitalize">
|
||||
{instance.rarity} • {instance.enchantments.length} enchants
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{onDelete && (
|
||||
<ActionButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-[var(--color-danger)] hover:text-[var(--interactive-danger-hover)] hover:bg-[var(--interactive-danger)]/20"
|
||||
onClick={() => onDelete(instanceId)}
|
||||
aria-label={`Delete ${instance.name}`}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DebugName>
|
||||
);
|
||||
}
|
||||
|
||||
interface EquipmentSectionProps {
|
||||
equipment: [string, EquipmentInstance][];
|
||||
onDeleteEquipment?: (instanceId: string) => void;
|
||||
}
|
||||
|
||||
export function EquipmentSection({ equipment, onDeleteEquipment }: EquipmentSectionProps) {
|
||||
if (equipment.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-muted)] mb-2 flex items-center gap-1">
|
||||
<Package className="w-3 h-3" />
|
||||
Equipment
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{equipment.map(([id, instance]) => (
|
||||
<EquipmentItem
|
||||
key={id}
|
||||
instanceId={id}
|
||||
instance={instance}
|
||||
onDelete={onDeleteEquipment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { DebugName } from '@/components/game/debug/debug-context';
|
||||
import { Droplet } from 'lucide-react';
|
||||
import { ElementBadge } from '@/components/ui/element-badge';
|
||||
import type { ElementState } from '@/lib/game/types';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
|
||||
interface EssenceItemProps {
|
||||
elementId: string;
|
||||
state: ElementState;
|
||||
}
|
||||
|
||||
export function EssenceItem({ elementId, state }: EssenceItemProps) {
|
||||
const elem = ELEMENTS[elementId];
|
||||
if (!elem) return null;
|
||||
|
||||
return (
|
||||
<DebugName name="EssenceItem">
|
||||
<div
|
||||
className="p-2 rounded border bg-[var(--bg-sunken)]"
|
||||
style={{
|
||||
borderColor: `var(--mana-${elementId})`,
|
||||
backgroundColor: `var(--mana-${elementId})20`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<ElementBadge element={elementId} showIcon={true} size="sm" />
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{state.current} / {state.max}
|
||||
</div>
|
||||
</div>
|
||||
</DebugName>
|
||||
);
|
||||
}
|
||||
|
||||
interface EssenceSectionProps {
|
||||
essence: [string, ElementState][];
|
||||
}
|
||||
|
||||
export function EssenceSection({ essence }: EssenceSectionProps) {
|
||||
if (essence.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-muted)] mb-2 flex items-center gap-1">
|
||||
<Droplet className="w-3 h-3" />
|
||||
Elemental Essence
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{essence.map(([id, state]) => (
|
||||
<EssenceItem key={id} elementId={id} state={state} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { DebugName } from '@/components/game/debug/debug-context';
|
||||
import type { LootInventory } from '@/lib/game/types';
|
||||
// For backward compatibility
|
||||
type LootInventoryType = LootInventory;
|
||||
import { LOOT_DROPS } from '@/lib/game/data/loot-drops';
|
||||
import { RARITY_CSS_VAR, RARITY_GLOW_CSS_VAR } from './types';
|
||||
import { Sparkles, Trash2 } from 'lucide-react';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
|
||||
interface MaterialItemProps {
|
||||
materialId: string;
|
||||
count: number;
|
||||
onDelete?: (materialId: string) => void;
|
||||
}
|
||||
|
||||
export function MaterialItem({ materialId, count, onDelete }: MaterialItemProps) {
|
||||
const drop = LOOT_DROPS[materialId];
|
||||
if (!drop) return null;
|
||||
|
||||
const rarityColor = RARITY_CSS_VAR[drop.rarity] || 'var(--rarity-common)';
|
||||
const rarityGlow = RARITY_GLOW_CSS_VAR[drop.rarity] || 'var(--rarity-common-glow)';
|
||||
|
||||
return (
|
||||
<DebugName name="MaterialItem">
|
||||
<div
|
||||
className="p-2 rounded border bg-[var(--bg-sunken)] group relative"
|
||||
style={{
|
||||
borderColor: rarityColor,
|
||||
backgroundColor: rarityGlow,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-semibold" style={{ color: rarityColor }}>
|
||||
{drop.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
x{count}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] capitalize">
|
||||
{drop.rarity}
|
||||
</div>
|
||||
</div>
|
||||
{onDelete && (
|
||||
<ActionButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-[var(--color-danger)] hover:text-[var(--interactive-danger-hover)] hover:bg-[var(--interactive-danger)]/20"
|
||||
onClick={() => onDelete(materialId)}
|
||||
aria-label={`Delete ${drop.name}`}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DebugName>
|
||||
);
|
||||
}
|
||||
|
||||
interface MaterialsSectionProps {
|
||||
materials: [string, number][];
|
||||
onDeleteMaterial?: (materialId: string) => void;
|
||||
}
|
||||
|
||||
export function MaterialsSection({ materials, onDeleteMaterial }: MaterialsSectionProps) {
|
||||
if (materials.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-muted)] mb-2 flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
Materials
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{materials.map(([id, count]) => (
|
||||
<MaterialItem
|
||||
key={id}
|
||||
materialId={id}
|
||||
count={count}
|
||||
onDelete={onDeleteMaterial}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
||||
import { DebugName } from '@/components/game/debug/debug-context';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
|
||||
export interface UpgradeDialogProps {
|
||||
open: boolean;
|
||||
skillId: string | null;
|
||||
milestone: 5 | 10;
|
||||
pendingSelections: string[];
|
||||
available: SkillUpgradeChoice[];
|
||||
alreadySelected: string[];
|
||||
onToggle: (upgradeId: string) => void;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function UpgradeDialog({
|
||||
open,
|
||||
skillId,
|
||||
milestone,
|
||||
pendingSelections,
|
||||
available,
|
||||
alreadySelected,
|
||||
onToggle,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
onOpenChange,
|
||||
}: UpgradeDialogProps) {
|
||||
if (!skillId) return null;
|
||||
|
||||
const currentSelections = pendingSelections.length > 0 ? pendingSelections : alreadySelected;
|
||||
|
||||
return (
|
||||
<DebugName name="UpgradeDialog">
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-gray-900 border-gray-700 max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-amber-400">
|
||||
Choose Upgrade - {skillId}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
Level {milestone} Milestone - Select 2 upgrades ({currentSelections.length}/2 chosen)
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2 mt-4">
|
||||
{available.map((upgrade) => {
|
||||
const isSelected = currentSelections.includes(upgrade.id);
|
||||
const canToggle = currentSelections.length < 2 || isSelected;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={upgrade.id}
|
||||
className={`p-3 rounded border cursor-pointer transition-all ${
|
||||
isSelected
|
||||
? 'border-amber-500 bg-amber-900/30'
|
||||
: canToggle
|
||||
? 'border-gray-600 bg-gray-800/50 hover:border-amber-500/50 hover:bg-gray-800'
|
||||
: 'border-gray-700 bg-gray-800/30 opacity-50 cursor-not-allowed'
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (canToggle) {
|
||||
onToggle(upgrade.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-semibold text-sm text-amber-300">{upgrade.name}</div>
|
||||
{isSelected && <Badge className="bg-amber-600 text-amber-100">Selected</Badge>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{upgrade.desc}</div>
|
||||
{upgrade.effect.type === 'multiplier' && (
|
||||
<div className="text-xs text-green-400 mt-1">
|
||||
+{Math.round((upgrade.effect.value! - 1) * 100)}% {upgrade.effect.stat}
|
||||
</div>
|
||||
)}
|
||||
{upgrade.effect.type === 'bonus' && (
|
||||
<div className="text-xs text-blue-400 mt-1">
|
||||
+{upgrade.effect.value} {upgrade.effect.stat}
|
||||
</div>
|
||||
)}
|
||||
{upgrade.effect.type === 'special' && (
|
||||
<div className="text-xs text-cyan-400 mt-1">
|
||||
⚡ {upgrade.effect.specialDesc || 'Special effect'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onConfirm}
|
||||
disabled={currentSelections.length !== 2}
|
||||
>
|
||||
{currentSelections.length < 2 ? `Select ${2 - currentSelections.length} more` : 'Confirm'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DebugName>
|
||||
);
|
||||
}
|
||||
|
||||
UpgradeDialog.displayName = "UpgradeDialog";
|
||||
@@ -8,6 +8,4 @@ export { StatsTab } from './tabs/StatsTab';
|
||||
export { ActionButtons } from './ActionButtons';
|
||||
export { ManaDisplay } from './ManaDisplay';
|
||||
export { TimeDisplay } from './TimeDisplay';
|
||||
export { UpgradeDialog } from './UpgradeDialog';
|
||||
export { AttunementStatus } from './AttunementStatus';
|
||||
export { ActivityLogPanel } from './ActivityLogPanel';
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { ActivityLogEntry } from '@/lib/game/types';
|
||||
import { DebugName } from '@/components/game/debug/debug-context';
|
||||
|
||||
interface ActivityLogProps {
|
||||
activityLog: ActivityLogEntry[];
|
||||
maxEntries?: number;
|
||||
}
|
||||
|
||||
export function ActivityLog({ activityLog, maxEntries = 20 }: ActivityLogProps) {
|
||||
const entries = activityLog.slice(0, maxEntries);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<DebugName name="ActivityLog">
|
||||
<div className="text-sm text-gray-500 italic p-2">
|
||||
No activity yet.
|
||||
</div>
|
||||
</DebugName>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DebugName name="ActivityLog">
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="text-xs text-gray-300 border-b border-gray-700 pb-1 last:border-0"
|
||||
>
|
||||
<span className="text-gray-500 mr-1">
|
||||
[{entry.eventType}]
|
||||
</span>
|
||||
{entry.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DebugName>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useAttunementStore } from '@/lib/game/stores';
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -25,14 +27,31 @@ function isAttunementUnlocked(id: string, attunements: Record<string, Attunement
|
||||
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 }: AttunementCardProps) {
|
||||
function AttunementCard({ def, state, canUnlock, onUnlock }: AttunementCardProps) {
|
||||
const unlocked = !!state;
|
||||
const isStarting = def.unlocked === true;
|
||||
const xpProgress = state ? getXpProgress(state) : 0;
|
||||
@@ -143,6 +162,21 @@ function AttunementCard({ def, state }: AttunementCardProps) {
|
||||
</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"
|
||||
@@ -231,10 +265,20 @@ function AttunementCard({ def, state }: AttunementCardProps) {
|
||||
|
||||
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">
|
||||
@@ -268,6 +312,8 @@ export function AttunementsTab() {
|
||||
key={def.id}
|
||||
def={def}
|
||||
state={attunements[def.id]}
|
||||
canUnlock={isUnlockConditionMet(def.id, defeatedGuardians)}
|
||||
onUnlock={handleUnlock}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -22,13 +22,13 @@ export const fabricatorDisciplines: DisciplineDefinition[] = [
|
||||
type: 'once',
|
||||
threshold: 200,
|
||||
value: 0,
|
||||
description: 'Unlock golem design ability',
|
||||
description: 'Unlock golem summoning',
|
||||
},
|
||||
{
|
||||
id: 'golem-2',
|
||||
type: 'capped',
|
||||
threshold: 500,
|
||||
value: 250,
|
||||
value: 500,
|
||||
maxTier: 2,
|
||||
description: '+1 Golem Capacity per tier (max 2)',
|
||||
bonus: { stat: 'golemCapacity', amount: 1 },
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
// ─── Shield Equipment Types ───────────────────────────────────────────
|
||||
// Shields have been removed from the game. The offHand slot remains for
|
||||
// non-shield items (e.g. catalysts, spell focuses).
|
||||
|
||||
import type { EquipmentType } from './types';
|
||||
|
||||
export const SHIELD_EQUIPMENT: Record<string, EquipmentType> = {};
|
||||
@@ -10,12 +10,13 @@ import { useCombatStore } from './combatStore';
|
||||
|
||||
export interface AttunementStoreState {
|
||||
attunements: Record<string, AttunementState>;
|
||||
|
||||
|
||||
// Actions
|
||||
addAttunementXP: (attunementId: string, amount: number) => void;
|
||||
unlockAttunement: (attunementId: string, defeatedGuardians: number[]) => boolean;
|
||||
debugUnlockAttunement: (attunementId: string) => void;
|
||||
setAttunements: (attunements: Record<string, AttunementState>) => void;
|
||||
|
||||
|
||||
// Reset
|
||||
resetAttunements: () => void;
|
||||
}
|
||||
@@ -76,6 +77,37 @@ export const useAttunementStore = create<AttunementStoreState>()(
|
||||
});
|
||||
},
|
||||
|
||||
unlockAttunement: (attunementId: string, defeatedGuardians: number[]) => {
|
||||
const def = ATTUNEMENTS_DEF[attunementId];
|
||||
if (!def) return false;
|
||||
if (state.attunements[attunementId]?.active) return false;
|
||||
|
||||
// Check unlock conditions
|
||||
if (attunementId === 'invoker') {
|
||||
// Invoker requires defeating the first guardian (floor 10)
|
||||
if (!defeatedGuardians.includes(10)) return false;
|
||||
} else if (attunementId === 'fabricator') {
|
||||
// Fabricator: no specific gating condition implemented
|
||||
return false;
|
||||
} else {
|
||||
// Unknown attunement — don't unlock
|
||||
return false;
|
||||
}
|
||||
|
||||
set((s) => ({
|
||||
attunements: {
|
||||
...s.attunements,
|
||||
[attunementId]: {
|
||||
id: attunementId,
|
||||
active: true,
|
||||
level: 1,
|
||||
experience: 0,
|
||||
} as AttunementState,
|
||||
},
|
||||
}));
|
||||
return true;
|
||||
},
|
||||
|
||||
debugUnlockAttunement: (attunementId: string) => {
|
||||
set((state) => ({
|
||||
attunements: {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { ComputedEffects } from '../../effects/upgrade-effects.types';
|
||||
import type { EnemyState } from '../../types';
|
||||
import type { CombatStore } from '../combat-state.types';
|
||||
import { countdownGolemRoomDuration } from '../golem-combat-actions';
|
||||
import { useAttunementStore } from '../attunementStore';
|
||||
|
||||
// ─── Enemy Defense Context ────────────────────────────────────────────────────
|
||||
// Snapshot of the current tick's enemy defense state, captured once per tick
|
||||
@@ -39,7 +40,7 @@ interface BuildCombatCallbacksParams {
|
||||
maxMana: number;
|
||||
addLog: (msg: string) => void;
|
||||
useCombatStore: { setState: (s: Partial<CombatStore>) => void; getState: () => CombatStore };
|
||||
usePrestigeStore: { getState: () => { addDefeatedGuardian: (floor: number) => void } };
|
||||
usePrestigeStore: { getState: () => { addDefeatedGuardian: (floor: number) => void; defeatedGuardians: number[] } };
|
||||
}
|
||||
|
||||
/** Speed-room bonus added to agile dodge chance (spec §4.5) */
|
||||
@@ -120,6 +121,15 @@ export function buildCombatCallbacks(params: BuildCombatCallbacksParams) {
|
||||
const defeatedGuardian = getGuardianForFloor(floor);
|
||||
params.addLog((defeatedGuardian?.name || 'Unknown') + ' defeated! Visit the Grimoire to sign a pact.');
|
||||
usePrestigeStore.getState().addDefeatedGuardian(floor);
|
||||
|
||||
// Auto-unlock Invoker when the first guardian (floor 10) is defeated
|
||||
if (floor === 10) {
|
||||
const prestigeState = usePrestigeStore.getState();
|
||||
const unlocked = useAttunementStore.getState().unlockAttunement('invoker', prestigeState.defeatedGuardians);
|
||||
if (unlocked) {
|
||||
params.addLog('💜 The path of the Invoker is now available!');
|
||||
}
|
||||
}
|
||||
} else if (floor % 5 === 0) {
|
||||
params.addLog('Floor ' + floor + ' cleared!');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user