fix: attunement system spec-vs-code discrepancies (issue #331)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m24s

- Fix conversion rate level scaling from linear (1+level*0.5) to exponential (1.5^(level-1)) in conversion-rates.ts
- Fix getAttunementLevelMultiplier formula to match spec §4.3
- Add level-up logging in attunementStore.ts via combat store addActivityLog
- Clarify getAttunementConversionRate returns flat base rate (level scaling applied separately)
- Update spec §8 to describe time-based puzzle room system matching code implementation
- Add 17 regression tests verifying exponential scaling, base rate behavior, and spec table values
This commit is contained in:
2026-06-08 22:08:17 +02:00
parent 64c1d2f51e
commit 573130cdb1
8 changed files with 199 additions and 13 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# Circular Dependencies
Generated: 2026-06-08T18:24:27.320Z
Generated: 2026-06-08T18:36:58.404Z
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
+2 -1
View File
@@ -1,6 +1,6 @@
{
"_meta": {
"generated": "2026-06-08T18:24:25.222Z",
"generated": "2026-06-08T18:36:56.442Z",
"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."
},
@@ -757,6 +757,7 @@
],
"stores/non-combat-room-actions.ts": [
"constants.ts",
"data/attunements.ts",
"stores/attunementStore.ts",
"stores/combat-state.types.ts",
"stores/discipline-slice.ts",
+1
View File
@@ -201,6 +201,7 @@ Mana-Loop/
│ │ │ ├── __tests__/
│ │ │ │ ├── achievements.test.ts
│ │ │ │ ├── activity-log.test.ts
│ │ │ │ ├── attunement-conversion-fix.test.ts
│ │ │ │ ├── bug-fixes.test.ts
│ │ │ │ ├── combat-actions.test.ts
│ │ │ │ ├── combat-utils.test.ts
@@ -295,8 +295,12 @@ per-attunement variants:
| `hybrid_enchanter_invoker` | Dual attunement challenge |
| `hybrid_fabricator_invoker` | Dual attunement challenge |
Progress scales at 1.52% per tick base, with attunement bonus of 2.53% per
relevant attunement level.
**Time-based progression system:** Each puzzle room has a base time requirement
that varies by floor range (4h for floors 120, 8h for 2150, 16h for 51100,
24h for 101+). Each relevant attunement reduces the total time needed, up to
a maximum 90% reduction shared across all relevant attunements. Progress
accumulates at `HOURS_PER_TICK` (0.04h) per tick. The room completes when
`puzzleProgress >= puzzleRequired`.
---
@@ -0,0 +1,164 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { getAttunementLevelMultiplier, getAttunementConversionRate, ATTUNEMENTS_DEF } from '../data/attunements';
import { computeConversionRates } from '../utils/conversion-rates';
import type { DisciplineEffectsResult } from '../effects/discipline-effects';
// ─── Test 1: getAttunementLevelMultiplier uses exponential formula ────────────
describe('getAttunementLevelMultiplier', () => {
it('should return 1.0 at level 1 (1.5^0 = 1)', () => {
expect(getAttunementLevelMultiplier(1)).toBeCloseTo(1.0, 5);
});
it('should return 1.5 at level 2 (1.5^1 = 1.5)', () => {
expect(getAttunementLevelMultiplier(2)).toBeCloseTo(1.5, 5);
});
it('should return ~2.25 at level 3 (1.5^2 = 2.25)', () => {
expect(getAttunementLevelMultiplier(3)).toBeCloseTo(2.25, 5);
});
it('should return ~19.22 at level 10 (1.5^9)', () => {
expect(getAttunementLevelMultiplier(10)).toBeCloseTo(Math.pow(1.5, 9), 5);
});
it('should NOT use linear formula (1 + level * 0.5)', () => {
// At level 10, linear would give 6.0, exponential gives ~19.22
const result = getAttunementLevelMultiplier(10);
expect(result).not.toBeCloseTo(6.0, 1);
expect(result).toBeGreaterThan(10);
});
});
// ─── Test 2: computeConversionRates uses exponential attunement scaling ────────
describe('computeConversionRates attunement scaling', () => {
const baseDisciplineEffects: DisciplineEffectsResult = {
bonuses: {},
conversions: {},
regen: {},
combat: { damageMult: 1, attackSpeedMult: 1, critChance: 0, critDamage: 1.5 },
defense: { armor: 0, barrier: 0, dodgeChance: 0, damageReduction: 0 },
utility: { capacityBonus: 0, efficiencyBonus: 0, speedBonus: 0, insightBonus: 0 },
};
const baseParams = {
disciplineEffects: baseDisciplineEffects,
attunements: {},
signedPacts: [],
pactElementMap: {},
invokerLevel: 1,
meditationMultiplier: 1,
grossRegen: {},
rawGrossRegen: 1000,
};
it('should apply exponential attunement multiplier to conversion rates', () => {
// Enchanter level 10 should give ~19.22x multiplier to transference conversion
const result = computeConversionRates({
...baseParams,
attunements: {
enchanter: { active: true, level: 10 },
},
});
const transferenceRate = result.rates['transference'];
expect(transferenceRate).toBeDefined();
// Base rate is 0.2, attunementMult should be ~19.22 at level 10
// finalRate = 0.2 * 19.22 * 1 * 1 ≈ 3.844
expect(transferenceRate!.attunementMult).toBeCloseTo(Math.pow(1.5, 9), 3);
expect(transferenceRate!.finalRate).toBeCloseTo(0.2 * Math.pow(1.5, 9), 2);
});
it('should apply exponential attunement multiplier for fabricator (earth)', () => {
const result = computeConversionRates({
...baseParams,
attunements: {
fabricator: { active: true, level: 5 },
},
});
const earthRate = result.rates['earth'];
expect(earthRate).toBeDefined();
// Base rate is 0.25, attunementMult should be 1.5^4 = 5.0625 at level 5
expect(earthRate!.attunementMult).toBeCloseTo(Math.pow(1.5, 4), 3);
expect(earthRate!.finalRate).toBeCloseTo(0.25 * Math.pow(1.5, 4), 2);
});
it('should NOT use linear attunement multiplier', () => {
// At level 10, linear would give 1 + 10*0.5 = 6, exponential gives ~19.22
const result = computeConversionRates({
...baseParams,
attunements: {
enchanter: { active: true, level: 10 },
},
});
const transferenceRate = result.rates['transference'];
// Linear would give attMult = 6, exponential gives ~19.22
expect(transferenceRate!.attunementMult).not.toBeCloseTo(6.0, 0);
expect(transferenceRate!.attunementMult).toBeGreaterThan(10);
});
it('should give level 1 attunement a 1.0x multiplier (no bonus)', () => {
const result = computeConversionRates({
...baseParams,
attunements: {
enchanter: { active: true, level: 1 },
},
});
const transferenceRate = result.rates['transference'];
expect(transferenceRate!.attunementMult).toBeCloseTo(1.0, 5);
});
});
// ─── Test 3: getAttunementConversionRate returns flat base rate ────────────────
describe('getAttunementConversionRate', () => {
it('should return base rate for enchanter (0.2)', () => {
expect(getAttunementConversionRate('enchanter', 1)).toBe(0.2);
});
it('should return same base rate regardless of level (level scaling applied separately)', () => {
const level1 = getAttunementConversionRate('enchanter', 1);
const level5 = getAttunementConversionRate('enchanter', 5);
const level10 = getAttunementConversionRate('enchanter', 10);
expect(level1).toBe(level5);
expect(level5).toBe(level10);
expect(level1).toBe(0.2);
});
it('should return base rate for fabricator (0.25)', () => {
expect(getAttunementConversionRate('fabricator', 1)).toBe(0.25);
});
it('should return 0 for invoker (no conversion)', () => {
expect(getAttunementConversionRate('invoker', 1)).toBe(0);
expect(getAttunementConversionRate('invoker', 10)).toBe(0);
});
it('should return 0 for unknown attunement', () => {
expect(getAttunementConversionRate('nonexistent', 5)).toBe(0);
});
});
// ─── Test 4: Spec table verification ──────────────────────────────────────────
describe('Spec table verification (§4.3)', () => {
it('should match spec conversion rate table for enchanter', () => {
// Spec: Level 10 Enchanter converts at 7.689/hr (0.2 * 1.5^9)
const level10Rate = 0.2 * Math.pow(1.5, 9);
expect(level10Rate).toBeCloseTo(7.689, 1);
});
it('should match spec conversion rate table for fabricator', () => {
// Spec: Level 10 Fabricator converts at 9.610/hr (0.25 * 1.5^9)
const level10Rate = 0.25 * Math.pow(1.5, 9);
expect(level10Rate).toBeCloseTo(9.610, 1);
});
it('should match spec regen table for enchanter', () => {
// Spec: Level 10 Enchanter regen = 19.221/hr (0.5 * 1.5^9)
const level10Regen = 0.5 * Math.pow(1.5, 9);
expect(level10Regen).toBeCloseTo(19.221, 1);
});
});
+7 -5
View File
@@ -99,10 +99,12 @@ export function getTotalAttunementRegen(attunements: Record<string, { active: bo
/**
* Get the attunement base conversion rate for a specific attunement.
* This is the flat base rate contribution to the unified conversion system.
* Level scaling is applied as a multiplier in computeConversionRates(), not here.
* Returns the flat base rate (no level scaling). Level scaling is applied
* separately in computeConversionRates() via the attunementMult multiplier.
* @param attunementId - The attunement ID (e.g. 'enchanter', 'fabricator')
* @param level - The attunement level (unused; kept for API compatibility)
*/
export function getAttunementConversionRate(attunementId: string, level: number): number {
export function getAttunementConversionRate(attunementId: string, _level: number): number {
const def = ATTUNEMENTS_DEF[attunementId];
if (!def || def.conversionRate <= 0) return 0;
return def.conversionRate;
@@ -110,10 +112,10 @@ export function getAttunementConversionRate(attunementId: string, level: number)
/**
* Get the attunement level multiplier for conversions.
* Each level adds +0.5 to the multiplier.
* Exponential scaling: 1.5^(level-1) per spec §4.3.
*/
export function getAttunementLevelMultiplier(level: number): number {
return 1 + (level || 1) * 0.5;
return Math.pow(1.5, (level || 1) - 1);
}
/** XP required for attunement level */
+15 -1
View File
@@ -5,7 +5,8 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { createSafeStorage } from '../utils/safe-persist';
import type { AttunementState } from '../types';
import { getAttunementXPForLevel, MAX_ATTUNEMENT_LEVEL } from '../data/attunements';
import { getAttunementXPForLevel, MAX_ATTUNEMENT_LEVEL, ATTUNEMENTS_DEF } from '../data/attunements';
import { useCombatStore } from './combatStore';
export interface AttunementStoreState {
attunements: Record<string, AttunementState>;
@@ -48,6 +49,19 @@ export const useAttunementStore = create<AttunementStoreState>()(
break;
}
}
// Log level-up (outside set to avoid nested state updates)
if (newLevel > attState.level) {
const def = ATTUNEMENTS_DEF[attunementId];
const name = def?.name || attunementId;
// Use setTimeout to log after state update completes
setTimeout(() => {
useCombatStore.getState().addActivityLog(
'attunement',
`${name} reached level ${newLevel}!`
);
}, 0);
}
return {
attunements: {
+3 -3
View File
@@ -24,7 +24,7 @@ export interface ConversionRateEntry {
pactBase: number;
/** Sum of base rates */
baseRate: number;
/** Attunement level multiplier: 1 + Σ(relevantAttunementLevel × 0.5) */
/** Attunement level multiplier: 1 + Σ(1.5^(attunementLevel-1) - 1) per relevant attunement */
attunementMult: number;
/** Pact level multiplier: 1 + Σ(pactCount_element × invokerLevel × 0.25) */
pactMult: number;
@@ -100,13 +100,13 @@ export function computeConversionRates(params: ConversionRateParams): Conversion
let totalRawDrain = 0;
// ── Step 1: Compute attunement level bonuses per element ──────────
// Each attunement level adds +0.5 to the multiplier for conversions
// Each attunement contributes 1.5^(level-1) exponential scaling to 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;
const bonus = Math.pow(1.5, level - 1) - 1;
// Determine which elements this attunement boosts based on its primary mana type
for (const [elem, cost] of Object.entries(CONVERSION_COSTS)) {