diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt
index 2f4026c..de01f87 100644
--- a/docs/circular-deps.txt
+++ b/docs/circular-deps.txt
@@ -1,4 +1,4 @@
# Circular Dependencies
-Generated: 2026-06-06T15:46:53.644Z
+Generated: 2026-06-06T16:37:23.532Z
No circular dependencies found. ✅
diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json
index 9f9b2b4..c038292 100644
--- a/docs/dependency-graph.json
+++ b/docs/dependency-graph.json
@@ -1,6 +1,6 @@
{
"_meta": {
- "generated": "2026-06-06T15:46:51.822Z",
+ "generated": "2026-06-06T16:37:21.673Z",
"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."
},
@@ -451,15 +451,9 @@
"data/fabricator-wizard-recipes.ts": [
"data/fabricator-recipe-types.ts"
],
- "data/golems/base-golems.ts": [
- "data/golems/types.ts"
- ],
"data/golems/cores.ts": [
"data/golems/types.ts"
],
- "data/golems/elemental-golems.ts": [
- "data/golems/types.ts"
- ],
"data/golems/frames.ts": [
"data/golems/types.ts"
],
@@ -472,9 +466,6 @@
"data/golems/golemEnchantments.ts",
"data/golems/mindCircuits.ts"
],
- "data/golems/hybrid-golems.ts": [
- "data/golems/types.ts"
- ],
"data/golems/index.ts": [
"data/golems/cores.ts",
"data/golems/frames.ts",
@@ -785,6 +776,7 @@
"stores/uiStore.ts"
],
"stores/pipelines/golem-combat.ts": [
+ "effects/discipline-effects.ts",
"stores/attunementStore.ts",
"stores/combatStore.ts",
"stores/golem-combat-actions.ts",
diff --git a/docs/project-structure.txt b/docs/project-structure.txt
index c0b0d9e..6da1451 100644
--- a/docs/project-structure.txt
+++ b/docs/project-structure.txt
@@ -332,6 +332,7 @@ Mana-Loop/
│ │ │ │ │ ├── cores.ts
│ │ │ │ │ ├── frames.ts
│ │ │ │ │ ├── golemEnchantments.ts
+│ │ │ │ │ ├── golemancy-data.test.ts
│ │ │ │ │ ├── golems-data.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── mindCircuits.ts
@@ -386,6 +387,7 @@ Mana-Loop/
│ │ │ │ ├── gameStore.types.ts
│ │ │ │ ├── golem-combat-actions.ts
│ │ │ │ ├── golemancy-actions.ts
+│ │ │ │ ├── golemancy-combat.test.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── manaStore.ts
│ │ │ │ ├── non-combat-room-actions.ts
diff --git a/src/components/game/tabs/GolemancyTab.tsx b/src/components/game/tabs/GolemancyTab.tsx
index 921c66b..764626c 100644
--- a/src/components/game/tabs/GolemancyTab.tsx
+++ b/src/components/game/tabs/GolemancyTab.tsx
@@ -6,6 +6,7 @@ import { useCombatStore } from '@/lib/game/stores/combatStore';
import { useAttunementStore } from '@/lib/game/stores/attunementStore';
import { useManaStore } from '@/lib/game/stores/manaStore';
import { usePrestigeStore } from '@/lib/game/stores/prestigeStore';
+import { computeDisciplineEffects } from '@/lib/game/effects/discipline-effects';
import {
CORES, FRAMES, MIND_CIRCUITS, GOLEM_ENCHANTMENTS,
ALL_CORES, ALL_FRAMES, ALL_MIND_CIRCUITS, ALL_GOLEM_ENCHANTMENTS,
@@ -66,7 +67,10 @@ export const GolemancyTab: React.FC = () => {
);
const fabricatorLevel = attunements.fabricator?.level ?? 0;
- const golemSlots = getGolemSlots(fabricatorLevel);
+ const baseGolemSlots = getGolemSlots(fabricatorLevel);
+ const discEffects = computeDisciplineEffects();
+ const discSlotBonus = Math.floor(discEffects.bonuses.golemCapacity || 0);
+ const golemSlots = Math.min(7, baseGolemSlots + discSlotBonus);
const enabledCount = golemancy.golemLoadout.filter(e => e.enabled).length;
// Unlock checks
@@ -170,7 +174,7 @@ export const GolemancyTab: React.FC = () => {
summoned when entering the spire if you can afford the cost.
- Slots: {enabledCount}/{golemSlots}
+ Slots: {enabledCount}/{golemSlots}{discSlotBonus > 0 ? (+{discSlotBonus} discipline) : null}
Active: {golemancy.activeGolems.length}
Designs: {Object.keys(golemancy.golemDesigns).length}
diff --git a/src/components/game/tabs/golemancy/golemancy-utils.test.ts b/src/components/game/tabs/golemancy/golemancy-utils.test.ts
index 858d392..7bbdf85 100644
--- a/src/components/game/tabs/golemancy/golemancy-utils.test.ts
+++ b/src/components/game/tabs/golemancy/golemancy-utils.test.ts
@@ -21,11 +21,19 @@ describe('getGolemSlots', () => {
expect(getGolemSlots(10)).toBe(5);
});
- it('caps at 5 slots for level 10+', () => {
+ it('returns base slots from fabricator level (cap applied at summon level)', () => {
expect(getGolemSlots(10)).toBe(5);
expect(getGolemSlots(11)).toBe(5);
+ // getGolemSlots returns base slots only; total cap of 7 is applied in summon logic
expect(getGolemSlots(20)).toBe(10);
});
+
+ it('base slots scale as floor(level/2)', () => {
+ expect(getGolemSlots(0)).toBe(0);
+ expect(getGolemSlots(1)).toBe(0);
+ expect(getGolemSlots(2)).toBe(1);
+ expect(getGolemSlots(10)).toBe(5);
+ });
});
// ─── Test: isComponentUnlocked ────────────────────────────────────────────────
diff --git a/src/lib/game/data/golems/frames.ts b/src/lib/game/data/golems/frames.ts
index 5dac4a3..57e0350 100644
--- a/src/lib/game/data/golems/frames.ts
+++ b/src/lib/game/data/golems/frames.ts
@@ -126,9 +126,9 @@ const CRYSTAL_STEEL_HYBRID_FRAME: FrameDefinition = {
specialEffect: 'guardianConstruct',
summonCost: [elemCost('crystal', 20), elemCost('metal', 15), rawCost(15)],
unlockRequirement: {
- type: 'attunement_level',
- attunement: 'fabricator',
- level: 5,
+ type: 'dual_attunement',
+ attunements: ['fabricator', 'enchanter'],
+ levels: [5, 5],
},
};
diff --git a/src/lib/game/data/golems/golemancy-data.test.ts b/src/lib/game/data/golems/golemancy-data.test.ts
new file mode 100644
index 0000000..1f6c372
--- /dev/null
+++ b/src/lib/game/data/golems/golemancy-data.test.ts
@@ -0,0 +1,344 @@
+// ─── Golemancy Data Layer Tests ───────────────────────────────────────────────
+// Tests component registries, unlock requirements, computed stats, and slot limits.
+
+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';
+import {
+ getGolemSlots,
+ isComponentUnlocked,
+ computeGolemStats,
+ canAffordGolemDesign,
+ createActiveGolem,
+} from '@/lib/game/data/golems/utils';
+import type { GolemDesign } from '@/lib/game/data/golems/types';
+
+// ─── Helper ───────────────────────────────────────────────────────────────────
+
+function makeDesign(coreId: string, frameId: string, circuitId: string, enchantIds: string[] = []): GolemDesign {
+ return {
+ id: `test_${coreId}_${frameId}_${circuitId}`,
+ name: `Test ${coreId} ${frameId}`,
+ core: CORES[coreId],
+ frame: FRAMES[frameId],
+ mindCircuit: MIND_CIRCUITS[circuitId],
+ enchantments: enchantIds.map(id => GOLEM_ENCHANTMENTS[id]).filter(Boolean),
+ selectedManaTypes: [],
+ selectedSpells: [],
+ };
+}
+
+// ─── Component Registry Completeness ──────────────────────────────────────────
+
+describe('Component registry completeness', () => {
+ it('has exactly 4 cores', () => {
+ expect(ALL_CORES.length).toBe(4);
+ expect(Object.keys(CORES).length).toBe(4);
+ });
+
+ it('has exactly 7 frames', () => {
+ expect(ALL_FRAMES.length).toBe(7);
+ expect(Object.keys(FRAMES).length).toBe(7);
+ });
+
+ it('has exactly 4 mind circuits', () => {
+ expect(ALL_MIND_CIRCUITS.length).toBe(4);
+ expect(Object.keys(MIND_CIRCUITS).length).toBe(4);
+ });
+
+ it('has exactly 8 enchantments', () => {
+ expect(ALL_GOLEM_ENCHANTMENTS.length).toBe(8);
+ expect(Object.keys(GOLEM_ENCHANTMENTS).length).toBe(8);
+ });
+
+ it('all cores have unique tiers 1-4', () => {
+ const tiers = ALL_CORES.map(c => c.tier).sort();
+ expect(tiers).toEqual([1, 2, 3, 4]);
+ });
+
+ it('all frames have valid special effects', () => {
+ const validSpecials = new Set(['none', 'aoe', 'slow', 'guardianConstruct']);
+ for (const frame of ALL_FRAMES) {
+ expect(validSpecials.has(frame.specialEffect)).toBe(true);
+ }
+ });
+
+ it('all circuits have valid behaviors', () => {
+ const validBehaviors = new Set(['basicOnly', 'castSpell1', 'alternate2', 'cycleAll']);
+ for (const circuit of ALL_MIND_CIRCUITS) {
+ expect(validBehaviors.has(circuit.behavior)).toBe(true);
+ }
+ });
+});
+
+// ─── Unlock Requirements ──────────────────────────────────────────────────────
+
+describe('Unlock requirements', () => {
+ it('basic core requires Fabricator 2', () => {
+ const req = CORES.basic.unlockRequirement;
+ expect(req.type).toBe('attunement_level');
+ expect(isComponentUnlocked(req, { fabricator: { active: true, level: 2 } }, [], [])).toBe(true);
+ expect(isComponentUnlocked(req, { fabricator: { active: true, level: 1 } }, [], [])).toBe(false);
+ });
+
+ it('intermediate core requires dual attunement Fabricator 4 + Enchanter 2', () => {
+ const req = CORES.intermediate.unlockRequirement;
+ expect(req.type).toBe('dual_attunement');
+ expect(isComponentUnlocked(req, { fabricator: { active: true, level: 4 }, enchanter: { active: true, level: 2 } }, [], [])).toBe(true);
+ expect(isComponentUnlocked(req, { fabricator: { active: true, level: 4 }, enchanter: { active: true, level: 1 } }, [], [])).toBe(false);
+ });
+
+ it('guardian core requires guardian pact', () => {
+ const req = CORES.guardian.unlockRequirement;
+ expect(req.type).toBe('guardian_pact');
+ expect(isComponentUnlocked(req, { invoker: { active: true, level: 5 }, fabricator: { active: true, level: 5 } }, [], [1])).toBe(true);
+ expect(isComponentUnlocked(req, { invoker: { active: true, level: 5 }, fabricator: { active: true, level: 5 } }, [], [])).toBe(false);
+ });
+
+ it('crystalSteelHybrid frame requires Fabricator 5 + Enchanter 5', () => {
+ const req = FRAMES.crystalSteelHybrid.unlockRequirement;
+ expect(req.type).toBe('dual_attunement');
+ expect(isComponentUnlocked(req, { fabricator: { active: true, level: 5 }, enchanter: { active: true, level: 5 } }, [], [])).toBe(true);
+ expect(isComponentUnlocked(req, { fabricator: { active: true, level: 5 }, enchanter: { active: true, level: 4 } }, [], [])).toBe(false);
+ });
+
+ it('sand frame requires sand mana unlocked', () => {
+ const req = FRAMES.sand.unlockRequirement;
+ expect(req.type).toBe('mana_unlocked');
+ expect(isComponentUnlocked(req, {}, ['sand'], [])).toBe(true);
+ expect(isComponentUnlocked(req, {}, ['earth'], [])).toBe(false);
+ });
+
+ it('guardian circuit requires Invoker 5 + Fabricator 5', () => {
+ const req = MIND_CIRCUITS.guardian.unlockRequirement;
+ expect(req.type).toBe('dual_attunement');
+ expect(isComponentUnlocked(req, { invoker: { active: true, level: 5 }, fabricator: { active: true, level: 5 } }, [], [])).toBe(true);
+ expect(isComponentUnlocked(req, { invoker: { active: true, level: 4 }, fabricator: { active: true, level: 5 } }, [], [])).toBe(false);
+ });
+});
+
+// ─── Computed Stats ───────────────────────────────────────────────────────────
+
+describe('Computed stats', () => {
+ it('basic earth golem has correct stats', () => {
+ const design = makeDesign('basic', 'earth', 'simple');
+ const stats = computeGolemStats(design);
+
+ expect(stats.manaCapacity).toBe(50);
+ expect(stats.manaRegen).toBe(0.5);
+ expect(stats.maxRoomDuration).toBe(3);
+ 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);
+ expect(stats.spellSlots).toBe(0);
+ expect(stats.enchantmentCapacity).toBeCloseTo(0.3);
+ expect(stats.specialEffect).toBe('none');
+ expect(stats.availableManaTypes).toEqual(['earth']);
+ });
+
+ it('advanced steel golem with advanced circuit', () => {
+ const design = makeDesign('advanced', 'steel', 'advanced');
+ const stats = computeGolemStats(design);
+
+ expect(stats.manaCapacity).toBe(200);
+ expect(stats.manaRegen).toBe(3.0);
+ expect(stats.maxRoomDuration).toBe(5);
+ expect(stats.baseDamage).toBe(18);
+ expect(stats.attackSpeed).toBe(1.6);
+ expect(stats.armorPierce).toBe(0.5);
+ expect(stats.magicAffinity).toBe(0.5);
+ expect(stats.spellSlots).toBe(2);
+ expect(stats.enchantmentCapacity).toBeCloseTo(1.0);
+ });
+
+ it('guardian crystalSteelHybrid golem with guardian circuit', () => {
+ const design = makeDesign('guardian', 'crystalSteelHybrid', 'guardian');
+ const stats = computeGolemStats(design);
+
+ expect(stats.manaCapacity).toBe(500);
+ expect(stats.manaRegen).toBe(10.0);
+ expect(stats.maxRoomDuration).toBe(8);
+ expect(stats.baseDamage).toBe(22);
+ expect(stats.attackSpeed).toBe(2.8);
+ expect(stats.armorPierce).toBe(0.7);
+ expect(stats.magicAffinity).toBe(1.0);
+ expect(stats.spellSlots).toBe(4);
+ expect(stats.enchantmentCapacity).toBeCloseTo(3.0);
+ expect(stats.specialEffect).toBe('guardianConstruct');
+ });
+
+ it('enchantments add to summon cost', () => {
+ const designNoEnch = makeDesign('basic', 'earth', 'simple');
+ const designWithEnch = makeDesign('basic', 'earth', 'simple', ['sword_fire', 'sword_frost']);
+
+ const statsNoEnch = computeGolemStats(designNoEnch);
+ const statsWithEnch = computeGolemStats(designWithEnch);
+
+ expect(statsWithEnch.totalSummonCost.length).toBeGreaterThan(statsNoEnch.totalSummonCost.length);
+ });
+
+ it('upkeep = core.manaRegen * 2 per hour', () => {
+ const design = makeDesign('basic', 'earth', 'simple');
+ const stats = computeGolemStats(design);
+
+ expect(stats.upkeepCostPerHour.length).toBe(1);
+ expect(stats.upkeepCostPerHour[0].amount).toBe(1.0);
+ expect(stats.upkeepCostPerHour[0].element).toBe('earth');
+ });
+
+ it('selected mana types override core defaults', () => {
+ const design = makeDesign('intermediate', 'earth', 'simple');
+ design.selectedManaTypes = ['fire', 'water'];
+ const stats = computeGolemStats(design);
+
+ expect(stats.availableManaTypes).toEqual(['fire', 'water']);
+ });
+});
+
+// ─── Golem Slot Calculation ───────────────────────────────────────────────────
+
+describe('Golem slot calculation', () => {
+ it('returns 0 for fabricator level < 2', () => {
+ expect(getGolemSlots(0)).toBe(0);
+ expect(getGolemSlots(1)).toBe(0);
+ });
+
+ it('scales as floor(level/2)', () => {
+ 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('total slots capped at 7 (base + discipline bonus)', () => {
+ const baseSlots = getGolemSlots(10);
+ const discBonus = 2;
+ const total = Math.min(7, baseSlots + discBonus);
+ expect(total).toBe(7);
+ });
+
+ it('total slots cannot exceed 7 even with high fabricator level', () => {
+ const baseSlots = getGolemSlots(20);
+ const discBonus = 2;
+ const total = Math.min(7, baseSlots + discBonus);
+ expect(total).toBe(7);
+ });
+});
+
+// ─── Affordability ────────────────────────────────────────────────────────────
+
+describe('canAffordGolemDesign', () => {
+ it('returns true when player has enough resources', () => {
+ const design = makeDesign('basic', 'earth', 'simple');
+ const result = canAffordGolemDesign(design, 100, {
+ earth: { current: 50, max: 100, unlocked: true },
+ });
+ expect(result.canAfford).toBe(true);
+ });
+
+ it('returns false when raw mana insufficient', () => {
+ const design = makeDesign('basic', 'earth', 'simple');
+ 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 false when element mana insufficient', () => {
+ const design = makeDesign('basic', 'earth', 'simple');
+ const result = canAffordGolemDesign(design, 100, {
+ earth: { current: 5, max: 100, unlocked: true },
+ });
+ expect(result.canAfford).toBe(false);
+ expect(result.missing).toContain('earth');
+ });
+
+ it('returns false when element not unlocked', () => {
+ const design = makeDesign('basic', 'earth', 'simple');
+ const result = canAffordGolemDesign(design, 100, {});
+ expect(result.canAfford).toBe(false);
+ expect(result.missing).toContain('not unlocked');
+ });
+});
+
+// ─── Active Golem Creation ────────────────────────────────────────────────────
+
+describe('createActiveGolem', () => {
+ it('creates active golem with correct initial state', () => {
+ const design = makeDesign('basic', 'earth', 'simple');
+ const active = createActiveGolem(design, 5);
+
+ expect(active.designId).toBe(design.id);
+ expect(active.summonedFloor).toBe(5);
+ expect(active.attackProgress).toBe(0);
+ expect(active.roomsRemaining).toBe(3);
+ expect(active.currentMana).toBe(50);
+ expect(active.spellCastIndex).toBe(0);
+ });
+
+ it('guardian golem starts with full mana', () => {
+ const design = makeDesign('guardian', 'crystalSteelHybrid', 'guardian');
+ const active = createActiveGolem(design, 10);
+
+ expect(active.currentMana).toBe(500);
+ expect(active.roomsRemaining).toBe(8);
+ });
+});
+
+// ─── Guardian Construct Requirements ──────────────────────────────────────────
+
+describe('Guardian Construct requirements', () => {
+ it('guardian core has tier 4', () => {
+ expect(CORES.guardian.tier).toBe(4);
+ });
+
+ it('crystalSteelHybrid frame has guardianConstruct special effect', () => {
+ expect(FRAMES.crystalSteelHybrid.specialEffect).toBe('guardianConstruct');
+ });
+
+ it('guardian circuit has cycleAll behavior', () => {
+ expect(MIND_CIRCUITS.guardian.behavior).toBe('cycleAll');
+ });
+
+ it('guardian circuit has 4 spell slots', () => {
+ expect(MIND_CIRCUITS.guardian.spellSlots).toBe(4);
+ });
+});
+
+// ─── All Component Combinations ───────────────────────────────────────────────
+
+describe('All component combinations', () => {
+ it('every core+frame+circuit combination produces valid stats', () => {
+ for (const core of ALL_CORES) {
+ for (const frame of ALL_FRAMES) {
+ for (const circuit of ALL_MIND_CIRCUITS) {
+ const design = makeDesign(core.id, frame.id, circuit.id);
+ const stats = computeGolemStats(design);
+
+ expect(stats.manaCapacity).toBeGreaterThan(0);
+ expect(stats.manaRegen).toBeGreaterThan(0);
+ expect(stats.maxRoomDuration).toBeGreaterThan(0);
+ expect(stats.baseDamage).toBeGreaterThan(0);
+ expect(stats.attackSpeed).toBeGreaterThan(0);
+ expect(stats.armorPierce).toBeGreaterThanOrEqual(0);
+ expect(stats.magicAffinity).toBeGreaterThanOrEqual(0);
+ expect(stats.aoeTargets).toBeGreaterThanOrEqual(1);
+ expect(stats.spellSlots).toBeGreaterThanOrEqual(0);
+ expect(stats.enchantmentCapacity).toBeGreaterThan(0);
+ expect(stats.totalSummonCost.length).toBeGreaterThan(0);
+ expect(stats.upkeepCostPerHour.length).toBeGreaterThan(0);
+ }
+ }
+ }
+ });
+});
diff --git a/src/lib/game/stores/combat-descent-actions.ts b/src/lib/game/stores/combat-descent-actions.ts
index 24be70c..604c4ad 100644
--- a/src/lib/game/stores/combat-descent-actions.ts
+++ b/src/lib/game/stores/combat-descent-actions.ts
@@ -8,6 +8,8 @@ import { getGuardianForFloor } from '../data/guardian-encounters';
import { usePrestigeStore } from './prestigeStore';
import { useManaStore } from './manaStore';
import { summonGolemsOnRoomEntry } from './golem-combat-actions';
+import { computeDisciplineEffects } from '../effects/discipline-effects';
+import { useAttunementStore } from './attunementStore';
import {
onEnterLibraryRoom,
onEnterRecoveryRoom,
@@ -145,6 +147,11 @@ function summonGolemsForRoom(get: GetFn, set: SetFn): void {
const loadout = s.golemancy?.golemLoadout ?? [];
if (loadout.length === 0) return;
+ const attStore = useAttunementStore.getState();
+ const fabLevel = attStore.attunements?.fabricator?.level ?? 0;
+ const discEffects = computeDisciplineEffects();
+ const discBonus = Math.floor(discEffects.bonuses.golemCapacity || 0);
+
const currentActiveGolems = s.golemancy?.activeGolems ?? [];
const summonResult = summonGolemsOnRoomEntry(
loadout,
@@ -152,7 +159,8 @@ function summonGolemsForRoom(get: GetFn, set: SetFn): void {
manaState.elements,
s.currentFloor,
currentActiveGolems,
- 0, // discBonus — computed in pipeline, not here
+ discBonus,
+ fabLevel,
);
if (summonResult.logMessages.length > 0) {
diff --git a/src/lib/game/stores/golem-combat-actions.ts b/src/lib/game/stores/golem-combat-actions.ts
index f66dcbf..1737b18 100644
--- a/src/lib/game/stores/golem-combat-actions.ts
+++ b/src/lib/game/stores/golem-combat-actions.ts
@@ -48,6 +48,7 @@ export function summonGolemsOnRoomEntry(
currentFloor: number,
existingActiveGolems: RuntimeActiveGolem[],
disciplineSlotsBonus: number,
+ fabricatorLevel: number,
): {
rawMana: number;
elements: Record;
@@ -60,12 +61,14 @@ export function summonGolemsOnRoomEntry(
const logMessages: string[] = [];
const activeCount = newActiveGolems.length;
+ const baseSlots = getGolemSlots(fabricatorLevel);
+ const totalSlots = Math.min(7, baseSlots + disciplineSlotsBonus);
for (const entry of loadout) {
if (!entry.enabled) continue;
- // Check slot availability
- if (newActiveGolems.length >= activeCount + disciplineSlotsBonus + getGolemSlots(0)) {
+ // Check slot availability (max 7 total per AC-1)
+ if (newActiveGolems.length >= totalSlots) {
logMessages.push('No golem slots available');
break;
}
diff --git a/src/lib/game/stores/golemancy-combat.test.ts b/src/lib/game/stores/golemancy-combat.test.ts
new file mode 100644
index 0000000..299325c
--- /dev/null
+++ b/src/lib/game/stores/golemancy-combat.test.ts
@@ -0,0 +1,313 @@
+// ─── Golemancy Combat Tests ────────────────────────────────────────────────────
+// Tests the golem combat actions: summoning, maintenance, mana regen, room duration.
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import {
+ CORES, FRAMES, MIND_CIRCUITS, GOLEM_ENCHANTMENTS,
+} from '@/lib/game/data/golems';
+import {
+ computeGolemStats,
+ createActiveGolem,
+} from '@/lib/game/data/golems/utils';
+import type { GolemDesign, SerializedDesign } from '@/lib/game/data/golems/types';
+import {
+ summonGolemsOnRoomEntry,
+ processGolemMaintenance,
+ processGolemManaRegen,
+ countdownGolemRoomDuration,
+} from '@/lib/game/stores/golem-combat-actions';
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function makeDesign(coreId: string, frameId: string, circuitId: string, enchantIds: string[] = []): GolemDesign {
+ return {
+ id: `test_${coreId}_${frameId}_${circuitId}`,
+ name: `Test ${coreId} ${frameId}`,
+ core: CORES[coreId],
+ frame: FRAMES[frameId],
+ mindCircuit: MIND_CIRCUITS[circuitId],
+ enchantments: enchantIds.map(id => GOLEM_ENCHANTMENTS[id]).filter(Boolean),
+ selectedManaTypes: [],
+ selectedSpells: [],
+ };
+}
+
+function makeSerialized(design: GolemDesign): SerializedDesign {
+ return {
+ id: design.id,
+ name: design.name,
+ coreId: design.core.id,
+ frameId: design.frame.id,
+ mindCircuitId: design.mindCircuit.id,
+ enchantmentIds: design.enchantments.map(e => e.id),
+ selectedManaTypes: design.selectedManaTypes,
+ selectedSpells: design.selectedSpells,
+ };
+}
+
+// ─── Summon Golems on Room Entry ──────────────────────────────────────────────
+
+describe('summonGolemsOnRoomEntry', () => {
+ const emptyElements: Record = {
+ earth: { current: 1000, max: 1000, unlocked: true },
+ crystal: { current: 1000, max: 1000, unlocked: true },
+ fire: { current: 1000, max: 1000, unlocked: true },
+ };
+
+ it('summons affordable golems from loadout', () => {
+ const design = makeDesign('basic', 'earth', 'simple');
+ const loadout = [{
+ designId: design.id,
+ design: makeSerialized(design) as any,
+ enabled: true,
+ }];
+
+ const result = summonGolemsOnRoomEntry(
+ loadout, 1000, emptyElements, 1, [], 0, 2,
+ );
+
+ expect(result.activeGolems.length).toBe(1);
+ expect(result.rawMana).toBeLessThan(1000);
+ });
+
+ it('skips unenabled designs', () => {
+ const design = makeDesign('basic', 'earth', 'simple');
+ const loadout = [{
+ designId: design.id,
+ design: makeSerialized(design) as any,
+ enabled: false,
+ }];
+
+ const result = summonGolemsOnRoomEntry(
+ loadout, 1000, emptyElements, 1, [], 0, 2,
+ );
+
+ expect(result.activeGolems.length).toBe(0);
+ });
+
+ it('skips designs that cannot be afforded', () => {
+ const design = makeDesign('guardian', 'crystalSteelHybrid', 'guardian');
+ const loadout = [{
+ designId: design.id,
+ design: makeSerialized(design) as any,
+ enabled: true,
+ }];
+
+ const result = summonGolemsOnRoomEntry(
+ loadout, 0, emptyElements, 1, [], 0, 10,
+ );
+
+ expect(result.activeGolems.length).toBe(0);
+ expect(result.logMessages.some(m => m.includes('Not enough mana'))).toBe(true);
+ });
+
+ it('respects slot limit (max 7)', () => {
+ const designs = Array.from({ length: 10 }, (_, i) => {
+ const d = makeDesign('basic', 'earth', 'simple');
+ d.id = `design_${i}`;
+ return d;
+ });
+
+ const loadout = designs.map(d => ({
+ designId: d.id,
+ design: makeSerialized(d) as any,
+ enabled: true,
+ }));
+
+ const result = summonGolemsOnRoomEntry(
+ loadout, 10000, emptyElements, 1, [], 0, 10,
+ );
+
+ expect(result.activeGolems.length).toBeLessThanOrEqual(7);
+ });
+
+ it('does not duplicate already active golems', () => {
+ const design = makeDesign('basic', 'earth', 'simple');
+ const active = createActiveGolem(design, 1);
+
+ const loadout = [{
+ designId: design.id,
+ design: makeSerialized(design) as any,
+ enabled: true,
+ }];
+
+ const result = summonGolemsOnRoomEntry(
+ loadout, 1000, emptyElements, 1, [active], 0, 2,
+ );
+
+ expect(result.activeGolems.length).toBe(1);
+ });
+
+ it('passes fabricator level to slot calculation', () => {
+ const design = makeDesign('basic', 'earth', 'simple');
+ const loadout = [{
+ designId: design.id,
+ design: makeSerialized(design) as any,
+ enabled: true,
+ }];
+
+ // With fabricator level 2, should have 1 base slot
+ const result = summonGolemsOnRoomEntry(
+ loadout, 1000, emptyElements, 1, [], 0, 2,
+ );
+
+ expect(result.activeGolems.length).toBe(1);
+ });
+});
+
+// ─── Golem Maintenance ───────────────────────────────────────────────────────
+
+describe('processGolemMaintenance', () => {
+ it('maintains golem when upkeep is affordable', () => {
+ const design = makeDesign('basic', 'earth', 'simple');
+ const active = createActiveGolem(design, 1);
+
+ const elements: Record = {
+ earth: { current: 1000, max: 1000, unlocked: true },
+ };
+
+ const result = processGolemMaintenance(
+ [active],
+ { [design.id]: makeSerialized(design) },
+ 1000,
+ elements,
+ );
+
+ expect(result.maintainedGolems.length).toBe(1);
+ });
+
+ it('dismisses golem when upkeep cannot be paid', () => {
+ const design = makeDesign('basic', 'earth', 'simple');
+ const active = createActiveGolem(design, 1);
+
+ const elements: Record = {
+ earth: { current: 0, max: 1000, unlocked: true },
+ };
+
+ const result = processGolemMaintenance(
+ [active],
+ { [design.id]: makeSerialized(design) },
+ 1000,
+ elements,
+ );
+
+ expect(result.maintainedGolems.length).toBe(0);
+ expect(result.logMessages.some(m => m.includes('dismissed'))).toBe(true);
+ });
+
+ it('maintains multiple golems independently', () => {
+ const design1 = makeDesign('basic', 'earth', 'simple');
+ const design2 = makeDesign('basic', 'earth', 'simple');
+ design2.id = 'test_design2';
+ const active1 = createActiveGolem(design1, 1);
+ const active2 = createActiveGolem(design2, 1);
+
+ const elements: Record = {
+ earth: { current: 1000, max: 1000, unlocked: true },
+ };
+
+ const result = processGolemMaintenance(
+ [active1, active2],
+ { [design1.id]: makeSerialized(design1), [design2.id]: makeSerialized(design2) },
+ 1000,
+ elements,
+ );
+
+ expect(result.maintainedGolems.length).toBe(2);
+ });
+});
+
+// ─── Golem Mana Regen ────────────────────────────────────────────────────────
+
+describe('processGolemManaRegen', () => {
+ it('regenerates golem mana each tick', () => {
+ const design = makeDesign('basic', 'earth', 'simple');
+ const active = createActiveGolem(design, 1);
+ active.currentMana = 0;
+
+ const result = processGolemManaRegen(
+ [active],
+ { [design.id]: makeSerialized(design) },
+ );
+
+ expect(result[0].currentMana).toBeGreaterThan(0);
+ expect(result[0].currentMana).toBeLessThanOrEqual(design.core.manaCapacity);
+ });
+
+ it('does not exceed mana capacity', () => {
+ const design = makeDesign('basic', 'earth', 'simple');
+ const active = createActiveGolem(design, 1);
+ active.currentMana = design.core.manaCapacity;
+
+ const result = processGolemManaRegen(
+ [active],
+ { [design.id]: makeSerialized(design) },
+ );
+
+ expect(result[0].currentMana).toBe(design.core.manaCapacity);
+ });
+
+ it('handles empty active golems array', () => {
+ const result = processGolemManaRegen([], {});
+ expect(result.length).toBe(0);
+ });
+});
+
+// ─── Room Duration Countdown ──────────────────────────────────────────────────
+
+describe('countdownGolemRoomDuration', () => {
+ it('decrements rooms remaining', () => {
+ const design = makeDesign('basic', 'earth', 'simple');
+ const active = createActiveGolem(design, 1);
+ active.roomsRemaining = 3;
+
+ const result = countdownGolemRoomDuration(
+ [active],
+ { [design.id]: makeSerialized(design) },
+ );
+
+ expect(result.remainingGolems.length).toBe(1);
+ expect(result.remainingGolems[0].roomsRemaining).toBe(2);
+ expect(result.dismissedNames.length).toBe(0);
+ });
+
+ it('dismisses golem when rooms remaining reaches 0', () => {
+ const design = makeDesign('basic', 'earth', 'simple');
+ const active = createActiveGolem(design, 1);
+ active.roomsRemaining = 1;
+
+ const result = countdownGolemRoomDuration(
+ [active],
+ { [design.id]: makeSerialized(design) },
+ );
+
+ expect(result.remainingGolems.length).toBe(0);
+ expect(result.dismissedNames.length).toBe(1);
+ expect(result.logMessages.some(m => m.includes('faded'))).toBe(true);
+ });
+
+ it('handles multiple golems with mixed remaining rooms', () => {
+ const design1 = makeDesign('basic', 'earth', 'simple');
+ const design2 = makeDesign('basic', 'earth', 'simple');
+ design2.id = 'test_design2';
+ const active1 = createActiveGolem(design1, 1);
+ const active2 = createActiveGolem(design2, 1);
+ active1.roomsRemaining = 2;
+ active2.roomsRemaining = 1;
+
+ const result = countdownGolemRoomDuration(
+ [active1, active2],
+ { [design1.id]: makeSerialized(design1), [design2.id]: makeSerialized(design2) },
+ );
+
+ expect(result.remainingGolems.length).toBe(1);
+ expect(result.remainingGolems[0].designId).toBe(design1.id);
+ expect(result.dismissedNames.length).toBe(1);
+ });
+
+ it('handles empty active golems array', () => {
+ const result = countdownGolemRoomDuration([], {});
+ expect(result.remainingGolems.length).toBe(0);
+ expect(result.dismissedNames.length).toBe(0);
+ });
+});
diff --git a/src/lib/game/stores/pipelines/golem-combat.ts b/src/lib/game/stores/pipelines/golem-combat.ts
index 3d774ee..f364d07 100644
--- a/src/lib/game/stores/pipelines/golem-combat.ts
+++ b/src/lib/game/stores/pipelines/golem-combat.ts
@@ -118,5 +118,6 @@ export function processGolemRoomEntry(
currentFloor,
cs.golemancy.activeGolems as any[],
discBonus,
+ fabLevel,
);
}