fix: deduct component consumption from element pools in mana conversion
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m20s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m20s
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.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
# Circular Dependencies
|
# 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.
|
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
|
1. 1) stores/golem-combat-actions.ts > stores/golem-combat-helpers.ts
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"_meta": {
|
"_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.",
|
"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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ Mana-Loop/
|
|||||||
│ │ │ │ ├── formatting.test.ts
|
│ │ │ │ ├── formatting.test.ts
|
||||||
│ │ │ │ ├── guardian-names.test.ts
|
│ │ │ │ ├── guardian-names.test.ts
|
||||||
│ │ │ │ ├── hasty-enchanter.test.ts
|
│ │ │ │ ├── hasty-enchanter.test.ts
|
||||||
|
│ │ │ │ ├── mana-conversion-component-deduction.test.ts
|
||||||
│ │ │ │ ├── mana-utils.test.ts
|
│ │ │ │ ├── mana-utils.test.ts
|
||||||
│ │ │ │ ├── melee-auto-attack.test.ts
|
│ │ │ │ ├── melee-auto-attack.test.ts
|
||||||
│ │ │ │ ├── melee-defense-bypass.test.ts
|
│ │ │ │ ├── melee-defense-bypass.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<string>(),
|
||||||
|
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<string>(),
|
||||||
|
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<string>(),
|
||||||
|
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<string>(),
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -184,12 +184,6 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// Compute per-element net regen: produced rate - drain from being used as component
|
||||||
const elementRegen: Record<string, number> = {};
|
const elementRegen: Record<string, number> = {};
|
||||||
for (const [elem, entry] of Object.entries(conversionResult.rates)) {
|
for (const [elem, entry] of Object.entries(conversionResult.rates)) {
|
||||||
@@ -197,6 +191,18 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
|||||||
const drained = conversionResult.elementDrain[elem] || 0;
|
const drained = conversionResult.elementDrain[elem] || 0;
|
||||||
elementRegen[elem] = produced - drained;
|
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
|
// Net raw regen = gross regen - conversion drains - incursion
|
||||||
const netRawRegen = Math.max(0, baseRegen * (1 - incursionStrength) * meditationMultiplier - conversionResult.totalRawDrain);
|
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;
|
const actualRegen = Math.floor(Math.min(netRawRegen * HOURS_PER_TICK, maxMana - rawMana) * 1000) / 1000;
|
||||||
|
|||||||
Reference in New Issue
Block a user