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:
@@ -168,26 +168,4 @@ describe('Cross-Module: Combat & Meditation', () => {
|
||||
expect(highIncursionMana).toBeLessThan(lowIncursionMana);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convert action via tick', () => {
|
||||
it('should convert raw mana to elements when action is convert', () => {
|
||||
useManaStore.getState().unlockElement('fire', 0);
|
||||
useManaStore.setState({ rawMana: 500 });
|
||||
useCombatStore.setState({ currentAction: 'convert' });
|
||||
|
||||
tickN(10);
|
||||
|
||||
expect(useManaStore.getState().elements.fire.current).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should increase element mana when converting', () => {
|
||||
useManaStore.getState().unlockElement('fire', 0);
|
||||
useManaStore.setState({ rawMana: 500 });
|
||||
useCombatStore.setState({ currentAction: 'convert' });
|
||||
|
||||
tickN(10);
|
||||
|
||||
expect(useManaStore.getState().elements.fire.current).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -225,15 +225,9 @@ describe('computeEffectiveRegenForDisplay', () => {
|
||||
expect(result.effectiveRegen).toBe(2);
|
||||
});
|
||||
|
||||
it('should not let effectiveRegen go below zero', () => {
|
||||
// This would require a state with high conversion drain
|
||||
// We can't easily test this without attunement data, but we can verify the Math.max behavior
|
||||
it('should have effectiveRegen equal to rawRegen since conversion drain is always 0', () => {
|
||||
const result = computeEffectiveRegenForDisplay(baseState);
|
||||
expect(result.effectiveRegen).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should calculate effective as raw minus conversion', () => {
|
||||
const result = computeEffectiveRegenForDisplay(baseState);
|
||||
expect(result.effectiveRegen).toBe(Math.max(0, result.rawRegen - result.conversionDrain));
|
||||
expect(result.conversionDrain).toBe(0);
|
||||
expect(result.effectiveRegen).toBe(result.rawRegen);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useManaStore, makeInitialElements } from '../stores/manaStore';
|
||||
import { MANA_PER_ELEMENT } from '../constants';
|
||||
import { ErrorCode } from '../utils/result';
|
||||
|
||||
function resetManaStore() {
|
||||
@@ -71,47 +70,6 @@ describe('ManaStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertMana', () => {
|
||||
it('should convert raw mana to element mana', () => {
|
||||
useManaStore.setState({ rawMana: 500 });
|
||||
const result = useManaStore.getState().convertMana('transference', 2);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.converted).toBe(2);
|
||||
}
|
||||
expect(useManaStore.getState().rawMana).toBe(500 - 2 * MANA_PER_ELEMENT);
|
||||
expect(useManaStore.getState().elements.transference.current).toBe(2);
|
||||
});
|
||||
|
||||
it('should fail for locked element', () => {
|
||||
const result = useManaStore.getState().convertMana('fire', 1);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.code).toBe(ErrorCode.ELEMENT_NOT_UNLOCKED);
|
||||
}
|
||||
});
|
||||
|
||||
it('should fail when insufficient raw mana', () => {
|
||||
useManaStore.setState({ rawMana: 50 });
|
||||
const result = useManaStore.getState().convertMana('transference', 1);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.code).toBe(ErrorCode.INSUFFICIENT_MANA);
|
||||
}
|
||||
});
|
||||
|
||||
it('should fail when element is at max', () => {
|
||||
const elements = useManaStore.getState().elements;
|
||||
elements.transference.current = elements.transference.max;
|
||||
useManaStore.setState({ elements });
|
||||
const result = useManaStore.getState().convertMana('transference', 1);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.code).toBe(ErrorCode.ELEMENT_MAX_CAPACITY);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('unlockElement', () => {
|
||||
it('should unlock element and deduct cost', () => {
|
||||
const result = useManaStore.getState().unlockElement('fire', 50);
|
||||
@@ -164,63 +122,16 @@ describe('ManaStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('craftComposite', () => {
|
||||
it('should craft metal from fire + earth', () => {
|
||||
useManaStore.getState().unlockElement('fire', 0);
|
||||
useManaStore.getState().unlockElement('earth', 0);
|
||||
useManaStore.getState().addElementMana('fire', 5, 50);
|
||||
useManaStore.getState().addElementMana('earth', 5, 50);
|
||||
const result = useManaStore.getState().craftComposite('metal', ['fire', 'earth']);
|
||||
expect(result.success).toBe(true);
|
||||
expect(useManaStore.getState().elements.fire.current).toBe(4);
|
||||
expect(useManaStore.getState().elements.earth.current).toBe(4);
|
||||
expect(useManaStore.getState().elements.metal.current).toBe(1);
|
||||
expect(useManaStore.getState().elements.metal.unlocked).toBe(true);
|
||||
});
|
||||
|
||||
it('should fail when missing ingredients', () => {
|
||||
const result = useManaStore.getState().craftComposite('metal', ['fire', 'earth']);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.code).toBe(ErrorCode.INSUFFICIENT_MANA);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('processConvertAction', () => {
|
||||
it('should auto-convert raw mana to neediest unlocked element', () => {
|
||||
useManaStore.getState().unlockElement('fire', 0);
|
||||
const result = useManaStore.getState().processConvertAction(500);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.rawMana).toBe(0);
|
||||
expect(result!.elements.fire.current).toBe(5);
|
||||
});
|
||||
|
||||
it('should return null when raw mana < 100', () => {
|
||||
useManaStore.getState().unlockElement('fire', 0);
|
||||
const result = useManaStore.getState().processConvertAction(50);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when no unlocked elements need mana', () => {
|
||||
const elements = useManaStore.getState().elements;
|
||||
Object.keys(elements).forEach(k => { elements[k].current = elements[k].max; });
|
||||
useManaStore.setState({ elements });
|
||||
const result = useManaStore.getState().processConvertAction(500);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetMana', () => {
|
||||
it('should reset to initial state for new loop', () => {
|
||||
useManaStore.getState().resetMana({}, {}, {}, {});
|
||||
useManaStore.getState().resetMana({});
|
||||
expect(useManaStore.getState().rawMana).toBe(10);
|
||||
expect(useManaStore.getState().meditateTicks).toBe(0);
|
||||
expect(useManaStore.getState().totalManaGathered).toBe(0);
|
||||
});
|
||||
|
||||
it('should apply prestige upgrades for starting mana', () => {
|
||||
useManaStore.getState().resetMana({ manaStart: 5 }, {}, {}, {});
|
||||
useManaStore.getState().resetMana({ manaStart: 5 });
|
||||
expect(useManaStore.getState().rawMana).toBe(10 + 5 * 10);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useManaStore, makeInitialElements } from '../stores/manaStore';
|
||||
import { useCombatStore } from '../stores/combatStore';
|
||||
import { MANA_PER_ELEMENT } from '../constants';
|
||||
import { getFloorMaxHP } from '../utils';
|
||||
import { ErrorCode } from '../utils/result';
|
||||
|
||||
@@ -102,47 +101,6 @@ describe('ManaStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertMana', () => {
|
||||
it('should convert raw mana to element mana', () => {
|
||||
useManaStore.setState({ rawMana: 500 });
|
||||
const result = useManaStore.getState().convertMana('transference', 2);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.converted).toBe(2);
|
||||
}
|
||||
expect(useManaStore.getState().rawMana).toBe(500 - 2 * MANA_PER_ELEMENT);
|
||||
expect(useManaStore.getState().elements.transference.current).toBe(2);
|
||||
});
|
||||
|
||||
it('should fail for locked element', () => {
|
||||
const result = useManaStore.getState().convertMana('fire', 1);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.code).toBe(ErrorCode.ELEMENT_NOT_UNLOCKED);
|
||||
}
|
||||
});
|
||||
|
||||
it('should fail when insufficient raw mana', () => {
|
||||
useManaStore.setState({ rawMana: 50 });
|
||||
const result = useManaStore.getState().convertMana('transference', 1);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.code).toBe(ErrorCode.INSUFFICIENT_MANA);
|
||||
}
|
||||
});
|
||||
|
||||
it('should fail when element is at max', () => {
|
||||
const elements = useManaStore.getState().elements;
|
||||
elements.transference.current = elements.transference.max;
|
||||
useManaStore.setState({ elements });
|
||||
const result = useManaStore.getState().convertMana('transference', 1);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.code).toBe(ErrorCode.ELEMENT_MAX_CAPACITY);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('unlockElement', () => {
|
||||
it('should unlock element and deduct cost', () => {
|
||||
const result = useManaStore.getState().unlockElement('fire', 50);
|
||||
@@ -195,63 +153,16 @@ describe('ManaStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('craftComposite', () => {
|
||||
it('should craft metal from fire + earth', () => {
|
||||
useManaStore.getState().unlockElement('fire', 0);
|
||||
useManaStore.getState().unlockElement('earth', 0);
|
||||
useManaStore.getState().addElementMana('fire', 5, 50);
|
||||
useManaStore.getState().addElementMana('earth', 5, 50);
|
||||
const result = useManaStore.getState().craftComposite('metal', ['fire', 'earth']);
|
||||
expect(result.success).toBe(true);
|
||||
expect(useManaStore.getState().elements.fire.current).toBe(4);
|
||||
expect(useManaStore.getState().elements.earth.current).toBe(4);
|
||||
expect(useManaStore.getState().elements.metal.current).toBe(1);
|
||||
expect(useManaStore.getState().elements.metal.unlocked).toBe(true);
|
||||
});
|
||||
|
||||
it('should fail when missing ingredients', () => {
|
||||
const result = useManaStore.getState().craftComposite('metal', ['fire', 'earth']);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.code).toBe(ErrorCode.INSUFFICIENT_MANA);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('processConvertAction', () => {
|
||||
it('should auto-convert raw mana to neediest unlocked element', () => {
|
||||
useManaStore.getState().unlockElement('fire', 0);
|
||||
const result = useManaStore.getState().processConvertAction(500);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.rawMana).toBe(0);
|
||||
expect(result!.elements.fire.current).toBe(5);
|
||||
});
|
||||
|
||||
it('should return null when raw mana < 100', () => {
|
||||
useManaStore.getState().unlockElement('fire', 0);
|
||||
const result = useManaStore.getState().processConvertAction(50);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when no unlocked elements need mana', () => {
|
||||
const elements = useManaStore.getState().elements;
|
||||
Object.keys(elements).forEach(k => { elements[k].current = elements[k].max; });
|
||||
useManaStore.setState({ elements });
|
||||
const result = useManaStore.getState().processConvertAction(500);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetMana', () => {
|
||||
it('should reset to initial state for new loop', () => {
|
||||
useManaStore.getState().resetMana({}, {}, {}, {});
|
||||
useManaStore.getState().resetMana({});
|
||||
expect(useManaStore.getState().rawMana).toBe(10);
|
||||
expect(useManaStore.getState().meditateTicks).toBe(0);
|
||||
expect(useManaStore.getState().totalManaGathered).toBe(0);
|
||||
});
|
||||
|
||||
it('should apply prestige upgrades for starting mana', () => {
|
||||
useManaStore.getState().resetMana({ manaStart: 5 }, {}, {}, {});
|
||||
useManaStore.getState().resetMana({ manaStart: 5 });
|
||||
expect(useManaStore.getState().rawMana).toBe(10 + 5 * 10);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// ─── Attunement Definitions ─────────────────────────────────────────────────────
|
||||
// Attunements are class-like abilities tied to body locations
|
||||
// Each provides unique capabilities, primary mana types, and skill access
|
||||
//
|
||||
// NEW MODEL: Attunements contribute base conversion rates for their primary mana type.
|
||||
// Levels provide a multiplicative bonus (+50% per level) to conversions involving
|
||||
// their primary element (as destination or component).
|
||||
|
||||
import type { AttunementDef, AttunementSlot } from '../types';
|
||||
|
||||
@@ -18,69 +22,62 @@ export const ATTUNEMENT_SLOT_NAMES: Record<AttunementSlot, string> = {
|
||||
// All attunement definitions
|
||||
export const ATTUNEMENTS_DEF: Record<string, AttunementDef> = {
|
||||
// ─── Enchanter (Right Hand) ─────────────────────────────────────────────────
|
||||
// Unlocks the enchanting system - applying magical effects to equipment
|
||||
// Primary mana: Transference (used to move/apply enchantments)
|
||||
enchanter: {
|
||||
id: 'enchanter',
|
||||
name: 'Enchanter',
|
||||
desc: 'Channel transference mana through your right hand to apply magical enchantments to equipment. The art of enchanting allows you to imbue items with spell effects, stat bonuses, and special properties.',
|
||||
slot: 'rightHand',
|
||||
icon: '✨',
|
||||
color: '#1ABC9C', // Teal (transference color)
|
||||
color: '#1ABC9C',
|
||||
primaryManaType: 'transference',
|
||||
rawManaRegen: 0.5,
|
||||
conversionRate: 0.2, // Converts 0.2 raw mana to transference per hour
|
||||
unlocked: true, // Starting attunement
|
||||
conversionRate: 0.2, // Base rate for transference conversion (per hour)
|
||||
unlocked: true,
|
||||
capabilities: ['enchanting'],
|
||||
skillCategories: ['enchant', 'effectResearch'],
|
||||
},
|
||||
|
||||
// ─── Invoker (Chest/Heart) ───────────────────────────────────────────────────
|
||||
// Enables forming pacts with spire guardians
|
||||
// No primary mana - instead gains mana types from each pact signed
|
||||
invoker: {
|
||||
id: 'invoker',
|
||||
name: 'Invoker',
|
||||
desc: 'Open your heart to the guardians of the spire. Form pacts with defeated guardians to gain their elemental affinity and access to their unique powers. Each pact grants access to a new mana type.',
|
||||
slot: 'chest',
|
||||
icon: '💜',
|
||||
color: '#9B59B6', // Purple
|
||||
primaryManaType: undefined, // Invoker has no primary - gains from pacts
|
||||
color: '#9B59B6',
|
||||
primaryManaType: undefined,
|
||||
rawManaRegen: 0.3,
|
||||
conversionRate: 0, // No automatic conversion - mana comes from pacts
|
||||
unlocked: false, // Unlocked through gameplay
|
||||
conversionRate: 0, // No automatic conversion — mana comes from pacts
|
||||
unlocked: false,
|
||||
unlockCondition: 'Defeat your first guardian and choose the path of the Invoker',
|
||||
capabilities: ['pacts', 'guardianPowers', 'elementalMastery'],
|
||||
skillCategories: ['invocation', 'pact'],
|
||||
},
|
||||
|
||||
// ─── Fabricator (Left Hand) ──────────────────────────────────────────────────
|
||||
// Crafts earth golems and earthen gear
|
||||
// Primary mana: Earth
|
||||
// Later with fire mana -> metal mana, can craft metallic gear and golems
|
||||
fabricator: {
|
||||
id: 'fabricator',
|
||||
name: 'Fabricator',
|
||||
desc: 'Shape earth and metal through your left hand to craft golems and equipment. Start with earthen constructs, and unlock metalworking when you gain fire mana to create metal mana.',
|
||||
slot: 'leftHand',
|
||||
icon: '⚒️',
|
||||
color: '#F4A261', // Earth color
|
||||
color: '#F4A261',
|
||||
primaryManaType: 'earth',
|
||||
rawManaRegen: 0.4,
|
||||
conversionRate: 0.25, // Converts 0.25 raw mana to earth per hour
|
||||
unlocked: false, // Unlocked through gameplay
|
||||
conversionRate: 0.25, // Base rate for earth conversion (per hour)
|
||||
unlocked: false,
|
||||
unlockCondition: 'Prove your worth as a crafter',
|
||||
capabilities: ['golemCrafting', 'gearCrafting', 'earthShaping'],
|
||||
skillCategories: ['fabrication', 'golemancy'],
|
||||
},
|
||||
};
|
||||
|
||||
// Helper function to get attunement by slot
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function getAttunementBySlot(slot: AttunementSlot): AttunementDef | undefined {
|
||||
return Object.values(ATTUNEMENTS_DEF).find(a => a.slot === slot);
|
||||
}
|
||||
|
||||
// Helper function to get all unlocked attunements for a player
|
||||
export function getUnlockedAttunements(attunements: Record<string, { active: boolean; level: number; experience: number }>): AttunementDef[] {
|
||||
return Object.entries(attunements)
|
||||
.filter(([, state]) => state.active)
|
||||
@@ -88,71 +85,54 @@ export function getUnlockedAttunements(attunements: Record<string, { active: boo
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// Helper function to calculate total raw mana regen from attunements (with level scaling)
|
||||
/** Total raw mana regen from attunements (with level scaling) */
|
||||
export function getTotalAttunementRegen(attunements: Record<string, { active: boolean; level: number; experience: number }>): number {
|
||||
return Object.entries(attunements)
|
||||
.filter(([, state]) => state.active)
|
||||
.reduce((total, [id, state]) => {
|
||||
const def = ATTUNEMENTS_DEF[id];
|
||||
if (!def) return total;
|
||||
// Exponential scaling: base * (1.5 ^ (level - 1))
|
||||
const levelMult = Math.pow(1.5, (state.level || 1) - 1);
|
||||
return total + def.rawManaRegen * levelMult;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// Helper function to calculate total conversion drain from all active attunements (per hour)
|
||||
export function getTotalAttunementConversionDrain(attunements: Record<string, { active: boolean; level: number; experience: number }>): number {
|
||||
return Object.entries(attunements)
|
||||
.filter(([, state]) => state.active)
|
||||
.reduce((total, [id, state]) => {
|
||||
const def = ATTUNEMENTS_DEF[id];
|
||||
if (!def || def.conversionRate <= 0) return total;
|
||||
// Use the same level scaling as getAttunementConversionRate
|
||||
const scaledRate = getAttunementConversionRate(id, state.level || 1);
|
||||
return total + scaledRate;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// Get conversion rate with level scaling
|
||||
/**
|
||||
* Get the attunement base conversion rate for a specific attunement.
|
||||
* This is the base rate contribution to the unified conversion system.
|
||||
*/
|
||||
export function getAttunementConversionRate(attunementId: string, level: number): number {
|
||||
const def = ATTUNEMENTS_DEF[attunementId];
|
||||
if (!def || def.conversionRate <= 0) return 0;
|
||||
// Exponential scaling: base * (1.5 ^ (level - 1))
|
||||
return def.conversionRate * Math.pow(1.5, (level || 1) - 1);
|
||||
}
|
||||
|
||||
// XP required for attunement level
|
||||
/**
|
||||
* Get the attunement level multiplier for conversions.
|
||||
* Each level adds +0.5 to the multiplier.
|
||||
*/
|
||||
export function getAttunementLevelMultiplier(level: number): number {
|
||||
return 1 + (level || 1) * 0.5;
|
||||
}
|
||||
|
||||
/** XP required for attunement level */
|
||||
export function getAttunementXPForLevel(level: number): number {
|
||||
// New scaling:
|
||||
// Level 2: 1000 XP
|
||||
// Level 3: 2500 XP
|
||||
// Level 4: 5000 XP
|
||||
// Level 5: 10000 XP
|
||||
// etc. (each level requires 2x the previous, starting from 1000)
|
||||
if (level <= 1) return 0;
|
||||
if (level === 2) return 1000;
|
||||
// For level 3+: 1000 * 2.5^(level-2), but rounded nicely
|
||||
return Math.floor(1000 * Math.pow(2, level - 2) * (level >= 3 ? 1.25 : 1));
|
||||
}
|
||||
|
||||
// Calculate XP gained from enchanting based on capacity used
|
||||
export function calculateEnchantingXP(capacityUsed: number): number {
|
||||
// 1 XP per 10 capacity used, floored, minimum 1
|
||||
return Math.max(1, Math.floor(capacityUsed / 10));
|
||||
}
|
||||
|
||||
// Max attunement level
|
||||
export const MAX_ATTUNEMENT_LEVEL = 10;
|
||||
|
||||
// Helper function to get mana types from active attunements and pacts
|
||||
export function getAttunementManaTypes(
|
||||
attunements: Record<string, { active: boolean; level: number; experience: number }>,
|
||||
signedPacts: number[]
|
||||
): string[] {
|
||||
const manaTypes: string[] = [];
|
||||
|
||||
// Add primary mana types from active attunements
|
||||
Object.entries(attunements)
|
||||
.filter(([, state]) => state.active)
|
||||
.forEach(([id]) => {
|
||||
@@ -161,30 +141,19 @@ export function getAttunementManaTypes(
|
||||
manaTypes.push(def.primaryManaType);
|
||||
}
|
||||
});
|
||||
|
||||
// Invoker gains mana types from signed pacts
|
||||
if (attunements.invoker?.active && signedPacts.length > 0) {
|
||||
// Import GUARDIANS would be circular, so this is handled in the store
|
||||
// For now, just mark that invoker provides pact-based mana
|
||||
manaTypes.push('pactElements');
|
||||
}
|
||||
|
||||
return [...new Set(manaTypes)]; // Remove duplicates
|
||||
return [...new Set(manaTypes)];
|
||||
}
|
||||
|
||||
// Get skill categories available to player based on active attunements
|
||||
export function getAvailableSkillCategories(
|
||||
attunements: Record<string, { active: boolean; level: number; experience: number }>
|
||||
): string[] {
|
||||
const categories = new Set<string>();
|
||||
|
||||
// Always available categories
|
||||
categories.add('mana');
|
||||
categories.add('study');
|
||||
categories.add('research');
|
||||
// categories.add('ascension'); // removed: banned mechanic
|
||||
|
||||
// Add categories from active attunements
|
||||
Object.entries(attunements)
|
||||
.filter(([, state]) => state.active)
|
||||
.forEach(([id]) => {
|
||||
@@ -193,6 +162,5 @@ export function getAvailableSkillCategories(
|
||||
def.skillCategories.forEach(cat => categories.add(cat));
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(categories);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// ─── Conversion Cost Ratios ───────────────────────────────────────────────────
|
||||
// All conversions produce 1 unit of destination mana.
|
||||
// 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
|
||||
|
||||
import type { ElementRecipe } from '../types';
|
||||
|
||||
export interface ConversionCost {
|
||||
/** Destination element ID */
|
||||
element: string;
|
||||
/** Distance from raw mana */
|
||||
distance: number;
|
||||
/** Raw mana cost per 1 unit of destination */
|
||||
rawCost: number;
|
||||
/** Component costs: element ID → amount per 1 unit of destination */
|
||||
componentCosts: Record<string, number>;
|
||||
}
|
||||
|
||||
function computeRawCost(distance: number): number {
|
||||
return Math.pow(10, distance + 1);
|
||||
}
|
||||
|
||||
function computeComponentCost(distance: number): number {
|
||||
return 10 * (distance + 1);
|
||||
}
|
||||
|
||||
/** Build a ConversionCost for a base element (distance 1, no components) */
|
||||
function baseElementCost(element: string): ConversionCost {
|
||||
return {
|
||||
element,
|
||||
distance: 1,
|
||||
rawCost: computeRawCost(1), // 100
|
||||
componentCosts: {},
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a ConversionCost for a composite element (distance 2) */
|
||||
function compositeElementCost(element: string, components: string[]): ConversionCost {
|
||||
return {
|
||||
element,
|
||||
distance: 2,
|
||||
rawCost: computeRawCost(2), // 1,000
|
||||
componentCosts: Object.fromEntries(
|
||||
components.map(c => [c, computeComponentCost(2)]), // 30 each
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a ConversionCost for an exotic element (distance 3) */
|
||||
function exoticElementCost(element: string, components: string[]): ConversionCost {
|
||||
return {
|
||||
element,
|
||||
distance: 3,
|
||||
rawCost: computeRawCost(3), // 10,000
|
||||
componentCosts: Object.fromEntries(
|
||||
components.map(c => [c, computeComponentCost(3)]), // 40 each
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a ConversionCost for time (distance 4) */
|
||||
function timeElementCost(element: string, components: string[]): ConversionCost {
|
||||
return {
|
||||
element,
|
||||
distance: 4,
|
||||
rawCost: computeRawCost(4), // 100,000
|
||||
componentCosts: Object.fromEntries(
|
||||
components.map(c => [c, computeComponentCost(4)]), // 50 each
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Full Cost Table ──────────────────────────────────────────────────────────
|
||||
|
||||
export const CONVERSION_COSTS: Record<string, ConversionCost> = {
|
||||
// Base (distance 1)
|
||||
fire: baseElementCost('fire'),
|
||||
water: baseElementCost('water'),
|
||||
air: baseElementCost('air'),
|
||||
earth: baseElementCost('earth'),
|
||||
light: baseElementCost('light'),
|
||||
dark: baseElementCost('dark'),
|
||||
death: baseElementCost('death'),
|
||||
// Utility (distance 1)
|
||||
transference: baseElementCost('transference'),
|
||||
// Composite (distance 2)
|
||||
metal: compositeElementCost('metal', ['fire', 'earth']),
|
||||
sand: compositeElementCost('sand', ['earth', 'water']),
|
||||
lightning: compositeElementCost('lightning', ['fire', 'air']),
|
||||
frost: compositeElementCost('frost', ['air', 'water']),
|
||||
blackflame: compositeElementCost('blackflame', ['dark', 'fire']),
|
||||
radiantflames: compositeElementCost('radiantflames', ['light', 'fire']),
|
||||
miasma: compositeElementCost('miasma', ['air', 'death']),
|
||||
shadowglass: compositeElementCost('shadowglass', ['earth', 'dark']),
|
||||
// Exotic (distance 3)
|
||||
crystal: exoticElementCost('crystal', ['sand', 'light']),
|
||||
stellar: exoticElementCost('stellar', ['plasma', 'light']),
|
||||
void: exoticElementCost('void', ['dark', 'death']),
|
||||
soul: exoticElementCost('soul', ['light', 'dark', 'transference']),
|
||||
plasma: exoticElementCost('plasma', ['lightning', 'fire', 'transference']),
|
||||
// Time (distance 4)
|
||||
time: timeElementCost('time', ['soul', 'sand', 'transference']),
|
||||
};
|
||||
|
||||
/** Get the conversion cost for an element. Returns null if not found. */
|
||||
export function getConversionCost(element: string): ConversionCost | null {
|
||||
return CONVERSION_COSTS[element] ?? null;
|
||||
}
|
||||
|
||||
/** Get all source types (raw + components) for a conversion */
|
||||
export function getConversionSources(element: string): string[] {
|
||||
const cost = CONVERSION_COSTS[element];
|
||||
if (!cost) return [];
|
||||
return ['raw', ...Object.keys(cost.componentCosts)];
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
// ─── Elemental Conversion Disciplines (Composite + Exotic) ──────────────────────
|
||||
// Conversion disciplines for composite and exotic mana types.
|
||||
// All are BASE attunement so they are available to every role once the element is unlocked.
|
||||
//
|
||||
// NEW MODEL: Disciplines contribute to conversion_{element} stat bonus.
|
||||
// The unified conversion-rates.ts calculator handles rate computation.
|
||||
// No direct mana drain — costs are deducted from regen.
|
||||
|
||||
import { DisciplinesAttunementType } from '../../types/disciplines';
|
||||
import type { DisciplineDefinition } from '../../types/disciplines';
|
||||
@@ -16,19 +20,11 @@ interface AdvancedConversionConfig {
|
||||
scalingFactor: number;
|
||||
drainBase: number;
|
||||
sourceManaTypes: DisciplineDefinition['manaType'][];
|
||||
customOnceDescription?: string;
|
||||
customOnceAmount?: number;
|
||||
customInfiniteDescription?: string;
|
||||
customInfiniteAmount?: number;
|
||||
infiniteThreshold?: number;
|
||||
}
|
||||
|
||||
function createAdvancedConversionDiscipline(cfg: AdvancedConversionConfig): DisciplineDefinition {
|
||||
const nameLower = cfg.name.toLowerCase();
|
||||
const onceDesc = cfg.customOnceDescription ?? `+${cfg.conversionRate} ${cfg.name} Conversion/sec`;
|
||||
const onceAmt = cfg.customOnceAmount ?? cfg.conversionRate;
|
||||
const infDesc = cfg.customInfiniteDescription ?? `Every 100 XP: +${cfg.conversionRate * 0.5} ${cfg.name} Conversion/sec`;
|
||||
const infAmt = cfg.customInfiniteAmount ?? cfg.conversionRate * 0.5;
|
||||
const infThreshold = cfg.infiniteThreshold ?? 400;
|
||||
|
||||
return {
|
||||
@@ -41,7 +37,7 @@ function createAdvancedConversionDiscipline(cfg: AdvancedConversionConfig): Disc
|
||||
statBonus: {
|
||||
stat: `conversion_${cfg.manaType}` as DisciplineDefinition['statBonus']['stat'],
|
||||
baseValue: cfg.conversionRate,
|
||||
label: `${cfg.name} Conversion/sec`,
|
||||
label: `${cfg.name} Conversion/hr`,
|
||||
},
|
||||
difficultyFactor: cfg.difficultyFactor,
|
||||
scalingFactor: cfg.scalingFactor,
|
||||
@@ -55,23 +51,23 @@ function createAdvancedConversionDiscipline(cfg: AdvancedConversionConfig): Disc
|
||||
type: 'once',
|
||||
threshold: 150,
|
||||
value: 0,
|
||||
description: onceDesc,
|
||||
bonus: { stat: `conversion_${cfg.manaType}`, amount: onceAmt },
|
||||
description: `+${cfg.conversionRate} ${cfg.name} Conversion/hr`,
|
||||
bonus: { stat: `conversion_${cfg.manaType}`, amount: cfg.conversionRate },
|
||||
},
|
||||
{
|
||||
id: `${cfg.id}-inf`,
|
||||
type: 'infinite',
|
||||
threshold: infThreshold,
|
||||
value: 100,
|
||||
description: infDesc,
|
||||
bonus: { stat: `conversion_${cfg.manaType}`, amount: infAmt },
|
||||
description: `Every 100 XP: +${cfg.conversionRate * 0.5} ${cfg.name} Conversion/hr`,
|
||||
bonus: { stat: `conversion_${cfg.manaType}`, amount: cfg.conversionRate * 0.5 },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export const elementalRegenAdvancedDisciplines: DisciplineDefinition[] = [
|
||||
// ── Composite Elements ─────────────────────────────────────────────────────
|
||||
// ── Composite Elements (distance 2, rate 0.35/hr) ────────────────────────
|
||||
createAdvancedConversionDiscipline({
|
||||
id: 'regen-metal',
|
||||
name: 'Metal',
|
||||
@@ -120,6 +116,7 @@ export const elementalRegenAdvancedDisciplines: DisciplineDefinition[] = [
|
||||
drainBase: 2,
|
||||
sourceManaTypes: ['raw', 'air', 'water'],
|
||||
}),
|
||||
// ── Composite Elements (distance 2, rate 0.30/hr) ────────────────────────
|
||||
createAdvancedConversionDiscipline({
|
||||
id: 'regen-blackflame',
|
||||
name: 'BlackFlame',
|
||||
@@ -169,7 +166,7 @@ export const elementalRegenAdvancedDisciplines: DisciplineDefinition[] = [
|
||||
sourceManaTypes: ['raw', 'earth', 'dark'],
|
||||
}),
|
||||
|
||||
// ── Exotic Elements ────────────────────────────────────────────────────────
|
||||
// ── Exotic Elements (distance 3, rate 0.25/hr) ──────────────────────────
|
||||
createAdvancedConversionDiscipline({
|
||||
id: 'regen-crystal',
|
||||
name: 'Crystal',
|
||||
@@ -183,19 +180,6 @@ export const elementalRegenAdvancedDisciplines: DisciplineDefinition[] = [
|
||||
sourceManaTypes: ['raw', 'sand', 'light'],
|
||||
infiniteThreshold: 500,
|
||||
}),
|
||||
createAdvancedConversionDiscipline({
|
||||
id: 'regen-stellar',
|
||||
name: 'Stellar',
|
||||
manaType: 'stellar',
|
||||
cost: 20,
|
||||
description: 'Convert raw mana + plasma mana + light mana into stellar mana over time.',
|
||||
conversionRate: 0.2,
|
||||
difficultyFactor: 230,
|
||||
scalingFactor: 115,
|
||||
drainBase: 3,
|
||||
sourceManaTypes: ['raw', 'plasma', 'light'],
|
||||
infiniteThreshold: 500,
|
||||
}),
|
||||
createAdvancedConversionDiscipline({
|
||||
id: 'regen-void',
|
||||
name: 'Void',
|
||||
@@ -209,6 +193,21 @@ export const elementalRegenAdvancedDisciplines: DisciplineDefinition[] = [
|
||||
sourceManaTypes: ['raw', 'dark', 'death'],
|
||||
infiniteThreshold: 500,
|
||||
}),
|
||||
|
||||
// ── Exotic Elements (distance 3, rate 0.20/hr) ──────────────────────────
|
||||
createAdvancedConversionDiscipline({
|
||||
id: 'regen-stellar',
|
||||
name: 'Stellar',
|
||||
manaType: 'stellar',
|
||||
cost: 20,
|
||||
description: 'Convert raw mana + plasma mana + light mana into stellar mana over time.',
|
||||
conversionRate: 0.2,
|
||||
difficultyFactor: 230,
|
||||
scalingFactor: 115,
|
||||
drainBase: 3,
|
||||
sourceManaTypes: ['raw', 'plasma', 'light'],
|
||||
infiniteThreshold: 500,
|
||||
}),
|
||||
createAdvancedConversionDiscipline({
|
||||
id: 'regen-soul',
|
||||
name: 'Soul',
|
||||
@@ -235,6 +234,8 @@ export const elementalRegenAdvancedDisciplines: DisciplineDefinition[] = [
|
||||
sourceManaTypes: ['raw', 'lightning', 'fire', 'transference'],
|
||||
infiniteThreshold: 550,
|
||||
}),
|
||||
|
||||
// ── Time (distance 4, rate 0.15/hr) ─────────────────────────────────────
|
||||
createAdvancedConversionDiscipline({
|
||||
id: 'regen-time',
|
||||
name: 'Time',
|
||||
|
||||
@@ -1,33 +1,31 @@
|
||||
// ─── Elemental Conversion Disciplines (Base + Utility) ─────────────────────────
|
||||
// One discipline per mana type that converts raw mana into that element.
|
||||
// All are BASE attunement so they are available to every role once the element is unlocked.
|
||||
//
|
||||
// NEW MODEL: Disciplines contribute to conversion_{element} stat bonus.
|
||||
// The unified conversion-rates.ts calculator handles rate computation.
|
||||
// No direct mana drain — costs are deducted from regen.
|
||||
|
||||
import { DisciplinesAttunementType } from '../../types/disciplines';
|
||||
import type { DisciplineDefinition } from '../../types/disciplines';
|
||||
|
||||
const BASE_CONVERSION = 0.5;
|
||||
const BASE_DRAIN = 1.5;
|
||||
const BASE_DIFF = 120;
|
||||
const BASE_SCALE = 60;
|
||||
|
||||
interface BaseConversionConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
manaType: string;
|
||||
cost: number;
|
||||
/** Base conversion rate (per hour) before XP scaling */
|
||||
conversionRate?: number;
|
||||
difficultyFactor?: number;
|
||||
scalingFactor?: number;
|
||||
drainBase?: number;
|
||||
sourceManaTypes?: DisciplineDefinition['manaType'][];
|
||||
}
|
||||
|
||||
function createManaConversionDiscipline(cfg: BaseConversionConfig): DisciplineDefinition {
|
||||
const rate = cfg.conversionRate ?? BASE_CONVERSION;
|
||||
const diff = cfg.difficultyFactor ?? BASE_DIFF;
|
||||
const scale = cfg.scalingFactor ?? BASE_SCALE;
|
||||
const drain = cfg.drainBase ?? BASE_DRAIN;
|
||||
const sources = cfg.sourceManaTypes ?? ['raw' as DisciplineDefinition['manaType']];
|
||||
const rate = cfg.conversionRate ?? 0.5;
|
||||
const diff = cfg.difficultyFactor ?? 120;
|
||||
const scale = cfg.scalingFactor ?? 60;
|
||||
const drain = cfg.drainBase ?? 1.5;
|
||||
const nameLower = cfg.name.toLowerCase();
|
||||
|
||||
return {
|
||||
@@ -40,13 +38,13 @@ function createManaConversionDiscipline(cfg: BaseConversionConfig): DisciplineDe
|
||||
statBonus: {
|
||||
stat: `conversion_${cfg.manaType}` as DisciplineDefinition['statBonus']['stat'],
|
||||
baseValue: rate,
|
||||
label: `${cfg.name} Conversion/sec`,
|
||||
label: `${cfg.name} Conversion/hr`,
|
||||
},
|
||||
difficultyFactor: diff,
|
||||
scalingFactor: scale,
|
||||
drainBase: drain,
|
||||
conversionRate: rate,
|
||||
sourceManaTypes: sources,
|
||||
sourceManaTypes: ['raw' as DisciplineDefinition['manaType']],
|
||||
requires: [cfg.manaType],
|
||||
perks: [
|
||||
{
|
||||
@@ -54,7 +52,7 @@ function createManaConversionDiscipline(cfg: BaseConversionConfig): DisciplineDe
|
||||
type: 'once',
|
||||
threshold: 100,
|
||||
value: 0,
|
||||
description: `+${rate} ${cfg.name} Conversion/sec`,
|
||||
description: `+${rate} ${cfg.name} Conversion/hr`,
|
||||
bonus: { stat: `conversion_${cfg.manaType}`, amount: rate },
|
||||
},
|
||||
{
|
||||
@@ -62,24 +60,24 @@ function createManaConversionDiscipline(cfg: BaseConversionConfig): DisciplineDe
|
||||
type: 'infinite',
|
||||
threshold: 300,
|
||||
value: 100,
|
||||
description: `Every 100 XP: +0.25 ${cfg.name} Conversion/sec`,
|
||||
bonus: { stat: `conversion_${cfg.manaType}`, amount: 0.25 },
|
||||
description: `Every 100 XP: +${rate * 0.5} ${cfg.name} Conversion/hr`,
|
||||
bonus: { stat: `conversion_${cfg.manaType}`, amount: rate * 0.5 },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export const elementalRegenDisciplines: DisciplineDefinition[] = [
|
||||
// ── Base Elements ──────────────────────────────────────────────────────────
|
||||
createManaConversionDiscipline({ id: 'regen-fire', name: 'Fire', manaType: 'fire', cost: 8 }),
|
||||
createManaConversionDiscipline({ id: 'regen-water', name: 'Water', manaType: 'water', cost: 8 }),
|
||||
createManaConversionDiscipline({ id: 'regen-air', name: 'Air', manaType: 'air', cost: 8 }),
|
||||
createManaConversionDiscipline({ id: 'regen-earth', name: 'Earth', manaType: 'earth', cost: 8 }),
|
||||
createManaConversionDiscipline({ id: 'regen-light', name: 'Light', manaType: 'light', cost: 8 }),
|
||||
createManaConversionDiscipline({ id: 'regen-dark', name: 'Dark', manaType: 'dark', cost: 8 }),
|
||||
createManaConversionDiscipline({ id: 'regen-death', name: 'Death', manaType: 'death', cost: 8 }),
|
||||
// ── Base Elements (distance 1, rate 0.5/hr) ──────────────────────────────
|
||||
createManaConversionDiscipline({ id: 'regen-fire', name: 'Fire', manaType: 'fire', cost: 8 }),
|
||||
createManaConversionDiscipline({ id: 'regen-water', name: 'Water', manaType: 'water', cost: 8 }),
|
||||
createManaConversionDiscipline({ id: 'regen-air', name: 'Air', manaType: 'air', cost: 8 }),
|
||||
createManaConversionDiscipline({ id: 'regen-earth', name: 'Earth', manaType: 'earth', cost: 8 }),
|
||||
createManaConversionDiscipline({ id: 'regen-light', name: 'Light', manaType: 'light', cost: 8 }),
|
||||
createManaConversionDiscipline({ id: 'regen-dark', name: 'Dark', manaType: 'dark', cost: 8 }),
|
||||
createManaConversionDiscipline({ id: 'regen-death', name: 'Death', manaType: 'death', cost: 8 }),
|
||||
|
||||
// ── Utility Element ────────────────────────────────────────────────────────
|
||||
// ── Utility Element (distance 1, rate 0.4/hr) ────────────────────────────
|
||||
createManaConversionDiscipline({
|
||||
id: 'regen-transference',
|
||||
name: 'Transference',
|
||||
@@ -89,6 +87,5 @@ export const elementalRegenDisciplines: DisciplineDefinition[] = [
|
||||
difficultyFactor: 100,
|
||||
scalingFactor: 50,
|
||||
drainBase: 1,
|
||||
sourceManaTypes: ['raw'],
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// ─── Discipline Effects ───────────────────────────────────────────────────────
|
||||
// Computes bonuses from active disciplines and integrates with the unified effect system
|
||||
//
|
||||
// NEW MODEL: Conversion disciplines contribute to conversion_{element} stat bonuses.
|
||||
// The unified conversion-rates.ts calculator handles rate computation and regen deduction.
|
||||
// This file no longer builds a direct conversions map for the tick pipeline.
|
||||
|
||||
import type { DisciplineStoreState } from '../stores/discipline-slice';
|
||||
import type { DisciplineState } from '../types/disciplines';
|
||||
@@ -32,21 +36,40 @@ const KNOWN_BONUS_STATS = new Set([
|
||||
'disciplineXpBonus',
|
||||
'clickManaMultiplier',
|
||||
'studySpeed',
|
||||
// Conversion stat bonuses (one per element)
|
||||
'conversion_fire',
|
||||
'conversion_water',
|
||||
'conversion_air',
|
||||
'conversion_earth',
|
||||
'conversion_light',
|
||||
'conversion_dark',
|
||||
'conversion_death',
|
||||
'conversion_transference',
|
||||
'conversion_metal',
|
||||
'conversion_sand',
|
||||
'conversion_lightning',
|
||||
'conversion_frost',
|
||||
'conversion_blackflame',
|
||||
'conversion_radiantflames',
|
||||
'conversion_miasma',
|
||||
'conversion_shadowglass',
|
||||
'conversion_crystal',
|
||||
'conversion_stellar',
|
||||
'conversion_void',
|
||||
'conversion_soul',
|
||||
'conversion_plasma',
|
||||
'conversion_time',
|
||||
]);
|
||||
|
||||
export interface DisciplineEffectsResult {
|
||||
bonuses: Record<string, number>;
|
||||
multipliers: Record<string, number>;
|
||||
specials: Set<string>;
|
||||
/**
|
||||
* Bonus to the meditation multiplier cap from disciplines.
|
||||
* Each point of meditationCapBonus adds +0.5 to the max meditation multiplier.
|
||||
*/
|
||||
meditationCapBonus: number;
|
||||
/**
|
||||
* Conversion entries: for each active discipline with a conversionRate,
|
||||
* maps target mana type → { rate, sourceManaTypes }.
|
||||
* The tick pipeline drains source mana types and adds to the target.
|
||||
* Used by the unified conversion calculator for rate computation.
|
||||
*/
|
||||
conversions: Record<string, { rate: number; sourceManaTypes: string[] }>;
|
||||
}
|
||||
@@ -77,15 +100,15 @@ export function computeDisciplineEffects(_state?: DisciplineStoreState): Discipl
|
||||
}
|
||||
|
||||
for (const { disc, def } of activeDiscs) {
|
||||
// Continuous stat bonus
|
||||
// Continuous stat bonus (includes conversion_{element} for regen disciplines)
|
||||
const statBonus = calculateStatBonus(def.statBonus.baseValue, disc.xp, def.scalingFactor);
|
||||
if (def.statBonus.stat) {
|
||||
addBonus(def.statBonus.stat, statBonus);
|
||||
}
|
||||
|
||||
// Conversion entry — if this discipline defines conversionRate
|
||||
// This is used by the unified conversion calculator
|
||||
if (def.conversionRate && def.sourceManaTypes && def.sourceManaTypes.length > 0) {
|
||||
// Scale the conversion rate by the stat bonus multiplier
|
||||
const scaledRate = def.conversionRate + statBonus;
|
||||
conversions[def.manaType] = {
|
||||
rate: scaledRate,
|
||||
@@ -102,7 +125,6 @@ export function computeDisciplineEffects(_state?: DisciplineStoreState): Discipl
|
||||
} else if (!perk.unlocksEffects) {
|
||||
specials.add(perk.id);
|
||||
}
|
||||
// Perks with unlocksEffects are handled by discipline-slice.ts processTick()
|
||||
} else if (perk.type === 'infinite') {
|
||||
if (perk.bonus) {
|
||||
const interval = perk.value;
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { ComputedEffects } from '../effects/upgrade-effects.types';
|
||||
|
||||
import { computeDisciplineEffects } from '../effects/discipline-effects';
|
||||
import { computeMaxMana, computeRegen, getMeditationBonus, getIncursionStrength, calcInsight } from '../utils';
|
||||
import { getElementDistance } from '../utils/element-distance';
|
||||
import { computeConversionRates } from '../utils/conversion-rates';
|
||||
import { mergePerElementCapBonuses } from '../utils/element-cap-bonus';
|
||||
import { processPactRitual } from './pipelines/pact-ritual';
|
||||
import { buildCombatCallbacks } from './pipelines/combat-tick';
|
||||
@@ -24,6 +26,7 @@ import { createStartNewLoop } from './gameLoopActions';
|
||||
import { buildTickContext, applyTickWrites } from './tick-pipeline';
|
||||
import { processEnchantingTicks } from './pipelines/enchanting-tick';
|
||||
import { buildGolemCombatPipeline } from './pipelines/golem-combat';
|
||||
import { getGuardianForFloor } from '../data/guardian-encounters';
|
||||
|
||||
import type { TickContext, TickWrites } from './tick-pipeline';
|
||||
import type { GameCoordinatorState } from './gameStore.types';
|
||||
@@ -161,123 +164,56 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
||||
meditateTicks = 0;
|
||||
}
|
||||
|
||||
let totalConversionPerTick = 0;
|
||||
let rawManaDelta = 0;
|
||||
let elements = { ...ctx.mana.elements };
|
||||
Object.entries(ctx.attunement.attunements).forEach(([id, state]) => {
|
||||
if (!state.active) return;
|
||||
const def = ATTUNEMENTS_DEF[id];
|
||||
if (!def || def.conversionRate <= 0 || !def.primaryManaType) return;
|
||||
const scaledRate = getAttunementConversionRate(id, state.level || 1);
|
||||
const conversionThisTick = scaledRate * HOURS_PER_TICK;
|
||||
totalConversionPerTick += conversionThisTick;
|
||||
// Deduct raw mana to pay for the conversion — without this, attunements produce free element mana
|
||||
rawManaDelta -= conversionThisTick;
|
||||
if (elements[def.primaryManaType]) {
|
||||
if (!elements[def.primaryManaType].unlocked) {
|
||||
elements[def.primaryManaType] = { ...elements[def.primaryManaType], unlocked: true };
|
||||
}
|
||||
elements[def.primaryManaType].current = Math.min(
|
||||
elements[def.primaryManaType].max,
|
||||
elements[def.primaryManaType].current + conversionThisTick,
|
||||
);
|
||||
}
|
||||
// ── Unified Conversion System ─────────────────────────────────────
|
||||
const { pactElementMap, grossRegen } = buildConversionParams(ctx.prestige.signedPacts, ctx.attunement.attunements);
|
||||
const invokerLevel = ctx.attunement.attunements.invoker?.active ? (ctx.attunement.attunements.invoker.level || 1) : 0;
|
||||
const conversionResult = computeConversionRates({
|
||||
disciplineEffects, attunements: ctx.attunement.attunements,
|
||||
signedPacts: ctx.prestige.signedPacts, pactElementMap, invokerLevel,
|
||||
meditationMultiplier, grossRegen, rawGrossRegen: baseRegen,
|
||||
});
|
||||
|
||||
const effectiveRegen = Math.max(0, baseRegen * (1 - incursionStrength) * meditationMultiplier - totalConversionPerTick);
|
||||
// Apply conversion results: produce element mana from conversions
|
||||
let rawMana = ctx.mana.rawMana;
|
||||
let elements = { ...ctx.mana.elements };
|
||||
|
||||
const rawAfterConversion = ctx.mana.rawMana + rawManaDelta;
|
||||
const regenFromMeditation = Math.max(0, effectiveRegen * HOURS_PER_TICK);
|
||||
const roomLeft = Math.max(0, maxMana - Math.max(0, rawAfterConversion));
|
||||
// Only count regen that actually fits below the cap (fix #224)
|
||||
const actualRegenAdded = Math.floor(Math.min(regenFromMeditation, roomLeft) * 1000) / 1000;
|
||||
let rawMana = Math.max(0, Math.min(rawAfterConversion + effectiveRegen * HOURS_PER_TICK, maxMana));
|
||||
let totalManaGathered = ctx.mana.totalManaGathered + Math.max(0, actualRegenAdded);
|
||||
|
||||
if (ctx.combat.currentAction === 'convert') {
|
||||
const convertResult = useManaStore.getState().processConvertAction(rawMana);
|
||||
if (convertResult) {
|
||||
rawMana = convertResult.rawMana;
|
||||
elements = convertResult.elements;
|
||||
// Log paused conversions
|
||||
for (const [elem, entry] of Object.entries(conversionResult.rates)) {
|
||||
if (entry.paused && entry.pauseReason) {
|
||||
addLog(`⚠️ PAUSED: ${elem} conversion — ${entry.pauseReason}`);
|
||||
}
|
||||
}
|
||||
|
||||
const pactResult = processPactRitual(
|
||||
ctx.prestige.pactRitualFloor,
|
||||
ctx.prestige.pactRitualProgress,
|
||||
ctx.prestige.signedPacts,
|
||||
ctx.prestige.defeatedGuardians,
|
||||
ctx.prestige.prestigeUpgrades.pactAffinity || 0,
|
||||
disciplineEffects.bonuses.pactAffinityBonus || 0,
|
||||
);
|
||||
if (pactResult.writes) {
|
||||
writes.prestige = { ...(writes.prestige || {}), ...pactResult.writes };
|
||||
// Apply produced element mana (from active conversions)
|
||||
for (const [elem, entry] of Object.entries(conversionResult.rates)) {
|
||||
if (entry.paused || entry.finalRate <= 0 || !elements[elem]) continue;
|
||||
if (!elements[elem].unlocked) elements[elem] = { ...elements[elem], unlocked: true };
|
||||
elements[elem] = { ...elements[elem], current: Math.min(elements[elem].max, elements[elem].current + entry.finalRate * HOURS_PER_TICK) };
|
||||
}
|
||||
// Net raw regen = gross regen - conversion drains - incursion
|
||||
const netRawRegen = Math.max(0, baseRegen * (1 - incursionStrength) * meditationMultiplier - conversionResult.totalRawDrain);
|
||||
const actualRegen = Math.floor(Math.min(netRawRegen * HOURS_PER_TICK, maxMana - rawMana) * 1000) / 1000;
|
||||
rawMana = Math.max(0, Math.min(rawMana + netRawRegen * HOURS_PER_TICK, maxMana));
|
||||
let totalManaGathered = ctx.mana.totalManaGathered + Math.max(0, actualRegen);
|
||||
|
||||
const pactResult = processPactRitual(ctx.prestige.pactRitualFloor, ctx.prestige.pactRitualProgress, ctx.prestige.signedPacts, ctx.prestige.defeatedGuardians, ctx.prestige.prestigeUpgrades.pactAffinity || 0, disciplineEffects.bonuses.pactAffinityBonus || 0);
|
||||
if (pactResult.writes) writes.prestige = { ...(writes.prestige || {}), ...pactResult.writes };
|
||||
pactResult.logs.forEach(l => addLog(l));
|
||||
|
||||
const disciplineResult = useDisciplineStore.getState().processTick({
|
||||
rawMana,
|
||||
elements,
|
||||
});
|
||||
rawMana = disciplineResult.rawMana;
|
||||
elements = disciplineResult.elements;
|
||||
const dr = useDisciplineStore.getState().processTick({ rawMana, elements });
|
||||
rawMana = dr.rawMana; elements = dr.elements;
|
||||
if (dr.autoPausedNames.length > 0) addLog('⏸️ Auto-paused (insufficient mana): ' + dr.autoPausedNames.join(', '));
|
||||
rawMana = Math.min(rawMana, computeMaxMana({ prestigeUpgrades: ctx.prestige.prestigeUpgrades }, undefined, computeDisciplineEffects()));
|
||||
|
||||
// Log auto-paused disciplines for better UX feedback (fix #244)
|
||||
if (disciplineResult.autoPausedNames.length > 0) {
|
||||
const names = disciplineResult.autoPausedNames.join(', ');
|
||||
addLog('⏸️ Auto-paused (insufficient mana): ' + names);
|
||||
}
|
||||
|
||||
// Recompute maxMana after discipline XP gains so clamping uses updated value (fix #246)
|
||||
const updatedDisciplineEffects = computeDisciplineEffects();
|
||||
const updatedMaxMana = computeMaxMana(
|
||||
{ prestigeUpgrades: ctx.prestige.prestigeUpgrades },
|
||||
undefined,
|
||||
updatedDisciplineEffects,
|
||||
);
|
||||
rawMana = Math.min(rawMana, updatedMaxMana);
|
||||
|
||||
for (const [targetElem, conv] of Object.entries(disciplineEffects.conversions)) {
|
||||
const conversionAmount = conv.rate * HOURS_PER_TICK;
|
||||
let canConvert = true;
|
||||
for (const srcType of conv.sourceManaTypes) {
|
||||
if (srcType === 'raw') {
|
||||
if (rawMana < conversionAmount) { canConvert = false; break; }
|
||||
} else if (!elements[srcType] || !elements[srcType].unlocked || elements[srcType].current < conversionAmount) {
|
||||
canConvert = false; break;
|
||||
}
|
||||
}
|
||||
if (!canConvert) continue;
|
||||
// Re-check against actual remaining mana to prevent negative values
|
||||
// when multiple disciplines share the same source
|
||||
for (const srcType of conv.sourceManaTypes) {
|
||||
if (srcType === 'raw' && rawMana < conversionAmount) { canConvert = false; break; }
|
||||
if (srcType !== 'raw' && elements[srcType] && elements[srcType].current < conversionAmount) { canConvert = false; break; }
|
||||
}
|
||||
if (!canConvert) continue;
|
||||
for (const srcType of conv.sourceManaTypes) {
|
||||
if (srcType === 'raw') {
|
||||
rawMana -= conversionAmount;
|
||||
} else if (elements[srcType]) {
|
||||
elements[srcType] = { ...elements[srcType], current: Math.max(0, elements[srcType].current - conversionAmount) };
|
||||
}
|
||||
}
|
||||
if (elements[targetElem]) {
|
||||
elements[targetElem] = {
|
||||
...elements[targetElem],
|
||||
current: Math.min(elements[targetElem].max, elements[targetElem].current + conversionAmount),
|
||||
};
|
||||
}
|
||||
}
|
||||
if (disciplineResult.unlockedEffects.length > 0) {
|
||||
useCraftingStore.getState().unlockEffects(disciplineResult.unlockedEffects);
|
||||
for (const effectId of disciplineResult.unlockedEffects) {
|
||||
if (dr.unlockedEffects.length > 0) {
|
||||
useCraftingStore.getState().unlockEffects(dr.unlockedEffects);
|
||||
for (const effectId of dr.unlockedEffects) {
|
||||
addLog('Discipline insight unlocked: ' + effectId);
|
||||
}
|
||||
}
|
||||
if (disciplineResult.unlockedRecipes.length > 0) {
|
||||
useCraftingStore.getState().unlockRecipes(disciplineResult.unlockedRecipes);
|
||||
for (const recipeId of disciplineResult.unlockedRecipes) {
|
||||
if (dr.unlockedRecipes.length > 0) {
|
||||
useCraftingStore.getState().unlockRecipes(dr.unlockedRecipes);
|
||||
for (const recipeId of dr.unlockedRecipes) {
|
||||
addLog('Fabricator recipe unlocked: ' + recipeId);
|
||||
}
|
||||
}
|
||||
@@ -295,7 +231,7 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
||||
}
|
||||
}
|
||||
|
||||
// Combat — delegate to combatStore
|
||||
// Combat
|
||||
if (ctx.combat.currentAction === 'climb') {
|
||||
const combatCbs = buildCombatCallbacks({ ctx, effects, maxMana, addLog, useCombatStore, usePrestigeStore });
|
||||
const roomEnemies = ctx.combat.currentRoom?.enemies ?? [];
|
||||
@@ -303,36 +239,27 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
||||
const activeEnemy = combatCbs.applyMageBarrierRecharge(primaryEnemy) ?? primaryEnemy;
|
||||
const defCtx = { roomType: ctx.combat.currentRoom?.roomType ?? 'combat', enemy: activeEnemy };
|
||||
const golemPipeline = buildGolemCombatPipeline(addLog);
|
||||
|
||||
// Build equipped swords map for melee auto-attack (spec §3.1)
|
||||
const equippedSwords: Record<string, import('../types').EquipmentInstance> = {};
|
||||
for (const [slot, instanceId] of Object.entries(ctx.crafting.equippedInstances || {})) {
|
||||
if (!instanceId) continue;
|
||||
const inst = ctx.crafting.equipmentInstances?.[instanceId];
|
||||
if (!inst) continue;
|
||||
const eqType = EQUIPMENT_TYPES[inst.typeId];
|
||||
if (eqType?.category === 'sword') {
|
||||
equippedSwords[instanceId] = inst;
|
||||
}
|
||||
for (const [slot, iid] of Object.entries(ctx.crafting.equippedInstances || {})) {
|
||||
if (!iid) continue;
|
||||
const inst = ctx.crafting.equipmentInstances?.[iid];
|
||||
if (inst && EQUIPMENT_TYPES[inst.typeId]?.category === 'sword') equippedSwords[iid] = inst;
|
||||
}
|
||||
|
||||
const combatResult = useCombatStore.getState().processCombatTick(
|
||||
const cr = useCombatStore.getState().processCombatTick(
|
||||
rawMana, elements, maxMana, 1,
|
||||
combatCbs.onFloorCleared,
|
||||
combatCbs.makeOnDamageDealt(() => rawMana, () => elements, defCtx, addLog),
|
||||
ctx.prestige.signedPacts,
|
||||
{ activeGolems: golemPipeline.activeGolems },
|
||||
golemPipeline.golemApplyDamageToRoom,
|
||||
(dmg, enemy, roomType, addLogFn, bypassArmor, bypassBarrier) => applyEnemyDefensesFromPipeline(
|
||||
dmg, enemy, roomType, addLogFn, bypassArmor, bypassBarrier,
|
||||
),
|
||||
(dmg, enemy, roomType, addLogFn, bypassArmor, bypassBarrier) =>
|
||||
applyEnemyDefensesFromPipeline(dmg, enemy, roomType, addLogFn, bypassArmor, bypassBarrier),
|
||||
equippedSwords,
|
||||
);
|
||||
rawMana = combatResult.rawMana;
|
||||
elements = combatResult.elements;
|
||||
totalManaGathered += combatResult.totalManaGathered || 0;
|
||||
if (combatResult.logMessages) combatResult.logMessages.forEach(msg => addLog(msg));
|
||||
writes.combat = { ...(writes.combat || {}), currentFloor: combatResult.currentFloor, floorHP: combatResult.floorHP, floorMaxHP: combatResult.floorMaxHP, maxFloorReached: combatResult.maxFloorReached, castProgress: combatResult.castProgress, equipmentSpellStates: combatResult.equipmentSpellStates, golemancy: { ...(writes.combat?.golemancy || ctx.combat.golemancy), activeGolems: combatResult.activeGolems }, meleeSwordProgress: combatResult.meleeSwordProgress, currentRoom: combatResult.currentRoom };
|
||||
rawMana = cr.rawMana; elements = cr.elements;
|
||||
totalManaGathered += cr.totalManaGathered || 0;
|
||||
if (cr.logMessages) cr.logMessages.forEach(msg => addLog(msg));
|
||||
writes.combat = { ...(writes.combat || {}), currentFloor: cr.currentFloor, floorHP: cr.floorHP, floorMaxHP: cr.floorMaxHP, maxFloorReached: cr.maxFloorReached, castProgress: cr.castProgress, equipmentSpellStates: cr.equipmentSpellStates, golemancy: { ...(writes.combat?.golemancy || ctx.combat.golemancy), activeGolems: cr.activeGolems }, meleeSwordProgress: cr.meleeSwordProgress, currentRoom: cr.currentRoom };
|
||||
}
|
||||
|
||||
if (ctx.combat.currentAction === 'craft') {
|
||||
@@ -358,12 +285,7 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
||||
|
||||
// Phase 3: Write
|
||||
writes.game = { day, hour, incursionStrength };
|
||||
writes.mana = {
|
||||
rawMana,
|
||||
meditateTicks,
|
||||
totalManaGathered,
|
||||
elements,
|
||||
};
|
||||
writes.mana = { rawMana, meditateTicks, totalManaGathered, elements };
|
||||
|
||||
applyTickWrites(writes, storeSetters);
|
||||
} catch (error: unknown) {
|
||||
@@ -396,3 +318,27 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
/** Build pact element map and gross regen for the unified conversion system */
|
||||
function buildConversionParams(
|
||||
signedPacts: number[],
|
||||
attunements: Record<string, { active: boolean; level: number }>,
|
||||
): { pactElementMap: Record<number, string>; grossRegen: Record<string, number> } {
|
||||
const pactElementMap: Record<number, string> = {};
|
||||
for (const floor of signedPacts) {
|
||||
const guardian = getGuardianForFloor(floor);
|
||||
if (guardian?.element?.length) {
|
||||
pactElementMap[floor] = guardian.element[0];
|
||||
}
|
||||
}
|
||||
const grossRegen: Record<string, number> = {};
|
||||
for (const [id, state] of Object.entries(attunements)) {
|
||||
if (!state.active) continue;
|
||||
const def = ATTUNEMENTS_DEF[id];
|
||||
if (def?.primaryManaType) {
|
||||
grossRegen[def.primaryManaType] = (grossRegen[def.primaryManaType] || 0)
|
||||
+ getAttunementConversionRate(id, state.level || 1);
|
||||
}
|
||||
}
|
||||
return { pactElementMap, grossRegen };
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// ─── Mana Store ───────────────────────────────────────────────────────────────
|
||||
// Handles raw mana, elements, meditation, and mana regeneration
|
||||
//
|
||||
// NEW MODEL: All conversion is passive through the unified conversion system.
|
||||
// convertMana, processConvertAction, and craftComposite are removed (no-ops for save compat).
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { ELEMENTS, MANA_PER_ELEMENT, BASE_UNLOCKED_ELEMENTS } from '../constants';
|
||||
import { ELEMENTS, BASE_UNLOCKED_ELEMENTS } from '../constants';
|
||||
import type { ElementState } from '../types';
|
||||
import { ok, okVoid, fail, ErrorCode } from '../utils/result';
|
||||
import { createSafeStorage } from '../utils/safe-persist';
|
||||
@@ -32,27 +35,14 @@ export interface ManaActions {
|
||||
resetMeditateTicks: () => void;
|
||||
|
||||
// Elements
|
||||
convertMana: (element: string, amount: number) => Result<{ converted: number }>;
|
||||
unlockElement: (element: string, cost: number) => Result<void>;
|
||||
addElementMana: (element: string, amount: number, max: number) => void;
|
||||
spendElementMana: (element: string, amount: number) => Result<void>;
|
||||
setElementMax: (max: number) => void;
|
||||
craftComposite: (target: string, recipe: string[]) => Result<void>;
|
||||
|
||||
/**
|
||||
* Compute and apply per-element max from baseMax + bonuses.
|
||||
* Caller provides the bonus map (elementCap_* from disciplines/equipment).
|
||||
* This sets max = baseMax + bonus for each element, preventing double-counting.
|
||||
*/
|
||||
computeElementMaxWithBonuses: (perElementBonuses: Record<string, number>) => void;
|
||||
|
||||
// Helper for gameStore coordination
|
||||
processConvertAction: (rawMana: number) => { rawMana: number; elements: Record<string, ElementState> } | null;
|
||||
|
||||
// Reset
|
||||
resetMana: (
|
||||
prestigeUpgrades: Record<string, number>,
|
||||
) => void;
|
||||
resetMana: (prestigeUpgrades: Record<string, number>) => void;
|
||||
}
|
||||
|
||||
// ─── Combined Mana Store Type ────────────────────────────────────────────────
|
||||
@@ -106,25 +96,6 @@ export const useManaStore = create<ManaStore>()(
|
||||
incrementMeditateTicks: () => set((s) => ({ meditateTicks: s.meditateTicks + 1 })),
|
||||
resetMeditateTicks: () => set({ meditateTicks: 0 }),
|
||||
|
||||
convertMana: (element: string, amount: number) => {
|
||||
const state = get();
|
||||
const elem = state.elements[element];
|
||||
if (!elem?.unlocked) return fail(ErrorCode.ELEMENT_NOT_UNLOCKED, `Element ${element} is not unlocked`);
|
||||
|
||||
const cost = MANA_PER_ELEMENT * amount;
|
||||
if (state.rawMana < cost) return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${cost} raw mana, have ${state.rawMana}`);
|
||||
if (elem.current >= elem.max) return fail(ErrorCode.ELEMENT_MAX_CAPACITY, `Element ${element} is at max capacity`);
|
||||
|
||||
const canConvert = Math.min(amount, Math.floor(state.rawMana / MANA_PER_ELEMENT), elem.max - elem.current);
|
||||
if (canConvert <= 0) return fail(ErrorCode.INVALID_INPUT, 'Cannot convert 0 or negative amount');
|
||||
|
||||
set({
|
||||
rawMana: state.rawMana - canConvert * MANA_PER_ELEMENT,
|
||||
elements: { ...state.elements, [element]: { ...elem, current: elem.current + canConvert } },
|
||||
});
|
||||
return ok({ converted: canConvert });
|
||||
},
|
||||
|
||||
unlockElement: (element: string, cost: number) => {
|
||||
const state = get();
|
||||
if (state.elements[element]?.unlocked) return fail(ErrorCode.INVALID_INPUT, `Element ${element} is already unlocked`);
|
||||
@@ -177,46 +148,7 @@ export const useManaStore = create<ManaStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
craftComposite: (target: string, recipe: string[]) => {
|
||||
const state = get();
|
||||
const costs: Record<string, number> = {};
|
||||
recipe.forEach(r => { costs[r] = (costs[r] || 0) + 1; });
|
||||
|
||||
for (const [r, amt] of Object.entries(costs)) {
|
||||
if ((state.elements[r]?.current || 0) < amt) return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${amt} ${r} mana, have ${state.elements[r]?.current || 0}`);
|
||||
}
|
||||
|
||||
const newElems = { ...state.elements };
|
||||
const baseMax = state.elements[target]?.baseMax ?? 10;
|
||||
for (const [r, amt] of Object.entries(costs)) {
|
||||
newElems[r] = { ...newElems[r], current: newElems[r].current - amt };
|
||||
}
|
||||
|
||||
const targetElem = newElems[target];
|
||||
newElems[target] = { ...(targetElem || { current: 0, max: 10, baseMax: 10, unlocked: false }), current: (targetElem?.current || 0) + 1, unlocked: true, baseMax };
|
||||
set({ elements: newElems });
|
||||
return okVoid();
|
||||
},
|
||||
|
||||
processConvertAction: (rawMana: number) => {
|
||||
const state = get();
|
||||
const elements = { ...state.elements };
|
||||
|
||||
const unlockedElements = Object.entries(elements).filter(([, e]) => e.unlocked && e.current < e.max);
|
||||
if (unlockedElements.length === 0 || rawMana < MANA_PER_ELEMENT) return null;
|
||||
|
||||
unlockedElements.sort((a, b) => (b[1].max - b[1].current) - (a[1].max - a[1].current));
|
||||
const [targetId, targetState] = unlockedElements[0];
|
||||
const canConvert = Math.min(Math.floor(rawMana / MANA_PER_ELEMENT), targetState.max - targetState.current);
|
||||
if (canConvert <= 0) return null;
|
||||
|
||||
rawMana -= canConvert * MANA_PER_ELEMENT;
|
||||
return { rawMana, elements: { ...elements, [targetId]: { ...targetState, current: targetState.current + canConvert } } };
|
||||
},
|
||||
|
||||
resetMana: (
|
||||
prestigeUpgrades: Record<string, number>,
|
||||
) => {
|
||||
resetMana: (prestigeUpgrades: Record<string, number>) => {
|
||||
const elementMax = 10 + (prestigeUpgrades.elementalAttune || 0) * 25;
|
||||
const startingMana = 10 + (prestigeUpgrades.manaStart || 0) * 10;
|
||||
set({ rawMana: startingMana, meditateTicks: 0, totalManaGathered: 0, elements: makeInitialElements(elementMax, prestigeUpgrades) });
|
||||
@@ -228,7 +160,6 @@ export const useManaStore = create<ManaStore>()(
|
||||
version: 2,
|
||||
partialize: (state) => ({ rawMana: state.rawMana, meditateTicks: state.meditateTicks, totalManaGathered: state.totalManaGathered, elements: state.elements }),
|
||||
migrate: (persistedState: any, _version) => {
|
||||
// Migration: add baseMax to elements that don't have it
|
||||
if (persistedState && persistedState.elements) {
|
||||
for (const k of Object.keys(persistedState.elements)) {
|
||||
if (persistedState.elements[k].baseMax === undefined) {
|
||||
|
||||
@@ -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