diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 8f53dd8..e9106fa 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,5 +1,5 @@ # Circular Dependencies -Generated: 2026-06-08T10:56:12.135Z +Generated: 2026-06-08T11:14:13.243Z Found: 1 circular chain(s) — these MUST be fixed before modifying involved files. 1. 1) stores/golem-combat-actions.ts > stores/golem-combat-helpers.ts diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index 5e1e4aa..25b3a71 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-06-08T10:56:10.139Z", + "generated": "2026-06-08T11:14:11.296Z", "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." }, diff --git a/docs/project-structure.txt b/docs/project-structure.txt index 897d32d..20ca0a3 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -392,6 +392,7 @@ Mana-Loop/ │ │ │ │ ├── golem-combat-actions.ts │ │ │ │ ├── golem-combat-helpers.test.ts │ │ │ │ ├── golem-combat-helpers.ts +│ │ │ │ ├── golem-combat-maintenance.test.ts │ │ │ │ ├── golemancy-actions.ts │ │ │ │ ├── golemancy-combat.test.ts │ │ │ │ ├── index.ts diff --git a/src/components/game/tabs/golemancy/GolemancyComponents.test.ts b/src/components/game/tabs/golemancy/GolemancyComponents.test.ts index bab47c7..ff7cf61 100644 --- a/src/components/game/tabs/golemancy/GolemancyComponents.test.ts +++ b/src/components/game/tabs/golemancy/GolemancyComponents.test.ts @@ -83,10 +83,14 @@ describe('computeGolemStats', () => { // 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 + // Upkeep = core.manaRegen * 2 per hour, split across selected mana types + expect(stats.upkeepCostPerHour.length).toBe(3); // crystal, metal, fire + expect(stats.upkeepCostPerHour[0].amount).toBe(2.0); // 6.0 / 3 expect(stats.upkeepCostPerHour[0].element).toBe('crystal'); + expect(stats.upkeepCostPerHour[1].amount).toBe(2.0); + expect(stats.upkeepCostPerHour[1].element).toBe('metal'); + expect(stats.upkeepCostPerHour[2].amount).toBe(2.0); + expect(stats.upkeepCostPerHour[2].element).toBe('fire'); }); it('computes total summon cost from all components', async () => { diff --git a/src/lib/game/data/golems/golemancy-data.test.ts b/src/lib/game/data/golems/golemancy-data.test.ts index 3f3cf92..0b0b7f5 100644 --- a/src/lib/game/data/golems/golemancy-data.test.ts +++ b/src/lib/game/data/golems/golemancy-data.test.ts @@ -186,11 +186,49 @@ describe('Computed stats', () => { const design = makeDesign('basic', 'earth', 'simple'); const stats = computeGolemStats(design); + // Basic core has 1 mana type (earth), so all upkeep goes to earth expect(stats.upkeepCostPerHour.length).toBe(1); expect(stats.upkeepCostPerHour[0].amount).toBe(1.0); expect(stats.upkeepCostPerHour[0].element).toBe('earth'); }); + it('multi-type core splits upkeep evenly across selected mana types', () => { + const design = makeDesign('intermediate', 'earth', 'simple'); + design.selectedManaTypes = ['fire', 'water']; + const stats = computeGolemStats(design); + + // Intermediate core: manaRegen=1.5, upkeep=3.0/hr split across 2 types = 1.5 each + expect(stats.upkeepCostPerHour.length).toBe(2); + expect(stats.upkeepCostPerHour[0].element).toBe('fire'); + expect(stats.upkeepCostPerHour[0].amount).toBe(1.5); + expect(stats.upkeepCostPerHour[1].element).toBe('water'); + expect(stats.upkeepCostPerHour[1].amount).toBe(1.5); + }); + + it('advanced core (3 types) splits upkeep three ways', () => { + const design = makeDesign('advanced', 'steel', 'simple'); + design.selectedManaTypes = ['fire', 'water', 'earth']; + const stats = computeGolemStats(design); + + // Advanced core: manaRegen=3.0, upkeep=6.0/hr split across 3 types = 2.0 each + expect(stats.upkeepCostPerHour.length).toBe(3); + const totalUpkeep = stats.upkeepCostPerHour.reduce((sum, c) => sum + c.amount, 0); + expect(totalUpkeep).toBe(6.0); + for (const cost of stats.upkeepCostPerHour) { + expect(cost.amount).toBe(2.0); + } + }); + + it('single-type core does not split upkeep', () => { + const design = makeDesign('basic', 'earth', 'simple'); + // Basic core has manaTypes=['earth'], selectedManaTypes=[] => uses core.manaTypes + const stats = computeGolemStats(design); + + expect(stats.upkeepCostPerHour.length).toBe(1); + expect(stats.upkeepCostPerHour[0].amount).toBe(1.0); // 0.5 * 2 = 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']; diff --git a/src/lib/game/data/golems/utils.ts b/src/lib/game/data/golems/utils.ts index 18b4310..1b978bd 100644 --- a/src/lib/game/data/golems/utils.ts +++ b/src/lib/game/data/golems/utils.ts @@ -81,14 +81,16 @@ export function computeGolemStats(design: GolemDesign): ComputedGolemStats { ...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, - }, - ]; + // Player upkeep = Core.manaRegen × 2 per hour, split across all mana types (spec §13) + const upkeepManaTypes = design.selectedManaTypes.length > 0 + ? design.selectedManaTypes + : core.manaTypes; + const upkeepPerType = (core.manaRegen * 2) / Math.max(1, upkeepManaTypes.length); + const upkeepCostPerHour: GolemManaCost[] = upkeepManaTypes.map((mt) => ({ + type: 'element' as const, + element: mt, + amount: upkeepPerType, + })); // Enchantment capacity = Frame.MagicAffinity(%) × Core.TierMultiplier // magicAffinity is stored as decimal (0.3 = 30%) per spec §5.1, so multiply by 100 diff --git a/src/lib/game/stores/golem-combat-actions.test.ts b/src/lib/game/stores/golem-combat-actions.test.ts index e8d69cb..f8347a0 100644 --- a/src/lib/game/stores/golem-combat-actions.test.ts +++ b/src/lib/game/stores/golem-combat-actions.test.ts @@ -1,10 +1,11 @@ -// ─── Golem Combat Actions Regression Tests (Issue #313) ──────────────────────── -// Tests the 5 fixes applied to processGolemAttacks: +// ─── Golem Combat Actions Regression Tests ──────────────────────────────────── +// Issue #313: Tests the 5 fixes applied to processGolemAttacks: // 1. Spell damage uses actual SPELLS_DEF[spellId].dmg * frame.magicAffinity // 2. Spell mana cost uses actual SPELLS_DEF[spellId].cost.amount // 3. Elemental matchup applied to basic attacks // 4. Enchantment effects applied to basic attacks // 5. Armor pierce bypasses armor fraction instead of multiplying damage +// import { describe, it, expect } from 'vitest'; import { @@ -344,4 +345,3 @@ describe('processGolemAttacks - armor pierce (fix #5)', () => { }); }); - diff --git a/src/lib/game/stores/golem-combat-actions.ts b/src/lib/game/stores/golem-combat-actions.ts index a6a6a11..9d1e78d 100644 --- a/src/lib/game/stores/golem-combat-actions.ts +++ b/src/lib/game/stores/golem-combat-actions.ts @@ -168,19 +168,40 @@ export function processGolemMaintenance( if (!core) continue; const upkeepPerTick = core.manaRegen * 2 * HOURS_PER_TICK; - const upkeepElement = core.primaryManaType; - const elem = upkeepElement ? newElements[upkeepElement] : null; + // For multi-type cores, split upkeep evenly across all mana types (spec §13) + const upkeepManaTypes = + design.selectedManaTypes.length > 0 + ? design.selectedManaTypes + : core.manaTypes; + const splitCount = Math.max(1, upkeepManaTypes.length); + const upkeepPerType = upkeepPerTick / splitCount; - if (upkeepElement && elem && elem.unlocked && elem.current >= upkeepPerTick) { - newElements[upkeepElement] = { ...elem, current: elem.current - upkeepPerTick }; + // Check if player can afford upkeep across all required mana types + let canMaintain = true; + for (const manaType of upkeepManaTypes) { + const elem = newElements[manaType]; + if (!elem || !elem.unlocked || elem.current < upkeepPerType) { + canMaintain = false; + break; + } + } + + if (canMaintain) { + for (const manaType of upkeepManaTypes) { + const elem = newElements[manaType]!; + newElements[manaType] = { ...elem, current: elem.current - upkeepPerType }; + } maintainedGolems.push(golem); - } else if (!upkeepElement && newRawMana >= upkeepPerTick) { - 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`); + const missingTypes = upkeepManaTypes + .filter((t) => { + const elem = newElements[t]; + return !elem || !elem.unlocked || elem.current < upkeepPerType; + }) + .join(', '); + logMessages.push( + `${design.name} dismissed — insufficient mana for upkeep (${missingTypes})`, + ); } } diff --git a/src/lib/game/stores/golem-combat-maintenance.test.ts b/src/lib/game/stores/golem-combat-maintenance.test.ts new file mode 100644 index 0000000..2521316 --- /dev/null +++ b/src/lib/game/stores/golem-combat-maintenance.test.ts @@ -0,0 +1,202 @@ +// ─── Golem Maintenance Upkeep Tests (Issue #315) ─────────────────────────────── +// Tests that multi-type golem cores split upkeep evenly across all mana types. + +import { describe, it, expect } from 'vitest'; +import { + CORES, FRAMES, MIND_CIRCUITS, +} from '@/lib/game/data/golems'; +import type { GolemDesign, SerializedDesign } from '@/lib/game/data/golems/types'; +import type { RuntimeActiveGolem } from '@/lib/game/types'; +import { processGolemMaintenance } from './golem-combat-actions'; + +// ─── Helpers ─────────────────────────────────────────────────────────────────── + +function makeDesign( + coreId: string, + frameId: string, + circuitId: string, + enchantIds: string[] = [], + selectedSpells: string[] = [], +): GolemDesign { + return { + id: `test_${coreId}_${frameId}_${circuitId}`, + name: `Test ${coreId} ${frameId}`, + core: CORES[coreId], + frame: FRAMES[frameId], + mindCircuit: MIND_CIRCUITS[circuitId], + enchantments: [], + 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: [], + selectedManaTypes: design.selectedManaTypes, + selectedSpells: design.selectedSpells, + }; +} + +function makeActiveGolem(design: GolemDesign, currentMana?: number, attackProgress = 0): RuntimeActiveGolem { + return { + designId: design.id, + summonedFloor: 1, + attackProgress, + roomsRemaining: 3, + currentMana: currentMana ?? design.core.manaCapacity, + spellCastIndex: 0, + }; +} + +// ─── Tests ───────────────────────────────────────────────────────────────────── + +describe('processGolemMaintenance - multi-type core upkeep splitting (fix #315)', () => { + it('single-type core (basic) deducts upkeep from one element', () => { + const design = makeDesign('basic', 'earth', 'simple'); + const serialized = makeSerialized(design); + const golem = makeActiveGolem(design); + + const elements = { + earth: { current: 50, max: 100, unlocked: true }, + }; + + const result = processGolemMaintenance( + [golem], + { [design.id]: serialized }, + 100, + elements, + ); + + expect(result.maintainedGolems.length).toBe(1); + // Basic core: manaRegen=0.5, upkeep=1.0/hr, HOURS_PER_TICK=0.04 => 0.04 per tick + const expectedDeduction = 0.5 * 2 * 0.04; // 0.04 + expect(result.elements.earth.current).toBeCloseTo(50 - expectedDeduction); + }); + + it('multi-type core splits upkeep evenly across selected mana types', () => { + const design = makeDesign('intermediate', 'earth', 'simple'); + design.selectedManaTypes = ['fire', 'water']; + const serialized = makeSerialized(design); + const golem = makeActiveGolem(design); + + const elements = { + fire: { current: 50, max: 100, unlocked: true }, + water: { current: 50, max: 100, unlocked: true }, + }; + + const result = processGolemMaintenance( + [golem], + { [design.id]: serialized }, + 100, + elements, + ); + + expect(result.maintainedGolems.length).toBe(1); + // Intermediate core: manaRegen=1.5, upkeep=3.0/hr split across 2 types + // Per type: 1.5/hr * 0.04 = 0.06 per tick + const expectedDeduction = (1.5 * 2 * 0.04) / 2; // 0.06 + expect(result.elements.fire.current).toBeCloseTo(50 - expectedDeduction); + expect(result.elements.water.current).toBeCloseTo(50 - expectedDeduction); + }); + + it('advanced core (3 types) splits upkeep three ways', () => { + const design = makeDesign('advanced', 'steel', 'simple'); + design.selectedManaTypes = ['fire', 'water', 'earth']; + const serialized = makeSerialized(design); + const golem = makeActiveGolem(design); + + const elements = { + fire: { current: 50, max: 100, unlocked: true }, + water: { current: 50, max: 100, unlocked: true }, + earth: { current: 50, max: 100, unlocked: true }, + }; + + const result = processGolemMaintenance( + [golem], + { [design.id]: serialized }, + 100, + elements, + ); + + expect(result.maintainedGolems.length).toBe(1); + // Advanced core: manaRegen=3.0, upkeep=6.0/hr split across 3 types + // Per type: 2.0/hr * 0.04 = 0.08 per tick + const expectedDeduction = (3.0 * 2 * 0.04) / 3; // 0.08 + expect(result.elements.fire.current).toBeCloseTo(50 - expectedDeduction); + expect(result.elements.water.current).toBeCloseTo(50 - expectedDeduction); + expect(result.elements.earth.current).toBeCloseTo(50 - expectedDeduction); + }); + + it('dismisses golem when one mana type is insufficient for split upkeep', () => { + const design = makeDesign('intermediate', 'earth', 'simple'); + design.selectedManaTypes = ['fire', 'water']; + const serialized = makeSerialized(design); + const golem = makeActiveGolem(design); + + const elements = { + fire: { current: 50, max: 100, unlocked: true }, + water: { current: 0.001, max: 100, unlocked: true }, // Almost empty + }; + + const result = processGolemMaintenance( + [golem], + { [design.id]: serialized }, + 100, + elements, + ); + + expect(result.maintainedGolems.length).toBe(0); + expect(result.logMessages[0]).toContain('dismissed'); + expect(result.logMessages[0]).toContain('water'); + }); + + it('dismisses golem when one mana type is not unlocked', () => { + const design = makeDesign('intermediate', 'earth', 'simple'); + design.selectedManaTypes = ['fire', 'water']; + const serialized = makeSerialized(design); + const golem = makeActiveGolem(design); + + const elements = { + fire: { current: 50, max: 100, unlocked: true }, + // water not present => not unlocked + }; + + const result = processGolemMaintenance( + [golem], + { [design.id]: serialized }, + 100, + elements, + ); + + expect(result.maintainedGolems.length).toBe(0); + expect(result.logMessages[0]).toContain('dismissed'); + }); + + it('does not deduct any mana when upkeep cannot be paid (atomic check)', () => { + const design = makeDesign('intermediate', 'earth', 'simple'); + design.selectedManaTypes = ['fire', 'water']; + const serialized = makeSerialized(design); + const golem = makeActiveGolem(design); + + const elements = { + fire: { current: 50, max: 100, unlocked: true }, + water: { current: 0.001, max: 100, unlocked: true }, + }; + + const result = processGolemMaintenance( + [golem], + { [design.id]: serialized }, + 100, + elements, + ); + + // fire should NOT have been deducted since water couldn't pay its share + expect(result.elements.fire.current).toBe(50); + }); +});