fix: rebalance rawCost and componentCost formulas to be achievable with realistic regen
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m30s

- Changed rawCost from exponential 10^(d+1) to linear 2*distance
  - Base (d=1): 100 → 2
  - Composite (d=2): 1,000 → 4
  - Exotic (d=3): 10,000 → 6
  - Time (d=4): 100,000 → 8

- Changed componentCost from 10*(d+1) to 3*distance
  - Composite (d=2): 30 → 6 per component
  - Exotic (d=3): 40 → 9 per component
  - Time (d=4): 50 → 12 per component

- Updated test comments and expectations in conversion-pause-bug-regression.test.ts
  and mana-conversion-component-deduction.test.ts to match new values

Root cause: The exponential rawCost formula produced values 100-10000x too high,
making mana conversion permanently paused since drain (rate × cost) always exceeded
even late-game raw regen (~20-50/hr). The new linear formula allows conversions to
be sustainable at all game stages.

Fixes #378
This commit is contained in:
2026-06-12 12:30:00 +02:00
parent 280847a231
commit c17a8755ae
5 changed files with 29 additions and 29 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# Circular Dependencies
Generated: 2026-06-12T08:05:45.261Z
Generated: 2026-06-12T10:15:03.641Z
Found: 4 circular chain(s) — these MUST be fixed before modifying involved files.
1. 1) data/guardian-encounters.ts > data/guardian-procedural.ts
+1 -1
View File
@@ -1,6 +1,6 @@
{
"_meta": {
"generated": "2026-06-12T08:05:43.098Z",
"generated": "2026-06-12T10:15:01.393Z",
"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."
},
@@ -41,9 +41,9 @@ describe('Bug #348 — conversion pause regression', () => {
rawGrossRegen,
});
// Earth conversion: baseRate=22.04, rawCost=100
// rawDrain = 22.04 * 1.0 * 1.0 * 1.0 * 100 = 2204/hr
// 2204 < 3342.95 => should NOT be paused
// Earth conversion: baseRate=22.04, rawCost=2
// rawDrain = 22.04 * 1.0 * 1.0 * 1.0 * 2 = 44.08/hr
// 44.08 < 3342.95 => should NOT be paused
const earthEntry = result.rates['earth'];
expect(earthEntry).toBeDefined();
expect(earthEntry.paused).toBe(false);
@@ -32,14 +32,14 @@ describe('Mana Conversion — component consumption deduction (bug #293)', () =>
});
// 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
// metal rate = 0.35, fire component cost = 6 per unit (3 * distance 2)
// elementDrain[fire] = 0.35 * 6 = 2.1/hr
expect(result.elementDrain['fire']).toBeGreaterThan(0);
expect(result.elementDrain['fire']).toBeCloseTo(0.35 * 30, 5);
expect(result.elementDrain['fire']).toBeCloseTo(0.35 * 6, 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);
expect(result.elementDrain['earth']).toBeCloseTo(0.35 * 6, 5);
});
it('should compute elementRegen as produced - drained', () => {
@@ -65,7 +65,7 @@ describe('Mana Conversion — component consumption deduction (bug #293)', () =>
rawGrossRegen: 10000,
});
// Fire: produced = 0.5/hr, drained = 0.35 * 30 = 10.5/hr
// Fire: produced = 0.5/hr, drained = 0.35 * 6 = 2.1/hr
const fireEntry = result.rates['fire'];
expect(fireEntry.finalRate).toBeCloseTo(0.5, 5);
@@ -76,7 +76,7 @@ describe('Mana Conversion — component consumption deduction (bug #293)', () =>
// Verify net: produced - drained
const fireProduced = fireEntry.finalRate;
const fireDrained = result.elementDrain['fire'] || 0;
expect(fireProduced - fireDrained).toBeCloseTo(0.5 - 10.5, 5);
expect(fireProduced - fireDrained).toBeCloseTo(0.5 - 2.1, 5);
});
it('should have zero elementDrain when element is not used as component', () => {
@@ -135,15 +135,15 @@ describe('Mana Conversion — component consumption deduction (bug #293)', () =>
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);
// Fire: produced = 0.5, drained by metal (0.35 * 6 = 2.1) and lightning (0.35 * 6 = 2.1)
// Total fire drain = 2.1 + 2.1 = 4.2
expect(result.elementDrain['fire']).toBeCloseTo(4.2, 5);
// Earth: produced = 0 (no earth discipline), drained by metal (0.35 * 30 = 10.5)
expect(result.elementDrain['earth']).toBeCloseTo(10.5, 5);
// Earth: produced = 0 (no earth discipline), drained by metal (0.35 * 6 = 2.1)
expect(result.elementDrain['earth']).toBeCloseTo(2.1, 5);
// Air: produced = 0 (no air discipline), drained by lightning (0.35 * 30 = 10.5)
expect(result.elementDrain['air']).toBeCloseTo(10.5, 5);
// Air: produced = 0 (no air discipline), drained by lightning (0.35 * 6 = 2.1)
expect(result.elementDrain['air']).toBeCloseTo(2.1, 5);
// Metal: produced = 0.35, not consumed by anything in this setup
expect(result.elementDrain['metal'] || 0).toBe(0);
+11 -11
View File
@@ -3,8 +3,8 @@
// Costs are deducted from regen (not from the mana pool).
//
// For a destination element at distance d:
// rawCost = 10^(d+1)
// componentCost = 10 * (d+1) per component
// rawCost = 2 * d
// componentCost = 3 * d per component
import type { ElementRecipe } from '../types';
@@ -20,11 +20,11 @@ export interface ConversionCost {
}
function computeRawCost(distance: number): number {
return Math.pow(10, distance + 1);
return 2 * distance;
}
function computeComponentCost(distance: number): number {
return 10 * (distance + 1);
return 3 * distance;
}
/** Build a ConversionCost for a base element (distance 1, no components) */
@@ -32,14 +32,14 @@ function baseElementCost(element: string): ConversionCost {
return {
element,
distance: 1,
rawCost: computeRawCost(1), // 100
rawCost: computeRawCost(1), // 2
componentCosts: {},
};
}
/** Build a ConversionCost for a composite element (distance 2) */
function compositeElementCost(element: string, components: string[]): ConversionCost {
const costPerComponent = computeComponentCost(2); // 30 each
const costPerComponent = computeComponentCost(2); // 6 each
const componentCosts: Record<string, number> = {};
for (const c of components) {
componentCosts[c] = (componentCosts[c] || 0) + costPerComponent;
@@ -47,14 +47,14 @@ function compositeElementCost(element: string, components: string[]): Conversion
return {
element,
distance: 2,
rawCost: computeRawCost(2), // 1,000
rawCost: computeRawCost(2), // 4
componentCosts,
};
}
/** Build a ConversionCost for an exotic element (distance 3) */
function exoticElementCost(element: string, components: string[]): ConversionCost {
const costPerComponent = computeComponentCost(3); // 40 each
const costPerComponent = computeComponentCost(3); // 9 each
const componentCosts: Record<string, number> = {};
for (const c of components) {
componentCosts[c] = (componentCosts[c] || 0) + costPerComponent;
@@ -62,14 +62,14 @@ function exoticElementCost(element: string, components: string[]): ConversionCos
return {
element,
distance: 3,
rawCost: computeRawCost(3), // 10,000
rawCost: computeRawCost(3), // 6
componentCosts,
};
}
/** Build a ConversionCost for time (distance 4) */
function timeElementCost(element: string, components: string[]): ConversionCost {
const costPerComponent = computeComponentCost(4); // 50 each
const costPerComponent = computeComponentCost(4); // 12 each
const componentCosts: Record<string, number> = {};
for (const c of components) {
componentCosts[c] = (componentCosts[c] || 0) + costPerComponent;
@@ -77,7 +77,7 @@ function timeElementCost(element: string, components: string[]): ConversionCost
return {
element,
distance: 4,
rawCost: computeRawCost(4), // 100,000
rawCost: computeRawCost(4), // 8
componentCosts,
};
}