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, ); }