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 # 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. 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) data/guardian-encounters.ts > data/guardian-procedural.ts
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"_meta": { "_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.", "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."
}, },
@@ -41,9 +41,9 @@ describe('Bug #348 — conversion pause regression', () => {
rawGrossRegen, rawGrossRegen,
}); });
// Earth conversion: baseRate=22.04, rawCost=100 // Earth conversion: baseRate=22.04, rawCost=2
// rawDrain = 22.04 * 1.0 * 1.0 * 1.0 * 100 = 2204/hr // rawDrain = 22.04 * 1.0 * 1.0 * 1.0 * 2 = 44.08/hr
// 2204 < 3342.95 => should NOT be paused // 44.08 < 3342.95 => should NOT be paused
const earthEntry = result.rates['earth']; const earthEntry = result.rates['earth'];
expect(earthEntry).toBeDefined(); expect(earthEntry).toBeDefined();
expect(earthEntry.paused).toBe(false); 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 // Fire is used as a component for metal conversion
// metal rate = 0.35, fire component cost = 30 per unit // metal rate = 0.35, fire component cost = 6 per unit (3 * distance 2)
// elementDrain[fire] = 0.35 * 30 = 10.5/hr // elementDrain[fire] = 0.35 * 6 = 2.1/hr
expect(result.elementDrain['fire']).toBeGreaterThan(0); 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 // Earth is also used as a component for metal conversion
expect(result.elementDrain['earth']).toBeGreaterThan(0); 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', () => { it('should compute elementRegen as produced - drained', () => {
@@ -65,7 +65,7 @@ describe('Mana Conversion — component consumption deduction (bug #293)', () =>
rawGrossRegen: 10000, 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']; const fireEntry = result.rates['fire'];
expect(fireEntry.finalRate).toBeCloseTo(0.5, 5); expect(fireEntry.finalRate).toBeCloseTo(0.5, 5);
@@ -76,7 +76,7 @@ describe('Mana Conversion — component consumption deduction (bug #293)', () =>
// Verify net: produced - drained // Verify net: produced - drained
const fireProduced = fireEntry.finalRate; const fireProduced = fireEntry.finalRate;
const fireDrained = result.elementDrain['fire'] || 0; 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', () => { 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, rawGrossRegen: 10000,
}); });
// Fire: produced = 0.5, drained by metal (0.35 * 30 = 10.5) and lightning (0.35 * 30 = 10.5) // Fire: produced = 0.5, drained by metal (0.35 * 6 = 2.1) and lightning (0.35 * 6 = 2.1)
// Total fire drain = 10.5 + 10.5 = 21.0 // Total fire drain = 2.1 + 2.1 = 4.2
expect(result.elementDrain['fire']).toBeCloseTo(21.0, 5); expect(result.elementDrain['fire']).toBeCloseTo(4.2, 5);
// Earth: produced = 0 (no earth discipline), drained by metal (0.35 * 30 = 10.5) // Earth: produced = 0 (no earth discipline), drained by metal (0.35 * 6 = 2.1)
expect(result.elementDrain['earth']).toBeCloseTo(10.5, 5); expect(result.elementDrain['earth']).toBeCloseTo(2.1, 5);
// Air: produced = 0 (no air discipline), drained by lightning (0.35 * 30 = 10.5) // Air: produced = 0 (no air discipline), drained by lightning (0.35 * 6 = 2.1)
expect(result.elementDrain['air']).toBeCloseTo(10.5, 5); expect(result.elementDrain['air']).toBeCloseTo(2.1, 5);
// Metal: produced = 0.35, not consumed by anything in this setup // Metal: produced = 0.35, not consumed by anything in this setup
expect(result.elementDrain['metal'] || 0).toBe(0); 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). // Costs are deducted from regen (not from the mana pool).
// //
// For a destination element at distance d: // For a destination element at distance d:
// rawCost = 10^(d+1) // rawCost = 2 * d
// componentCost = 10 * (d+1) per component // componentCost = 3 * d per component
import type { ElementRecipe } from '../types'; import type { ElementRecipe } from '../types';
@@ -20,11 +20,11 @@ export interface ConversionCost {
} }
function computeRawCost(distance: number): number { function computeRawCost(distance: number): number {
return Math.pow(10, distance + 1); return 2 * distance;
} }
function computeComponentCost(distance: number): number { function computeComponentCost(distance: number): number {
return 10 * (distance + 1); return 3 * distance;
} }
/** Build a ConversionCost for a base element (distance 1, no components) */ /** Build a ConversionCost for a base element (distance 1, no components) */
@@ -32,14 +32,14 @@ function baseElementCost(element: string): ConversionCost {
return { return {
element, element,
distance: 1, distance: 1,
rawCost: computeRawCost(1), // 100 rawCost: computeRawCost(1), // 2
componentCosts: {}, componentCosts: {},
}; };
} }
/** Build a ConversionCost for a composite element (distance 2) */ /** Build a ConversionCost for a composite element (distance 2) */
function compositeElementCost(element: string, components: string[]): ConversionCost { function compositeElementCost(element: string, components: string[]): ConversionCost {
const costPerComponent = computeComponentCost(2); // 30 each const costPerComponent = computeComponentCost(2); // 6 each
const componentCosts: Record<string, number> = {}; const componentCosts: Record<string, number> = {};
for (const c of components) { for (const c of components) {
componentCosts[c] = (componentCosts[c] || 0) + costPerComponent; componentCosts[c] = (componentCosts[c] || 0) + costPerComponent;
@@ -47,14 +47,14 @@ function compositeElementCost(element: string, components: string[]): Conversion
return { return {
element, element,
distance: 2, distance: 2,
rawCost: computeRawCost(2), // 1,000 rawCost: computeRawCost(2), // 4
componentCosts, componentCosts,
}; };
} }
/** Build a ConversionCost for an exotic element (distance 3) */ /** Build a ConversionCost for an exotic element (distance 3) */
function exoticElementCost(element: string, components: string[]): ConversionCost { function exoticElementCost(element: string, components: string[]): ConversionCost {
const costPerComponent = computeComponentCost(3); // 40 each const costPerComponent = computeComponentCost(3); // 9 each
const componentCosts: Record<string, number> = {}; const componentCosts: Record<string, number> = {};
for (const c of components) { for (const c of components) {
componentCosts[c] = (componentCosts[c] || 0) + costPerComponent; componentCosts[c] = (componentCosts[c] || 0) + costPerComponent;
@@ -62,14 +62,14 @@ function exoticElementCost(element: string, components: string[]): ConversionCos
return { return {
element, element,
distance: 3, distance: 3,
rawCost: computeRawCost(3), // 10,000 rawCost: computeRawCost(3), // 6
componentCosts, componentCosts,
}; };
} }
/** Build a ConversionCost for time (distance 4) */ /** Build a ConversionCost for time (distance 4) */
function timeElementCost(element: string, components: string[]): ConversionCost { function timeElementCost(element: string, components: string[]): ConversionCost {
const costPerComponent = computeComponentCost(4); // 50 each const costPerComponent = computeComponentCost(4); // 12 each
const componentCosts: Record<string, number> = {}; const componentCosts: Record<string, number> = {};
for (const c of components) { for (const c of components) {
componentCosts[c] = (componentCosts[c] || 0) + costPerComponent; componentCosts[c] = (componentCosts[c] || 0) + costPerComponent;
@@ -77,7 +77,7 @@ function timeElementCost(element: string, components: string[]): ConversionCost
return { return {
element, element,
distance: 4, distance: 4,
rawCost: computeRawCost(4), // 100,000 rawCost: computeRawCost(4), // 8
componentCosts, componentCosts,
}; };
} }