From fa448f233c33dd87379078caf5afdda383cf0cea Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Mon, 8 Jun 2026 12:43:16 +0200 Subject: [PATCH] fix: deduct component consumption from element pools in mana conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug #293: elementDrain was computed but never subtracted from element pools. The tick pipeline now applies net regen (produced - drained) instead of gross production to element pools, matching the spec §8 regen deduction model. --- docs/circular-deps.txt | 2 +- docs/dependency-graph.json | 2 +- docs/project-structure.txt | 1 + ...ana-conversion-component-deduction.test.ts | 155 ++++++++++++++++++ src/lib/game/stores/gameStore.ts | 18 +- 5 files changed, 170 insertions(+), 8 deletions(-) create mode 100644 src/lib/game/__tests__/mana-conversion-component-deduction.test.ts diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 8eaddb4..40028dc 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,5 +1,5 @@ # Circular Dependencies -Generated: 2026-06-08T09:39:59.023Z +Generated: 2026-06-08T09:52:22.958Z 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 345fda9..5a6acef 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-06-08T09:39:57.039Z", + "generated": "2026-06-08T09:52:20.951Z", "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 6b11e5e..897d32d 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -229,6 +229,7 @@ Mana-Loop/ │ │ │ │ ├── formatting.test.ts │ │ │ │ ├── guardian-names.test.ts │ │ │ │ ├── hasty-enchanter.test.ts +│ │ │ │ ├── mana-conversion-component-deduction.test.ts │ │ │ │ ├── mana-utils.test.ts │ │ │ │ ├── melee-auto-attack.test.ts │ │ │ │ ├── melee-defense-bypass.test.ts diff --git a/src/lib/game/__tests__/mana-conversion-component-deduction.test.ts b/src/lib/game/__tests__/mana-conversion-component-deduction.test.ts new file mode 100644 index 0000000..9baf45d --- /dev/null +++ b/src/lib/game/__tests__/mana-conversion-component-deduction.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from 'vitest'; +import { computeConversionRates } from '../utils/conversion-rates'; + +// ═══════════════════════════════════════════════════════════════════════════════ +// MANA CONVERSION: COMPONENT CONSUMPTION DEDUCTION TESTS +// Bug #293: elementDrain computed but never subtracted from element pools +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('Mana Conversion — component consumption deduction (bug #293)', () => { + describe('computeConversionRates elementDrain', () => { + it('should compute non-zero elementDrain for elements used as components', () => { + const disciplineEffects = { + bonuses: { conversion_fire: 0.5, conversion_metal: 0.35 }, + multipliers: {}, + specials: new Set(), + meditationCapBonus: 0, + conversions: { + fire: { rate: 0.5, sourceManaTypes: ['raw'] }, + metal: { rate: 0.35, sourceManaTypes: ['raw'] }, + }, + }; + + const result = computeConversionRates({ + disciplineEffects: disciplineEffects as any, + attunements: {}, + signedPacts: [], + pactElementMap: {}, + invokerLevel: 0, + meditationMultiplier: 1, + grossRegen: { fire: 1000, earth: 1000 }, + rawGrossRegen: 10000, + }); + + // Fire is used as a component for metal conversion + // metal rate = 0.35, fire component cost = 30 per unit + // elementDrain[fire] = 0.35 * 30 = 10.5/hr + expect(result.elementDrain['fire']).toBeGreaterThan(0); + expect(result.elementDrain['fire']).toBeCloseTo(0.35 * 30, 5); + + // Earth is also used as a component for metal conversion + expect(result.elementDrain['earth']).toBeGreaterThan(0); + expect(result.elementDrain['earth']).toBeCloseTo(0.35 * 30, 5); + }); + + it('should compute elementRegen as produced - drained', () => { + const disciplineEffects = { + bonuses: { conversion_fire: 0.5, conversion_metal: 0.35 }, + multipliers: {}, + specials: new Set(), + meditationCapBonus: 0, + conversions: { + fire: { rate: 0.5, sourceManaTypes: ['raw'] }, + metal: { rate: 0.35, sourceManaTypes: ['raw'] }, + }, + }; + + const result = computeConversionRates({ + disciplineEffects: disciplineEffects as any, + attunements: {}, + signedPacts: [], + pactElementMap: {}, + invokerLevel: 0, + meditationMultiplier: 1, + grossRegen: { fire: 1000, earth: 1000 }, + rawGrossRegen: 10000, + }); + + // Fire: produced = 0.5/hr, drained = 0.35 * 30 = 10.5/hr + const fireEntry = result.rates['fire']; + expect(fireEntry.finalRate).toBeCloseTo(0.5, 5); + + // Metal: produced = 0.35/hr, no drain (nothing consumes metal as component) + const metalEntry = result.rates['metal']; + expect(metalEntry.finalRate).toBeCloseTo(0.35, 5); + + // Verify net: produced - drained + const fireProduced = fireEntry.finalRate; + const fireDrained = result.elementDrain['fire'] || 0; + expect(fireProduced - fireDrained).toBeCloseTo(0.5 - 10.5, 5); + }); + + it('should have zero elementDrain when element is not used as component', () => { + const disciplineEffects = { + bonuses: { conversion_fire: 0.5 }, + multipliers: {}, + specials: new Set(), + meditationCapBonus: 0, + conversions: { + fire: { rate: 0.5, sourceManaTypes: ['raw'] }, + }, + }; + + const result = computeConversionRates({ + disciplineEffects: disciplineEffects as any, + attunements: {}, + signedPacts: [], + pactElementMap: {}, + invokerLevel: 0, + meditationMultiplier: 1, + grossRegen: {}, + rawGrossRegen: 10000, + }); + + // Fire is not used as a component by any active conversion + expect(result.elementDrain['fire']).toBe(0); + }); + + it('should compute correct net regen for downstream elements', () => { + // Test the full chain: fire+earth -> metal, fire+air -> lightning + // Both metal and lightning consume fire as a component + const disciplineEffects = { + bonuses: { + conversion_fire: 0.5, + conversion_metal: 0.35, + conversion_lightning: 0.35, + }, + multipliers: {}, + specials: new Set(), + meditationCapBonus: 0, + conversions: { + fire: { rate: 0.5, sourceManaTypes: ['raw'] }, + metal: { rate: 0.35, sourceManaTypes: ['raw'] }, + lightning: { rate: 0.35, sourceManaTypes: ['raw'] }, + }, + }; + + const result = computeConversionRates({ + disciplineEffects: disciplineEffects as any, + attunements: {}, + signedPacts: [], + pactElementMap: {}, + invokerLevel: 0, + meditationMultiplier: 1, + grossRegen: { fire: 1000, earth: 1000, air: 1000 }, + rawGrossRegen: 10000, + }); + + // Fire: produced = 0.5, drained by metal (0.35 * 30 = 10.5) and lightning (0.35 * 30 = 10.5) + // Total fire drain = 10.5 + 10.5 = 21.0 + expect(result.elementDrain['fire']).toBeCloseTo(21.0, 5); + + // Earth: produced = 0 (no earth discipline), drained by metal (0.35 * 30 = 10.5) + expect(result.elementDrain['earth']).toBeCloseTo(10.5, 5); + + // Air: produced = 0 (no air discipline), drained by lightning (0.35 * 30 = 10.5) + expect(result.elementDrain['air']).toBeCloseTo(10.5, 5); + + // Metal: produced = 0.35, not consumed by anything in this setup + expect(result.elementDrain['metal'] || 0).toBe(0); + + // Lightning: produced = 0.35, not consumed by anything in this setup + expect(result.elementDrain['lightning'] || 0).toBe(0); + }); + }); +}); diff --git a/src/lib/game/stores/gameStore.ts b/src/lib/game/stores/gameStore.ts index 9a69dd7..8533aaf 100644 --- a/src/lib/game/stores/gameStore.ts +++ b/src/lib/game/stores/gameStore.ts @@ -184,12 +184,6 @@ export const useGameStore = create()( } } - // Apply produced element mana (from active conversions) - for (const [elem, entry] of Object.entries(conversionResult.rates)) { - if (entry.paused || entry.finalRate <= 0 || !elements[elem]) continue; - if (!elements[elem].unlocked) elements[elem] = { ...elements[elem], unlocked: true }; - elements[elem] = { ...elements[elem], current: Math.min(elements[elem].max, elements[elem].current + entry.finalRate * HOURS_PER_TICK) }; - } // Compute per-element net regen: produced rate - drain from being used as component const elementRegen: Record = {}; for (const [elem, entry] of Object.entries(conversionResult.rates)) { @@ -197,6 +191,18 @@ export const useGameStore = create()( const drained = conversionResult.elementDrain[elem] || 0; elementRegen[elem] = produced - drained; } + // Apply net element regen to pools: produced - drained (component consumption) + for (const [elem, entry] of Object.entries(conversionResult.rates)) { + if (entry.paused || !elements[elem]) continue; + if (!elements[elem].unlocked) elements[elem] = { ...elements[elem], unlocked: true }; + const netRate = elementRegen[elem]; + if (netRate === 0) continue; + const delta = netRate * HOURS_PER_TICK; + const newCurrent = delta >= 0 + ? Math.min(elements[elem].max, elements[elem].current + delta) + : Math.max(0, elements[elem].current + delta); + elements[elem] = { ...elements[elem], current: newCurrent }; + } // Net raw regen = gross regen - conversion drains - incursion const netRawRegen = Math.max(0, baseRegen * (1 - incursionStrength) * meditationMultiplier - conversionResult.totalRawDrain); const actualRegen = Math.floor(Math.min(netRawRegen * HOURS_PER_TICK, maxMana - rawMana) * 1000) / 1000;