feat: overhaul mana conversion system to unified regen-deduction model
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m2s
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:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) ────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user