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

- 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:
2026-06-09 11:47:35 +02:00
parent 3ad919a047
commit 93ffa0768b
16 changed files with 98 additions and 543 deletions
+1 -1
View File
@@ -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 -1
View File
@@ -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."
},
-7
View File
@@ -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
+1 -13
View File
@@ -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>
-111
View File
@@ -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>
);
}
-118
View File
@@ -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";
-2
View File
@@ -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';
-39
View File
@@ -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>
);
}
+48 -2
View File
@@ -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>
+2 -2
View File
@@ -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 },
-7
View File
@@ -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> = {};
+34 -2
View File
@@ -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: {
+11 -1
View File
@@ -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!');
}