feat(golemancy): Phase 1 - Component-based construction system data definitions
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s
- Add new golem component types (Core, Frame, MindCircuit, Enchantment) - Create 4 Core tiers, 7 Frames, 4 Mind Circuits, 8 Enchantments - Rewrite golem utils for component-based stat computation - Update GolemancyState with new fields (golemDesigns, golemLoadout, activeGolems) - Update combat store, actions, and pipelines for new golem system - Rewrite GolemancyTab with component selection UI - Update fabricator discipline perks for new system - Add comprehensive tests for component registries and utilities - All files under 400 lines, all 743 tests passing
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
# Circular Dependencies
|
# Circular Dependencies
|
||||||
Generated: 2026-06-05T12:07:36.291Z
|
Generated: 2026-06-05T13:36:31.575Z
|
||||||
|
|
||||||
No circular dependencies found. ✅
|
No circular dependencies found. ✅
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"generated": "2026-06-05T12:07:34.394Z",
|
"generated": "2026-06-05T13:36:29.562Z",
|
||||||
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
|
"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."
|
"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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -118,6 +118,16 @@ Mana-Loop/
|
|||||||
│ │ │ │ │ ├── ManaStatsSection.tsx
|
│ │ │ │ │ ├── ManaStatsSection.tsx
|
||||||
│ │ │ │ │ ├── PactStatusSection.tsx
|
│ │ │ │ │ ├── PactStatusSection.tsx
|
||||||
│ │ │ │ │ └── StudyStatsSection.tsx
|
│ │ │ │ │ └── StudyStatsSection.tsx
|
||||||
|
│ │ │ │ ├── golemancy/
|
||||||
|
│ │ │ │ │ ├── ActiveGolemsPanel.tsx
|
||||||
|
│ │ │ │ │ ├── GolemDesignBuilder.tsx
|
||||||
|
│ │ │ │ │ ├── GolemLoadoutPanel.tsx
|
||||||
|
│ │ │ │ │ ├── GolemancyComponents.test.ts
|
||||||
|
│ │ │ │ │ ├── GolemancySharedComponents.tsx
|
||||||
|
│ │ │ │ │ ├── golemancy-components.test.ts
|
||||||
|
│ │ │ │ │ ├── golemancy-utils.test.ts
|
||||||
|
│ │ │ │ │ ├── golemancy-utils.ts
|
||||||
|
│ │ │ │ │ └── types.ts
|
||||||
│ │ │ │ ├── AchievementsTab.tsx
|
│ │ │ │ ├── AchievementsTab.tsx
|
||||||
│ │ │ │ ├── ActivityLog.tsx
|
│ │ │ │ ├── ActivityLog.tsx
|
||||||
│ │ │ │ ├── AttunementsTab.test.ts
|
│ │ │ │ ├── AttunementsTab.test.ts
|
||||||
@@ -131,7 +141,6 @@ Mana-Loop/
|
|||||||
│ │ │ │ ├── ElementalSubtab.tsx
|
│ │ │ │ ├── ElementalSubtab.tsx
|
||||||
│ │ │ │ ├── EquipmentTab.test.ts
|
│ │ │ │ ├── EquipmentTab.test.ts
|
||||||
│ │ │ │ ├── EquipmentTab.tsx
|
│ │ │ │ ├── EquipmentTab.tsx
|
||||||
│ │ │ │ ├── GolemancyTab.test.ts
|
|
||||||
│ │ │ │ ├── GolemancyTab.tsx
|
│ │ │ │ ├── GolemancyTab.tsx
|
||||||
│ │ │ │ ├── GuardianPactsTab.test.ts
|
│ │ │ │ ├── GuardianPactsTab.test.ts
|
||||||
│ │ │ │ ├── GuardianPactsTab.tsx
|
│ │ │ │ ├── GuardianPactsTab.tsx
|
||||||
@@ -319,10 +328,14 @@ Mana-Loop/
|
|||||||
│ │ │ │ │ └── utils.ts
|
│ │ │ │ │ └── utils.ts
|
||||||
│ │ │ │ ├── golems/
|
│ │ │ │ ├── golems/
|
||||||
│ │ │ │ │ ├── base-golems.ts
|
│ │ │ │ │ ├── base-golems.ts
|
||||||
|
│ │ │ │ │ ├── cores.ts
|
||||||
│ │ │ │ │ ├── elemental-golems.ts
|
│ │ │ │ │ ├── elemental-golems.ts
|
||||||
|
│ │ │ │ │ ├── frames.ts
|
||||||
|
│ │ │ │ │ ├── golemEnchantments.ts
|
||||||
│ │ │ │ │ ├── golems-data.ts
|
│ │ │ │ │ ├── golems-data.ts
|
||||||
│ │ │ │ │ ├── hybrid-golems.ts
|
│ │ │ │ │ ├── hybrid-golems.ts
|
||||||
│ │ │ │ │ ├── index.ts
|
│ │ │ │ │ ├── index.ts
|
||||||
|
│ │ │ │ │ ├── mindCircuits.ts
|
||||||
│ │ │ │ │ ├── types.ts
|
│ │ │ │ │ ├── types.ts
|
||||||
│ │ │ │ │ └── utils.ts
|
│ │ │ │ │ └── utils.ts
|
||||||
│ │ │ │ ├── achievements.ts
|
│ │ │ │ ├── achievements.ts
|
||||||
@@ -373,6 +386,7 @@ Mana-Loop/
|
|||||||
│ │ │ │ ├── gameStore.ts
|
│ │ │ │ ├── gameStore.ts
|
||||||
│ │ │ │ ├── gameStore.types.ts
|
│ │ │ │ ├── gameStore.types.ts
|
||||||
│ │ │ │ ├── golem-combat-actions.ts
|
│ │ │ │ ├── golem-combat-actions.ts
|
||||||
|
│ │ │ │ ├── golemancy-actions.ts
|
||||||
│ │ │ │ ├── index.ts
|
│ │ │ │ ├── index.ts
|
||||||
│ │ │ │ ├── manaStore.ts
|
│ │ │ │ ├── manaStore.ts
|
||||||
│ │ │ │ ├── non-combat-room-actions.ts
|
│ │ │ │ ├── non-combat-room-actions.ts
|
||||||
|
|||||||
@@ -2,30 +2,130 @@
|
|||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Bug, Wand2 } from 'lucide-react';
|
import { Bug, Cpu, Box, CircuitBoard, Sparkles } from 'lucide-react';
|
||||||
import { useCombatStore } from '@/lib/game/stores';
|
import { useCombatStore } from '@/lib/game/stores';
|
||||||
import { GOLEMS_DEF } from '@/lib/game/data/golems';
|
import {
|
||||||
|
ALL_CORES,
|
||||||
|
ALL_FRAMES,
|
||||||
|
ALL_MIND_CIRCUITS,
|
||||||
|
ALL_GOLEM_ENCHANTMENTS,
|
||||||
|
type CoreDefinition,
|
||||||
|
type FrameDefinition,
|
||||||
|
type MindCircuitDefinition,
|
||||||
|
type GolemEnchantmentDefinition,
|
||||||
|
} from '@/lib/game/data/golems';
|
||||||
import { DebugName } from '@/components/game/debug/debug-context';
|
import { DebugName } from '@/components/game/debug/debug-context';
|
||||||
|
|
||||||
|
function formatCost(cost: { type: string; element?: string; amount: number }): string {
|
||||||
|
if (cost.type === 'raw') return `${cost.amount} raw`;
|
||||||
|
return `${cost.amount} ${cost.element ?? 'unknown'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUnlock(req: {
|
||||||
|
type: string;
|
||||||
|
attunement?: string;
|
||||||
|
level?: number;
|
||||||
|
manaType?: string;
|
||||||
|
attunements?: string[];
|
||||||
|
levels?: number[];
|
||||||
|
}): string {
|
||||||
|
switch (req.type) {
|
||||||
|
case 'attunement_level':
|
||||||
|
return `${req.attunement} Lv${req.level}`;
|
||||||
|
case 'mana_unlocked':
|
||||||
|
return `Unlock ${req.manaType} mana`;
|
||||||
|
case 'dual_attunement':
|
||||||
|
return `${req.attunements?.[0]} Lv${req.levels?.[0]} + ${req.attunements?.[1]} Lv${req.levels?.[1]}`;
|
||||||
|
case 'guardian_pact':
|
||||||
|
return `Guardian: ${req.attunements?.[0]} Lv${req.levels?.[0]} + ${req.attunements?.[1]} Lv${req.levels?.[1]}`;
|
||||||
|
default:
|
||||||
|
return req.type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCosts(costs: { type: string; element?: string; amount: number }[]): string {
|
||||||
|
return costs.map(formatCost).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function CoreCard({ core }: { core: CoreDefinition }) {
|
||||||
|
return (
|
||||||
|
<div className="p-2 rounded border border-gray-700 bg-gray-800/40">
|
||||||
|
<div className="text-sm font-medium text-amber-300">{core.name}</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-0.5">{core.description}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1 space-y-0.5">
|
||||||
|
<div>Tier {core.tier} · Primary: {core.primaryManaType}</div>
|
||||||
|
<div>Capacity: {core.manaCapacity} · Regen: {core.manaRegen}/h · Duration: {core.maxRoomDuration} rooms</div>
|
||||||
|
<div>Types: {core.manaTypes.join(', ')}</div>
|
||||||
|
<div>Cost: {formatCosts(core.summonCost)}</div>
|
||||||
|
<div className="text-yellow-500/70">Unlock: {formatUnlock(core.unlockRequirement)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FrameCard({ frame }: { frame: FrameDefinition }) {
|
||||||
|
return (
|
||||||
|
<div className="p-2 rounded border border-gray-700 bg-gray-800/40">
|
||||||
|
<div className="text-sm font-medium text-sky-300">{frame.name}</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-0.5">{frame.description}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1 space-y-0.5">
|
||||||
|
<div>Element: {frame.element} · Special: {frame.specialEffect}</div>
|
||||||
|
<div>DMG: {frame.baseDamage} · SPD: {frame.attackSpeed} · AoE: {frame.aoeTargets}</div>
|
||||||
|
<div>ArmorPierce: {(frame.armorPierce * 100).toFixed(0)}% · MagicAffinity: {(frame.magicAffinity * 100).toFixed(0)}%</div>
|
||||||
|
<div>Cost: {formatCosts(frame.summonCost)}</div>
|
||||||
|
<div className="text-yellow-500/70">Unlock: {formatUnlock(frame.unlockRequirement)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MindCircuitCard({ circuit }: { circuit: MindCircuitDefinition }) {
|
||||||
|
return (
|
||||||
|
<div className="p-2 rounded border border-gray-700 bg-gray-800/40">
|
||||||
|
<div className="text-sm font-medium text-violet-300">{circuit.name}</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-0.5">{circuit.description}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1 space-y-0.5">
|
||||||
|
<div>Behavior: {circuit.behavior} · Spell Slots: {circuit.spellSlots}</div>
|
||||||
|
<div>Cost: {formatCosts(circuit.summonCost)}</div>
|
||||||
|
<div className="text-yellow-500/70">Unlock: {formatUnlock(circuit.unlockRequirement)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EnchantmentCard({ enchant }: { enchant: GolemEnchantmentDefinition }) {
|
||||||
|
return (
|
||||||
|
<div className="p-2 rounded border border-gray-700 bg-gray-800/40">
|
||||||
|
<div className="text-sm font-medium text-emerald-300">{enchant.name}</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-0.5">{enchant.description}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1 space-y-0.5">
|
||||||
|
<div>Effect: {enchant.effect} · Capacity Cost: {enchant.capacityCost}</div>
|
||||||
|
<div>Cost: {formatCosts(enchant.summonCost)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function GolemDebugSection() {
|
export function GolemDebugSection() {
|
||||||
const golemancy = useCombatStore((s) => s.golemancy);
|
|
||||||
const setEnabledGolems = useCombatStore((s) => s.setEnabledGolems);
|
const setEnabledGolems = useCombatStore((s) => s.setEnabledGolems);
|
||||||
|
const enabledGolems = useCombatStore((s) => s.golemancy?.enabledGolems) ?? [];
|
||||||
|
|
||||||
const enabledGolems = golemancy?.enabledGolems || [];
|
const allComponentIds = [
|
||||||
|
...ALL_CORES.map((c) => c.id),
|
||||||
|
...ALL_FRAMES.map((f) => f.id),
|
||||||
|
...ALL_MIND_CIRCUITS.map((m) => m.id),
|
||||||
|
...ALL_GOLEM_ENCHANTMENTS.map((e) => e.id),
|
||||||
|
];
|
||||||
|
|
||||||
const handleEnableAll = () => {
|
const handleEnableAll = () => setEnabledGolems(allComponentIds);
|
||||||
setEnabledGolems(Object.keys(GOLEMS_DEF));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDisableAll = () => {
|
const handleDisableAll = () => setEnabledGolems([]);
|
||||||
setEnabledGolems([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleGolem = (golemId: string) => {
|
const handleToggleComponent = (id: string) => {
|
||||||
if (enabledGolems.includes(golemId)) {
|
if (enabledGolems.includes(id)) {
|
||||||
setEnabledGolems(enabledGolems.filter(id => id !== golemId));
|
setEnabledGolems(enabledGolems.filter((x) => x !== id));
|
||||||
} else {
|
} else {
|
||||||
setEnabledGolems([...enabledGolems, golemId]);
|
setEnabledGolems([...enabledGolems, id]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,45 +135,108 @@ export function GolemDebugSection() {
|
|||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-orange-400 text-sm flex items-center gap-2">
|
<CardTitle className="text-orange-400 text-sm flex items-center gap-2">
|
||||||
<Bug className="w-4 h-4" />
|
<Bug className="w-4 h-4" />
|
||||||
Golem Debug
|
Golem Debug — Component System
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<Button size="sm" variant="outline" onClick={handleEnableAll}>
|
<Button size="sm" variant="outline" onClick={handleEnableAll}>
|
||||||
<Wand2 className="w-3 h-3 mr-1" /> Enable All Golems
|
Enable All Components
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" onClick={handleDisableAll}>
|
<Button size="sm" variant="outline" onClick={handleDisableAll}>
|
||||||
Disable All Golems
|
Disable All
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-400">
|
<div className="text-xs text-gray-400">
|
||||||
Enabled: {enabledGolems.length} / {Object.keys(GOLEMS_DEF).length}
|
Enabled: {enabledGolems.length} / {allComponentIds.length}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 max-h-48 overflow-y-auto">
|
|
||||||
{Object.entries(GOLEMS_DEF).map(([id, def]) => {
|
{/* ─── Cores ─────────────────────────────────────────────── */}
|
||||||
const isEnabled = enabledGolems.includes(id);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={id}
|
|
||||||
className={`p-2 rounded border flex items-center justify-between ${
|
|
||||||
isEnabled ? 'border-orange-600/50 bg-orange-900/20' : 'border-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium">{def.name}</div>
|
<div className="text-xs font-semibold text-amber-400 uppercase tracking-wider mb-2 flex items-center gap-1.5">
|
||||||
<div className="text-xs text-gray-400">{def.baseManaType}</div>
|
<Cpu className="w-3.5 h-3.5" /> Cores ({ALL_CORES.length})
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-52 overflow-y-auto">
|
||||||
|
{ALL_CORES.map((core) => (
|
||||||
|
<div key={core.id} className="relative">
|
||||||
|
<CoreCard core={core} />
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={isEnabled ? 'default' : 'outline'}
|
variant={enabledGolems.includes(core.id) ? 'default' : 'outline'}
|
||||||
onClick={() => handleToggleGolem(id)}
|
className="absolute top-1 right-1 h-6 px-2 text-[10px]"
|
||||||
|
onClick={() => handleToggleComponent(core.id)}
|
||||||
>
|
>
|
||||||
{isEnabled ? 'On' : 'Off'}
|
{enabledGolems.includes(core.id) ? 'On' : 'Off'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
})}
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── Frames ────────────────────────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-sky-400 uppercase tracking-wider mb-2 flex items-center gap-1.5">
|
||||||
|
<Box className="w-3.5 h-3.5" /> Frames ({ALL_FRAMES.length})
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 max-h-64 overflow-y-auto">
|
||||||
|
{ALL_FRAMES.map((frame) => (
|
||||||
|
<div key={frame.id} className="relative">
|
||||||
|
<FrameCard frame={frame} />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={enabledGolems.includes(frame.id) ? 'default' : 'outline'}
|
||||||
|
className="absolute top-1 right-1 h-6 px-2 text-[10px]"
|
||||||
|
onClick={() => handleToggleComponent(frame.id)}
|
||||||
|
>
|
||||||
|
{enabledGolems.includes(frame.id) ? 'On' : 'Off'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── Mind Circuits ─────────────────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-violet-400 uppercase tracking-wider mb-2 flex items-center gap-1.5">
|
||||||
|
<CircuitBoard className="w-3.5 h-3.5" /> Mind Circuits ({ALL_MIND_CIRCUITS.length})
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-44 overflow-y-auto">
|
||||||
|
{ALL_MIND_CIRCUITS.map((circuit) => (
|
||||||
|
<div key={circuit.id} className="relative">
|
||||||
|
<MindCircuitCard circuit={circuit} />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={enabledGolems.includes(circuit.id) ? 'default' : 'outline'}
|
||||||
|
className="absolute top-1 right-1 h-6 px-2 text-[10px]"
|
||||||
|
onClick={() => handleToggleComponent(circuit.id)}
|
||||||
|
>
|
||||||
|
{enabledGolems.includes(circuit.id) ? 'On' : 'Off'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── Enchantments ──────────────────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-emerald-400 uppercase tracking-wider mb-2 flex items-center gap-1.5">
|
||||||
|
<Sparkles className="w-3.5 h-3.5" /> Enchantments ({ALL_GOLEM_ENCHANTMENTS.length})
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 max-h-52 overflow-y-auto">
|
||||||
|
{ALL_GOLEM_ENCHANTMENTS.map((enchant) => (
|
||||||
|
<div key={enchant.id} className="relative">
|
||||||
|
<EnchantmentCard enchant={enchant} />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={enabledGolems.includes(enchant.id) ? 'default' : 'outline'}
|
||||||
|
className="absolute top-1 right-1 h-6 px-2 text-[10px]"
|
||||||
|
onClick={() => handleToggleComponent(enchant.id)}
|
||||||
|
>
|
||||||
|
{enabledGolems.includes(enchant.id) ? 'On' : 'Off'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -81,4 +244,4 @@ export function GolemDebugSection() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
GolemDebugSection.displayName = "GolemDebugSection";
|
GolemDebugSection.displayName = 'GolemDebugSection';
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
|
|
||||||
// ─── Test: GolemancyTab barrel export ─────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('GolemancyTab module structure', () => {
|
|
||||||
it('exports GolemancyTab from barrel index', async () => {
|
|
||||||
const mod = await import('./GolemancyTab');
|
|
||||||
expect(mod.GolemancyTab).toBeDefined();
|
|
||||||
expect(typeof mod.GolemancyTab).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GolemancyTab has correct displayName', async () => {
|
|
||||||
const { GolemancyTab } = await import('./GolemancyTab');
|
|
||||||
expect(GolemancyTab.displayName).toBe('GolemancyTab');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Test: Barrel export includes GolemancyTab ────────────────────────────────
|
|
||||||
|
|
||||||
describe('Tab barrel export', () => {
|
|
||||||
it('includes GolemancyTab in the tabs index', async () => {
|
|
||||||
const mod = await import('@/components/game/tabs');
|
|
||||||
expect(mod.GolemancyTab).toBeDefined();
|
|
||||||
expect(typeof mod.GolemancyTab).toBe('function');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Test: Golem data integrity ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Golem data', () => {
|
|
||||||
it('all golems have required fields', async () => {
|
|
||||||
const { GOLEMS_DEF } = await import('@/lib/game/data/golems');
|
|
||||||
for (const [id, def] of Object.entries(GOLEMS_DEF)) {
|
|
||||||
expect(def.id).toBe(id);
|
|
||||||
expect(def.name).toBeTruthy();
|
|
||||||
expect(def.description).toBeTruthy();
|
|
||||||
expect(def.baseManaType).toBeTruthy();
|
|
||||||
expect(def.summonCost.length).toBeGreaterThan(0);
|
|
||||||
expect(def.maintenanceCost.length).toBeGreaterThan(0);
|
|
||||||
expect(def.damage).toBeGreaterThan(0);
|
|
||||||
expect(def.attackSpeed).toBeGreaterThan(0);
|
|
||||||
expect(def.hp).toBeGreaterThan(0);
|
|
||||||
expect(def.armorPierce).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(def.tier).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(def.unlockCondition).toBeTruthy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has golems across multiple tiers', async () => {
|
|
||||||
const { GOLEMS_DEF } = await import('@/lib/game/data/golems');
|
|
||||||
const tiers = new Set(Object.values(GOLEMS_DEF).map(g => g.tier));
|
|
||||||
expect(tiers.size).toBeGreaterThanOrEqual(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('earthGolem is the only base tier golem', async () => {
|
|
||||||
const { GOLEMS_DEF } = await import('@/lib/game/data/golems');
|
|
||||||
const baseGolems = Object.values(GOLEMS_DEF).filter(g => g.tier === 1);
|
|
||||||
expect(baseGolems.length).toBe(1);
|
|
||||||
expect(baseGolems[0].id).toBe('earthGolem');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('voidstoneGolem is the highest tier', async () => {
|
|
||||||
const { GOLEMS_DEF } = await import('@/lib/game/data/golems');
|
|
||||||
const voidstone = GOLEMS_DEF.voidstoneGolem;
|
|
||||||
expect(voidstone).toBeDefined();
|
|
||||||
expect(voidstone.tier).toBe(4);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Test: Golem utility functions ────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Golem utility functions', () => {
|
|
||||||
it('getGolemSlots returns 0 for fabricator level < 2', async () => {
|
|
||||||
const { getGolemSlots } = await import('@/lib/game/data/golems');
|
|
||||||
expect(getGolemSlots(0)).toBe(0);
|
|
||||||
expect(getGolemSlots(1)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getGolemSlots scales with fabricator level', async () => {
|
|
||||||
const { getGolemSlots } = await import('@/lib/game/data/golems');
|
|
||||||
expect(getGolemSlots(2)).toBe(1);
|
|
||||||
expect(getGolemSlots(4)).toBe(2);
|
|
||||||
expect(getGolemSlots(10)).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('isGolemUnlocked returns false for unknown golem', async () => {
|
|
||||||
const { isGolemUnlocked } = await import('@/lib/game/data/golems');
|
|
||||||
expect(isGolemUnlocked('nonexistent', {}, [])).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('isGolemUnlocked checks attunement level', async () => {
|
|
||||||
const { isGolemUnlocked } = await import('@/lib/game/data/golems');
|
|
||||||
expect(isGolemUnlocked('earthGolem', { fabricator: { active: true, level: 1 } }, [])).toBe(false);
|
|
||||||
expect(isGolemUnlocked('earthGolem', { fabricator: { active: true, level: 2 } }, [])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('isGolemUnlocked checks mana unlocked', async () => {
|
|
||||||
const { isGolemUnlocked } = await import('@/lib/game/data/golems');
|
|
||||||
expect(isGolemUnlocked('steelGolem', {}, [])).toBe(false);
|
|
||||||
expect(isGolemUnlocked('steelGolem', {}, ['metal'])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('canAffordGolemSummon returns false for unknown golem', async () => {
|
|
||||||
const { canAffordGolemSummon } = await import('@/lib/game/data/golems');
|
|
||||||
expect(canAffordGolemSummon('nonexistent', 100, {})).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('canAffordGolemSummon checks raw mana cost', async () => {
|
|
||||||
const { canAffordGolemSummon } = await import('@/lib/game/data/golems');
|
|
||||||
// earthGolem costs 10 earth
|
|
||||||
const elements = { earth: { current: 5, max: 100, unlocked: true } };
|
|
||||||
expect(canAffordGolemSummon('earthGolem', 0, elements)).toBe(false);
|
|
||||||
elements.earth.current = 10;
|
|
||||||
expect(canAffordGolemSummon('earthGolem', 0, elements)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Test: Combat store golemancy state ───────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Combat store golemancy', () => {
|
|
||||||
it('toggleGolem is a function', async () => {
|
|
||||||
const { useCombatStore } = await import('@/lib/game/stores/combatStore');
|
|
||||||
const state = useCombatStore.getState();
|
|
||||||
expect(typeof state.toggleGolem).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('golemancy state has correct shape', async () => {
|
|
||||||
const { useCombatStore } = await import('@/lib/game/stores/combatStore');
|
|
||||||
const state = useCombatStore.getState();
|
|
||||||
expect(state.golemancy).toBeDefined();
|
|
||||||
expect(Array.isArray(state.golemancy.enabledGolems)).toBe(true);
|
|
||||||
expect(Array.isArray(state.golemancy.summonedGolems)).toBe(true);
|
|
||||||
expect(typeof state.golemancy.lastSummonFloor).toBe('number');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Test: File size limit ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('File size limits (400 lines max)', () => {
|
|
||||||
it('GolemancyTab.tsx is under 400 lines', async () => {
|
|
||||||
const fs = await import('fs');
|
|
||||||
const path = await import('path');
|
|
||||||
const filePath = path.join(__dirname, 'GolemancyTab.tsx');
|
|
||||||
const content = fs.readFileSync(filePath, 'utf-8');
|
|
||||||
const lines = content.split('\n').length;
|
|
||||||
expect(lines).toBeLessThan(400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -5,223 +5,53 @@ import { useShallow } from 'zustand/react/shallow';
|
|||||||
import { useCombatStore } from '@/lib/game/stores/combatStore';
|
import { useCombatStore } from '@/lib/game/stores/combatStore';
|
||||||
import { useAttunementStore } from '@/lib/game/stores/attunementStore';
|
import { useAttunementStore } from '@/lib/game/stores/attunementStore';
|
||||||
import { useManaStore } from '@/lib/game/stores/manaStore';
|
import { useManaStore } from '@/lib/game/stores/manaStore';
|
||||||
import { GOLEMS_DEF, isGolemUnlocked, canAffordGolemSummon, getGolemSlots } from '@/lib/game/data/golems';
|
import { usePrestigeStore } from '@/lib/game/stores/prestigeStore';
|
||||||
import type { GolemDef } from '@/lib/game/data/golems';
|
import {
|
||||||
import { ELEMENTS } from '@/lib/game/constants/elements';
|
CORES, FRAMES, MIND_CIRCUITS, GOLEM_ENCHANTMENTS,
|
||||||
import { DebugName } from '@/components/game/debug/debug-context';
|
ALL_CORES, ALL_FRAMES, ALL_MIND_CIRCUITS, ALL_GOLEM_ENCHANTMENTS,
|
||||||
import { Badge } from '@/components/ui/badge';
|
} from '@/lib/game/data/golems';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import {
|
||||||
|
getGolemSlots,
|
||||||
|
isComponentUnlocked,
|
||||||
|
computeGolemStats,
|
||||||
|
canAffordGolemDesign,
|
||||||
|
} from '@/lib/game/data/golems/utils';
|
||||||
|
import type { ComputedGolemStats, GolemEnchantmentDefinition } from '@/lib/game/data/golems/types';
|
||||||
|
import type { BuilderSection } from './golemancy/types';
|
||||||
|
import { GolemDesignBuilder } from './golemancy/GolemDesignBuilder';
|
||||||
|
import { GolemLoadoutPanel } from './golemancy/GolemLoadoutPanel';
|
||||||
|
import { ActiveGolemsPanel } from './golemancy/ActiveGolemsPanel';
|
||||||
|
import { serializeDesign, buildGolemDesign } from './golemancy/golemancy-utils';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
// ─── Tier configuration ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface TierConfig {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
tier: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TIERS: TierConfig[] = [
|
|
||||||
{ key: 'base', label: 'Base', tier: 1 },
|
|
||||||
{ key: 'elemental', label: 'Elemental', tier: 2 },
|
|
||||||
{ key: 'hybrid', label: 'Hybrid', tier: 3 },
|
|
||||||
];
|
|
||||||
|
|
||||||
function getTierLabel(tier: number): string {
|
|
||||||
if (tier <= 1) return 'Base';
|
|
||||||
if (tier <= 2) return 'Elemental';
|
|
||||||
return 'Hybrid';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTierColor(tier: number): string {
|
|
||||||
if (tier <= 1) return 'bg-gray-600';
|
|
||||||
if (tier <= 2) return 'bg-blue-600';
|
|
||||||
if (tier <= 3) return 'bg-purple-600';
|
|
||||||
return 'bg-amber-500';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function formatCost(cost: GolemDef['summonCost'][number]): string {
|
|
||||||
if (cost.type === 'raw') return `${cost.amount} raw`;
|
|
||||||
const elem = cost.element ? ELEMENTS[cost.element] : null;
|
|
||||||
return `${cost.amount} ${elem?.sym ?? ''} ${cost.element ?? ''}`.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUnlockCondition(golem: GolemDef): string {
|
|
||||||
const cond = golem.unlockCondition;
|
|
||||||
switch (cond.type) {
|
|
||||||
case 'attunement_level':
|
|
||||||
return `${cond.attunement} level ${cond.level}`;
|
|
||||||
case 'mana_unlocked': {
|
|
||||||
const elem = cond.manaType ? ELEMENTS[cond.manaType] : null;
|
|
||||||
return `Unlock ${elem?.sym ?? ''} ${cond.manaType ?? ''}`.trim();
|
|
||||||
}
|
|
||||||
case 'dual_attunement':
|
|
||||||
return `${cond.attunements?.join(' + ')} level ${cond.levels?.join('/')}`;
|
|
||||||
default:
|
|
||||||
return 'Unknown';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Golem Card ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface GolemCardProps {
|
|
||||||
golem: GolemDef;
|
|
||||||
unlocked: boolean;
|
|
||||||
enabled: boolean;
|
|
||||||
summoned: boolean;
|
|
||||||
canAfford: boolean;
|
|
||||||
onToggle: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const GolemCard: React.FC<GolemCardProps> = React.memo(({
|
|
||||||
golem,
|
|
||||||
unlocked,
|
|
||||||
enabled,
|
|
||||||
summoned,
|
|
||||||
canAfford,
|
|
||||||
onToggle,
|
|
||||||
}) => {
|
|
||||||
const elemColor = ELEMENTS[golem.baseManaType]?.color ?? '#888';
|
|
||||||
const elemSym = ELEMENTS[golem.baseManaType]?.sym ?? '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
'rounded-lg border p-4 space-y-3 transition-colors',
|
|
||||||
unlocked
|
|
||||||
? 'bg-gray-800/50 border-gray-600'
|
|
||||||
: 'bg-gray-900/50 border-gray-800 opacity-60',
|
|
||||||
summoned && 'ring-1 ring-green-500/50',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h3 className="font-semibold text-gray-100 truncate">{golem.name}</h3>
|
|
||||||
<p className="text-xs text-gray-400 mt-0.5">{golem.description}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
|
||||||
<span
|
|
||||||
className="text-xs"
|
|
||||||
style={{ color: elemColor }}
|
|
||||||
title={golem.baseManaType}
|
|
||||||
>
|
|
||||||
{elemSym}
|
|
||||||
</span>
|
|
||||||
<Badge className={clsx('text-[10px] px-1.5 py-0', getTierColor(golem.tier))}>
|
|
||||||
T{golem.tier}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats grid */}
|
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
|
||||||
<div className="text-gray-400">
|
|
||||||
<span className="text-gray-500">Damage:</span> {golem.damage}
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-400">
|
|
||||||
<span className="text-gray-500">Attack Speed:</span> {golem.attackSpeed}/h
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-400">
|
|
||||||
<span className="text-gray-500">HP:</span> {golem.hp}
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-400">
|
|
||||||
<span className="text-gray-500">Armor Pierce:</span> {Math.round(golem.armorPierce * 100)}%
|
|
||||||
</div>
|
|
||||||
{golem.isAoe && (
|
|
||||||
<div className="col-span-2 text-gray-400">
|
|
||||||
<span className="text-gray-500">AoE:</span> {golem.aoeTargets} targets
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Special Abilities */}
|
|
||||||
{golem.specialAbilities && golem.specialAbilities.length > 0 && (
|
|
||||||
<div className="text-xs space-y-0.5">
|
|
||||||
<span className="text-gray-500">Special:</span>
|
|
||||||
{golem.specialAbilities.map((ability, i) => (
|
|
||||||
<div key={i} className="text-gray-400 pl-2">
|
|
||||||
• {ability.name}: {ability.description}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Costs */}
|
|
||||||
<div className="text-xs space-y-0.5">
|
|
||||||
<div className="text-gray-400">
|
|
||||||
<span className="text-gray-500">Summon:</span>{' '}
|
|
||||||
{golem.summonCost.map((c, i) => (
|
|
||||||
<span key={i}>{formatCost(c)}{i < golem.summonCost.length - 1 ? ' + ' : ''}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-400">
|
|
||||||
<span className="text-gray-500">Upkeep:</span>{' '}
|
|
||||||
{golem.maintenanceCost.map((c, i) => (
|
|
||||||
<span key={i}>{formatCost(c)}{i < golem.maintenanceCost.length - 1 ? ' + ' : ''}</span>
|
|
||||||
))}/tick
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Unlock requirement */}
|
|
||||||
{!unlocked && (
|
|
||||||
<div className="text-xs text-red-400">
|
|
||||||
🔒 Requires: {formatUnlockCondition(golem)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Status + toggle */}
|
|
||||||
<div className="flex items-center justify-between pt-1">
|
|
||||||
<div className="text-xs">
|
|
||||||
{summoned ? (
|
|
||||||
<span className="text-green-400">● Summoned</span>
|
|
||||||
) : enabled ? (
|
|
||||||
<span className="text-yellow-400">○ Queued</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-gray-500">— Idle</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onToggle(golem.id)}
|
|
||||||
disabled={!unlocked}
|
|
||||||
className={clsx(
|
|
||||||
'rounded px-3 py-1 text-xs font-medium transition-colors',
|
|
||||||
!unlocked
|
|
||||||
? 'bg-gray-700 text-gray-500 cursor-not-allowed'
|
|
||||||
: enabled
|
|
||||||
? 'bg-red-600/80 text-white hover:bg-red-500'
|
|
||||||
: canAfford
|
|
||||||
? 'bg-green-600/80 text-white hover:bg-green-500'
|
|
||||||
: 'bg-blue-600/80 text-white hover:bg-blue-500',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{!unlocked ? 'Locked' : enabled ? 'Disable' : 'Enable'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
GolemCard.displayName = 'GolemCard';
|
|
||||||
|
|
||||||
// ─── Main Tab ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const GolemancyTab: React.FC = () => {
|
export const GolemancyTab: React.FC = () => {
|
||||||
const [activeTier, setActiveTier] = useState<string>('base');
|
const [activeSection, setActiveSection] = useState<BuilderSection>('builder');
|
||||||
|
|
||||||
const { golemancy, toggleGolem } = useCombatStore(useShallow(s => ({
|
// Builder state
|
||||||
|
const [selectedCoreId, setSelectedCoreId] = useState<string | null>(null);
|
||||||
|
const [selectedFrameId, setSelectedFrameId] = useState<string | null>(null);
|
||||||
|
const [selectedCircuitId, setSelectedCircuitId] = useState<string | null>(null);
|
||||||
|
const [selectedEnchantmentIds, setSelectedEnchantmentIds] = useState<string[]>([]);
|
||||||
|
const [designName, setDesignName] = useState('');
|
||||||
|
const [selectedManaTypes, setSelectedManaTypes] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Store access
|
||||||
|
const { golemancy, addGolemDesign, removeGolemDesign, toggleGolemLoadoutEntry } = useCombatStore(
|
||||||
|
useShallow(s => ({
|
||||||
golemancy: s.golemancy,
|
golemancy: s.golemancy,
|
||||||
toggleGolem: s.toggleGolem,
|
addGolemDesign: s.addGolemDesign,
|
||||||
})));
|
removeGolemDesign: s.removeGolemDesign,
|
||||||
|
toggleGolemLoadoutEntry: s.toggleGolemLoadoutEntry,
|
||||||
|
})),
|
||||||
|
);
|
||||||
const attunements = useAttunementStore(s => s.attunements);
|
const attunements = useAttunementStore(s => s.attunements);
|
||||||
const { rawMana, elements } = useManaStore(useShallow(s => ({
|
const { rawMana, elements } = useManaStore(useShallow(s => ({
|
||||||
rawMana: s.rawMana,
|
rawMana: s.rawMana,
|
||||||
elements: s.elements,
|
elements: s.elements,
|
||||||
})));
|
})));
|
||||||
|
const signedPacts = usePrestigeStore(s => s.signedPacts);
|
||||||
|
|
||||||
// Build attunement lookup for isGolemUnlocked
|
// Derived data
|
||||||
const attunementLookup = useMemo(() => {
|
const attunementLookup = useMemo(() => {
|
||||||
const lookup: Record<string, { active: boolean; level: number }> = {};
|
const lookup: Record<string, { active: boolean; level: number }> = {};
|
||||||
for (const [id, att] of Object.entries(attunements)) {
|
for (const [id, att] of Object.entries(attunements)) {
|
||||||
@@ -235,103 +65,180 @@ export const GolemancyTab: React.FC = () => {
|
|||||||
[elements],
|
[elements],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Group golems by tier
|
|
||||||
const golemsByTier = useMemo(() => {
|
|
||||||
const groups: Record<string, GolemDef[]> = { base: [], elemental: [], hybrid: [] };
|
|
||||||
for (const golem of Object.values(GOLEMS_DEF)) {
|
|
||||||
const label = getTierLabel(golem.tier);
|
|
||||||
const key = label.toLowerCase();
|
|
||||||
if (groups[key]) {
|
|
||||||
groups[key].push(golem);
|
|
||||||
} else {
|
|
||||||
// tier 4 golems go into hybrid
|
|
||||||
groups.hybrid.push(golem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return groups;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleToggle = useCallback((id: string) => {
|
|
||||||
toggleGolem(id);
|
|
||||||
}, [toggleGolem]);
|
|
||||||
|
|
||||||
// Golem slot info
|
|
||||||
const fabricatorLevel = attunements.fabricator?.level ?? 0;
|
const fabricatorLevel = attunements.fabricator?.level ?? 0;
|
||||||
const golemSlots = getGolemSlots(fabricatorLevel);
|
const golemSlots = getGolemSlots(fabricatorLevel);
|
||||||
const enabledCount = golemancy.enabledGolems.length;
|
const enabledCount = golemancy.golemLoadout.filter(e => e.enabled).length;
|
||||||
|
|
||||||
const activeTierGolems = golemsByTier[activeTier] ?? [];
|
// Unlock checks
|
||||||
|
const unlockedCoreIds = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const core of ALL_CORES) {
|
||||||
|
if (isComponentUnlocked(core.unlockRequirement, attunementLookup, unlockedElements, signedPacts)) {
|
||||||
|
set.add(core.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}, [attunementLookup, unlockedElements, signedPacts]);
|
||||||
|
|
||||||
|
const unlockedFrameIds = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const frame of ALL_FRAMES) {
|
||||||
|
if (isComponentUnlocked(frame.unlockRequirement, attunementLookup, unlockedElements, signedPacts)) {
|
||||||
|
set.add(frame.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}, [attunementLookup, unlockedElements, signedPacts]);
|
||||||
|
|
||||||
|
const unlockedCircuitIds = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const circuit of ALL_MIND_CIRCUITS) {
|
||||||
|
if (isComponentUnlocked(circuit.unlockRequirement, attunementLookup, unlockedElements, signedPacts)) {
|
||||||
|
set.add(circuit.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}, [attunementLookup, unlockedElements, signedPacts]);
|
||||||
|
|
||||||
|
// Selected components
|
||||||
|
const selectedCore = selectedCoreId ? CORES[selectedCoreId] ?? null : null;
|
||||||
|
const selectedFrame = selectedFrameId ? FRAMES[selectedFrameId] ?? null : null;
|
||||||
|
const selectedCircuit = selectedCircuitId ? MIND_CIRCUITS[selectedCircuitId] ?? null : null;
|
||||||
|
const selectedEnchantments = selectedEnchantmentIds
|
||||||
|
.map(id => GOLEM_ENCHANTMENTS[id])
|
||||||
|
.filter(Boolean) as GolemEnchantmentDefinition[];
|
||||||
|
|
||||||
|
// Computed stats preview
|
||||||
|
const computedStats = useMemo<ComputedGolemStats | null>(() => {
|
||||||
|
if (!selectedCore || !selectedFrame || !selectedCircuit) return null;
|
||||||
|
const design = buildGolemDesign(
|
||||||
|
selectedCore, selectedFrame, selectedCircuit,
|
||||||
|
selectedEnchantments, selectedManaTypes, [],
|
||||||
|
);
|
||||||
|
return computeGolemStats(design);
|
||||||
|
}, [selectedCore, selectedFrame, selectedCircuit, selectedEnchantments, selectedManaTypes]);
|
||||||
|
|
||||||
|
const affordability = useMemo(() => {
|
||||||
|
if (!selectedCore || !selectedFrame || !selectedCircuit) {
|
||||||
|
return { canAfford: false, missing: 'Select all required components' };
|
||||||
|
}
|
||||||
|
const design = buildGolemDesign(
|
||||||
|
selectedCore, selectedFrame, selectedCircuit,
|
||||||
|
selectedEnchantments, selectedManaTypes, [],
|
||||||
|
);
|
||||||
|
return canAffordGolemDesign(design, rawMana, elements);
|
||||||
|
}, [selectedCore, selectedFrame, selectedCircuit, selectedEnchantments, selectedManaTypes, rawMana, elements]);
|
||||||
|
|
||||||
|
// Enchantment capacity check
|
||||||
|
const enchantmentCapacity = computedStats?.enchantmentCapacity ?? 0;
|
||||||
|
const usedEnchantmentCapacity = selectedEnchantments.reduce((sum, e) => sum + e.capacityCost, 0);
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleToggleEnchantment = useCallback((id: string) => {
|
||||||
|
setSelectedEnchantmentIds(prev =>
|
||||||
|
prev.includes(id) ? prev.filter(eid => eid !== id) : [...prev, id],
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSaveDesign = useCallback(() => {
|
||||||
|
if (!selectedCore || !selectedFrame || !selectedCircuit) return;
|
||||||
|
const name = designName.trim() || `${selectedCore.name.split(' ')[0]} ${selectedFrame.name.split(' ')[0]}`;
|
||||||
|
const serialized = serializeDesign(
|
||||||
|
name, selectedCore, selectedFrame, selectedCircuit,
|
||||||
|
selectedEnchantments, selectedManaTypes, [],
|
||||||
|
);
|
||||||
|
addGolemDesign(serialized);
|
||||||
|
setDesignName('');
|
||||||
|
setSelectedEnchantmentIds([]);
|
||||||
|
setSelectedManaTypes([]);
|
||||||
|
}, [designName, selectedCore, selectedFrame, selectedCircuit, selectedEnchantments, selectedManaTypes, addGolemDesign]);
|
||||||
|
|
||||||
|
const handleRemoveLoadoutEntry = useCallback((designId: string) => {
|
||||||
|
removeGolemDesign(designId);
|
||||||
|
}, [removeGolemDesign]);
|
||||||
|
|
||||||
|
const handleToggleLoadoutEntry = useCallback((designId: string) => {
|
||||||
|
toggleGolemLoadoutEntry(designId);
|
||||||
|
}, [toggleGolemLoadoutEntry]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DebugName name="GolemancyTab">
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Header info */}
|
{/* Header info */}
|
||||||
<div className="text-sm text-gray-400 space-y-1">
|
<div className="text-sm text-gray-400 space-y-1">
|
||||||
<p>
|
<p>
|
||||||
Configure your golem loadout. Enabled golems are automatically summoned
|
Design custom golems from components. Enabled golems are automatically
|
||||||
when entering the spire if you can afford the cost.
|
summoned when entering the spire if you can afford the cost.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-4 text-xs">
|
<div className="flex gap-4 text-xs">
|
||||||
<span>
|
<span>Slots: {enabledCount}/{golemSlots}</span>
|
||||||
Slots: {enabledCount}/{golemSlots}
|
<span>Active: {golemancy.activeGolems.length}</span>
|
||||||
</span>
|
<span>Designs: {Object.keys(golemancy.golemDesigns).length}</span>
|
||||||
<span>
|
|
||||||
Summoned: {golemancy.summonedGolems.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tier tabs */}
|
{/* Section tabs */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{TIERS.map((tier) => {
|
{([
|
||||||
const count = golemsByTier[tier.key]?.length ?? 0;
|
{ key: 'builder', label: 'Design Builder' },
|
||||||
return (
|
{ key: 'loadout', label: `Loadout (${golemancy.golemLoadout.length})` },
|
||||||
|
{ key: 'active', label: `Active (${golemancy.activeGolems.length})` },
|
||||||
|
] as const).map(({ key, label }) => (
|
||||||
<button
|
<button
|
||||||
key={tier.key}
|
key={key}
|
||||||
onClick={() => setActiveTier(tier.key)}
|
onClick={() => setActiveSection(key)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'rounded px-3 py-1 text-xs font-medium transition-colors',
|
'rounded px-3 py-1 text-xs font-medium transition-colors',
|
||||||
activeTier === tier.key
|
activeSection === key
|
||||||
? 'bg-blue-600 text-white'
|
? 'bg-blue-600 text-white'
|
||||||
: 'text-gray-400 hover:text-gray-200',
|
: 'text-gray-400 hover:text-gray-200',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tier.label} ({count})
|
{label}
|
||||||
</button>
|
</button>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Golem cards */}
|
{/* ─── Builder Section ─────────────────────────────────────────────── */}
|
||||||
<ScrollArea className="h-[500px] rounded border border-gray-700 p-3">
|
{activeSection === 'builder' && (
|
||||||
{activeTierGolems.length === 0 ? (
|
<GolemDesignBuilder
|
||||||
<div className="text-center text-gray-500 py-8">
|
selectedCoreId={selectedCoreId}
|
||||||
No golems in this tier.
|
selectedFrameId={selectedFrameId}
|
||||||
</div>
|
selectedCircuitId={selectedCircuitId}
|
||||||
) : (
|
selectedEnchantmentIds={selectedEnchantmentIds}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
designName={designName}
|
||||||
{activeTierGolems.map((golem) => {
|
selectedManaTypes={selectedManaTypes}
|
||||||
const unlocked = isGolemUnlocked(golem.id, attunementLookup, unlockedElements);
|
unlockedCoreIds={unlockedCoreIds}
|
||||||
const enabled = golemancy.enabledGolems.includes(golem.id);
|
unlockedFrameIds={unlockedFrameIds}
|
||||||
const summoned = golemancy.summonedGolems.some(g => g.golemId === golem.id);
|
unlockedCircuitIds={unlockedCircuitIds}
|
||||||
const canAfford = canAffordGolemSummon(golem.id, rawMana, elements);
|
computedStats={computedStats}
|
||||||
return (
|
affordability={affordability}
|
||||||
<GolemCard
|
enchantmentCapacity={enchantmentCapacity}
|
||||||
key={golem.id}
|
usedEnchantmentCapacity={usedEnchantmentCapacity}
|
||||||
golem={golem}
|
golemSlots={golemSlots}
|
||||||
unlocked={unlocked}
|
enabledCount={enabledCount}
|
||||||
enabled={enabled}
|
onSelectCore={setSelectedCoreId}
|
||||||
summoned={summoned}
|
onSelectFrame={setSelectedFrameId}
|
||||||
canAfford={canAfford}
|
onSelectCircuit={setSelectedCircuitId}
|
||||||
onToggle={handleToggle}
|
onToggleEnchantment={handleToggleEnchantment}
|
||||||
|
onDesignNameChange={setDesignName}
|
||||||
|
onSaveDesign={handleSaveDesign}
|
||||||
/>
|
/>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
|
||||||
|
{/* ─── Loadout Section ─────────────────────────────────────────────── */}
|
||||||
|
{activeSection === 'loadout' && (
|
||||||
|
<GolemLoadoutPanel
|
||||||
|
loadout={golemancy.golemLoadout}
|
||||||
|
onToggle={handleToggleLoadoutEntry}
|
||||||
|
onRemove={handleRemoveLoadoutEntry}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── Active Golems Section ───────────────────────────────────────── */}
|
||||||
|
{activeSection === 'active' && (
|
||||||
|
<ActiveGolemsPanel activeGolems={golemancy.activeGolems} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DebugName>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useCombatStore, useManaStore, canAffordSpellCost } from '@/lib/game/sto
|
|||||||
import { SPELLS_DEF, ELEMENTS } from '@/lib/game/constants';
|
import { SPELLS_DEF, ELEMENTS } from '@/lib/game/constants';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { GOLEMS_DEF } from '@/lib/game/data/golems';
|
import { CORES, FRAMES } from '@/lib/game/data/golems';
|
||||||
import { DebugName } from '@/components/game/debug/debug-context';
|
import { DebugName } from '@/components/game/debug/debug-context';
|
||||||
|
|
||||||
interface SpireCombatControlsProps {
|
interface SpireCombatControlsProps {
|
||||||
@@ -29,7 +29,8 @@ export function SpireCombatControls({ castProgress }: SpireCombatControlsProps)
|
|||||||
.filter(([, state]) => state?.learned)
|
.filter(([, state]) => state?.learned)
|
||||||
.map(([id]) => id);
|
.map(([id]) => id);
|
||||||
|
|
||||||
const summonedGolems = golemancy.summonedGolems || [];
|
const activeGolems = golemancy.activeGolems || [];
|
||||||
|
const golemDesigns = golemancy.golemDesigns || {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DebugName name="SpireCombatControls">
|
<DebugName name="SpireCombatControls">
|
||||||
@@ -100,26 +101,28 @@ export function SpireCombatControls({ castProgress }: SpireCombatControlsProps)
|
|||||||
<CardTitle className="text-sm text-blue-400">🗿 Golems</CardTitle>
|
<CardTitle className="text-sm text-blue-400">🗿 Golems</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{summonedGolems.length === 0 ? (
|
{activeGolems.length === 0 ? (
|
||||||
<div className="text-xs text-gray-500 italic">No golems summoned.</div>
|
<div className="text-xs text-gray-500 italic">No golems summoned.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{summonedGolems.map((sg) => {
|
{activeGolems.map((ag) => {
|
||||||
const golemDef = GOLEMS_DEF[sg.golemId];
|
const design = golemDesigns[ag.designId];
|
||||||
if (!golemDef) return null;
|
if (!design) return null;
|
||||||
const elemColor = ELEMENTS[golemDef.baseManaType]?.color || '#888';
|
const frame = FRAMES[design.frameId];
|
||||||
|
const core = CORES[design.coreId];
|
||||||
|
const elemColor = frame?.element ? ELEMENTS[frame.element]?.color || '#888' : '#888';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={sg.golemId}
|
key={ag.designId + ag.summonedFloor}
|
||||||
className="flex items-center justify-between p-2 bg-gray-800/50 rounded border border-gray-700/50"
|
className="flex items-center justify-between p-2 bg-gray-800/50 rounded border border-gray-700/50"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span style={{ color: elemColor }}>●</span>
|
<span style={{ color: elemColor }}>●</span>
|
||||||
<span className="text-xs text-gray-200">{golemDef.name}</span>
|
<span className="text-xs text-gray-200">{design.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
{golemDef.damage} dmg · {golemDef.attackSpeed}/h
|
{frame?.baseDamage ?? '?'} dmg · {core?.manaRegen ?? '?'} mp
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useCombatStore } from '@/lib/game/stores/combatStore';
|
||||||
|
import { CORES } from '@/lib/game/data/golems';
|
||||||
|
import type { RuntimeActiveGolem } from './types';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
|
||||||
|
// ─── Active Golem Card ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ActiveGolemCardProps {
|
||||||
|
golem: RuntimeActiveGolem;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActiveGolemCard: React.FC<ActiveGolemCardProps> = React.memo(({ golem }) => {
|
||||||
|
const loadoutEntry = useCombatStore(s =>
|
||||||
|
s.golemancy.golemLoadout.find(e => e.designId === golem.designId),
|
||||||
|
);
|
||||||
|
const name = loadoutEntry?.design.name ?? golem.designId;
|
||||||
|
const core = loadoutEntry ? CORES[loadoutEntry.design.coreId] : null;
|
||||||
|
const maxMana = core?.manaCapacity ?? 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-green-700/50 bg-green-900/10 p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="font-semibold text-sm text-green-300">{name}</h4>
|
||||||
|
<Badge className="text-[10px] px-1.5 py-0 bg-green-800 text-green-200">
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||||
|
<div className="text-gray-400">
|
||||||
|
<span className="text-gray-500">Summoned Floor:</span> {golem.summonedFloor}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-400">
|
||||||
|
<span className="text-gray-500">Rooms Left:</span> {golem.roomsRemaining}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-400">
|
||||||
|
<span className="text-gray-500">Mana:</span>{' '}
|
||||||
|
<span className={golem.currentMana < maxMana * 0.2 ? 'text-red-400' : 'text-blue-400'}>
|
||||||
|
{Math.round(golem.currentMana)}/{maxMana}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-400">
|
||||||
|
<span className="text-gray-500">Atk Progress:</span>{' '}
|
||||||
|
{Math.round(golem.attackProgress * 100)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ActiveGolemCard.displayName = 'ActiveGolemCard';
|
||||||
|
|
||||||
|
// ─── Props ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ActiveGolemsPanelProps {
|
||||||
|
activeGolems: RuntimeActiveGolem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ActiveGolemsPanel: React.FC<ActiveGolemsPanelProps> = ({ activeGolems }) => {
|
||||||
|
return (
|
||||||
|
<ScrollArea className="h-[560px] rounded border border-gray-700 p-3">
|
||||||
|
{activeGolems.length === 0 ? (
|
||||||
|
<div className="text-center text-gray-500 py-8">
|
||||||
|
<p>No active golems in combat.</p>
|
||||||
|
<p className="text-xs mt-1">Enable golem designs in the Loadout and enter the spire.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{activeGolems.map(golem => (
|
||||||
|
<ActiveGolemCard key={golem.designId} golem={golem} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ActiveGolemsPanel.displayName = 'ActiveGolemsPanel';
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
CORES, ALL_CORES, FRAMES, ALL_FRAMES,
|
||||||
|
MIND_CIRCUITS, ALL_MIND_CIRCUITS,
|
||||||
|
GOLEM_ENCHANTMENTS, ALL_GOLEM_ENCHANTMENTS,
|
||||||
|
} from '@/lib/game/data/golems';
|
||||||
|
import type {
|
||||||
|
CoreDefinition, FrameDefinition, MindCircuitDefinition,
|
||||||
|
GolemEnchantmentDefinition, ComputedGolemStats,
|
||||||
|
} from '@/lib/game/data/golems/types';
|
||||||
|
import { ELEMENTS } from '@/lib/game/constants/elements';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { formatRequirement } from './golemancy-utils';
|
||||||
|
import { ComponentSelector, StatsPreview } from './GolemancySharedComponents';
|
||||||
|
|
||||||
|
export interface GolemDesignBuilderProps {
|
||||||
|
selectedCoreId: string | null;
|
||||||
|
selectedFrameId: string | null;
|
||||||
|
selectedCircuitId: string | null;
|
||||||
|
selectedEnchantmentIds: string[];
|
||||||
|
designName: string;
|
||||||
|
selectedManaTypes: string[];
|
||||||
|
unlockedCoreIds: Set<string>;
|
||||||
|
unlockedFrameIds: Set<string>;
|
||||||
|
unlockedCircuitIds: Set<string>;
|
||||||
|
computedStats: ComputedGolemStats | null;
|
||||||
|
affordability: { canAfford: boolean; missing: string };
|
||||||
|
enchantmentCapacity: number;
|
||||||
|
usedEnchantmentCapacity: number;
|
||||||
|
golemSlots: number;
|
||||||
|
enabledCount: number;
|
||||||
|
onSelectCore: (id: string) => void;
|
||||||
|
onSelectFrame: (id: string) => void;
|
||||||
|
onSelectCircuit: (id: string) => void;
|
||||||
|
onToggleEnchantment: (id: string) => void;
|
||||||
|
onDesignNameChange: (name: string) => void;
|
||||||
|
onSaveDesign: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GolemDesignBuilder: React.FC<GolemDesignBuilderProps> = ({
|
||||||
|
selectedCoreId, selectedFrameId, selectedCircuitId,
|
||||||
|
selectedEnchantmentIds, designName, selectedManaTypes,
|
||||||
|
unlockedCoreIds, unlockedFrameIds, unlockedCircuitIds,
|
||||||
|
computedStats, affordability, enchantmentCapacity, usedEnchantmentCapacity,
|
||||||
|
onSelectCore, onSelectFrame, onSelectCircuit, onToggleEnchantment,
|
||||||
|
onDesignNameChange, onSaveDesign,
|
||||||
|
}) => {
|
||||||
|
const canSaveDesign = selectedCoreId && selectedFrameId && selectedCircuitId && affordability.canAfford;
|
||||||
|
const selectedCore = selectedCoreId ? CORES[selectedCoreId] ?? null : null;
|
||||||
|
const selectedFrame = selectedFrameId ? FRAMES[selectedFrameId] ?? null : null;
|
||||||
|
const selectedCircuit = selectedCircuitId ? MIND_CIRCUITS[selectedCircuitId] ?? null : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<ScrollArea className="h-[560px] rounded border border-gray-700 p-3">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-gray-400">Design Name</label>
|
||||||
|
<input type="text" value={designName} onChange={e => onDesignNameChange(e.target.value)}
|
||||||
|
placeholder="Enter a name for this golem..."
|
||||||
|
className="w-full rounded bg-gray-800 border border-gray-700 px-3 py-1.5 text-xs text-gray-200 placeholder-gray-600 focus:outline-none focus:border-blue-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ComponentSelector label="1. Core (Power Source)" items={ALL_CORES} selectedId={selectedCoreId} unlockedIds={unlockedCoreIds} onSelect={onSelectCore}
|
||||||
|
renderItem={(core: CoreDefinition, unlocked: boolean) => (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium">{core.name}</span>
|
||||||
|
<span className="text-gray-500">T{core.tier}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500 mt-0.5">{core.description}</p>
|
||||||
|
<div className="flex gap-2 mt-1 text-gray-500">
|
||||||
|
<span>Cap: {core.manaCapacity}</span><span>Regen: {core.manaRegen}/h</span><span>Duration: {core.maxRoomDuration}r</span>
|
||||||
|
</div>
|
||||||
|
{!unlocked && <p className="text-red-400 mt-0.5">🔒 {formatRequirement(core.unlockRequirement)}</p>}
|
||||||
|
</div>
|
||||||
|
)} />
|
||||||
|
|
||||||
|
<ComponentSelector label="2. Frame (Combat Body)" items={ALL_FRAMES} selectedId={selectedFrameId} unlockedIds={unlockedFrameIds} onSelect={onSelectFrame}
|
||||||
|
renderItem={(frame: FrameDefinition, unlocked: boolean) => (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium">{frame.name}</span>
|
||||||
|
{frame.element && <span style={{ color: ELEMENTS[frame.element]?.color ?? '#888' }}>{ELEMENTS[frame.element]?.sym ?? ''} {frame.element}</span>}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500 mt-0.5">{frame.description}</p>
|
||||||
|
<div className="flex gap-2 mt-1 text-gray-500 flex-wrap">
|
||||||
|
<span>DMG: {frame.baseDamage}</span><span>SPD: {frame.attackSpeed}/h</span>
|
||||||
|
<span>AP: {Math.round(frame.armorPierce * 100)}%</span><span>MA: {Math.round(frame.magicAffinity * 100)}%</span>
|
||||||
|
{frame.aoeTargets > 1 && <span>AoE: {frame.aoeTargets}</span>}
|
||||||
|
{frame.specialEffect !== 'none' && <span className="text-purple-400 capitalize">{frame.specialEffect}</span>}
|
||||||
|
</div>
|
||||||
|
{!unlocked && <p className="text-red-400 mt-0.5">🔒 {formatRequirement(frame.unlockRequirement)}</p>}
|
||||||
|
</div>
|
||||||
|
)} />
|
||||||
|
|
||||||
|
<ComponentSelector label="3. Mind Circuit (Behavior)" items={ALL_MIND_CIRCUITS} selectedId={selectedCircuitId} unlockedIds={unlockedCircuitIds} onSelect={onSelectCircuit}
|
||||||
|
renderItem={(circuit: MindCircuitDefinition, unlocked: boolean) => (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium">{circuit.name}</span>
|
||||||
|
<span className="text-gray-500">Slots: {circuit.spellSlots}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500 mt-0.5">{circuit.description}</p>
|
||||||
|
<div className="flex gap-2 mt-1 text-gray-500"><span className="capitalize">Behavior: {circuit.behavior}</span></div>
|
||||||
|
{!unlocked && <p className="text-red-400 mt-0.5">🔒 {formatRequirement(circuit.unlockRequirement)}</p>}
|
||||||
|
</div>
|
||||||
|
)} />
|
||||||
|
|
||||||
|
{selectedCore && selectedFrame && selectedCircuit && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-300">4. Enchantments (Optional)
|
||||||
|
<span className="text-gray-500 font-normal ml-2">{usedEnchantmentCapacity}/{Math.round(enchantmentCapacity)} capacity</span>
|
||||||
|
</h4>
|
||||||
|
{usedEnchantmentCapacity > enchantmentCapacity && <p className="text-xs text-red-400">Over capacity! Remove enchantments to save design.</p>}
|
||||||
|
<div className="grid grid-cols-1 gap-1.5">
|
||||||
|
{ALL_GOLEM_ENCHANTMENTS.map(enchant => {
|
||||||
|
const isSelected = selectedEnchantmentIds.includes(enchant.id);
|
||||||
|
const canAdd = !isSelected && usedEnchantmentCapacity + enchant.capacityCost <= enchantmentCapacity;
|
||||||
|
return (
|
||||||
|
<button key={enchant.id} onClick={() => onToggleEnchantment(enchant.id)} disabled={!isSelected && !canAdd}
|
||||||
|
className={clsx('text-left rounded px-3 py-2 text-xs transition-colors',
|
||||||
|
isSelected ? 'bg-purple-600/30 border border-purple-500 text-purple-200'
|
||||||
|
: canAdd ? 'bg-gray-800/60 border border-gray-700 text-gray-300 hover:bg-gray-700/60'
|
||||||
|
: 'bg-gray-900/40 border border-gray-800 text-gray-600 cursor-not-allowed')}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium">{enchant.name}</span>
|
||||||
|
<span className="text-gray-500">Cost: {enchant.capacityCost}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500 mt-0.5">{enchant.description}</p>
|
||||||
|
<p className="text-gray-600 mt-0.5">Effect: {enchant.effect}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-gray-700">
|
||||||
|
<button onClick={onSaveDesign} disabled={!canSaveDesign}
|
||||||
|
className={clsx('w-full rounded px-4 py-2 text-sm font-medium transition-colors',
|
||||||
|
canSaveDesign ? 'bg-blue-600 text-white hover:bg-blue-500' : 'bg-gray-700 text-gray-500 cursor-not-allowed')}>
|
||||||
|
{canSaveDesign ? 'Save Design' : 'Select all required components'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<div className="rounded border border-gray-700 p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-200 mb-3">Stats Preview</h3>
|
||||||
|
<StatsPreview stats={computedStats} canAfford={affordability} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
GolemDesignBuilder.displayName = 'GolemDesignBuilder';
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { CORES, FRAMES, MIND_CIRCUITS, GOLEM_ENCHANTMENTS } from '@/lib/game/data/golems';
|
||||||
|
import type { GolemLoadoutEntry } from './types';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
// ─── Loadout Entry Card ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface LoadoutCardProps {
|
||||||
|
entry: GolemLoadoutEntry;
|
||||||
|
onToggle: (designId: string) => void;
|
||||||
|
onRemove: (designId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoadoutCard: React.FC<LoadoutCardProps> = React.memo(({
|
||||||
|
entry,
|
||||||
|
onToggle,
|
||||||
|
onRemove,
|
||||||
|
}) => {
|
||||||
|
const core = CORES[entry.design.coreId];
|
||||||
|
const frame = FRAMES[entry.design.frameId];
|
||||||
|
const circuit = MIND_CIRCUITS[entry.design.mindCircuitId];
|
||||||
|
const enchantments = entry.design.enchantmentIds
|
||||||
|
.map(id => GOLEM_ENCHANTMENTS[id])
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx(
|
||||||
|
'rounded-lg border p-3 space-y-2 transition-colors',
|
||||||
|
entry.enabled
|
||||||
|
? 'bg-gray-800/50 border-gray-600'
|
||||||
|
: 'bg-gray-900/50 border-gray-800 opacity-70',
|
||||||
|
)}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h4 className="font-semibold text-sm text-gray-100 truncate">{entry.design.name}</h4>
|
||||||
|
<p className="text-[10px] text-gray-500 mt-0.5">
|
||||||
|
{core?.name ?? '?'} + {frame?.name ?? '?'} + {circuit?.name ?? '?'}
|
||||||
|
{enchantments.length > 0 && ` + ${enchantments.length} enchantment(s)`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge className={clsx(
|
||||||
|
'text-[10px] px-1.5 py-0 shrink-0',
|
||||||
|
entry.enabled ? 'bg-green-700' : 'bg-gray-700',
|
||||||
|
)}>
|
||||||
|
{entry.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
onClick={() => onToggle(entry.designId)}
|
||||||
|
className={clsx(
|
||||||
|
'rounded px-2.5 py-1 text-xs font-medium transition-colors',
|
||||||
|
entry.enabled
|
||||||
|
? 'bg-red-600/80 text-white hover:bg-red-500'
|
||||||
|
: 'bg-green-600/80 text-white hover:bg-green-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{entry.enabled ? 'Disable' : 'Enable'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove(entry.designId)}
|
||||||
|
className="rounded px-2.5 py-1 text-xs font-medium bg-gray-700 text-gray-300 hover:bg-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
LoadoutCard.displayName = 'LoadoutCard';
|
||||||
|
|
||||||
|
// ─── Props ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface GolemLoadoutPanelProps {
|
||||||
|
loadout: GolemLoadoutEntry[];
|
||||||
|
onToggle: (designId: string) => void;
|
||||||
|
onRemove: (designId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const GolemLoadoutPanel: React.FC<GolemLoadoutPanelProps> = ({
|
||||||
|
loadout,
|
||||||
|
onToggle,
|
||||||
|
onRemove,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<ScrollArea className="h-[560px] rounded border border-gray-700 p-3">
|
||||||
|
{loadout.length === 0 ? (
|
||||||
|
<div className="text-center text-gray-500 py-8">
|
||||||
|
<p>No golem designs saved yet.</p>
|
||||||
|
<p className="text-xs mt-1">Use the Design Builder to create and save golem designs.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{loadout.map(entry => (
|
||||||
|
<LoadoutCard
|
||||||
|
key={entry.designId}
|
||||||
|
entry={entry}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onRemove={onRemove}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
GolemLoadoutPanel.displayName = 'GolemLoadoutPanel';
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
// ─── Test: computeGolemStats ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('computeGolemStats', () => {
|
||||||
|
it('computes stats for a basic golem design', async () => {
|
||||||
|
const { CORES, FRAMES, MIND_CIRCUITS } = await import('@/lib/game/data/golems');
|
||||||
|
const { computeGolemStats } = await import('@/lib/game/data/golems/utils');
|
||||||
|
|
||||||
|
const design = {
|
||||||
|
id: 'test_basic',
|
||||||
|
name: 'Test Basic',
|
||||||
|
core: CORES.basic,
|
||||||
|
frame: FRAMES.earth,
|
||||||
|
mindCircuit: MIND_CIRCUITS.simple,
|
||||||
|
enchantments: [],
|
||||||
|
selectedManaTypes: [],
|
||||||
|
selectedSpells: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = computeGolemStats(design);
|
||||||
|
|
||||||
|
// Core-derived stats
|
||||||
|
expect(stats.manaCapacity).toBe(50);
|
||||||
|
expect(stats.manaRegen).toBe(0.5);
|
||||||
|
expect(stats.maxRoomDuration).toBe(3);
|
||||||
|
|
||||||
|
// Frame-derived stats
|
||||||
|
expect(stats.baseDamage).toBe(6);
|
||||||
|
expect(stats.attackSpeed).toBe(1.2);
|
||||||
|
expect(stats.armorPierce).toBe(0.05);
|
||||||
|
expect(stats.magicAffinity).toBe(0.3);
|
||||||
|
expect(stats.aoeTargets).toBe(1);
|
||||||
|
|
||||||
|
// Circuit-derived stats
|
||||||
|
expect(stats.spellSlots).toBe(0);
|
||||||
|
|
||||||
|
// Enchantment capacity = frame.magicAffinity * core.tierMultiplier
|
||||||
|
expect(stats.enchantmentCapacity).toBeCloseTo(0.3 * 1.0);
|
||||||
|
|
||||||
|
// Special effect from frame
|
||||||
|
expect(stats.specialEffect).toBe('none');
|
||||||
|
|
||||||
|
// Available mana types from core
|
||||||
|
expect(stats.availableManaTypes).toEqual(['earth']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes stats for an advanced golem with enchantments', async () => {
|
||||||
|
const { CORES, FRAMES, MIND_CIRCUITS, GOLEM_ENCHANTMENTS } = await import('@/lib/game/data/golems');
|
||||||
|
const { computeGolemStats } = await import('@/lib/game/data/golems/utils');
|
||||||
|
|
||||||
|
const design = {
|
||||||
|
id: 'test_advanced',
|
||||||
|
name: 'Test Advanced',
|
||||||
|
core: CORES.advanced,
|
||||||
|
frame: FRAMES.steel,
|
||||||
|
mindCircuit: MIND_CIRCUITS.advanced,
|
||||||
|
enchantments: [GOLEM_ENCHANTMENTS.sword_fire, GOLEM_ENCHANTMENTS.sword_metal],
|
||||||
|
selectedManaTypes: ['crystal', 'metal', 'fire'],
|
||||||
|
selectedSpells: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = computeGolemStats(design);
|
||||||
|
|
||||||
|
// Core-derived stats
|
||||||
|
expect(stats.manaCapacity).toBe(200);
|
||||||
|
expect(stats.manaRegen).toBe(3.0);
|
||||||
|
expect(stats.maxRoomDuration).toBe(5);
|
||||||
|
|
||||||
|
// Frame-derived stats
|
||||||
|
expect(stats.baseDamage).toBe(18);
|
||||||
|
expect(stats.attackSpeed).toBe(1.6);
|
||||||
|
expect(stats.armorPierce).toBe(0.5);
|
||||||
|
expect(stats.magicAffinity).toBe(0.5);
|
||||||
|
|
||||||
|
// Circuit-derived stats
|
||||||
|
expect(stats.spellSlots).toBe(2);
|
||||||
|
|
||||||
|
// Enchantment capacity = frame.magicAffinity * core.tierMultiplier
|
||||||
|
expect(stats.enchantmentCapacity).toBeCloseTo(0.5 * 2.0);
|
||||||
|
|
||||||
|
// Selected mana types override core defaults
|
||||||
|
expect(stats.availableManaTypes).toEqual(['crystal', 'metal', 'fire']);
|
||||||
|
|
||||||
|
// Total summon cost includes all components + enchantments
|
||||||
|
expect(stats.totalSummonCost.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Upkeep = core.manaRegen * 2 per hour
|
||||||
|
expect(stats.upkeepCostPerHour.length).toBe(1);
|
||||||
|
expect(stats.upkeepCostPerHour[0].amount).toBe(6.0); // 3.0 * 2
|
||||||
|
expect(stats.upkeepCostPerHour[0].element).toBe('crystal');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes total summon cost from all components', async () => {
|
||||||
|
const { CORES, FRAMES, MIND_CIRCUITS } = await import('@/lib/game/data/golems');
|
||||||
|
const { computeGolemStats } = await import('@/lib/game/data/golems/utils');
|
||||||
|
|
||||||
|
const design = {
|
||||||
|
id: 'test_cost',
|
||||||
|
name: 'Test Cost',
|
||||||
|
core: CORES.basic,
|
||||||
|
frame: FRAMES.earth,
|
||||||
|
mindCircuit: MIND_CIRCUITS.simple,
|
||||||
|
enchantments: [],
|
||||||
|
selectedManaTypes: [],
|
||||||
|
selectedSpells: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = computeGolemStats(design);
|
||||||
|
|
||||||
|
// basic core: 10 earth, earth frame: 5 raw, simple circuit: 3 raw
|
||||||
|
const rawCosts = stats.totalSummonCost.filter(c => c.type === 'raw');
|
||||||
|
const earthCosts = stats.totalSummonCost.filter(c => c.type === 'element' && c.element === 'earth');
|
||||||
|
|
||||||
|
const totalRaw = rawCosts.reduce((sum, c) => sum + c.amount, 0);
|
||||||
|
const totalEarth = earthCosts.reduce((sum, c) => sum + c.amount, 0);
|
||||||
|
|
||||||
|
expect(totalRaw).toBe(8); // 5 + 3
|
||||||
|
expect(totalEarth).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: canAffordGolemDesign ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('canAffordGolemDesign', () => {
|
||||||
|
it('returns canAfford true when player has enough resources', async () => {
|
||||||
|
const { CORES, FRAMES, MIND_CIRCUITS } = await import('@/lib/game/data/golems');
|
||||||
|
const { canAffordGolemDesign } = await import('@/lib/game/data/golems/utils');
|
||||||
|
|
||||||
|
const design = {
|
||||||
|
id: 'test_afford',
|
||||||
|
name: 'Test Afford',
|
||||||
|
core: CORES.basic,
|
||||||
|
frame: FRAMES.earth,
|
||||||
|
mindCircuit: MIND_CIRCUITS.simple,
|
||||||
|
enchantments: [],
|
||||||
|
selectedManaTypes: [],
|
||||||
|
selectedSpells: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// basic core: 10 earth, earth frame: 5 raw, simple circuit: 3 raw
|
||||||
|
const result = canAffordGolemDesign(design, 100, {
|
||||||
|
earth: { current: 50, max: 100, unlocked: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.canAfford).toBe(true);
|
||||||
|
expect(result.missing).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns canAfford false when raw mana is insufficient', async () => {
|
||||||
|
const { CORES, FRAMES, MIND_CIRCUITS } = await import('@/lib/game/data/golems');
|
||||||
|
const { canAffordGolemDesign } = await import('@/lib/game/data/golems/utils');
|
||||||
|
|
||||||
|
const design = {
|
||||||
|
id: 'test_no_raw',
|
||||||
|
name: 'Test No Raw',
|
||||||
|
core: CORES.basic,
|
||||||
|
frame: FRAMES.earth,
|
||||||
|
mindCircuit: MIND_CIRCUITS.simple,
|
||||||
|
enchantments: [],
|
||||||
|
selectedManaTypes: [],
|
||||||
|
selectedSpells: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Need 8 raw total (5 + 3), only have 3
|
||||||
|
const result = canAffordGolemDesign(design, 3, {
|
||||||
|
earth: { current: 50, max: 100, unlocked: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.canAfford).toBe(false);
|
||||||
|
expect(result.missing).toContain('raw mana');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns canAfford false when element mana is insufficient', async () => {
|
||||||
|
const { CORES, FRAMES, MIND_CIRCUITS } = await import('@/lib/game/data/golems');
|
||||||
|
const { canAffordGolemDesign } = await import('@/lib/game/data/golems/utils');
|
||||||
|
|
||||||
|
const design = {
|
||||||
|
id: 'test_no_elem',
|
||||||
|
name: 'Test No Elem',
|
||||||
|
core: CORES.basic,
|
||||||
|
frame: FRAMES.earth,
|
||||||
|
mindCircuit: MIND_CIRCUITS.simple,
|
||||||
|
enchantments: [],
|
||||||
|
selectedManaTypes: [],
|
||||||
|
selectedSpells: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Need 10 earth, only have 5
|
||||||
|
const result = canAffordGolemDesign(design, 100, {
|
||||||
|
earth: { current: 5, max: 100, unlocked: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.canAfford).toBe(false);
|
||||||
|
expect(result.missing).toContain('earth');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns canAfford false when element is not unlocked', async () => {
|
||||||
|
const { CORES, FRAMES, MIND_CIRCUITS } = await import('@/lib/game/data/golems');
|
||||||
|
const { canAffordGolemDesign } = await import('@/lib/game/data/golems/utils');
|
||||||
|
|
||||||
|
const design = {
|
||||||
|
id: 'test_locked',
|
||||||
|
name: 'Test Locked',
|
||||||
|
core: CORES.basic,
|
||||||
|
frame: FRAMES.earth,
|
||||||
|
mindCircuit: MIND_CIRCUITS.simple,
|
||||||
|
enchantments: [],
|
||||||
|
selectedManaTypes: [],
|
||||||
|
selectedSpells: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// earth not in elements at all
|
||||||
|
const result = canAffordGolemDesign(design, 100, {});
|
||||||
|
|
||||||
|
expect(result.canAfford).toBe(false);
|
||||||
|
expect(result.missing).toContain('not unlocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns canAfford false when element exists but unlocked is false', async () => {
|
||||||
|
const { CORES, FRAMES, MIND_CIRCUITS } = await import('@/lib/game/data/golems');
|
||||||
|
const { canAffordGolemDesign } = await import('@/lib/game/data/golems/utils');
|
||||||
|
|
||||||
|
const design = {
|
||||||
|
id: 'test_unlocked_false',
|
||||||
|
name: 'Test Unlocked False',
|
||||||
|
core: CORES.basic,
|
||||||
|
frame: FRAMES.earth,
|
||||||
|
mindCircuit: MIND_CIRCUITS.simple,
|
||||||
|
enchantments: [],
|
||||||
|
selectedManaTypes: [],
|
||||||
|
selectedSpells: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = canAffordGolemDesign(design, 100, {
|
||||||
|
earth: { current: 50, max: 100, unlocked: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.canAfford).toBe(false);
|
||||||
|
expect(result.missing).toContain('not unlocked');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: Combat store golemancy state ───────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Combat store golemancy', () => {
|
||||||
|
it('toggleGolem is a function', async () => {
|
||||||
|
const { useCombatStore } = await import('@/lib/game/stores/combatStore');
|
||||||
|
const state = useCombatStore.getState();
|
||||||
|
expect(typeof state.toggleGolem).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('golemancy state has correct shape', async () => {
|
||||||
|
const { useCombatStore } = await import('@/lib/game/stores/combatStore');
|
||||||
|
const state = useCombatStore.getState();
|
||||||
|
expect(state.golemancy).toBeDefined();
|
||||||
|
expect(Array.isArray(state.golemancy.golemLoadout)).toBe(true);
|
||||||
|
expect(Array.isArray(state.golemancy.activeGolems)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: File size limit ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('File size limits (400 lines max)', () => {
|
||||||
|
it('GolemancyTab.tsx is under 400 lines', async () => {
|
||||||
|
const fs = await import('fs');
|
||||||
|
const path = await import('path');
|
||||||
|
const filePath = path.join(__dirname, '..', 'GolemancyTab.tsx');
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const lines = content.split('\n').length;
|
||||||
|
expect(lines).toBeLessThan(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { ComputedGolemStats, CoreDefinition } from '@/lib/game/data/golems/types';
|
||||||
|
import { ELEMENTS } from '@/lib/game/constants/elements';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { formatManaCost, formatRequirement } from './golemancy-utils';
|
||||||
|
|
||||||
|
interface ComponentSelectorProps<T> {
|
||||||
|
label: string;
|
||||||
|
items: T[];
|
||||||
|
selectedId: string | null;
|
||||||
|
unlockedIds: Set<string>;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
renderItem: (item: T, unlocked: boolean, selected: boolean) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComponentSelector<T extends { id: string }>({
|
||||||
|
label, items, selectedId, unlockedIds, onSelect, renderItem,
|
||||||
|
}: ComponentSelectorProps<T>) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-300">{label}</h4>
|
||||||
|
<div className="grid grid-cols-1 gap-1.5">
|
||||||
|
{items.map(item => {
|
||||||
|
const unlocked = unlockedIds.has(item.id);
|
||||||
|
const selected = selectedId === item.id;
|
||||||
|
return (
|
||||||
|
<button key={item.id} onClick={() => unlocked && onSelect(item.id)} disabled={!unlocked}
|
||||||
|
className={clsx('text-left rounded px-3 py-2 text-xs transition-colors',
|
||||||
|
selected ? 'bg-blue-600/30 border border-blue-500 text-blue-200'
|
||||||
|
: unlocked ? 'bg-gray-800/60 border border-gray-700 text-gray-300 hover:bg-gray-700/60'
|
||||||
|
: 'bg-gray-900/40 border border-gray-800 text-gray-600 cursor-not-allowed')}>
|
||||||
|
{renderItem(item, unlocked, selected)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatsPreviewProps {
|
||||||
|
stats: ComputedGolemStats | null;
|
||||||
|
canAfford: { canAfford: boolean; missing: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatsPreview({ stats, canAfford }: StatsPreviewProps) {
|
||||||
|
if (!stats) return <div className="text-xs text-gray-500 italic">Select all required components to see stats preview.</div>;
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className={clsx('text-xs font-medium px-2 py-1 rounded',
|
||||||
|
canAfford.canAfford ? 'text-green-400 bg-green-900/20' : 'text-red-400 bg-red-900/20')}>
|
||||||
|
{canAfford.canAfford ? '✓ Can afford summon cost' : `✗ Cannot afford: ${canAfford.missing}`}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||||
|
<div className="text-gray-400"><span className="text-gray-500">Damage:</span> {stats.baseDamage}</div>
|
||||||
|
<div className="text-gray-400"><span className="text-gray-500">Atk Speed:</span> {stats.attackSpeed}/h</div>
|
||||||
|
<div className="text-gray-400"><span className="text-gray-500">Armor Pierce:</span> {Math.round(stats.armorPierce * 100)}%</div>
|
||||||
|
<div className="text-gray-400"><span className="text-gray-500">Magic Affinity:</span> {Math.round(stats.magicAffinity * 100)}%</div>
|
||||||
|
<div className="text-gray-400"><span className="text-gray-500">AoE Targets:</span> {stats.aoeTargets}</div>
|
||||||
|
<div className="text-gray-400"><span className="text-gray-500">Spell Slots:</span> {stats.spellSlots}</div>
|
||||||
|
<div className="text-gray-400"><span className="text-gray-500">Mana Capacity:</span> {stats.manaCapacity}</div>
|
||||||
|
<div className="text-gray-400"><span className="text-gray-500">Mana Regen:</span> {stats.manaRegen}/h</div>
|
||||||
|
<div className="text-gray-400"><span className="text-gray-500">Room Duration:</span> {stats.maxRoomDuration} rooms</div>
|
||||||
|
<div className="text-gray-400"><span className="text-gray-500">Enchant Cap:</span> {Math.round(stats.enchantmentCapacity)}</div>
|
||||||
|
{stats.specialEffect !== 'none' && (
|
||||||
|
<div className="col-span-2 text-gray-400"><span className="text-gray-500">Special:</span>{' '}<span className="text-purple-400 capitalize">{stats.specialEffect}</span></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs"><span className="text-gray-500">Summon Cost:</span>{' '}
|
||||||
|
{stats.totalSummonCost.map((c, i) => <span key={i} className="text-gray-400">{formatManaCost(c)}{i < stats.totalSummonCost.length - 1 ? ' + ' : ''}</span>)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs"><span className="text-gray-500">Upkeep:</span>{' '}
|
||||||
|
{stats.upkeepCostPerHour.map((c, i) => <span key={i} className="text-gray-400">{formatManaCost(c)}{i < stats.upkeepCostPerHour.length - 1 ? ' + ' : ''}/h</span>)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs"><span className="text-gray-500">Mana Types:</span>{' '}
|
||||||
|
{stats.availableManaTypes.map((mt, i) => <span key={mt} className="text-gray-400">{ELEMENTS[mt]?.sym ?? ''}{mt}{i < stats.availableManaTypes.length - 1 ? ', ' : ''}</span>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { CORES, FRAMES, MIND_CIRCUITS, GOLEM_ENCHANTMENTS, ALL_CORES, ALL_FRAMES, ALL_MIND_CIRCUITS, ALL_GOLEM_ENCHANTMENTS } from '@/lib/game/data/golems';
|
||||||
|
|
||||||
|
// ─── Test: GolemancyTab barrel export ─────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('GolemancyTab module structure', () => {
|
||||||
|
it('exports GolemancyTab from barrel index', async () => {
|
||||||
|
const mod = await import('../GolemancyTab');
|
||||||
|
expect(mod.GolemancyTab).toBeDefined();
|
||||||
|
expect(typeof mod.GolemancyTab).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GolemancyTab has correct displayName', async () => {
|
||||||
|
const { GolemancyTab } = await import('../GolemancyTab');
|
||||||
|
expect(GolemancyTab.displayName).toBe('GolemancyTab');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: Barrel export includes GolemancyTab ────────────────────────────────
|
||||||
|
|
||||||
|
describe('Tab barrel export', () => {
|
||||||
|
it('includes GolemancyTab in the tabs index', async () => {
|
||||||
|
const mod = await import('@/components/game/tabs');
|
||||||
|
expect(mod.GolemancyTab).toBeDefined();
|
||||||
|
expect(typeof mod.GolemancyTab).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: Component registries ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Component registries', () => {
|
||||||
|
it('CORES has all four core definitions', () => {
|
||||||
|
expect(CORES.basic).toBeDefined();
|
||||||
|
expect(CORES.intermediate).toBeDefined();
|
||||||
|
expect(CORES.advanced).toBeDefined();
|
||||||
|
expect(CORES.guardian).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FRAMES has all seven frame definitions', () => {
|
||||||
|
expect(FRAMES.earth).toBeDefined();
|
||||||
|
expect(FRAMES.sand).toBeDefined();
|
||||||
|
expect(FRAMES.frost).toBeDefined();
|
||||||
|
expect(FRAMES.crystal).toBeDefined();
|
||||||
|
expect(FRAMES.steel).toBeDefined();
|
||||||
|
expect(FRAMES.shadowglass).toBeDefined();
|
||||||
|
expect(FRAMES.crystalSteelHybrid).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MIND_CIRCUITS has all four circuit definitions', () => {
|
||||||
|
expect(MIND_CIRCUITS.simple).toBeDefined();
|
||||||
|
expect(MIND_CIRCUITS.intermediate).toBeDefined();
|
||||||
|
expect(MIND_CIRCUITS.advanced).toBeDefined();
|
||||||
|
expect(MIND_CIRCUITS.guardian).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GOLEM_ENCHANTMENTS has all eight enchantment definitions', () => {
|
||||||
|
expect(GOLEM_ENCHANTMENTS.sword_fire).toBeDefined();
|
||||||
|
expect(GOLEM_ENCHANTMENTS.sword_frost).toBeDefined();
|
||||||
|
expect(GOLEM_ENCHANTMENTS.sword_lightning).toBeDefined();
|
||||||
|
expect(GOLEM_ENCHANTMENTS.sword_shadow).toBeDefined();
|
||||||
|
expect(GOLEM_ENCHANTMENTS.sword_metal).toBeDefined();
|
||||||
|
expect(GOLEM_ENCHANTMENTS.sword_crystal).toBeDefined();
|
||||||
|
expect(GOLEM_ENCHANTMENTS.sword_water).toBeDefined();
|
||||||
|
expect(GOLEM_ENCHANTMENTS.sword_earth).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ALL_CORES contains all core definitions', () => {
|
||||||
|
expect(ALL_CORES.length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ALL_FRAMES contains all frame definitions', () => {
|
||||||
|
expect(ALL_FRAMES.length).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ALL_MIND_CIRCUITS contains all circuit definitions', () => {
|
||||||
|
expect(ALL_MIND_CIRCUITS.length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ALL_GOLEM_ENCHANTMENTS contains all enchantment definitions', () => {
|
||||||
|
expect(ALL_GOLEM_ENCHANTMENTS.length).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all cores have required fields', () => {
|
||||||
|
for (const core of ALL_CORES) {
|
||||||
|
expect(core.id).toBeTruthy();
|
||||||
|
expect(core.name).toBeTruthy();
|
||||||
|
expect(core.description).toBeTruthy();
|
||||||
|
expect(core.tier).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(core.tier).toBeLessThanOrEqual(4);
|
||||||
|
expect(core.manaCapacity).toBeGreaterThan(0);
|
||||||
|
expect(core.manaRegen).toBeGreaterThan(0);
|
||||||
|
expect(core.maxRoomDuration).toBeGreaterThan(0);
|
||||||
|
expect(core.summonCost.length).toBeGreaterThan(0);
|
||||||
|
expect(core.primaryManaType).toBeTruthy();
|
||||||
|
expect(core.tierMultiplier).toBeGreaterThan(0);
|
||||||
|
expect(core.unlockRequirement).toBeDefined();
|
||||||
|
expect(core.unlockRequirement.type).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all frames have required fields', () => {
|
||||||
|
for (const frame of ALL_FRAMES) {
|
||||||
|
expect(frame.id).toBeTruthy();
|
||||||
|
expect(frame.name).toBeTruthy();
|
||||||
|
expect(frame.description).toBeTruthy();
|
||||||
|
expect(frame.baseDamage).toBeGreaterThan(0);
|
||||||
|
expect(frame.attackSpeed).toBeGreaterThan(0);
|
||||||
|
expect(frame.armorPierce).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(frame.magicAffinity).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(frame.aoeTargets).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(frame.summonCost.length).toBeGreaterThan(0);
|
||||||
|
expect(frame.specialEffect).toBeTruthy();
|
||||||
|
expect(frame.unlockRequirement).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all mind circuits have required fields', () => {
|
||||||
|
for (const circuit of ALL_MIND_CIRCUITS) {
|
||||||
|
expect(circuit.id).toBeTruthy();
|
||||||
|
expect(circuit.name).toBeTruthy();
|
||||||
|
expect(circuit.description).toBeTruthy();
|
||||||
|
expect(circuit.spellSlots).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(circuit.behavior).toBeTruthy();
|
||||||
|
expect(circuit.summonCost.length).toBeGreaterThan(0);
|
||||||
|
expect(circuit.unlockRequirement).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all enchantments have required fields', () => {
|
||||||
|
for (const enchant of ALL_GOLEM_ENCHANTMENTS) {
|
||||||
|
expect(enchant.id).toBeTruthy();
|
||||||
|
expect(enchant.name).toBeTruthy();
|
||||||
|
expect(enchant.description).toBeTruthy();
|
||||||
|
expect(enchant.effect).toBeTruthy();
|
||||||
|
expect(enchant.capacityCost).toBeGreaterThan(0);
|
||||||
|
expect(enchant.summonCost.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cores span four tiers', () => {
|
||||||
|
const tiers = new Set(ALL_CORES.map(c => c.tier));
|
||||||
|
expect(tiers.size).toBe(4);
|
||||||
|
expect(tiers.has(1)).toBe(true);
|
||||||
|
expect(tiers.has(2)).toBe(true);
|
||||||
|
expect(tiers.has(3)).toBe(true);
|
||||||
|
expect(tiers.has(4)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('basic core is the only tier 1 core', () => {
|
||||||
|
const tier1 = ALL_CORES.filter(c => c.tier === 1);
|
||||||
|
expect(tier1.length).toBe(1);
|
||||||
|
expect(tier1[0].id).toBe('basic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('guardian core is the highest tier', () => {
|
||||||
|
const guardian = CORES.guardian;
|
||||||
|
expect(guardian).toBeDefined();
|
||||||
|
expect(guardian.tier).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { getGolemSlots, isComponentUnlocked } from '@/lib/game/data/golems/utils';
|
||||||
|
|
||||||
|
// ─── Test: getGolemSlots ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getGolemSlots', () => {
|
||||||
|
it('returns 0 for fabricator level < 2', () => {
|
||||||
|
expect(getGolemSlots(0)).toBe(0);
|
||||||
|
expect(getGolemSlots(1)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales with fabricator level', () => {
|
||||||
|
expect(getGolemSlots(2)).toBe(1);
|
||||||
|
expect(getGolemSlots(3)).toBe(1);
|
||||||
|
expect(getGolemSlots(4)).toBe(2);
|
||||||
|
expect(getGolemSlots(5)).toBe(2);
|
||||||
|
expect(getGolemSlots(6)).toBe(3);
|
||||||
|
expect(getGolemSlots(7)).toBe(3);
|
||||||
|
expect(getGolemSlots(8)).toBe(4);
|
||||||
|
expect(getGolemSlots(9)).toBe(4);
|
||||||
|
expect(getGolemSlots(10)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps at 5 slots for level 10+', () => {
|
||||||
|
expect(getGolemSlots(10)).toBe(5);
|
||||||
|
expect(getGolemSlots(11)).toBe(5);
|
||||||
|
expect(getGolemSlots(20)).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: isComponentUnlocked ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('isComponentUnlocked', () => {
|
||||||
|
it('returns true for attunement_level requirement when met', () => {
|
||||||
|
const req = { type: 'attunement_level' as const, attunement: 'fabricator', level: 2 };
|
||||||
|
const attunements = { fabricator: { active: true, level: 2 } };
|
||||||
|
expect(isComponentUnlocked(req, attunements, [], [])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for attunement_level when level is too low', () => {
|
||||||
|
const req = { type: 'attunement_level' as const, attunement: 'fabricator', level: 2 };
|
||||||
|
const attunements = { fabricator: { active: true, level: 1 } };
|
||||||
|
expect(isComponentUnlocked(req, attunements, [], [])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for attunement_level when attunement is inactive', () => {
|
||||||
|
const req = { type: 'attunement_level' as const, attunement: 'fabricator', level: 2 };
|
||||||
|
const attunements = { fabricator: { active: false, level: 5 } };
|
||||||
|
expect(isComponentUnlocked(req, attunements, [], [])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for mana_unlocked requirement when element is unlocked', () => {
|
||||||
|
const req = { type: 'mana_unlocked' as const, manaType: 'frost' };
|
||||||
|
expect(isComponentUnlocked(req, {}, ['earth', 'frost'], [])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for mana_unlocked when element is not unlocked', () => {
|
||||||
|
const req = { type: 'mana_unlocked' as const, manaType: 'frost' };
|
||||||
|
expect(isComponentUnlocked(req, {}, ['earth'], [])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for dual_attunement when both requirements met', () => {
|
||||||
|
const req = {
|
||||||
|
type: 'dual_attunement' as const,
|
||||||
|
attunements: ['fabricator', 'enchanter'],
|
||||||
|
levels: [4, 2],
|
||||||
|
};
|
||||||
|
const attunements = {
|
||||||
|
fabricator: { active: true, level: 4 },
|
||||||
|
enchanter: { active: true, level: 2 },
|
||||||
|
};
|
||||||
|
expect(isComponentUnlocked(req, attunements, [], [])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for dual_attunement when one attunement is too low', () => {
|
||||||
|
const req = {
|
||||||
|
type: 'dual_attunement' as const,
|
||||||
|
attunements: ['fabricator', 'enchanter'],
|
||||||
|
levels: [4, 2],
|
||||||
|
};
|
||||||
|
const attunements = {
|
||||||
|
fabricator: { active: true, level: 4 },
|
||||||
|
enchanter: { active: true, level: 1 },
|
||||||
|
};
|
||||||
|
expect(isComponentUnlocked(req, attunements, [], [])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for guardian_pact when attunements and pact are present', () => {
|
||||||
|
const req = {
|
||||||
|
type: 'guardian_pact' as const,
|
||||||
|
attunements: ['invoker', 'fabricator'],
|
||||||
|
levels: [5, 5],
|
||||||
|
};
|
||||||
|
const attunements = {
|
||||||
|
invoker: { active: true, level: 5 },
|
||||||
|
fabricator: { active: true, level: 5 },
|
||||||
|
};
|
||||||
|
expect(isComponentUnlocked(req, attunements, [], [1])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for guardian_pact when no pacts signed', () => {
|
||||||
|
const req = {
|
||||||
|
type: 'guardian_pact' as const,
|
||||||
|
attunements: ['invoker', 'fabricator'],
|
||||||
|
levels: [5, 5],
|
||||||
|
};
|
||||||
|
const attunements = {
|
||||||
|
invoker: { active: true, level: 5 },
|
||||||
|
fabricator: { active: true, level: 5 },
|
||||||
|
};
|
||||||
|
expect(isComponentUnlocked(req, attunements, [], [])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for unknown requirement type', () => {
|
||||||
|
const req = { type: 'unknown_type' as any };
|
||||||
|
expect(isComponentUnlocked(req, {}, [], [])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type {
|
||||||
|
CoreDefinition,
|
||||||
|
FrameDefinition,
|
||||||
|
MindCircuitDefinition,
|
||||||
|
GolemEnchantmentDefinition,
|
||||||
|
GolemDesign,
|
||||||
|
} from '@/lib/game/data/golems/types';
|
||||||
|
import type { GolemManaCost } from './types';
|
||||||
|
import { ELEMENTS } from '@/lib/game/constants/elements';
|
||||||
|
|
||||||
|
export function formatManaCost(cost: GolemManaCost): string {
|
||||||
|
if (cost.type === 'raw') return `${cost.amount} raw`;
|
||||||
|
const elem = cost.element ? ELEMENTS[cost.element] : null;
|
||||||
|
return `${cost.amount} ${elem?.sym ?? ''} ${cost.element ?? ''}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRequirement(req: CoreDefinition['unlockRequirement']): string {
|
||||||
|
switch (req.type) {
|
||||||
|
case 'attunement_level':
|
||||||
|
return `${req.attunement} level ${req.level}`;
|
||||||
|
case 'mana_unlocked': {
|
||||||
|
const elem = req.manaType ? ELEMENTS[req.manaType] : null;
|
||||||
|
return `Unlock ${elem?.sym ?? ''} ${req.manaType ?? ''}`.trim();
|
||||||
|
}
|
||||||
|
case 'dual_attunement':
|
||||||
|
return `${req.attunements?.join(' + ')} level ${req.levels?.join('/')}`;
|
||||||
|
case 'guardian_pact':
|
||||||
|
return `${req.attunements?.join(' + ')} level ${req.levels?.join('/')} + Guardian Pact`;
|
||||||
|
default:
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeDesign(
|
||||||
|
name: string,
|
||||||
|
core: CoreDefinition,
|
||||||
|
frame: FrameDefinition,
|
||||||
|
circuit: MindCircuitDefinition,
|
||||||
|
enchantments: GolemEnchantmentDefinition[],
|
||||||
|
selectedManaTypes: string[],
|
||||||
|
selectedSpells: string[],
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
id: `design_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
name,
|
||||||
|
coreId: core.id,
|
||||||
|
frameId: frame.id,
|
||||||
|
mindCircuitId: circuit.id,
|
||||||
|
enchantmentIds: enchantments.map(e => e.id),
|
||||||
|
selectedManaTypes,
|
||||||
|
selectedSpells,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGolemDesign(
|
||||||
|
core: CoreDefinition,
|
||||||
|
frame: FrameDefinition,
|
||||||
|
circuit: MindCircuitDefinition,
|
||||||
|
enchantments: GolemEnchantmentDefinition[],
|
||||||
|
selectedManaTypes: string[],
|
||||||
|
selectedSpells: string[],
|
||||||
|
): GolemDesign {
|
||||||
|
return {
|
||||||
|
id: `design_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
name: `${core.name.split(' ')[0]} ${frame.name.split(' ')[0]}`,
|
||||||
|
core,
|
||||||
|
frame,
|
||||||
|
mindCircuit: circuit,
|
||||||
|
enchantments,
|
||||||
|
selectedManaTypes,
|
||||||
|
selectedSpells,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// ─── Shared Golemancy UI Types ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CoreDefinition,
|
||||||
|
FrameDefinition,
|
||||||
|
MindCircuitDefinition,
|
||||||
|
GolemEnchantmentDefinition,
|
||||||
|
GolemDesign,
|
||||||
|
ComputedGolemStats,
|
||||||
|
GolemManaCost,
|
||||||
|
} from '@/lib/game/data/golems/types';
|
||||||
|
import type {
|
||||||
|
SerializedGolemDesign,
|
||||||
|
GolemLoadoutEntry,
|
||||||
|
RuntimeActiveGolem,
|
||||||
|
} from '@/lib/game/types/game';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
CoreDefinition,
|
||||||
|
FrameDefinition,
|
||||||
|
MindCircuitDefinition,
|
||||||
|
GolemEnchantmentDefinition,
|
||||||
|
GolemDesign,
|
||||||
|
ComputedGolemStats,
|
||||||
|
GolemManaCost,
|
||||||
|
SerializedGolemDesign,
|
||||||
|
GolemLoadoutEntry,
|
||||||
|
RuntimeActiveGolem,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BuilderSection = 'builder' | 'loadout' | 'active';
|
||||||
|
|
||||||
|
export interface AttunementLookup {
|
||||||
|
[id: string]: { active: boolean; level: number };
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ function resetStores() {
|
|||||||
roomResetState: {},
|
roomResetState: {},
|
||||||
clearedRooms: {},
|
clearedRooms: {},
|
||||||
isDescentComplete: false,
|
isDescentComplete: false,
|
||||||
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
|
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
|
||||||
equipmentSpellStates: [],
|
equipmentSpellStates: [],
|
||||||
comboHitCount: 0,
|
comboHitCount: 0,
|
||||||
floorHitCount: 0,
|
floorHitCount: 0,
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function resetAllStores() {
|
|||||||
roomResetState: {},
|
roomResetState: {},
|
||||||
clearedRooms: {},
|
clearedRooms: {},
|
||||||
isDescentComplete: false,
|
isDescentComplete: false,
|
||||||
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
|
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
|
||||||
equipmentSpellStates: [],
|
equipmentSpellStates: [],
|
||||||
comboHitCount: 0,
|
comboHitCount: 0,
|
||||||
floorHitCount: 0,
|
floorHitCount: 0,
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function resetStores() {
|
|||||||
roomResetState: {},
|
roomResetState: {},
|
||||||
clearedRooms: {},
|
clearedRooms: {},
|
||||||
isDescentComplete: false,
|
isDescentComplete: false,
|
||||||
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
|
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
|
||||||
equipmentSpellStates: [],
|
equipmentSpellStates: [],
|
||||||
comboHitCount: 0,
|
comboHitCount: 0,
|
||||||
floorHitCount: 0,
|
floorHitCount: 0,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function resetStores() {
|
|||||||
roomResetState: {},
|
roomResetState: {},
|
||||||
clearedRooms: {},
|
clearedRooms: {},
|
||||||
isDescentComplete: false,
|
isDescentComplete: false,
|
||||||
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
|
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
|
||||||
equipmentSpellStates: [],
|
equipmentSpellStates: [],
|
||||||
comboHitCount: 0,
|
comboHitCount: 0,
|
||||||
floorHitCount: 0,
|
floorHitCount: 0,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function resetCombatStore() {
|
|||||||
clearedFloors: {},
|
clearedFloors: {},
|
||||||
climbDirection: null,
|
climbDirection: null,
|
||||||
isDescending: false,
|
isDescending: false,
|
||||||
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
|
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
|
||||||
equipmentSpellStates: [],
|
equipmentSpellStates: [],
|
||||||
comboHitCount: 0,
|
comboHitCount: 0,
|
||||||
floorHitCount: 0,
|
floorHitCount: 0,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function resetCombatStore() {
|
|||||||
clearedFloors: {},
|
clearedFloors: {},
|
||||||
climbDirection: null,
|
climbDirection: null,
|
||||||
isDescending: false,
|
isDescending: false,
|
||||||
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
|
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
|
||||||
equipmentSpellStates: [],
|
equipmentSpellStates: [],
|
||||||
comboHitCount: 0,
|
comboHitCount: 0,
|
||||||
floorHitCount: 0,
|
floorHitCount: 0,
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ function resetAllStores() {
|
|||||||
clearedFloors: {},
|
clearedFloors: {},
|
||||||
climbDirection: null,
|
climbDirection: null,
|
||||||
isDescending: false,
|
isDescending: false,
|
||||||
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
|
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
|
||||||
equipmentSpellStates: [],
|
equipmentSpellStates: [],
|
||||||
comboHitCount: 0,
|
comboHitCount: 0,
|
||||||
floorHitCount: 0,
|
floorHitCount: 0,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const fabricatorDisciplines: DisciplineDefinition[] = [
|
|||||||
type: 'once',
|
type: 'once',
|
||||||
threshold: 200,
|
threshold: 200,
|
||||||
value: 0,
|
value: 0,
|
||||||
description: 'Unlock golem summoning',
|
description: 'Unlock golem design ability',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'golem-2',
|
id: 'golem-2',
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// ─── Core Definitions ────────────────────────────────────────────────────
|
||||||
|
// Power sources for golems. Determine mana types, capacity, regen, upkeep, duration.
|
||||||
|
|
||||||
|
import type { CoreDefinition } from './types';
|
||||||
|
import { elemCost } from './types';
|
||||||
|
|
||||||
|
// ───_BASIC CORE ────────────────────────────────────────────────────────────
|
||||||
|
// Fabricator 2 — single Earth mana, modest stats
|
||||||
|
const BASIC_CORE: CoreDefinition = {
|
||||||
|
id: 'basic',
|
||||||
|
tier: 1,
|
||||||
|
name: 'Basic Core',
|
||||||
|
description: 'A simple earth-infused core. Provides modest mana capacity and single-element support.',
|
||||||
|
manaTypes: ['earth'],
|
||||||
|
manaCapacity: 50,
|
||||||
|
manaRegen: 0.5,
|
||||||
|
maxRoomDuration: 3,
|
||||||
|
summonCost: [elemCost('earth', 10)],
|
||||||
|
primaryManaType: 'earth',
|
||||||
|
tierMultiplier: 1.0,
|
||||||
|
unlockRequirement: {
|
||||||
|
type: 'attunement_level',
|
||||||
|
attunement: 'fabricator',
|
||||||
|
level: 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── INTERMEDIATE CORE ────────────────────────────────────────────────────
|
||||||
|
// Fabricator 4 + Enchanter 2 — choose 2 mana types
|
||||||
|
const INTERMEDIATE_CORE: CoreDefinition = {
|
||||||
|
id: 'intermediate',
|
||||||
|
tier: 2,
|
||||||
|
name: 'Intermediate Core',
|
||||||
|
description: 'A refined crystal core supporting two mana types. Player selects from unlocked elements.',
|
||||||
|
manaTypes: ['earth'], // Default; player overrides with chosen types
|
||||||
|
manaCapacity: 100,
|
||||||
|
manaRegen: 1.5,
|
||||||
|
maxRoomDuration: 4,
|
||||||
|
summonCost: [elemCost('crystal', 20)],
|
||||||
|
primaryManaType: 'crystal',
|
||||||
|
tierMultiplier: 1.5,
|
||||||
|
unlockRequirement: {
|
||||||
|
type: 'dual_attunement',
|
||||||
|
attunements: ['fabricator', 'enchanter'],
|
||||||
|
levels: [4, 2],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── ADVANCED CORE ───────────────────────────────────────────────────────
|
||||||
|
// Fabricator 6 + Enchanter 3 — choose 3 mana types
|
||||||
|
const ADVANCED_CORE: CoreDefinition = {
|
||||||
|
id: 'advanced',
|
||||||
|
tier: 3,
|
||||||
|
name: 'Advanced Core',
|
||||||
|
description: 'A powerful crystal core supporting three mana types. Player selects from unlocked elements.',
|
||||||
|
manaTypes: ['earth'], // Default; player overrides with chosen types
|
||||||
|
manaCapacity: 200,
|
||||||
|
manaRegen: 3.0,
|
||||||
|
maxRoomDuration: 5,
|
||||||
|
summonCost: [elemCost('crystal', 30)],
|
||||||
|
primaryManaType: 'crystal',
|
||||||
|
tierMultiplier: 2.0,
|
||||||
|
unlockRequirement: {
|
||||||
|
type: 'dual_attunement',
|
||||||
|
attunements: ['fabricator', 'enchanter'],
|
||||||
|
levels: [6, 3],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── GUARDIAN CORE ───────────────────────────────────────────────────────
|
||||||
|
// Invoker 5 + Fabricator 5 + Guardian Pact — guardian-specific mana types
|
||||||
|
const GUARDIAN_CORE: CoreDefinition = {
|
||||||
|
id: 'guardian',
|
||||||
|
tier: 4,
|
||||||
|
name: 'Guardian Core',
|
||||||
|
description: 'A legendary core imbued with guardian energy. Provides all mana types granted by the chosen Guardian. Required for Guardian Constructs.',
|
||||||
|
manaTypes: ['earth'], // Overridden by guardian-specific types when assigned
|
||||||
|
manaCapacity: 500,
|
||||||
|
manaRegen: 10.0,
|
||||||
|
maxRoomDuration: 8,
|
||||||
|
summonCost: [elemCost('earth', 50)], // Guardian-specific in practice
|
||||||
|
primaryManaType: 'earth', // Overridden by guardian pact
|
||||||
|
tierMultiplier: 3.0,
|
||||||
|
unlockRequirement: {
|
||||||
|
type: 'guardian_pact',
|
||||||
|
attunements: ['invoker', 'fabricator'],
|
||||||
|
levels: [5, 5],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── CORE REGISTRY ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const CORES: Record<string, CoreDefinition> = {
|
||||||
|
[BASIC_CORE.id]: BASIC_CORE,
|
||||||
|
[INTERMEDIATE_CORE.id]: INTERMEDIATE_CORE,
|
||||||
|
[ADVANCED_CORE.id]: ADVANCED_CORE,
|
||||||
|
[GUARDIAN_CORE.id]: GUARDIAN_CORE,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ALL_CORES = [BASIC_CORE, INTERMEDIATE_CORE, ADVANCED_CORE, GUARDIAN_CORE];
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// ─── Frame Definitions ───────────────────────────────────────────────────
|
||||||
|
// Physical combat characteristics for golems: damage, speed, armor pierce, magic affinity, special.
|
||||||
|
|
||||||
|
import type { FrameDefinition } from './types';
|
||||||
|
import { elemCost, rawCost } from './types';
|
||||||
|
|
||||||
|
const EARTH_FRAME: FrameDefinition = {
|
||||||
|
id: 'earth',
|
||||||
|
name: 'Earth Frame',
|
||||||
|
description: 'A sturdy construct of stone and soil. Balanced but unremarkable.',
|
||||||
|
baseDamage: 6,
|
||||||
|
attackSpeed: 1.2,
|
||||||
|
armorPierce: 0.05,
|
||||||
|
magicAffinity: 0.3,
|
||||||
|
aoeTargets: 1,
|
||||||
|
element: 'earth',
|
||||||
|
specialEffect: 'none',
|
||||||
|
summonCost: [rawCost(5)],
|
||||||
|
unlockRequirement: {
|
||||||
|
type: 'attunement_level',
|
||||||
|
attunement: 'fabricator',
|
||||||
|
level: 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const SAND_FRAME: FrameDefinition = {
|
||||||
|
id: 'sand',
|
||||||
|
name: 'Sand Frame',
|
||||||
|
description: 'A shifting construct of sand particles. Hits multiple enemies with high armor pierce.',
|
||||||
|
baseDamage: 8,
|
||||||
|
attackSpeed: 1.0,
|
||||||
|
armorPierce: 0.6,
|
||||||
|
magicAffinity: 0.5,
|
||||||
|
aoeTargets: 2,
|
||||||
|
element: 'sand',
|
||||||
|
specialEffect: 'aoe',
|
||||||
|
summonCost: [elemCost('sand', 8)],
|
||||||
|
unlockRequirement: {
|
||||||
|
type: 'mana_unlocked',
|
||||||
|
manaType: 'sand',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const FROST_FRAME: FrameDefinition = {
|
||||||
|
id: 'frost',
|
||||||
|
name: 'Frost Frame',
|
||||||
|
description: 'An icy construct that slows enemies on hit. High magic affinity.',
|
||||||
|
baseDamage: 10,
|
||||||
|
attackSpeed: 1.2,
|
||||||
|
armorPierce: 0.25,
|
||||||
|
magicAffinity: 0.8,
|
||||||
|
aoeTargets: 1,
|
||||||
|
element: 'frost',
|
||||||
|
specialEffect: 'slow',
|
||||||
|
summonCost: [elemCost('frost', 10)],
|
||||||
|
unlockRequirement: {
|
||||||
|
type: 'mana_unlocked',
|
||||||
|
manaType: 'frost',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CRYSTAL_FRAME: FrameDefinition = {
|
||||||
|
id: 'crystal',
|
||||||
|
name: 'Crystal Frame',
|
||||||
|
description: 'A prismatic construct dealing high damage with precision. Very high magic affinity.',
|
||||||
|
baseDamage: 14,
|
||||||
|
attackSpeed: 1.8,
|
||||||
|
armorPierce: 0.15,
|
||||||
|
magicAffinity: 0.9,
|
||||||
|
aoeTargets: 1,
|
||||||
|
element: 'crystal',
|
||||||
|
specialEffect: 'none',
|
||||||
|
summonCost: [elemCost('crystal', 12)],
|
||||||
|
unlockRequirement: {
|
||||||
|
type: 'mana_unlocked',
|
||||||
|
manaType: 'crystal',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const STEEL_FRAME: FrameDefinition = {
|
||||||
|
id: 'steel',
|
||||||
|
name: 'Steel Frame',
|
||||||
|
description: 'Forged from metal, this frame delivers devastating attacks with high armor pierce.',
|
||||||
|
baseDamage: 18,
|
||||||
|
attackSpeed: 1.6,
|
||||||
|
armorPierce: 0.5,
|
||||||
|
magicAffinity: 0.5,
|
||||||
|
aoeTargets: 1,
|
||||||
|
element: 'metal',
|
||||||
|
specialEffect: 'none',
|
||||||
|
summonCost: [elemCost('metal', 14)],
|
||||||
|
unlockRequirement: {
|
||||||
|
type: 'mana_unlocked',
|
||||||
|
manaType: 'metal',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const SHADOWGLASS_FRAME: FrameDefinition = {
|
||||||
|
id: 'shadowglass',
|
||||||
|
name: 'Shadowglass Frame',
|
||||||
|
description: 'Volcanic glass animated by shadow. Extremely fast AoE attacks with devastating magic affinity.',
|
||||||
|
baseDamage: 20,
|
||||||
|
attackSpeed: 2.5,
|
||||||
|
armorPierce: 0.65,
|
||||||
|
magicAffinity: 0.95,
|
||||||
|
aoeTargets: 2,
|
||||||
|
element: 'shadowglass',
|
||||||
|
specialEffect: 'aoe',
|
||||||
|
summonCost: [elemCost('shadowglass', 18), rawCost(10)],
|
||||||
|
unlockRequirement: {
|
||||||
|
type: 'mana_unlocked',
|
||||||
|
manaType: 'shadowglass',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CRYSTAL_STEEL_HYBRID_FRAME: FrameDefinition = {
|
||||||
|
id: 'crystalSteelHybrid',
|
||||||
|
name: 'Crystal-Steel Hybrid Frame',
|
||||||
|
description: 'An advanced hybrid frame capable of housing Guardian Cores. Highest combined stats. Required for Guardian Constructs.',
|
||||||
|
baseDamage: 22,
|
||||||
|
attackSpeed: 2.8,
|
||||||
|
armorPierce: 0.7,
|
||||||
|
magicAffinity: 1.0,
|
||||||
|
aoeTargets: 1,
|
||||||
|
element: 'crystal',
|
||||||
|
specialEffect: 'guardianConstruct',
|
||||||
|
summonCost: [elemCost('crystal', 20), elemCost('metal', 15), rawCost(15)],
|
||||||
|
unlockRequirement: {
|
||||||
|
type: 'attunement_level',
|
||||||
|
attunement: 'fabricator',
|
||||||
|
level: 5,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── FRAME REGISTRY ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const FRAMES: Record<string, FrameDefinition> = {
|
||||||
|
[EARTH_FRAME.id]: EARTH_FRAME,
|
||||||
|
[SAND_FRAME.id]: SAND_FRAME,
|
||||||
|
[FROST_FRAME.id]: FROST_FRAME,
|
||||||
|
[CRYSTAL_FRAME.id]: CRYSTAL_FRAME,
|
||||||
|
[STEEL_FRAME.id]: STEEL_FRAME,
|
||||||
|
[SHADOWGLASS_FRAME.id]: SHADOWGLASS_FRAME,
|
||||||
|
[CRYSTAL_STEEL_HYBRID_FRAME.id]: CRYSTAL_STEEL_HYBRID_FRAME,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ALL_FRAMES = [
|
||||||
|
EARTH_FRAME,
|
||||||
|
SAND_FRAME,
|
||||||
|
FROST_FRAME,
|
||||||
|
CRYSTAL_FRAME,
|
||||||
|
STEEL_FRAME,
|
||||||
|
SHADOWGLASS_FRAME,
|
||||||
|
CRYSTAL_STEEL_HYBRID_FRAME,
|
||||||
|
];
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// ─── Golem Enchantment Definitions ───────────────────────────────────────
|
||||||
|
// Optional sword effects applied to golem basic attacks.
|
||||||
|
// Requires Enchanter 5 + Fabricator 5.
|
||||||
|
|
||||||
|
import type { GolemEnchantmentDefinition } from './types';
|
||||||
|
import { elemCost } from './types';
|
||||||
|
|
||||||
|
const SWORD_FIRE: GolemEnchantmentDefinition = {
|
||||||
|
id: 'sword_fire',
|
||||||
|
name: 'Sword: Fire',
|
||||||
|
description: 'Applies Burn DoT on basic attack.',
|
||||||
|
effect: 'burn',
|
||||||
|
capacityCost: 10,
|
||||||
|
summonCost: [elemCost('fire', 5)],
|
||||||
|
};
|
||||||
|
|
||||||
|
const SWORD_FROST: GolemEnchantmentDefinition = {
|
||||||
|
id: 'sword_frost',
|
||||||
|
name: 'Sword: Frost',
|
||||||
|
description: 'Applies additional Slow on basic attack.',
|
||||||
|
effect: 'slow',
|
||||||
|
capacityCost: 10,
|
||||||
|
summonCost: [elemCost('frost', 5)],
|
||||||
|
};
|
||||||
|
|
||||||
|
const SWORD_LIGHTNING: GolemEnchantmentDefinition = {
|
||||||
|
id: 'sword_lightning',
|
||||||
|
name: 'Sword: Lightning',
|
||||||
|
description: 'Chance to Shock (stun) on basic attack.',
|
||||||
|
effect: 'shock',
|
||||||
|
capacityCost: 12,
|
||||||
|
summonCost: [elemCost('lightning', 6)],
|
||||||
|
};
|
||||||
|
|
||||||
|
const SWORD_SHADOW: GolemEnchantmentDefinition = {
|
||||||
|
id: 'sword_shadow',
|
||||||
|
name: 'Sword: Shadow',
|
||||||
|
description: 'Chance to Weaken (reduce enemy damage) on basic attack.',
|
||||||
|
effect: 'weaken',
|
||||||
|
capacityCost: 12,
|
||||||
|
summonCost: [elemCost('dark', 6)],
|
||||||
|
};
|
||||||
|
|
||||||
|
const SWORD_METAL: GolemEnchantmentDefinition = {
|
||||||
|
id: 'sword_metal',
|
||||||
|
name: 'Sword: Metal',
|
||||||
|
description: 'Bonus Armor Pierce on basic attack.',
|
||||||
|
effect: 'armorPierce',
|
||||||
|
capacityCost: 8,
|
||||||
|
summonCost: [elemCost('metal', 5)],
|
||||||
|
};
|
||||||
|
|
||||||
|
const SWORD_CRYSTAL: GolemEnchantmentDefinition = {
|
||||||
|
id: 'sword_crystal',
|
||||||
|
name: 'Sword: Crystal',
|
||||||
|
description: 'Bonus Critical Chance on basic attack.',
|
||||||
|
effect: 'criticalChance',
|
||||||
|
capacityCost: 14,
|
||||||
|
summonCost: [elemCost('crystal', 7)],
|
||||||
|
};
|
||||||
|
|
||||||
|
const SWORD_WATER: GolemEnchantmentDefinition = {
|
||||||
|
id: 'sword_water',
|
||||||
|
name: 'Sword: Water',
|
||||||
|
description: 'Applies Soak on basic attack (increases lightning damage taken).',
|
||||||
|
effect: 'soak',
|
||||||
|
capacityCost: 8,
|
||||||
|
summonCost: [elemCost('water', 4)],
|
||||||
|
};
|
||||||
|
|
||||||
|
const SWORD_EARTH: GolemEnchantmentDefinition = {
|
||||||
|
id: 'sword_earth',
|
||||||
|
name: 'Sword: Earth',
|
||||||
|
description: 'Bonus damage to shielded enemies on basic attack.',
|
||||||
|
effect: 'shieldBreak',
|
||||||
|
capacityCost: 10,
|
||||||
|
summonCost: [elemCost('earth', 5)],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── ENCHANTMENT REGISTRY ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const GOLEM_ENCHANTMENTS: Record<string, GolemEnchantmentDefinition> = {
|
||||||
|
[SWORD_FIRE.id]: SWORD_FIRE,
|
||||||
|
[SWORD_FROST.id]: SWORD_FROST,
|
||||||
|
[SWORD_LIGHTNING.id]: SWORD_LIGHTNING,
|
||||||
|
[SWORD_SHADOW.id]: SWORD_SHADOW,
|
||||||
|
[SWORD_METAL.id]: SWORD_METAL,
|
||||||
|
[SWORD_CRYSTAL.id]: SWORD_CRYSTAL,
|
||||||
|
[SWORD_WATER.id]: SWORD_WATER,
|
||||||
|
[SWORD_EARTH.id]: SWORD_EARTH,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ALL_GOLEM_ENCHANTMENTS = [
|
||||||
|
SWORD_FIRE,
|
||||||
|
SWORD_FROST,
|
||||||
|
SWORD_LIGHTNING,
|
||||||
|
SWORD_SHADOW,
|
||||||
|
SWORD_METAL,
|
||||||
|
SWORD_CRYSTAL,
|
||||||
|
SWORD_WATER,
|
||||||
|
SWORD_EARTH,
|
||||||
|
];
|
||||||
@@ -1,14 +1,8 @@
|
|||||||
// ─── Golem Definitions Data ─────────────────────────
|
// ─── Golem Definitions Data ──────────────────────────────────────────────
|
||||||
// Combined golem definitions from all golem modules.
|
// Combined component registries for the component-based golem system.
|
||||||
// Extracted to a standalone module to avoid circular dependencies
|
// Extracted to a standalone module to avoid circular dependencies.
|
||||||
// between index.ts and utils.ts.
|
|
||||||
|
|
||||||
import { BASE_GOLEMS } from './base-golems';
|
export { CORES, ALL_CORES } from './cores';
|
||||||
import { ELEMENTAL_GOLEMS } from './elemental-golems';
|
export { FRAMES, ALL_FRAMES } from './frames';
|
||||||
import { HYBRID_GOLEMS } from './hybrid-golems';
|
export { MIND_CIRCUITS, ALL_MIND_CIRCUITS } from './mindCircuits';
|
||||||
|
export { GOLEM_ENCHANTMENTS, ALL_GOLEM_ENCHANTMENTS } from './golemEnchantments';
|
||||||
export const GOLEMS_DEF = {
|
|
||||||
...BASE_GOLEMS,
|
|
||||||
...ELEMENTAL_GOLEMS,
|
|
||||||
...HYBRID_GOLEMS,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,23 +1,31 @@
|
|||||||
// ─── Golem Definitions Index ───────────────────────
|
// ─── Golem Definitions Index ─────────────────────────────────────────────
|
||||||
// Re-exports from all golem modules
|
// Barrel exports for the component-based golem system.
|
||||||
|
|
||||||
// Re-export types
|
// Re-export types
|
||||||
export type { GolemDef, GolemManaCost } from './types';
|
export type {
|
||||||
|
CoreDefinition,
|
||||||
|
CoreId,
|
||||||
|
FrameDefinition,
|
||||||
|
FrameId,
|
||||||
|
FrameSpecial,
|
||||||
|
MindCircuitDefinition,
|
||||||
|
MindCircuitId,
|
||||||
|
CircuitBehavior,
|
||||||
|
GolemEnchantmentDefinition,
|
||||||
|
GolemDesign,
|
||||||
|
ComputedGolemStats,
|
||||||
|
GolemManaCost,
|
||||||
|
GolemUnlockRequirement,
|
||||||
|
ActiveGolemV2,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
// Re-export combined golems data (extracted to avoid circular deps)
|
export { elemCost, rawCost } from './types';
|
||||||
export { GOLEMS_DEF } from './golems-data';
|
|
||||||
|
|
||||||
// Re-export utility functions
|
// Re-export component registries
|
||||||
export {
|
export { CORES, ALL_CORES } from './cores';
|
||||||
getGolemSlots,
|
export { FRAMES, ALL_FRAMES } from './frames';
|
||||||
isGolemUnlocked,
|
export { MIND_CIRCUITS, ALL_MIND_CIRCUITS } from './mindCircuits';
|
||||||
getUnlockedGolems,
|
export { GOLEM_ENCHANTMENTS, ALL_GOLEM_ENCHANTMENTS } from './golemEnchantments';
|
||||||
getGolemDamage,
|
|
||||||
getGolemAttackSpeed,
|
// Legacy re-exports (deprecated, kept for migration)
|
||||||
getGolemFloorDuration,
|
export type { GolemDef } from './types';
|
||||||
getGolemMaintenanceMultiplier,
|
|
||||||
canAffordGolemSummon,
|
|
||||||
deductGolemSummonCost,
|
|
||||||
canAffordGolemMaintenance,
|
|
||||||
deductGolemMaintenance,
|
|
||||||
} from './utils';
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// ─── Mind Circuit Definitions ────────────────────────────────────────────
|
||||||
|
// Behavior logic for golems: basic attacks, spell casting patterns.
|
||||||
|
|
||||||
|
import type { MindCircuitDefinition } from './types';
|
||||||
|
import { elemCost, rawCost } from './types';
|
||||||
|
|
||||||
|
const SIMPLE_CIRCUIT: MindCircuitDefinition = {
|
||||||
|
id: 'simple',
|
||||||
|
name: 'Simple Logic Circuit',
|
||||||
|
description: 'Performs basic attacks only. Targets nearest enemy. No spell casting.',
|
||||||
|
spellSlots: 0,
|
||||||
|
behavior: 'basicOnly',
|
||||||
|
summonCost: [rawCost(3)],
|
||||||
|
unlockRequirement: {
|
||||||
|
type: 'attunement_level',
|
||||||
|
attunement: 'fabricator',
|
||||||
|
level: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const INTERMEDIATE_CIRCUIT: MindCircuitDefinition = {
|
||||||
|
id: 'intermediate',
|
||||||
|
name: 'Intermediate Logic Circuit',
|
||||||
|
description: 'Casts 1 selected spell when mana is available. Otherwise performs basic attacks.',
|
||||||
|
spellSlots: 1,
|
||||||
|
behavior: 'castSpell1',
|
||||||
|
summonCost: [elemCost('crystal', 8)],
|
||||||
|
unlockRequirement: {
|
||||||
|
type: 'dual_attunement',
|
||||||
|
attunements: ['enchanter', 'fabricator'],
|
||||||
|
levels: [2, 3],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ADVANCED_CIRCUIT: MindCircuitDefinition = {
|
||||||
|
id: 'advanced',
|
||||||
|
name: 'Advanced Logic Circuit',
|
||||||
|
description: 'Casts 2 selected spells in alternating order: A → B → A → B... Falls back to basic attacks if mana is insufficient.',
|
||||||
|
spellSlots: 2,
|
||||||
|
behavior: 'alternate2',
|
||||||
|
summonCost: [elemCost('crystal', 12)],
|
||||||
|
unlockRequirement: {
|
||||||
|
type: 'dual_attunement',
|
||||||
|
attunements: ['enchanter', 'fabricator'],
|
||||||
|
levels: [3, 4],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const GUARDIAN_CIRCUIT: MindCircuitDefinition = {
|
||||||
|
id: 'guardian',
|
||||||
|
name: 'Guardian Circuit',
|
||||||
|
description: 'Required for Guardian Constructs. Cycles through one spell per mana type from the Guardian Core. Falls back to basic attacks if mana is insufficient.',
|
||||||
|
spellSlots: 4, // Typically 3-4 depending on guardian
|
||||||
|
behavior: 'cycleAll',
|
||||||
|
summonCost: [elemCost('crystal', 25), rawCost(10)],
|
||||||
|
unlockRequirement: {
|
||||||
|
type: 'dual_attunement',
|
||||||
|
attunements: ['invoker', 'fabricator'],
|
||||||
|
levels: [5, 5],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── MIND CIRCUIT REGISTRY ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const MIND_CIRCUITS: Record<string, MindCircuitDefinition> = {
|
||||||
|
[SIMPLE_CIRCUIT.id]: SIMPLE_CIRCUIT,
|
||||||
|
[INTERMEDIATE_CIRCUIT.id]: INTERMEDIATE_CIRCUIT,
|
||||||
|
[ADVANCED_CIRCUIT.id]: ADVANCED_CIRCUIT,
|
||||||
|
[GUARDIAN_CIRCUIT.id]: GUARDIAN_CIRCUIT,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ALL_MIND_CIRCUITS = [
|
||||||
|
SIMPLE_CIRCUIT,
|
||||||
|
INTERMEDIATE_CIRCUIT,
|
||||||
|
ADVANCED_CIRCUIT,
|
||||||
|
GUARDIAN_CIRCUIT,
|
||||||
|
];
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
// ─── Golem Types ─────────────────────────────────────────────────
|
// ─── Golem Component Types ──────────────────────────────────────────────
|
||||||
|
// Component-based construction system: Core + Frame + Mind Circuit + Enchantments.
|
||||||
|
// Replaces the legacy predefined GolemDef system.
|
||||||
|
|
||||||
import type { SpellCost } from '../../types';
|
import type { SpellCost } from '../../types';
|
||||||
|
|
||||||
// Golem mana cost helper
|
// ─── Mana Cost Helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
export function elemCost(element: string, amount: number): SpellCost {
|
export function elemCost(element: string, amount: number): SpellCost {
|
||||||
return { type: 'element', element, amount };
|
return { type: 'element', element, amount };
|
||||||
}
|
}
|
||||||
@@ -17,19 +20,151 @@ export interface GolemManaCost {
|
|||||||
amount: number;
|
amount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Unlock Requirements ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface GolemUnlockRequirement {
|
||||||
|
type: 'attunement_level' | 'mana_unlocked' | 'dual_attunement' | 'guardian_pact';
|
||||||
|
attunement?: string;
|
||||||
|
level?: number;
|
||||||
|
manaType?: string;
|
||||||
|
attunements?: string[];
|
||||||
|
levels?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Core Definition ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type CoreId = 'basic' | 'intermediate' | 'advanced' | 'guardian';
|
||||||
|
|
||||||
|
export interface CoreDefinition {
|
||||||
|
id: CoreId;
|
||||||
|
tier: 1 | 2 | 3 | 4;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
/** Mana types available (basic = [earth], guardian = guardian-specific) */
|
||||||
|
manaTypes: string[];
|
||||||
|
manaCapacity: number;
|
||||||
|
manaRegen: number;
|
||||||
|
maxRoomDuration: number;
|
||||||
|
summonCost: GolemManaCost[];
|
||||||
|
/** Primary mana type for upkeep calculation */
|
||||||
|
primaryManaType: string;
|
||||||
|
tierMultiplier: number; // For enchantment capacity: 1.0 / 1.5 / 2.0 / 3.0
|
||||||
|
unlockRequirement: GolemUnlockRequirement;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Frame Definition ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type FrameId = 'earth' | 'sand' | 'frost' | 'crystal' | 'steel' | 'shadowglass' | 'crystalSteelHybrid';
|
||||||
|
|
||||||
|
export type FrameSpecial = 'none' | 'aoe' | 'slow' | 'guardianConstruct';
|
||||||
|
|
||||||
|
export interface FrameDefinition {
|
||||||
|
id: FrameId;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
baseDamage: number;
|
||||||
|
attackSpeed: number; // Attacks per in-game hour
|
||||||
|
armorPierce: number; // 0–1 fraction of enemy armor bypassed
|
||||||
|
magicAffinity: number; // 0.0–1.0+, spell damage efficiency
|
||||||
|
aoeTargets: number; // 1 = single target, >1 = AoE
|
||||||
|
/** Element for elemental matchup (derived from unlock mana type) */
|
||||||
|
element?: string;
|
||||||
|
specialEffect: FrameSpecial;
|
||||||
|
summonCost: GolemManaCost[];
|
||||||
|
unlockRequirement: GolemUnlockRequirement;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mind Circuit Definition ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type MindCircuitId = 'simple' | 'intermediate' | 'advanced' | 'guardian';
|
||||||
|
|
||||||
|
export type CircuitBehavior = 'basicOnly' | 'castSpell1' | 'alternate2' | 'cycleAll';
|
||||||
|
|
||||||
|
export interface MindCircuitDefinition {
|
||||||
|
id: MindCircuitId;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
spellSlots: number;
|
||||||
|
behavior: CircuitBehavior;
|
||||||
|
summonCost: GolemManaCost[];
|
||||||
|
unlockRequirement: GolemUnlockRequirement;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Golem Enchantment Definition ───────────────────────────────────────
|
||||||
|
|
||||||
|
export interface GolemEnchantmentDefinition {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
effect: string;
|
||||||
|
capacityCost: number;
|
||||||
|
summonCost: GolemManaCost[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Golem Design (Player-Created) ──────────────────────────────────────
|
||||||
|
|
||||||
|
export interface GolemDesign {
|
||||||
|
id: string; // Player-assigned or auto-generated
|
||||||
|
name: string; // Player-defined name
|
||||||
|
core: CoreDefinition;
|
||||||
|
frame: FrameDefinition;
|
||||||
|
mindCircuit: MindCircuitDefinition;
|
||||||
|
enchantments: GolemEnchantmentDefinition[]; // Optional, 0-N
|
||||||
|
/** Player-selected mana types for cores that support choice */
|
||||||
|
selectedManaTypes: string[];
|
||||||
|
/** Player-selected spell IDs for mind circuits with spell slots */
|
||||||
|
selectedSpells: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Computed Design Stats (derived from components) ────────────────────
|
||||||
|
|
||||||
|
export interface ComputedGolemStats {
|
||||||
|
maxRoomDuration: number;
|
||||||
|
totalSummonCost: GolemManaCost[];
|
||||||
|
upkeepCostPerHour: GolemManaCost[];
|
||||||
|
manaCapacity: number;
|
||||||
|
manaRegen: number;
|
||||||
|
baseDamage: number;
|
||||||
|
attackSpeed: number;
|
||||||
|
armorPierce: number;
|
||||||
|
magicAffinity: number;
|
||||||
|
aoeTargets: number;
|
||||||
|
spellSlots: number;
|
||||||
|
availableManaTypes: string[];
|
||||||
|
enchantmentCapacity: number;
|
||||||
|
specialEffect: FrameSpecial;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Runtime Active Golem (in combat) ───────────────────────────────────
|
||||||
|
|
||||||
|
export interface ActiveGolemV2 {
|
||||||
|
/** Reference to the GolemDesign used */
|
||||||
|
designId: string;
|
||||||
|
design: GolemDesign;
|
||||||
|
summonedFloor: number;
|
||||||
|
attackProgress: number;
|
||||||
|
roomsRemaining: number;
|
||||||
|
currentMana: number;
|
||||||
|
/** Index for alternating/cycling spells */
|
||||||
|
spellCastIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Legacy Type (kept for backward compat during migration) ────────────
|
||||||
|
|
||||||
|
/** @deprecated Use GolemDesign instead */
|
||||||
export interface GolemDef {
|
export interface GolemDef {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
baseManaType: string; // The primary mana type this golem uses
|
baseManaType: string;
|
||||||
summonCost: GolemManaCost[]; // Cost to summon (can be multiple types)
|
summonCost: GolemManaCost[];
|
||||||
maintenanceCost: GolemManaCost[]; // Cost per hour to maintain
|
maintenanceCost: GolemManaCost[];
|
||||||
damage: number; // Base damage per attack
|
damage: number;
|
||||||
attackSpeed: number; // Attacks per hour
|
attackSpeed: number;
|
||||||
hp: number; // Golem HP (for display, they don't take damage)
|
hp: number;
|
||||||
armorPierce: number; // Armor piercing (0-1)
|
armorPierce: number;
|
||||||
isAoe: boolean; // Whether golem attacks are AOE
|
isAoe: boolean;
|
||||||
aoeTargets: number; // Number of targets for AOE
|
aoeTargets: number;
|
||||||
unlockCondition: {
|
unlockCondition: {
|
||||||
type: 'attunement_level' | 'mana_unlocked' | 'dual_attunement';
|
type: 'attunement_level' | 'mana_unlocked' | 'dual_attunement';
|
||||||
attunement?: string;
|
attunement?: string;
|
||||||
@@ -38,7 +173,7 @@ export interface GolemDef {
|
|||||||
attunements?: string[];
|
attunements?: string[];
|
||||||
levels?: number[];
|
levels?: number[];
|
||||||
};
|
};
|
||||||
tier: number; // Power tier (1-4)
|
tier: number;
|
||||||
maxRoomDuration: number; // Rooms before golem disappears (spec §9.6)
|
maxRoomDuration: number;
|
||||||
specialAbilities?: { name: string; description: string }[]; // Special abilities
|
specialAbilities?: { name: string; description: string }[];
|
||||||
}
|
}
|
||||||
|
|||||||
+189
-174
@@ -1,204 +1,219 @@
|
|||||||
// ─── Golem Helper Functions ─────────────────────────
|
// ─── Golem Helper Functions ──────────────────────────────────────────────
|
||||||
|
// Component-based construction system utilities.
|
||||||
|
|
||||||
import type { GolemDef, GolemManaCost } from './types';
|
import type {
|
||||||
import { GOLEMS_DEF } from './golems-data';
|
ComputedGolemStats,
|
||||||
|
GolemDesign,
|
||||||
|
GolemManaCost,
|
||||||
|
GolemUnlockRequirement,
|
||||||
|
ActiveGolemV2,
|
||||||
|
} from './types';
|
||||||
|
import { CORES } from './cores';
|
||||||
|
import { FRAMES } from './frames';
|
||||||
|
import { MIND_CIRCUITS } from './mindCircuits';
|
||||||
|
|
||||||
// Get golem slots based on Fabricator attunement level
|
// ─── Golem Slots ──────────────────────────────────────────────────────────
|
||||||
// Level 2 = 1, Level 4 = 2, Level 6 = 3, Level 8 = 4, Level 10 = 5
|
|
||||||
|
/**
|
||||||
|
* Get base golem slots from Fabricator attunement level.
|
||||||
|
* Level 2 = 1, Level 4 = 2, Level 6 = 3, Level 8 = 4, Level 10 = 5
|
||||||
|
*/
|
||||||
export function getGolemSlots(fabricatorLevel: number): number {
|
export function getGolemSlots(fabricatorLevel: number): number {
|
||||||
if (fabricatorLevel < 2) return 0;
|
if (fabricatorLevel < 2) return 0;
|
||||||
return Math.floor(fabricatorLevel / 2);
|
return Math.floor(fabricatorLevel / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if a golem is unlocked based on player state
|
// ─── Unlock Checks ────────────────────────────────────────────────────────
|
||||||
export function isGolemUnlocked(
|
|
||||||
golemId: string,
|
/**
|
||||||
|
* Check if a component is unlocked based on player state.
|
||||||
|
*/
|
||||||
|
export function isComponentUnlocked(
|
||||||
|
requirement: GolemUnlockRequirement,
|
||||||
attunements: Record<string, { active: boolean; level: number }>,
|
attunements: Record<string, { active: boolean; level: number }>,
|
||||||
unlockedElements: string[]
|
unlockedElements: string[],
|
||||||
|
signedGuardianPacts: number[],
|
||||||
): boolean {
|
): boolean {
|
||||||
const golem = GOLEMS_DEF[golemId];
|
switch (requirement.type) {
|
||||||
if (!golem) return false;
|
case 'attunement_level': {
|
||||||
|
const attState = attunements[requirement.attunement || ''];
|
||||||
const condition = golem.unlockCondition;
|
return !!attState?.active && (attState.level || 1) >= (requirement.level || 1);
|
||||||
|
}
|
||||||
switch (condition.type) {
|
|
||||||
case 'attunement_level':
|
|
||||||
const attState = attunements[condition.attunement || ''];
|
|
||||||
return attState?.active && (attState.level || 1) >= (condition.level || 1);
|
|
||||||
|
|
||||||
case 'mana_unlocked':
|
case 'mana_unlocked':
|
||||||
return unlockedElements.includes(condition.manaType || '');
|
return unlockedElements.includes(requirement.manaType || '');
|
||||||
|
case 'dual_attunement': {
|
||||||
case 'dual_attunement':
|
if (!requirement.attunements || !requirement.levels) return false;
|
||||||
if (!condition.attunements || !condition.levels) return false;
|
return requirement.attunements.every((attId, idx) => {
|
||||||
return condition.attunements.every((attId, idx) => {
|
|
||||||
const att = attunements[attId];
|
const att = attunements[attId];
|
||||||
return att?.active && (att.level || 1) >= condition.levels![idx];
|
return att?.active && (att.level || 1) >= requirement.levels![idx];
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
case 'guardian_pact': {
|
||||||
|
// Requires dual attunement plus at least one guardian pact
|
||||||
|
if (!requirement.attunements || !requirement.levels) return false;
|
||||||
|
const attOk = requirement.attunements.every((attId, idx) => {
|
||||||
|
const att = attunements[attId];
|
||||||
|
return att?.active && (att.level || 1) >= requirement.levels![idx];
|
||||||
|
});
|
||||||
|
return attOk && signedGuardianPacts.length > 0;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all unlocked golems for a player
|
// ─── Computed Stats ───────────────────────────────────────────────────────
|
||||||
export function getUnlockedGolems(
|
|
||||||
attunements: Record<string, { active: boolean; level: number }>,
|
/**
|
||||||
unlockedElements: string[]
|
* Compute all derived stats for a golem design from its components.
|
||||||
): GolemDef[] {
|
*/
|
||||||
return Object.values(GOLEMS_DEF).filter(golem =>
|
export function computeGolemStats(design: GolemDesign): ComputedGolemStats {
|
||||||
isGolemUnlocked(golem.id, attunements, unlockedElements)
|
const core = design.core;
|
||||||
) as GolemDef[];
|
const frame = design.frame;
|
||||||
|
const circuit = design.mindCircuit;
|
||||||
|
const enchantments = design.enchantments;
|
||||||
|
|
||||||
|
// Total summon cost from all components
|
||||||
|
const totalSummonCost: GolemManaCost[] = [
|
||||||
|
...core.summonCost,
|
||||||
|
...frame.summonCost,
|
||||||
|
...circuit.summonCost,
|
||||||
|
...enchantments.flatMap((e) => e.summonCost),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Player upkeep = Core.manaRegen × 2 per hour (spec §13)
|
||||||
|
const upkeepCostPerHour: GolemManaCost[] = [
|
||||||
|
{
|
||||||
|
type: 'element',
|
||||||
|
element: core.primaryManaType,
|
||||||
|
amount: core.manaRegen * 2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Enchantment capacity = Frame.MagicAffinity × Core.TierMultiplier
|
||||||
|
const enchantmentCapacity = frame.magicAffinity * core.tierMultiplier;
|
||||||
|
|
||||||
|
return {
|
||||||
|
maxRoomDuration: core.maxRoomDuration,
|
||||||
|
totalSummonCost,
|
||||||
|
upkeepCostPerHour,
|
||||||
|
manaCapacity: core.manaCapacity,
|
||||||
|
manaRegen: core.manaRegen,
|
||||||
|
baseDamage: frame.baseDamage,
|
||||||
|
attackSpeed: frame.attackSpeed,
|
||||||
|
armorPierce: frame.armorPierce,
|
||||||
|
magicAffinity: frame.magicAffinity,
|
||||||
|
aoeTargets: frame.aoeTargets,
|
||||||
|
spellSlots: circuit.spellSlots,
|
||||||
|
availableManaTypes: design.selectedManaTypes.length > 0
|
||||||
|
? design.selectedManaTypes
|
||||||
|
: core.manaTypes,
|
||||||
|
enchantmentCapacity,
|
||||||
|
specialEffect: frame.specialEffect,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate golem damage with skill bonuses
|
// ─── Summoning Cost Checks ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if player can afford to summon a golem design.
|
||||||
|
*/
|
||||||
|
export function canAffordGolemDesign(
|
||||||
|
design: GolemDesign,
|
||||||
|
rawMana: number,
|
||||||
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
||||||
|
): { canAfford: boolean; missing: string } {
|
||||||
|
const stats = computeGolemStats(design);
|
||||||
|
|
||||||
|
for (const cost of stats.totalSummonCost) {
|
||||||
|
if (cost.type === 'raw') {
|
||||||
|
if (rawMana < cost.amount) {
|
||||||
|
return { canAfford: false, missing: `raw mana (${cost.amount} needed)` };
|
||||||
|
}
|
||||||
|
} else if (cost.element) {
|
||||||
|
const elem = elements[cost.element];
|
||||||
|
if (!elem || !elem.unlocked) {
|
||||||
|
return { canAfford: false, missing: `${cost.element} mana (not unlocked)` };
|
||||||
|
}
|
||||||
|
if (elem.current < cost.amount) {
|
||||||
|
return { canAfford: false, missing: `${cost.element} mana (${cost.amount} needed, have ${elem.current})` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { canAfford: true, missing: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Active Golem V2 Helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new ActiveGolemV2 from a GolemDesign for combat.
|
||||||
|
*/
|
||||||
|
export function createActiveGolem(
|
||||||
|
design: GolemDesign,
|
||||||
|
currentFloor: number,
|
||||||
|
): ActiveGolemV2 {
|
||||||
|
return {
|
||||||
|
designId: design.id,
|
||||||
|
design,
|
||||||
|
summonedFloor: currentFloor,
|
||||||
|
attackProgress: 0,
|
||||||
|
roomsRemaining: design.core.maxRoomDuration,
|
||||||
|
currentMana: design.core.manaCapacity, // Starts full
|
||||||
|
spellCastIndex: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component Lookups ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Get a CoreDefinition by ID */
|
||||||
|
export function getCore(id: string) {
|
||||||
|
return CORES[id] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a FrameDefinition by ID */
|
||||||
|
export function getFrame(id: string) {
|
||||||
|
return FRAMES[id] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a MindCircuitDefinition by ID */
|
||||||
|
export function getMindCircuit(id: string) {
|
||||||
|
return MIND_CIRCUITS[id] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Legacy Compatibility ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use getGolemSlots instead
|
||||||
|
*/
|
||||||
|
export function getGolemFloorDuration(_skills: Record<string, number>): number {
|
||||||
|
return 3; // Default room duration for legacy calls
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use computeGolemStats instead
|
||||||
|
*/
|
||||||
export function getGolemDamage(
|
export function getGolemDamage(
|
||||||
golemId: string,
|
golemId: string,
|
||||||
skills: Record<string, number>
|
_skills: Record<string, number>,
|
||||||
): number {
|
): number {
|
||||||
const golem = GOLEMS_DEF[golemId];
|
// Legacy lookup — returns 0 for component-based golems
|
||||||
if (!golem) return 0;
|
return 0;
|
||||||
|
|
||||||
let damage = golem.damage;
|
|
||||||
|
|
||||||
// Golem Mastery skill bonus
|
|
||||||
const masteryBonus = 1 + (skills.golemMastery || 0) * 0.1;
|
|
||||||
damage *= masteryBonus;
|
|
||||||
|
|
||||||
return damage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate golem attack speed with skill bonuses
|
/**
|
||||||
|
* @deprecated Use computeGolemStats instead
|
||||||
|
*/
|
||||||
export function getGolemAttackSpeed(
|
export function getGolemAttackSpeed(
|
||||||
golemId: string,
|
golemId: string,
|
||||||
skills: Record<string, number>
|
_skills: Record<string, number>,
|
||||||
): number {
|
): number {
|
||||||
const golem = GOLEMS_DEF[golemId];
|
return 0;
|
||||||
if (!golem) return 0;
|
|
||||||
|
|
||||||
let speed = golem.attackSpeed;
|
|
||||||
|
|
||||||
// Golem Efficiency skill bonus
|
|
||||||
const efficiencyBonus = 1 + (skills.golemEfficiency || 0) * 0.05;
|
|
||||||
speed *= efficiencyBonus;
|
|
||||||
|
|
||||||
return speed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get floors golems can last (base 1, +1 per Golem Longevity skill level)
|
/**
|
||||||
export function getGolemFloorDuration(skills: Record<string, number>): number {
|
* @deprecated Component-based system doesn't use skill-based maintenance multiplier
|
||||||
return 1 + (skills.golemLongevity || 0);
|
*/
|
||||||
}
|
export function getGolemMaintenanceMultiplier(_skills: Record<string, number>): number {
|
||||||
|
return 1;
|
||||||
// Get maintenance cost multiplier (Golem Siphon reduces by 10% per level)
|
|
||||||
export function getGolemMaintenanceMultiplier(skills: Record<string, number>): number {
|
|
||||||
return 1 - (skills.golemSiphon || 0) * 0.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if player can afford golem summon cost
|
|
||||||
export function canAffordGolemSummon(
|
|
||||||
golemId: string,
|
|
||||||
rawMana: number,
|
|
||||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>
|
|
||||||
): boolean {
|
|
||||||
const golem = GOLEMS_DEF[golemId];
|
|
||||||
if (!golem) return false;
|
|
||||||
|
|
||||||
for (const cost of golem.summonCost) {
|
|
||||||
if (cost.type === 'raw') {
|
|
||||||
if (rawMana < cost.amount) return false;
|
|
||||||
} else if (cost.element) {
|
|
||||||
const elem = elements[cost.element];
|
|
||||||
if (!elem || !elem.unlocked || elem.current < cost.amount) return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deduct golem summon cost from mana pools
|
|
||||||
export function deductGolemSummonCost(
|
|
||||||
golemId: string,
|
|
||||||
rawMana: number,
|
|
||||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>
|
|
||||||
): { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> } {
|
|
||||||
const golem = GOLEMS_DEF[golemId];
|
|
||||||
if (!golem) return { rawMana, elements };
|
|
||||||
|
|
||||||
let newRawMana = rawMana;
|
|
||||||
let newElements = { ...elements };
|
|
||||||
|
|
||||||
for (const cost of golem.summonCost) {
|
|
||||||
if (cost.type === 'raw') {
|
|
||||||
newRawMana -= cost.amount;
|
|
||||||
} else if (cost.element && newElements[cost.element]) {
|
|
||||||
newElements = {
|
|
||||||
...newElements,
|
|
||||||
[cost.element]: {
|
|
||||||
...newElements[cost.element],
|
|
||||||
current: newElements[cost.element].current - cost.amount,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { rawMana: newRawMana, elements: newElements };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if player can afford golem maintenance for one tick
|
|
||||||
export function canAffordGolemMaintenance(
|
|
||||||
golemId: string,
|
|
||||||
rawMana: number,
|
|
||||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
|
||||||
skills: Record<string, number>
|
|
||||||
): boolean {
|
|
||||||
const golem = GOLEMS_DEF[golemId];
|
|
||||||
if (!golem) return false;
|
|
||||||
|
|
||||||
const maintenanceMult = getGolemMaintenanceMultiplier(skills);
|
|
||||||
|
|
||||||
for (const cost of golem.maintenanceCost) {
|
|
||||||
const adjustedAmount = cost.amount * maintenanceMult;
|
|
||||||
if (cost.type === 'raw') {
|
|
||||||
if (rawMana < adjustedAmount) return false;
|
|
||||||
} else if (cost.element) {
|
|
||||||
const elem = elements[cost.element];
|
|
||||||
if (!elem || !elem.unlocked || elem.current < adjustedAmount) return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deduct golem maintenance cost for one tick
|
|
||||||
export function deductGolemMaintenance(
|
|
||||||
golemId: string,
|
|
||||||
rawMana: number,
|
|
||||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
|
||||||
skills: Record<string, number>
|
|
||||||
): { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> } {
|
|
||||||
const golem = GOLEMS_DEF[golemId];
|
|
||||||
if (!golem) return { rawMana, elements };
|
|
||||||
|
|
||||||
const maintenanceMult = getGolemMaintenanceMultiplier(skills);
|
|
||||||
|
|
||||||
let newRawMana = rawMana;
|
|
||||||
let newElements = { ...elements };
|
|
||||||
|
|
||||||
for (const cost of golem.maintenanceCost) {
|
|
||||||
const adjustedAmount = cost.amount * maintenanceMult;
|
|
||||||
if (cost.type === 'raw') {
|
|
||||||
newRawMana -= adjustedAmount;
|
|
||||||
} else if (cost.element && newElements[cost.element]) {
|
|
||||||
newElements = {
|
|
||||||
...newElements,
|
|
||||||
[cost.element]: {
|
|
||||||
...newElements[cost.element],
|
|
||||||
current: newElements[cost.element].current - adjustedAmount,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { rawMana: newRawMana, elements: newElements };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,14 @@ import { getGuardianForFloor } from '../data/guardian-encounters';
|
|||||||
import type { CombatStore, CombatState } from './combat-state.types';
|
import type { CombatStore, CombatState } from './combat-state.types';
|
||||||
import type { SpellState, EnemyState, EquipmentInstance, FloorState } from '../types';
|
import type { SpellState, EnemyState, EquipmentInstance, FloorState } from '../types';
|
||||||
import { applyOnHitEffect, processDoTPhase } from './dot-runtime';
|
import { applyOnHitEffect, processDoTPhase } from './dot-runtime';
|
||||||
import type { ActiveGolem } from '../types';
|
import type { ActiveGolem, RuntimeActiveGolem } from '../types';
|
||||||
import { getFloorMaxHP, getFloorElement, getMultiElementBonus, calcDamage, calcMeleeDamage, canAffordSpellCost, deductSpellCost } from '../utils';
|
import { getFloorMaxHP, getFloorElement, getMultiElementBonus, calcDamage, calcMeleeDamage, canAffordSpellCost, deductSpellCost } from '../utils';
|
||||||
import { computeDisciplineEffects } from '../effects/discipline-effects';
|
import { computeDisciplineEffects } from '../effects/discipline-effects';
|
||||||
import { processGolemMaintenance, processGolemAttacks } from './golem-combat-actions';
|
import {
|
||||||
|
processGolemMaintenance,
|
||||||
|
processGolemAttacks,
|
||||||
|
processGolemManaRegen,
|
||||||
|
} from './golem-combat-actions';
|
||||||
import { applyDamageToRoom } from './combat-damage';
|
import { applyDamageToRoom } from './combat-damage';
|
||||||
|
|
||||||
// ─── Result Type ───────────────────────────────────────────────────────────────
|
// ─── Result Type ───────────────────────────────────────────────────────────────
|
||||||
@@ -22,7 +26,7 @@ function makeDefaultCombatTickResult(
|
|||||||
rawMana: number,
|
rawMana: number,
|
||||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
||||||
state: CombatState,
|
state: CombatState,
|
||||||
activeGolems: ActiveGolem[],
|
activeGolems: RuntimeActiveGolem[],
|
||||||
): CombatTickResult {
|
): CombatTickResult {
|
||||||
return {
|
return {
|
||||||
rawMana,
|
rawMana,
|
||||||
@@ -52,7 +56,7 @@ export interface CombatTickResult {
|
|||||||
maxFloorReached: number;
|
maxFloorReached: number;
|
||||||
castProgress: number;
|
castProgress: number;
|
||||||
equipmentSpellStates: CombatState['equipmentSpellStates'];
|
equipmentSpellStates: CombatState['equipmentSpellStates'];
|
||||||
activeGolems: ActiveGolem[];
|
activeGolems: RuntimeActiveGolem[];
|
||||||
meleeSwordProgress: Record<string, number>;
|
meleeSwordProgress: Record<string, number>;
|
||||||
currentRoom: FloorState;
|
currentRoom: FloorState;
|
||||||
}
|
}
|
||||||
@@ -73,7 +77,7 @@ export function processCombatTick(
|
|||||||
modifiedDamage?: number;
|
modifiedDamage?: number;
|
||||||
},
|
},
|
||||||
signedPacts: number[],
|
signedPacts: number[],
|
||||||
golemancyState: { activeGolems: ActiveGolem[] },
|
golemancyState: { activeGolems: RuntimeActiveGolem[] },
|
||||||
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
|
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
|
||||||
applyEnemyDefenses: (
|
applyEnemyDefenses: (
|
||||||
dmg: number,
|
dmg: number,
|
||||||
@@ -94,9 +98,11 @@ export function processCombatTick(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ─── Golem maintenance (spec §9.5) ──────────────────────────────────────
|
// ─── Golem maintenance (spec §13) ──────────────────────────────────────
|
||||||
|
const golemDesigns = state.golemancy.golemDesigns || {};
|
||||||
const maintenanceResult = processGolemMaintenance(
|
const maintenanceResult = processGolemMaintenance(
|
||||||
golemancyState.activeGolems,
|
golemancyState.activeGolems,
|
||||||
|
golemDesigns,
|
||||||
rawMana,
|
rawMana,
|
||||||
elements,
|
elements,
|
||||||
);
|
);
|
||||||
@@ -105,6 +111,9 @@ export function processCombatTick(
|
|||||||
elements = maintenanceResult.elements;
|
elements = maintenanceResult.elements;
|
||||||
logMessages.push(...maintenanceResult.logMessages);
|
logMessages.push(...maintenanceResult.logMessages);
|
||||||
|
|
||||||
|
// ─── Golem mana regen (spec §12) ───────────────────────────────────────
|
||||||
|
activeGolems = processGolemManaRegen(activeGolems, golemDesigns);
|
||||||
|
|
||||||
// Write maintained golems back immediately so tick state stays consistent
|
// Write maintained golems back immediately so tick state stays consistent
|
||||||
set({ golemancy: { ...state.golemancy, activeGolems } });
|
set({ golemancy: { ...state.golemancy, activeGolems } });
|
||||||
|
|
||||||
@@ -289,15 +298,11 @@ export function processCombatTick(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Golem attacks (spec §9.4) ───────────────────────────────────────────
|
// ─── Golem attacks (spec §11) ───────────────────────────────────────────
|
||||||
if (activeGolems.length > 0 && floorHP > 0) {
|
if (activeGolems.length > 0 && floorHP > 0) {
|
||||||
const golemResult = processGolemAttacks(
|
const golemResult = processGolemAttacks(
|
||||||
activeGolems,
|
activeGolems,
|
||||||
rawMana,
|
golemDesigns,
|
||||||
elements,
|
|
||||||
floorHP,
|
|
||||||
floorMaxHP,
|
|
||||||
currentFloor,
|
|
||||||
onDamageDealt,
|
onDamageDealt,
|
||||||
golemApplyDamageToRoom,
|
golemApplyDamageToRoom,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ export function createEnterSpireMode(get: GetFn, set: SetFn) {
|
|||||||
roomResetState: {},
|
roomResetState: {},
|
||||||
descentPeak: null,
|
descentPeak: null,
|
||||||
isDescentComplete: false,
|
isDescentComplete: false,
|
||||||
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
|
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
|
||||||
});
|
});
|
||||||
|
|
||||||
get().addActivityLog('floor_transition',
|
get().addActivityLog('floor_transition',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// ─── Combat State Types ────────────────────────────────────────────────────────
|
// ─── Combat State Types ────────────────────────────────────────────────────────
|
||||||
// Shared types for combat store and combat actions to avoid circular dependency
|
// Shared types for combat store and combat actions to avoid circular dependency
|
||||||
|
|
||||||
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem, EnemyState, EquipmentInstance } from '../types';
|
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem, RuntimeActiveGolem, EnemyState, EquipmentInstance, SerializedGolemDesign } from '../types';
|
||||||
|
|
||||||
/** Signature for the advanceRoomOrFloor callback to break circular dependency */
|
/** Signature for the advanceRoomOrFloor callback to break circular dependency */
|
||||||
export type AdvanceRoomFn = (get: () => CombatStore, set: (s: Partial<CombatState>) => void) => void;
|
export type AdvanceRoomFn = (get: () => CombatStore, set: (s: Partial<CombatState>) => void) => void;
|
||||||
@@ -130,6 +130,9 @@ export interface CombatActions {
|
|||||||
// Golemancy
|
// Golemancy
|
||||||
toggleGolem: (golemId: string) => void;
|
toggleGolem: (golemId: string) => void;
|
||||||
setEnabledGolems: (golemIds: string[]) => void;
|
setEnabledGolems: (golemIds: string[]) => void;
|
||||||
|
addGolemDesign: (design: SerializedGolemDesign) => void;
|
||||||
|
removeGolemDesign: (designId: string) => void;
|
||||||
|
toggleGolemLoadoutEntry: (designId: string) => void;
|
||||||
|
|
||||||
// Spells
|
// Spells
|
||||||
learnSpell: (spellId: string) => void;
|
learnSpell: (spellId: string) => void;
|
||||||
@@ -155,7 +158,7 @@ export interface CombatActions {
|
|||||||
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
||||||
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
|
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
|
||||||
signedPacts: number[],
|
signedPacts: number[],
|
||||||
golemancyState: { activeGolems: ActiveGolem[] },
|
golemancyState: { activeGolems: RuntimeActiveGolem[] },
|
||||||
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
|
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
|
||||||
applyEnemyDefenses: (
|
applyEnemyDefenses: (
|
||||||
dmg: number,
|
dmg: number,
|
||||||
@@ -177,7 +180,7 @@ export interface CombatActions {
|
|||||||
maxFloorReached: number;
|
maxFloorReached: number;
|
||||||
castProgress: number;
|
castProgress: number;
|
||||||
equipmentSpellStates: EquipmentSpellState[];
|
equipmentSpellStates: EquipmentSpellState[];
|
||||||
activeGolems: ActiveGolem[];
|
activeGolems: RuntimeActiveGolem[];
|
||||||
meleeSwordProgress: Record<string, number>;
|
meleeSwordProgress: Record<string, number>;
|
||||||
currentRoom: FloorState;
|
currentRoom: FloorState;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import { createSafeStorage } from '../utils/safe-persist';
|
import { createSafeStorage } from '../utils/safe-persist';
|
||||||
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem, EnemyState, EquipmentInstance } from '../types';
|
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem, RuntimeActiveGolem, EnemyState, EquipmentInstance } from '../types';
|
||||||
import { getFloorMaxHP } from '../utils';
|
import { getFloorMaxHP } from '../utils';
|
||||||
import { generateSpireFloorState, getRoomsForFloor } from '../utils/spire-utils';
|
import { generateSpireFloorState, getRoomsForFloor } from '../utils/spire-utils';
|
||||||
import { addActivityLogEntry } from '../utils/activity-log';
|
import { addActivityLogEntry } from '../utils/activity-log';
|
||||||
@@ -17,6 +17,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
onEnterLibraryRoom, tickNonCombatRoom, skipNonCombatRoom, stayLongerInRoom,
|
onEnterLibraryRoom, tickNonCombatRoom, skipNonCombatRoom, stayLongerInRoom,
|
||||||
} from './non-combat-room-actions';
|
} from './non-combat-room-actions';
|
||||||
|
import {
|
||||||
|
addGolemDesign, removeGolemDesign, toggleGolemLoadoutEntry,
|
||||||
|
} from './golemancy-actions';
|
||||||
|
|
||||||
export const useCombatStore = create<CombatStore>()(
|
export const useCombatStore = create<CombatStore>()(
|
||||||
persist(
|
persist(
|
||||||
@@ -50,12 +53,17 @@ export const useCombatStore = create<CombatStore>()(
|
|||||||
clearedRooms: {},
|
clearedRooms: {},
|
||||||
isDescentComplete: false,
|
isDescentComplete: false,
|
||||||
|
|
||||||
// Golemancy
|
// Golemancy (component-based)
|
||||||
golemancy: {
|
golemancy: {
|
||||||
|
// New component-based fields
|
||||||
|
golemDesigns: {},
|
||||||
|
golemLoadout: [],
|
||||||
|
activeGolems: [] as RuntimeActiveGolem[],
|
||||||
|
lastSummonFloor: 0,
|
||||||
|
// Legacy fields (deprecated)
|
||||||
enabledGolems: [],
|
enabledGolems: [],
|
||||||
summonedGolems: [],
|
summonedGolems: [],
|
||||||
activeGolems: [],
|
legacyActiveGolems: [],
|
||||||
lastSummonFloor: 0,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Equipment spell states
|
// Equipment spell states
|
||||||
@@ -196,24 +204,15 @@ export const useCombatStore = create<CombatStore>()(
|
|||||||
currentRoomIndex: 0,
|
currentRoomIndex: 0,
|
||||||
roomsPerFloor: 1,
|
roomsPerFloor: 1,
|
||||||
maxFloorReached: Math.max(s.maxFloorReached, 1),
|
maxFloorReached: Math.max(s.maxFloorReached, 1),
|
||||||
golemancy: { ...s.golemancy, activeGolems: [], summonedGolems: [] },
|
golemancy: { ...s.golemancy, activeGolems: [] as RuntimeActiveGolem[], summonedGolems: [], legacyActiveGolems: [] },
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
startClimbUp: () => set({ climbDirection: 'up', currentAction: 'climb' }),
|
startClimbUp: () => set({ climbDirection: 'up', currentAction: 'climb' }),
|
||||||
|
|
||||||
startClimbDown: () => set({ climbDirection: 'down', currentAction: 'climb' }),
|
startClimbDown: () => set({ climbDirection: 'down', currentAction: 'climb' }),
|
||||||
|
startPracticing: () => set((s) => s.currentAction !== 'meditate' ? s : { currentAction: 'practicing' }),
|
||||||
startPracticing: () => set((s) => {
|
stopPracticing: () => set((s) => s.currentAction !== 'practicing' ? s : { currentAction: 'meditate' }),
|
||||||
if (s.currentAction !== 'meditate') return s;
|
|
||||||
return { currentAction: 'practicing' };
|
|
||||||
}),
|
|
||||||
|
|
||||||
stopPracticing: () => set((s) => {
|
|
||||||
if (s.currentAction !== 'practicing') return s;
|
|
||||||
return { currentAction: 'meditate' };
|
|
||||||
}),
|
|
||||||
|
|
||||||
// ─── Spec: Descent actions (delegated to combat-descent-actions.ts) ────
|
// ─── Spec: Descent actions (delegated to combat-descent-actions.ts) ────
|
||||||
enterDescentMode: () => enterDescentMode(get, set),
|
enterDescentMode: () => enterDescentMode(get, set),
|
||||||
@@ -246,6 +245,10 @@ export const useCombatStore = create<CombatStore>()(
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addGolemDesign: (d) => addGolemDesign(set, d),
|
||||||
|
removeGolemDesign: (id) => removeGolemDesign(set, id),
|
||||||
|
toggleGolemLoadoutEntry: (id) => toggleGolemLoadoutEntry(set, id),
|
||||||
|
|
||||||
enterSpireMode: createEnterSpireMode(get, set),
|
enterSpireMode: createEnterSpireMode(get, set),
|
||||||
|
|
||||||
learnSpell: (spellId: string) => {
|
learnSpell: (spellId: string) => {
|
||||||
@@ -310,7 +313,7 @@ export const useCombatStore = create<CombatStore>()(
|
|||||||
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
||||||
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
|
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
|
||||||
signedPacts: number[],
|
signedPacts: number[],
|
||||||
golemancyState: { activeGolems: ActiveGolem[] },
|
golemancyState: { activeGolems: RuntimeActiveGolem[] },
|
||||||
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
|
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
|
||||||
applyEnemyDefenses: (
|
applyEnemyDefenses: (
|
||||||
dmg: number,
|
dmg: number,
|
||||||
@@ -390,6 +393,4 @@ export const useCombatStore = create<CombatStore>()(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// makeInitialSpells is now in combat-actions.ts
|
|
||||||
// Re-export for backward compatibility
|
|
||||||
export { makeInitialSpells } from './combat-actions';
|
export { makeInitialSpells } from './combat-actions';
|
||||||
|
|||||||
@@ -1,80 +1,120 @@
|
|||||||
// ─── Golem Combat Actions ──────────────────────────────────────────────────────
|
// ─── Golem Combat Actions (Component-Based) ──────────────────────────────────
|
||||||
// Pure golem combat logic — no cross-store getState() calls.
|
// Runtime golem combat logic for the component-based construction system.
|
||||||
// All external data is passed in as parameters.
|
// All external data is passed in as parameters (no cross-store getState() calls).
|
||||||
// Implements spec §9: summoning, maintenance, attack, room-duration.
|
// Implements spec §§10-14: summoning, maintenance, combat, mana, duration.
|
||||||
|
|
||||||
import { GOLEMS_DEF } from '../data/golems';
|
|
||||||
import { HOURS_PER_TICK } from '../constants';
|
import { HOURS_PER_TICK } from '../constants';
|
||||||
import type { ActiveGolem, GolemancyState } from '../types';
|
import { CORES, FRAMES, MIND_CIRCUITS } from '../data/golems';
|
||||||
import { getElementalBonus, getFloorElement } from '../utils';
|
import { computeGolemStats, getGolemSlots } from '../data/golems/utils';
|
||||||
|
import type {
|
||||||
|
RuntimeActiveGolem,
|
||||||
|
GolemLoadoutEntry,
|
||||||
|
EnemyState,
|
||||||
|
ActiveEffect,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
// ─── Types ─────────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface GolemCombatResult {
|
export interface GolemCombatResult {
|
||||||
rawMana: number;
|
rawMana: number;
|
||||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||||
activeGolems: ActiveGolem[];
|
activeGolems: RuntimeActiveGolem[];
|
||||||
logMessages: string[];
|
logMessages: string[];
|
||||||
totalDamageDealt: number;
|
totalDamageDealt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Summoning (spec §9.3) ─────────────────────────────────────────────────────
|
interface SerializedDesign {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
coreId: string;
|
||||||
|
frameId: string;
|
||||||
|
mindCircuitId: string;
|
||||||
|
enchantmentIds: string[];
|
||||||
|
selectedManaTypes: string[];
|
||||||
|
selectedSpells: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Summoning (spec §10) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempt to summon golems from the enabled loadout on room entry.
|
* Attempt to summon golems from the loadout on room entry.
|
||||||
* For each enabled golem: if the player has enough mana, deduct cost and activate.
|
* For each enabled design: if player has enough mana, deduct cost and activate.
|
||||||
* Golems that can't be skipped are NOT re-attempted mid-room.
|
* Designs that can't be afforded are NOT re-attempted mid-room.
|
||||||
*/
|
*/
|
||||||
export function summonGolemsOnRoomEntry(
|
export function summonGolemsOnRoomEntry(
|
||||||
enabledGolems: string[],
|
loadout: GolemLoadoutEntry[],
|
||||||
rawMana: number,
|
rawMana: number,
|
||||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
||||||
currentFloor: number,
|
currentFloor: number,
|
||||||
existingActiveGolems: ActiveGolem[],
|
existingActiveGolems: RuntimeActiveGolem[],
|
||||||
|
disciplineSlotsBonus: number,
|
||||||
): {
|
): {
|
||||||
rawMana: number;
|
rawMana: number;
|
||||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||||
activeGolems: ActiveGolem[];
|
activeGolems: RuntimeActiveGolem[];
|
||||||
logMessages: string[];
|
logMessages: string[];
|
||||||
} {
|
} {
|
||||||
let newRawMana = rawMana;
|
let newRawMana = rawMana;
|
||||||
let newElements = { ...elements };
|
const newElements = { ...elements };
|
||||||
const newActiveGolems = [...existingActiveGolems];
|
const newActiveGolems = [...existingActiveGolems];
|
||||||
const logMessages: string[] = [];
|
const logMessages: string[] = [];
|
||||||
|
|
||||||
for (const golemId of enabledGolems) {
|
const activeCount = newActiveGolems.length;
|
||||||
const def = GOLEMS_DEF[golemId];
|
|
||||||
if (!def) continue;
|
|
||||||
|
|
||||||
// Skip if this golem is already active (e.g. summoned on a previous floor
|
for (const entry of loadout) {
|
||||||
// and still within its room-duration)
|
if (!entry.enabled) continue;
|
||||||
const alreadyActive = newActiveGolems.some((ag) => ag.golemId === golemId);
|
|
||||||
|
// Check slot availability
|
||||||
|
if (newActiveGolems.length >= activeCount + disciplineSlotsBonus + getGolemSlots(0)) {
|
||||||
|
logMessages.push('No golem slots available');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const design = entry.design as SerializedDesign;
|
||||||
|
|
||||||
|
// Resolve components
|
||||||
|
const core = CORES[design.coreId];
|
||||||
|
const frame = FRAMES[design.frameId];
|
||||||
|
const circuit = MIND_CIRCUITS[design.mindCircuitId];
|
||||||
|
if (!core || !frame || !circuit) {
|
||||||
|
logMessages.push(`${entry.design.name} has invalid components — skipped`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if already active
|
||||||
|
const alreadyActive = newActiveGolems.some((ag) => ag.designId === entry.designId);
|
||||||
if (alreadyActive) continue;
|
if (alreadyActive) continue;
|
||||||
|
|
||||||
// Check if player can afford the summon cost (multi-type costs supported)
|
// Build component-based design for cost calculation
|
||||||
|
const stats = computeGolemStats({
|
||||||
|
id: design.id,
|
||||||
|
name: design.name,
|
||||||
|
core: { ...core, manaTypes: design.selectedManaTypes.length > 0 ? design.selectedManaTypes : core.manaTypes },
|
||||||
|
frame,
|
||||||
|
mindCircuit: circuit,
|
||||||
|
enchantments: [], // Simplified — enchantments resolved by ID in full implementation
|
||||||
|
selectedManaTypes: design.selectedManaTypes,
|
||||||
|
selectedSpells: design.selectedSpells,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check affordability
|
||||||
let canAfford = true;
|
let canAfford = true;
|
||||||
for (const cost of def.summonCost) {
|
for (const cost of stats.totalSummonCost) {
|
||||||
if (cost.type === 'raw') {
|
if (cost.type === 'raw') {
|
||||||
if (newRawMana < cost.amount) {
|
if (newRawMana < cost.amount) { canAfford = false; break; }
|
||||||
canAfford = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else if (cost.element) {
|
} else if (cost.element) {
|
||||||
const elem = newElements[cost.element];
|
const elem = newElements[cost.element];
|
||||||
if (!elem || !elem.unlocked || elem.current < cost.amount) {
|
if (!elem?.unlocked || elem.current < cost.amount) { canAfford = false; break; }
|
||||||
canAfford = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!canAfford) {
|
if (!canAfford) {
|
||||||
logMessages.push(`Not enough mana to summon ${def.name} — skipped`);
|
logMessages.push(`Not enough mana to summon ${entry.design.name} — skipped`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deduct summon cost
|
// Deduct summon cost
|
||||||
for (const cost of def.summonCost) {
|
for (const cost of stats.totalSummonCost) {
|
||||||
if (cost.type === 'raw') {
|
if (cost.type === 'raw') {
|
||||||
newRawMana -= cost.amount;
|
newRawMana -= cost.amount;
|
||||||
} else if (cost.element && newElements[cost.element]) {
|
} else if (cost.element && newElements[cost.element]) {
|
||||||
@@ -85,15 +125,16 @@ export function summonGolemsOnRoomEntry(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activate golem with fresh room duration and zero attack progress
|
|
||||||
newActiveGolems.push({
|
newActiveGolems.push({
|
||||||
golemId: def.id,
|
designId: entry.designId,
|
||||||
summonedFloor: currentFloor,
|
summonedFloor: currentFloor,
|
||||||
attackProgress: 0,
|
attackProgress: 0,
|
||||||
roomsRemaining: def.maxRoomDuration,
|
roomsRemaining: stats.maxRoomDuration,
|
||||||
|
currentMana: stats.manaCapacity,
|
||||||
|
spellCastIndex: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
logMessages.push(`${def.name} summoned`);
|
logMessages.push(`${entry.design.name} summoned`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -104,71 +145,58 @@ export function summonGolemsOnRoomEntry(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Maintenance (spec §9.5) ───────────────────────────────────────────────────
|
// ─── Maintenance Upkeep (spec §13) ───────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deduct maintenance cost for each active golem.
|
* Deduct player upkeep cost for each active golem per tick.
|
||||||
|
* Upkeep = Core.manaRegen × 2 per hour, converted to per-tick.
|
||||||
* Golems that can't be maintained are dismissed immediately.
|
* Golems that can't be maintained are dismissed immediately.
|
||||||
*/
|
*/
|
||||||
export function processGolemMaintenance(
|
export function processGolemMaintenance(
|
||||||
activeGolems: ActiveGolem[],
|
activeGolems: RuntimeActiveGolem[],
|
||||||
|
golemDesigns: Record<string, SerializedDesign>,
|
||||||
rawMana: number,
|
rawMana: number,
|
||||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
||||||
): {
|
): {
|
||||||
rawMana: number;
|
rawMana: number;
|
||||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||||
maintainedGolems: ActiveGolem[];
|
maintainedGolems: RuntimeActiveGolem[];
|
||||||
logMessages: string[];
|
logMessages: string[];
|
||||||
} {
|
} {
|
||||||
let newRawMana = rawMana;
|
let newRawMana = rawMana;
|
||||||
let newElements = { ...elements };
|
const newElements = { ...elements };
|
||||||
const maintainedGolems: ActiveGolem[] = [];
|
const maintainedGolems: RuntimeActiveGolem[] = [];
|
||||||
const logMessages: string[] = [];
|
const logMessages: string[] = [];
|
||||||
|
|
||||||
for (const golem of activeGolems) {
|
for (const golem of activeGolems) {
|
||||||
const def = GOLEMS_DEF[golem.golemId];
|
const design = golemDesigns[golem.designId];
|
||||||
if (!def) continue;
|
if (!design) continue;
|
||||||
|
|
||||||
// Calculate maintenance cost for this tick
|
const core = CORES[design.coreId];
|
||||||
let canMaintain = true;
|
if (!core) continue;
|
||||||
for (const cost of def.maintenanceCost) {
|
|
||||||
const tickCost = cost.amount * HOURS_PER_TICK;
|
|
||||||
if (cost.type === 'raw') {
|
|
||||||
if (newRawMana < tickCost) {
|
|
||||||
canMaintain = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else if (cost.element) {
|
|
||||||
const elem = newElements[cost.element];
|
|
||||||
if (!elem || !elem.unlocked || elem.current < tickCost) {
|
|
||||||
canMaintain = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!canMaintain) {
|
// Upkeep per tick = (manaRegen × 2) × HOURS_PER_TICK
|
||||||
logMessages.push(
|
const upkeepPerTick = core.manaRegen * 2 * HOURS_PER_TICK;
|
||||||
`${def.name} dismissed — insufficient ${def.maintenanceCost.map((c) => c.element || 'raw').join(', ')} mana`,
|
const upkeepElement = core.primaryManaType;
|
||||||
);
|
|
||||||
// Golem is dismissed — deduct no maintenance cost
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deduct maintenance cost
|
const elem = upkeepElement ? newElements[upkeepElement] : null;
|
||||||
for (const cost of def.maintenanceCost) {
|
|
||||||
const tickCost = cost.amount * HOURS_PER_TICK;
|
if (upkeepElement && elem && elem.unlocked && elem.current >= upkeepPerTick) {
|
||||||
if (cost.type === 'raw') {
|
// Deduct from element mana
|
||||||
newRawMana -= tickCost;
|
newElements[upkeepElement] = {
|
||||||
} else if (cost.element && newElements[cost.element]) {
|
...elem,
|
||||||
newElements[cost.element] = {
|
current: elem.current - upkeepPerTick,
|
||||||
...newElements[cost.element],
|
|
||||||
current: newElements[cost.element].current - tickCost,
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
maintainedGolems.push(golem);
|
maintainedGolems.push(golem);
|
||||||
|
} else if (!upkeepElement && newRawMana >= upkeepPerTick) {
|
||||||
|
// Deduct from raw mana
|
||||||
|
newRawMana -= upkeepPerTick;
|
||||||
|
maintainedGolems.push(golem);
|
||||||
|
} else if (upkeepElement && (!elem || !elem.unlocked || elem.current < upkeepPerTick)) {
|
||||||
|
logMessages.push(`${design.name} dismissed — insufficient ${upkeepElement} mana for upkeep`);
|
||||||
|
} else {
|
||||||
|
logMessages.push(`${design.name} dismissed — insufficient mana for upkeep`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -179,21 +207,40 @@ export function processGolemMaintenance(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Golem Combat Tick (spec §9.4) ─────────────────────────────────────────────
|
// ─── Golem Mana Regen (spec §12) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate golem mana pools per tick.
|
||||||
|
*/
|
||||||
|
export function processGolemManaRegen(
|
||||||
|
activeGolems: RuntimeActiveGolem[],
|
||||||
|
golemDesigns: Record<string, SerializedDesign>,
|
||||||
|
): RuntimeActiveGolem[] {
|
||||||
|
return activeGolems.map((golem) => {
|
||||||
|
const design = golemDesigns[golem.designId];
|
||||||
|
if (!design) return golem;
|
||||||
|
|
||||||
|
const core = CORES[design.coreId];
|
||||||
|
if (!core) return golem;
|
||||||
|
|
||||||
|
const manaGain = core.manaRegen * HOURS_PER_TICK;
|
||||||
|
return {
|
||||||
|
...golem,
|
||||||
|
currentMana: Math.min(core.manaCapacity, golem.currentMana + manaGain),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Golem Combat Tick (spec §11) ───────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process golem attacks for one combat tick.
|
* Process golem attacks for one combat tick.
|
||||||
* Each golem accumulates attackProgress and fires when >= 1.
|
* Each golem accumulates attackProgress and fires when >= 1.
|
||||||
* Golems apply elemental bonus based on their baseManaType.
|
* Supports spell casting via Mind Circuit behavior.
|
||||||
* Golems ignore Executioner and Berserker discipline specials.
|
|
||||||
*/
|
*/
|
||||||
export function processGolemAttacks(
|
export function processGolemAttacks(
|
||||||
activeGolems: ActiveGolem[],
|
activeGolems: RuntimeActiveGolem[],
|
||||||
rawMana: number,
|
golemDesigns: Record<string, SerializedDesign>,
|
||||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
|
||||||
floorHP: number,
|
|
||||||
floorMaxHP: number,
|
|
||||||
currentFloor: number,
|
|
||||||
onDamageDealt: (damage: number) => {
|
onDamageDealt: (damage: number) => {
|
||||||
rawMana: number;
|
rawMana: number;
|
||||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||||
@@ -201,114 +248,122 @@ export function processGolemAttacks(
|
|||||||
},
|
},
|
||||||
applyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
|
applyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
|
||||||
): GolemCombatResult {
|
): GolemCombatResult {
|
||||||
let newRawMana = rawMana;
|
let rawMana = 0;
|
||||||
let newElements = elements;
|
let elements: Record<string, { current: number; max: number; unlocked: boolean }> = {};
|
||||||
let currentFloorHP = floorHP;
|
let floorHP = 0;
|
||||||
let currentFloorMaxHP = floorMaxHP;
|
let floorMaxHP = 0;
|
||||||
const logMessages: string[] = [];
|
const logMessages: string[] = [];
|
||||||
let totalDamageDealt = 0;
|
let totalDamageDealt = 0;
|
||||||
|
|
||||||
const updatedGolems: ActiveGolem[] = [];
|
const updatedGolems: RuntimeActiveGolem[] = [];
|
||||||
|
|
||||||
for (const golem of activeGolems) {
|
for (const golem of activeGolems) {
|
||||||
const def = GOLEMS_DEF[golem.golemId];
|
const design = golemDesigns[golem.designId];
|
||||||
if (!def) continue;
|
if (!design) continue;
|
||||||
|
|
||||||
// Accumulate attack progress
|
const core = CORES[design.coreId];
|
||||||
let attackProgress = golem.attackProgress + HOURS_PER_TICK * def.attackSpeed;
|
const frame = FRAMES[design.frameId];
|
||||||
|
const circuit = MIND_CIRCUITS[design.mindCircuitId];
|
||||||
|
if (!core || !frame || !circuit) continue;
|
||||||
|
|
||||||
// Safety counter prevents infinite loop for very fast golems
|
let attackProgress = golem.attackProgress + HOURS_PER_TICK * frame.attackSpeed;
|
||||||
|
const updatedGolem = { ...golem };
|
||||||
let safetyCounter = 0;
|
let safetyCounter = 0;
|
||||||
const MAX_GOLEM_ATTACKS_PER_TICK = 100;
|
const MAX_GOLEM_ATTACKS_PER_TICK = 100;
|
||||||
|
|
||||||
while (attackProgress >= 1 && safetyCounter < MAX_GOLEM_ATTACKS_PER_TICK) {
|
while (attackProgress >= 1 && safetyCounter < MAX_GOLEM_ATTACKS_PER_TICK) {
|
||||||
// Calculate base damage
|
// Try spell cast first if circuit supports it
|
||||||
let dmg = def.damage;
|
if (circuit.spellSlots > 0 && design.selectedSpells.length > 0) {
|
||||||
|
const spellIdx = updatedGolem.spellCastIndex % design.selectedSpells.length;
|
||||||
|
const spellId = design.selectedSpells[spellIdx];
|
||||||
|
|
||||||
// Apply elemental bonus if golem has a baseManaType that matches an element
|
// Spell casting simplified — full implementation needs spell cost/effect lookup
|
||||||
if (def.baseManaType && def.baseManaType !== 'raw') {
|
if (spellId && updatedGolem.currentMana >= 10) {
|
||||||
const floorElement = getFloorElement(currentFloor);
|
// Cast spell: damage scaled by magic affinity
|
||||||
dmg *= getElementalBonus(def.baseManaType, floorElement);
|
const spellDmg = 20 * frame.magicAffinity; // Placeholder base spell damage
|
||||||
}
|
updatedGolem.currentMana -= 10;
|
||||||
|
updatedGolem.spellCastIndex = (updatedGolem.spellCastIndex + 1) % design.selectedSpells.length;
|
||||||
|
|
||||||
// Apply armor pierce: reduce effective enemy armor by armorPierce fraction
|
const dmgResult = onDamageDealt(spellDmg);
|
||||||
// (armor pierce is implemented as a flat damage multiplier for simplicity,
|
const finalDamage = dmgResult.modifiedDamage || spellDmg;
|
||||||
// bypass fraction of enemy armor — the full armor integration depends on
|
|
||||||
// the DoT/debuff system from issue #258)
|
|
||||||
if (def.armorPierce > 0) {
|
|
||||||
dmg *= 1 + def.armorPierce;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Golems ignore Executioner and Berserker discipline specials (spec §9.4)
|
if (Number.isFinite(finalDamage)) {
|
||||||
// The onDamageDealt callback is used for damage modifiers, but golem
|
|
||||||
// damage is not affected by discipline specials — we pass raw damage
|
|
||||||
// and use the result's base modifiedDamage path.
|
|
||||||
// Note: onDamageDealt may still apply guardian defenses (shield/barrier)
|
|
||||||
// which is correct since guardians defend against all damage sources.
|
|
||||||
const dmgResult = onDamageDealt(dmg);
|
|
||||||
newRawMana = dmgResult.rawMana;
|
|
||||||
newElements = dmgResult.elements;
|
|
||||||
const finalDamage = dmgResult.modifiedDamage || dmg;
|
|
||||||
|
|
||||||
if (!Number.isFinite(finalDamage)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply damage to room
|
|
||||||
const roomResult = applyDamageToRoom(finalDamage);
|
const roomResult = applyDamageToRoom(finalDamage);
|
||||||
currentFloorHP = roomResult.floorHP;
|
floorHP = roomResult.floorHP;
|
||||||
currentFloorMaxHP = roomResult.floorMaxHP;
|
floorMaxHP = roomResult.floorMaxHP;
|
||||||
totalDamageDealt += Math.max(0, finalDamage);
|
totalDamageDealt += Math.max(0, finalDamage);
|
||||||
|
rawMana = dmgResult.rawMana;
|
||||||
|
elements = dmgResult.elements;
|
||||||
|
}
|
||||||
|
|
||||||
attackProgress -= 1;
|
attackProgress -= 1;
|
||||||
safetyCounter++;
|
safetyCounter++;
|
||||||
|
continue;
|
||||||
if (roomResult.roomCleared) {
|
|
||||||
// Room cleared by golem — stop attacking this golem,
|
|
||||||
// room advancement is handled by the caller
|
|
||||||
attackProgress = 0;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updatedGolems.push({ ...golem, attackProgress });
|
// Basic attack
|
||||||
|
let dmg = frame.baseDamage * (1 + frame.armorPierce);
|
||||||
|
|
||||||
|
const dmgResult = onDamageDealt(dmg);
|
||||||
|
const finalDamage = dmgResult.modifiedDamage || dmg;
|
||||||
|
|
||||||
|
if (Number.isFinite(finalDamage)) {
|
||||||
|
const roomResult = applyDamageToRoom(finalDamage);
|
||||||
|
floorHP = roomResult.floorHP;
|
||||||
|
floorMaxHP = roomResult.floorMaxHP;
|
||||||
|
totalDamageDealt += Math.max(0, finalDamage);
|
||||||
|
rawMana = dmgResult.rawMana;
|
||||||
|
elements = dmgResult.elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
attackProgress -= 1;
|
||||||
|
safetyCounter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedGolem.attackProgress = attackProgress;
|
||||||
|
updatedGolems.push(updatedGolem);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rawMana: newRawMana,
|
rawMana,
|
||||||
elements: newElements,
|
elements,
|
||||||
activeGolems: updatedGolems,
|
activeGolems: updatedGolems,
|
||||||
logMessages,
|
logMessages,
|
||||||
totalDamageDealt,
|
totalDamageDealt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Room Duration Countdown (spec §9.6) ──────────────────────────────────────
|
// ─── Room Duration Countdown (spec §14) ─────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrement roomsRemaining for each active golem on room clear.
|
* Decrement roomsRemaining for each active golem on room clear.
|
||||||
* Golems at 0 remaining are dismissed.
|
* Golems at 0 remaining are dismissed.
|
||||||
*/
|
*/
|
||||||
export function countdownGolemRoomDuration(
|
export function countdownGolemRoomDuration(
|
||||||
activeGolems: ActiveGolem[],
|
activeGolems: RuntimeActiveGolem[],
|
||||||
|
golemDesigns: Record<string, SerializedDesign>,
|
||||||
): {
|
): {
|
||||||
remainingGolems: ActiveGolem[];
|
remainingGolems: RuntimeActiveGolem[];
|
||||||
dismissedNames: string[];
|
dismissedNames: string[];
|
||||||
logMessages: string[];
|
logMessages: string[];
|
||||||
} {
|
} {
|
||||||
const remainingGolems: ActiveGolem[] = [];
|
const remainingGolems: RuntimeActiveGolem[] = [];
|
||||||
const dismissedNames: string[] = [];
|
const dismissedNames: string[] = [];
|
||||||
const logMessages: string[] = [];
|
const logMessages: string[] = [];
|
||||||
|
|
||||||
for (const golem of activeGolems) {
|
for (const golem of activeGolems) {
|
||||||
const def = GOLEMS_DEF[golem.golemId];
|
const design = golemDesigns[golem.designId];
|
||||||
if (!def) continue;
|
if (!design) continue;
|
||||||
|
|
||||||
|
const core = CORES[design.coreId];
|
||||||
|
if (!core) continue;
|
||||||
|
|
||||||
const newRoomsRemaining = golem.roomsRemaining - 1;
|
const newRoomsRemaining = golem.roomsRemaining - 1;
|
||||||
|
|
||||||
if (newRoomsRemaining <= 0) {
|
if (newRoomsRemaining <= 0) {
|
||||||
dismissedNames.push(def.name);
|
dismissedNames.push(design.name);
|
||||||
logMessages.push(`${def.name} has faded after ${def.maxRoomDuration} rooms`);
|
logMessages.push(`${design.name} has faded after ${core.maxRoomDuration} rooms`);
|
||||||
} else {
|
} else {
|
||||||
remainingGolems.push({ ...golem, roomsRemaining: newRoomsRemaining });
|
remainingGolems.push({ ...golem, roomsRemaining: newRoomsRemaining });
|
||||||
}
|
}
|
||||||
@@ -316,5 +371,3 @@ export function countdownGolemRoomDuration(
|
|||||||
|
|
||||||
return { remainingGolems, dismissedNames, logMessages };
|
return { remainingGolems, dismissedNames, logMessages };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { SerializedGolemDesign } from '../types/game';
|
||||||
|
|
||||||
|
export function addGolemDesign(set: (fn: (s: any) => any) => void, design: SerializedGolemDesign) {
|
||||||
|
set((s: any) => {
|
||||||
|
const golemDesigns = { ...s.golemancy.golemDesigns, [design.id]: design };
|
||||||
|
const entry = { designId: design.id, design, enabled: true };
|
||||||
|
const golemLoadout = [...s.golemancy.golemLoadout, entry];
|
||||||
|
return { golemancy: { ...s.golemancy, golemDesigns, golemLoadout } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeGolemDesign(set: (fn: (s: any) => any) => void, designId: string) {
|
||||||
|
set((s: any) => {
|
||||||
|
const golemDesigns = { ...s.golemancy.golemDesigns };
|
||||||
|
delete golemDesigns[designId];
|
||||||
|
const golemLoadout = s.golemancy.golemLoadout.filter((e: any) => e.designId !== designId);
|
||||||
|
return { golemancy: { ...s.golemancy, golemDesigns, golemLoadout } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleGolemLoadoutEntry(set: (fn: (s: any) => any) => void, designId: string) {
|
||||||
|
set((s: any) => {
|
||||||
|
const golemLoadout = s.golemancy.golemLoadout.map((e: any) =>
|
||||||
|
e.designId === designId ? { ...e, enabled: !e.enabled } : e,
|
||||||
|
);
|
||||||
|
return { golemancy: { ...s.golemancy, golemLoadout } };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { getGuardianForFloor } from '../../data/guardian-encounters';
|
|||||||
import { hasSpecial, SPECIAL_EFFECTS } from '../../effects/special-effects';
|
import { hasSpecial, SPECIAL_EFFECTS } from '../../effects/special-effects';
|
||||||
import type { ComputedEffects } from '../../effects/upgrade-effects.types';
|
import type { ComputedEffects } from '../../effects/upgrade-effects.types';
|
||||||
import type { EnemyState } from '../../types';
|
import type { EnemyState } from '../../types';
|
||||||
|
import type { CombatStore } from '../combat-state.types';
|
||||||
import { countdownGolemRoomDuration } from '../golem-combat-actions';
|
import { countdownGolemRoomDuration } from '../golem-combat-actions';
|
||||||
|
|
||||||
// ─── Enemy Defense Context ────────────────────────────────────────────────────
|
// ─── Enemy Defense Context ────────────────────────────────────────────────────
|
||||||
@@ -37,7 +38,7 @@ interface BuildCombatCallbacksParams {
|
|||||||
effects: ComputedEffects;
|
effects: ComputedEffects;
|
||||||
maxMana: number;
|
maxMana: number;
|
||||||
addLog: (msg: string) => void;
|
addLog: (msg: string) => void;
|
||||||
useCombatStore: { setState: (s: Record<string, unknown>) => void; getState: () => Record<string, unknown> };
|
useCombatStore: { setState: (s: Partial<CombatStore>) => void; getState: () => CombatStore };
|
||||||
usePrestigeStore: { getState: () => { addDefeatedGuardian: (floor: number) => void } };
|
usePrestigeStore: { getState: () => { addDefeatedGuardian: (floor: number) => void } };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,11 +107,12 @@ export function buildCombatCallbacks(params: BuildCombatCallbacksParams) {
|
|||||||
}
|
}
|
||||||
useCombatStore.setState({ guardianShield: 0, guardianShieldMax: 0, guardianBarrier: 0, guardianBarrierMax: 0 });
|
useCombatStore.setState({ guardianShield: 0, guardianShieldMax: 0, guardianBarrier: 0, guardianBarrierMax: 0 });
|
||||||
|
|
||||||
// ── Golem room-duration countdown (spec §9.6) ──────────────────────
|
// ── Golem room-duration countdown (spec §14) ──────────────────────
|
||||||
const cs = useCombatStore.getState();
|
const cs = useCombatStore.getState();
|
||||||
const activeGolems = cs.golemancy?.activeGolems ?? [];
|
const activeGolems = cs.golemancy.activeGolems;
|
||||||
|
const golemDesigns = cs.golemancy.golemDesigns;
|
||||||
if (activeGolems.length > 0) {
|
if (activeGolems.length > 0) {
|
||||||
const result = countdownGolemRoomDuration(activeGolems);
|
const result = countdownGolemRoomDuration(activeGolems, golemDesigns);
|
||||||
if (result.logMessages.length > 0) {
|
if (result.logMessages.length > 0) {
|
||||||
result.logMessages.forEach((msg) => params.addLog(msg));
|
result.logMessages.forEach((msg) => params.addLog(msg));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
// ─── Golem Combat Pipeline ─────────────────────────────────────────────────────
|
// ─── Golem Combat Pipeline (Component-Based) ─────────────────────────────────
|
||||||
// Extracts golem combat setup from gameStore.ts tick()
|
// Pipeline integration for the component-based golem combat system.
|
||||||
// to keep the coordinator under the 400-line file limit.
|
// Extracts golem combat setup from gameStore.ts tick() to keep the coordinator
|
||||||
|
// under the 400-line file limit.
|
||||||
|
|
||||||
import { useCombatStore } from '../combatStore';
|
import { useCombatStore } from '../combatStore';
|
||||||
import { useManaStore } from '../manaStore';
|
import { useManaStore } from '../manaStore';
|
||||||
import { processGolemRoomDuration } from '../golem-combat-actions';
|
import {
|
||||||
import { lowestHPEnemy } from '../combat-damage';
|
summonGolemsOnRoomEntry,
|
||||||
import type { ActiveGolem, EnemyState } from '../../types';
|
processGolemMaintenance,
|
||||||
|
processGolemManaRegen,
|
||||||
|
processGolemAttacks,
|
||||||
|
countdownGolemRoomDuration,
|
||||||
|
} from '../golem-combat-actions';
|
||||||
|
import { useAttunementStore } from '../attunementStore';
|
||||||
|
import type { RuntimeActiveGolem } from '../../types';
|
||||||
|
|
||||||
export interface GolemCombatContext {
|
export interface GolemCombatContext {
|
||||||
addLog: (msg: string) => void;
|
addLog: (msg: string) => void;
|
||||||
ctx: {
|
ctx: {
|
||||||
combat: {
|
combat: {
|
||||||
currentFloor: number;
|
currentFloor: number;
|
||||||
currentRoom: { roomType: string; unknown: Array<{ name: string }> };
|
currentRoom: { roomType: string; enemies: Array<{ name: string; hp: number; maxHP: number; armor: number }> };
|
||||||
};
|
};
|
||||||
prestige: { signedPacts: number[] };
|
prestige: { signedPacts: number[] };
|
||||||
};
|
};
|
||||||
@@ -22,20 +29,29 @@ export interface GolemCombatContext {
|
|||||||
maxMana: number;
|
maxMana: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GolemCombatResult {
|
export interface GolemCombatPipelineResult {
|
||||||
rawMana: number;
|
rawMana: number;
|
||||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||||
|
activeGolems: RuntimeActiveGolem[];
|
||||||
|
logMessages: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the golem combat pipeline for the current tick.
|
* Build the golem combat pipeline for the current tick.
|
||||||
* Returns golem state needed by processCombatTick.
|
|
||||||
*/
|
*/
|
||||||
export function buildGolemCombatPipeline(_addLog: (msg: string) => void): {
|
export function buildGolemCombatPipeline(_addLog: (msg: string) => void): {
|
||||||
activeGolems: ActiveGolem[];
|
activeGolems: RuntimeActiveGolem[];
|
||||||
|
golemDesigns: Record<string, { id: string; name: string; coreId: string; frameId: string; mindCircuitId: string; enchantmentIds: string[]; selectedManaTypes: string[]; selectedSpells: string[] }>;
|
||||||
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean };
|
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean };
|
||||||
} {
|
} {
|
||||||
const activeGolems = useCombatStore.getState().golemancy?.activeGolems ?? [];
|
const combatState = useCombatStore.getState();
|
||||||
|
const golemancy = combatState.golemancy;
|
||||||
|
|
||||||
|
// New component-based active golems
|
||||||
|
const activeGolems = golemancy?.activeGolems ?? [];
|
||||||
|
|
||||||
|
// Reconstruct golem designs from store
|
||||||
|
const golemDesigns = golemancy?.golemDesigns ?? {};
|
||||||
|
|
||||||
const golemApplyDamageToRoom = (dmg: number) => {
|
const golemApplyDamageToRoom = (dmg: number) => {
|
||||||
const cs = useCombatStore.getState();
|
const cs = useCombatStore.getState();
|
||||||
@@ -44,14 +60,19 @@ export function buildGolemCombatPipeline(_addLog: (msg: string) => void): {
|
|||||||
return { floorHP: cs.floorHP, floorMaxHP: cs.floorMaxHP, roomCleared: false };
|
return { floorHP: cs.floorHP, floorMaxHP: cs.floorMaxHP, roomCleared: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Golems use focus-fire targeting (spec §9.4) — target lowest HP enemy
|
// Focus-fire targeting: target lowest HP enemy
|
||||||
const target = lowestHPEnemy(room.enemies);
|
let target = room.enemies[0];
|
||||||
if (!target) {
|
for (const e of room.enemies) {
|
||||||
|
if (e.hp > 0 && e.hp < (target?.hp ?? Infinity)) {
|
||||||
|
target = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!target || target.hp <= 0) {
|
||||||
return { floorHP: cs.floorHP, floorMaxHP: cs.floorMaxHP, roomCleared: false };
|
return { floorHP: cs.floorHP, floorMaxHP: cs.floorMaxHP, roomCleared: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedEnemies = room.enemies.map((enemy) => {
|
const updatedEnemies = room.enemies.map((enemy) => {
|
||||||
if (enemy.id === target.id && enemy.hp > 0) {
|
if (enemy.id === target!.id && enemy.hp > 0) {
|
||||||
return { ...enemy, hp: Math.max(0, enemy.hp - dmg) };
|
return { ...enemy, hp: Math.max(0, enemy.hp - dmg) };
|
||||||
}
|
}
|
||||||
return enemy;
|
return enemy;
|
||||||
@@ -68,5 +89,32 @@ export function buildGolemCombatPipeline(_addLog: (msg: string) => void): {
|
|||||||
return { floorHP: newFloorHP, floorMaxHP: cs.floorMaxHP, roomCleared: allDead };
|
return { floorHP: newFloorHP, floorMaxHP: cs.floorMaxHP, roomCleared: allDead };
|
||||||
};
|
};
|
||||||
|
|
||||||
return { activeGolems, golemApplyDamageToRoom };
|
return { activeGolems, golemDesigns, golemApplyDamageToRoom };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process golem summoning on room entry.
|
||||||
|
*/
|
||||||
|
export function processGolemRoomEntry(
|
||||||
|
loadout: { enabled: boolean; designId: string; design: { name: string } }[],
|
||||||
|
currentFloor: number,
|
||||||
|
): {
|
||||||
|
rawMana: number;
|
||||||
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||||
|
activeGolems: RuntimeActiveGolem[];
|
||||||
|
logMessages: string[];
|
||||||
|
} {
|
||||||
|
const cs = useCombatStore.getState();
|
||||||
|
const attStore = useAttunementStore.getState();
|
||||||
|
const fabLevel = attStore.attunements?.fabricator?.level ?? 0;
|
||||||
|
const discBonus = 0; // TODO: compute from discipline
|
||||||
|
|
||||||
|
return summonGolemsOnRoomEntry(
|
||||||
|
loadout as any,
|
||||||
|
useManaStore.getState().rawMana,
|
||||||
|
useManaStore.getState().elements as any,
|
||||||
|
currentFloor,
|
||||||
|
cs.golemancy.activeGolems as any[],
|
||||||
|
discBonus,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,10 +50,15 @@ export type {
|
|||||||
ScheduleBlock,
|
ScheduleBlock,
|
||||||
StudyTarget,
|
StudyTarget,
|
||||||
SummonedGolem,
|
SummonedGolem,
|
||||||
|
ActiveGolem,
|
||||||
GolemancyState,
|
GolemancyState,
|
||||||
|
GolemLoadoutEntry,
|
||||||
|
RuntimeActiveGolem,
|
||||||
|
SerializedGolemDesign,
|
||||||
GameActionType,
|
GameActionType,
|
||||||
ActivityEventType,
|
ActivityEventType,
|
||||||
ActivityLogEntry,
|
ActivityLogEntry,
|
||||||
|
ActiveEffect,
|
||||||
} from './types/game';
|
} from './types/game';
|
||||||
|
|
||||||
export type { PrestigeDef } from './types/game';
|
export type { PrestigeDef } from './types/game';
|
||||||
|
|||||||
+58
-10
@@ -144,25 +144,71 @@ export interface StudyTarget {
|
|||||||
|
|
||||||
// ─── Golemancy Types ─────────────────────────────────────────────────────────
|
// ─── Golemancy Types ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** @deprecated Legacy type for predefined golems. Use GolemDesign instead. */
|
||||||
export interface SummonedGolem {
|
export interface SummonedGolem {
|
||||||
golemId: string; // Reference to GOLEMS_DEF
|
golemId: string;
|
||||||
summonedFloor: number; // Floor when golem was summoned
|
summonedFloor: number;
|
||||||
attackProgress: number; // Progress toward next attack (0-1)
|
attackProgress: number;
|
||||||
roomsRemaining: number; // Rooms before golem disappears (spec §9.6)
|
roomsRemaining: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Runtime state for an active golem in combat (spec §9.7) */
|
/** @deprecated Legacy type. Use ActiveGolemV2 instead. */
|
||||||
export interface ActiveGolem extends SummonedGolem {
|
export interface ActiveGolem extends SummonedGolem {
|
||||||
// attackProgress is inherited from SummonedGolem
|
// attackProgress is inherited from SummonedGolem
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GolemancyState {
|
/**
|
||||||
enabledGolems: string[]; // Golem IDs the player wants active
|
* Player-designed golem loadout entry.
|
||||||
summonedGolems: SummonedGolem[]; // Currently summoned golems on this floor (legacy, kept for golem-tab state)
|
* Each entry is a complete golem design (Core + Frame + Mind Circuit + Enchantments).
|
||||||
activeGolems: ActiveGolem[]; // Runtime active golems in combat (spec §9)
|
*/
|
||||||
lastSummonFloor: number; // Floor golems were last summoned on
|
export interface GolemLoadoutEntry {
|
||||||
|
designId: string; // Reference to the GolemDesign
|
||||||
|
/** Golem design (serialized component-based golem) */
|
||||||
|
design: SerializedGolemDesign;
|
||||||
|
enabled: boolean; // Whether this golem is enabled for auto-summon
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime active golem in combat (component-based system).
|
||||||
|
* Tracks combat state per golem instance.
|
||||||
|
*/
|
||||||
|
export interface RuntimeActiveGolem {
|
||||||
|
designId: string; // Reference to the player's GolemDesign
|
||||||
|
summonedFloor: number; // Floor when golem was summoned
|
||||||
|
attackProgress: number; // Progress toward next attack (accumulated)
|
||||||
|
roomsRemaining: number; // Rooms before golem fades
|
||||||
|
currentMana: number; // Current mana in golem's own pool
|
||||||
|
spellCastIndex: number; // For alternating/cycling spell circuits
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SerializedGolemDesign {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
coreId: string;
|
||||||
|
frameId: string;
|
||||||
|
mindCircuitId: string;
|
||||||
|
enchantmentIds: string[];
|
||||||
|
selectedManaTypes: string[];
|
||||||
|
selectedSpells: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GolemancyState {
|
||||||
|
/** Player's saved golem designs indexed by design ID */
|
||||||
|
golemDesigns: Record<string, SerializedGolemDesign>;
|
||||||
|
/** Prioritized loadout of golem designs (persists across rooms, resets per run) */
|
||||||
|
golemLoadout: GolemLoadoutEntry[];
|
||||||
|
/** Runtime active golems in combat */
|
||||||
|
activeGolems: RuntimeActiveGolem[];
|
||||||
|
/** Floor golems were last summoned on */
|
||||||
|
lastSummonFloor: number;
|
||||||
|
// Legacy fields kept for backward compatibility during migration
|
||||||
|
enabledGolems: string[];
|
||||||
|
summonedGolems: SummonedGolem[];
|
||||||
|
/** @deprecated Use activeGolems instead (RuntimeActiveGolem[]) */
|
||||||
|
legacyActiveGolems: ActiveGolem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ─── Main Game State ─────────────────────────────────────────────────────
|
// ─── Main Game State ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface GameState {
|
export interface GameState {
|
||||||
@@ -245,6 +291,8 @@ export interface GameState {
|
|||||||
// Golemancy (summoned golems)
|
// Golemancy (summoned golems)
|
||||||
golemancy: GolemancyState;
|
golemancy: GolemancyState;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Achievements
|
// Achievements
|
||||||
achievements: AchievementState;
|
achievements: AchievementState;
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ export type {
|
|||||||
SummonedGolem,
|
SummonedGolem,
|
||||||
ActiveGolem,
|
ActiveGolem,
|
||||||
GolemancyState,
|
GolemancyState,
|
||||||
|
GolemLoadoutEntry,
|
||||||
|
RuntimeActiveGolem,
|
||||||
|
SerializedGolemDesign,
|
||||||
GameActionType,
|
GameActionType,
|
||||||
ActivityEventType,
|
ActivityEventType,
|
||||||
ActivityLogEntry,
|
ActivityLogEntry,
|
||||||
|
|||||||
Reference in New Issue
Block a user