feat: overhaul mana conversion system to unified regen-deduction model
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m2s

- New files: element-distance.ts, conversion-costs.ts, conversion-rates.ts
- All conversion types (discipline, attunement, pact) use unified formula
- Conversion costs scale exponentially by element tier (10^(d+1) raw, 10*(d+1) per component)
- Costs deducted from regen, not from mana pool
- Auto-pause on insufficient regen with UI warning
- Meditation boosts conversion rates (reduced by distance)
- Attunement levels provide +50% multiplicative bonus per level
- Guardian pacts provide +0.15/hr base rate + invoker level bonus
- Removed convertMana, processConvertAction, craftComposite from manaStore
- Stats tab shows per-element conversion breakdown with formulas
- ManaDisplay shows per-element net regen rates
- All 916 tests pass, all files under 400 lines
This commit is contained in:
2026-06-04 18:12:41 +02:00
parent 94a2b671b9
commit ab3afae2a6
19 changed files with 742 additions and 572 deletions
+230
View File
@@ -0,0 +1,230 @@
// ─── Unified Conversion Rate Calculator ───────────────────────────────────────
// Computes conversion rates for all elements using the unified formula:
//
// finalRate = (disciplineRate + attunementBase + pactBase)
// × (1 + attunementLevelBonus + pactLevelBonus)
// × meditationMult
//
// All costs are deducted from regen, not from the mana pool.
import { CONVERSION_COSTS, getConversionCost } from '../data/conversion-costs';
import { getElementDistance } from './element-distance';
import type { DisciplineEffectsResult } from '../effects/discipline-effects';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface ConversionRateEntry {
element: string;
distance: number;
/** Base rate from disciplines (includes XP scaling + perks) */
disciplineRate: number;
/** Base rate from attunements */
attunementBase: number;
/** Base rate from guardian pacts */
pactBase: number;
/** Sum of base rates */
baseRate: number;
/** Attunement level multiplier: 1 + Σ(relevantAttunementLevel × 0.5) */
attunementMult: number;
/** Pact level multiplier: 1 + Σ(pactCount_element × invokerLevel × 0.25) */
pactMult: number;
/** Meditation multiplier (reduced by distance) */
meditationMult: number;
/** Final effective rate (per hour) */
finalRate: number;
/** Raw cost per unit of destination */
rawCost: number;
/** Component costs per unit of destination */
componentCosts: Record<string, number>;
/** Whether this conversion is paused due to insufficient regen */
paused: boolean;
/** Reason for pausing (which source is insufficient) */
pauseReason: string | null;
}
export interface ConversionRateResult {
/** Per-element conversion rates */
rates: Record<string, ConversionRateEntry>;
/** Total raw regen drain per hour */
totalRawDrain: number;
/** Per-element regen drain per hour (as component) */
elementDrain: Record<string, number>;
}
// ─── Attunement Base Rates (per spec §5) ──────────────────────────────────────
const ATTUNEMENT_BASE_RATES: Record<string, number> = {
transference: 0.2, // Enchanter
earth: 0.25, // Fabricator
};
// ─── Main Calculator ──────────────────────────────────────────────────────────
export interface ConversionRateParams {
/** Discipline effects (includes conversion stat bonuses) */
disciplineEffects: DisciplineEffectsResult;
/** Active attunements: id → { level } */
attunements: Record<string, { active: boolean; level: number }>;
/** Signed pact floor numbers */
signedPacts: number[];
/** Guardian element lookup: floor → primary element */
pactElementMap: Record<number, string>;
/** Invoker attunement level (for pact level bonus) */
invokerLevel: number;
/** Current meditation multiplier (1 = not meditating) */
meditationMultiplier: number;
/** Current gross regen per element (before conversion drains) */
grossRegen: Record<string, number>;
/** Raw gross regen */
rawGrossRegen: number;
}
/**
* Compute unified conversion rates for all elements.
* Returns per-element rates and regen drain totals.
*/
export function computeConversionRates(params: ConversionRateParams): ConversionRateResult {
const {
disciplineEffects,
attunements,
signedPacts,
pactElementMap,
invokerLevel,
meditationMultiplier,
grossRegen,
rawGrossRegen,
} = params;
const rates: Record<string, ConversionRateEntry> = {};
const elementDrain: Record<string, number> = {};
let totalRawDrain = 0;
// ── Step 1: Compute attunement level bonuses per element ──────────
// Each attunement level adds +0.5 to the multiplier for conversions
// where the attunement's primary element is the destination or a component.
const attunementBonuses: Record<string, number> = {};
for (const [id, state] of Object.entries(attunements)) {
if (!state.active) continue;
const level = state.level || 1;
const bonus = level * 0.5;
// Determine which elements this attunement boosts based on its primary mana type
for (const [elem, cost] of Object.entries(CONVERSION_COSTS)) {
const isDestination = elem === getAttunementPrimaryElement(id);
const isComponent = Object.keys(cost.componentCosts).includes(getAttunementPrimaryElement(id));
if (isDestination || isComponent) {
attunementBonuses[elem] = (attunementBonuses[elem] || 0) + bonus;
}
}
}
// ── Step 2: Compute pact bonuses per element ──────────────────────
const pactBaseRates: Record<string, number> = {};
const pactBonuses: Record<string, number> = {};
for (const floor of signedPacts) {
const element = pactElementMap[floor];
if (!element) continue;
pactBaseRates[element] = (pactBaseRates[element] || 0) + 0.15;
pactBonuses[element] = (pactBonuses[element] || 0) + invokerLevel * 0.25;
}
// ── Step 3: Compute rates for each element ────────────────────────
for (const [elem, cost] of Object.entries(CONVERSION_COSTS)) {
const distance = cost.distance;
// Discipline rate: from disciplineEffects.conversions or stat bonuses
const discRate = disciplineEffects.conversions[elem]?.rate
|| disciplineEffects.bonuses[`conversion_${elem}`]
|| 0;
// Attunement base rate
const attBase = ATTUNEMENT_BASE_RATES[elem] || 0;
// Pact base rate
const pactBase = pactBaseRates[elem] || 0;
// Combined base rate
const baseRate = discRate + attBase + pactBase;
// Multipliers
const attMult = 1 + (attunementBonuses[elem] || 0);
const pactMult = 1 + (pactBonuses[elem] || 0);
// Meditation multiplier (reduced by distance)
const medMult = distance > 0
? 1 + (meditationMultiplier - 1) / distance
: 1;
// Final rate
const finalRate = baseRate * attMult * pactMult * medMult;
// Check if paused (insufficient regen for any source)
let paused = false;
let pauseReason: string | null = null;
const rawDrain = finalRate * cost.rawCost;
if (rawDrain > rawGrossRegen) {
paused = true;
pauseReason = `Insufficient raw regen (need ${rawDrain.toFixed(2)}/hr, have ${rawGrossRegen.toFixed(2)}/hr)`;
} else {
for (const [comp, compCost] of Object.entries(cost.componentCosts)) {
const compDrain = finalRate * compCost;
const compGross = grossRegen[comp] || 0;
if (compDrain > compGross) {
paused = true;
pauseReason = `Insufficient ${comp} regen (need ${compDrain.toFixed(2)}/hr, have ${compGross.toFixed(2)}/hr)`;
break;
}
}
}
// Only accumulate drains for active (non-paused) conversions
if (!paused) {
totalRawDrain += rawDrain;
for (const [comp, compCost] of Object.entries(cost.componentCosts)) {
elementDrain[comp] = (elementDrain[comp] || 0) + finalRate * compCost;
}
}
rates[elem] = {
element: elem,
distance,
disciplineRate: discRate,
attunementBase: attBase,
pactBase,
baseRate,
attunementMult: attMult,
pactMult,
meditationMult: medMult,
finalRate: paused ? 0 : finalRate,
rawCost: cost.rawCost,
componentCosts: { ...cost.componentCosts },
paused,
pauseReason,
};
}
return { rates, totalRawDrain, elementDrain };
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function getAttunementPrimaryElement(attunementId: string): string {
const map: Record<string, string> = {
enchanter: 'transference',
fabricator: 'earth',
};
return map[attunementId] || '';
}
/**
* Compute the meditation multiplier for a specific element's conversion.
* Full strength for distance-1, half for distance-2, etc.
*/
export function getMeditationConversionMult(
meditationMultiplier: number,
elementDistance: number,
): number {
if (elementDistance <= 0) return 1;
return 1 + (meditationMultiplier - 1) / elementDistance;
}
+58
View File
@@ -0,0 +1,58 @@
// ─── Element Distance from Raw Mana ───────────────────────────────────────────
// Every mana type has a distance from raw mana. Used for:
// 1. Calculating conversion cost ratios
// 2. Calculating meditation multiplier strength for that element's conversion
//
// Distance tiers:
// Raw = 0
// Base (7) = 1
// Utility (1) = 1
// Composite(8) = 2
// Exotic (5) = 3
// Time (1) = 4
const ELEMENT_DISTANCES: Record<string, number> = {
raw: 0,
// Base (distance 1)
fire: 1,
water: 1,
air: 1,
earth: 1,
light: 1,
dark: 1,
death: 1,
// Utility (distance 1)
transference: 1,
// Composite (distance 2)
metal: 2,
sand: 2,
lightning: 2,
frost: 2,
blackflame: 2,
radiantflames: 2,
miasma: 2,
shadowglass: 2,
// Exotic tier 1 (distance 3)
crystal: 3,
stellar: 3,
void: 3,
soul: 3,
plasma: 3,
// Exotic tier 2 (distance 4)
time: 4,
};
/** Return the distance of an element from raw mana. Default 0 for unknown. */
export function getElementDistance(elementId: string): number {
return ELEMENT_DISTANCES[elementId] ?? 0;
}
/** Return the highest distance among a list of elements. */
export function getMaxDistance(elementIds: string[]): number {
let max = 0;
for (const id of elementIds) {
const d = getElementDistance(id);
if (d > max) max = d;
}
return max;
}
+2 -5
View File
@@ -3,7 +3,7 @@
import type { AttunementState } from '../types';
import type { ComputedEffects } from '../effects/upgrade-effects.types';
import { HOURS_PER_TICK } from '../constants';
import { getTotalAttunementRegen, getTotalAttunementConversionDrain } from '../data/attunements';
import { getTotalAttunementRegen } from '../data/attunements';
export interface DisciplineBonuses {
bonuses: Record<string, number>;
@@ -84,10 +84,7 @@ export function computeEffectiveRegenForDisplay(
discipline?: DisciplineBonuses,
): { rawRegen: number; conversionDrain: number; effectiveRegen: number } {
const rawRegen = computeRegen(state, effects, discipline);
const conversionDrain = getTotalAttunementConversionDrain(state.attunements || {});
const effectiveRegen = Math.max(0, rawRegen - conversionDrain);
return { rawRegen, conversionDrain, effectiveRegen };
return { rawRegen, conversionDrain: 0, effectiveRegen: rawRegen };
}
// ─── Effective Regen (dynamic) ────────────────────────────────────────────────