fix: Elemental Mana Capacity disciplines now increase element capacity
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m23s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m23s
- Add optional baseMax field to ElementState to track prestige-derived max separately from bonuses - Add computeElementMaxWithBonuses action to manaStore that computes max = baseMax + per-element bonus - Apply per-element cap bonuses from disciplines and equipment in game tick (elementCap_* keys) - Fix resetMana to use correct prestige key (elementalAttune instead of nonexistent elemMax) - Add store migration (v1->v2) to populate baseMax for existing saved games - Extract pact ritual processing to pipelines/pact-ritual.ts - Extract element cap bonus utilities to utils/element-cap-bonus.ts - Fix inline element types in crafting-fabricator.ts - Update test fixtures to include baseMax in element literals Fixes #185
This commit is contained in:
@@ -37,7 +37,7 @@ describe('DisciplineStore — reactivate after deactivate (bug #163)', () => {
|
||||
|
||||
it('should reactivate a discipline with elements after deactivating it', () => {
|
||||
useDisciplineStore.getState().activate('attune-fire', {
|
||||
elements: { fire: { unlocked: true, current: 100, max: 100 } },
|
||||
elements: { fire: { unlocked: true, current: 100, max: 100, baseMax: 100 } },
|
||||
});
|
||||
expect(useDisciplineStore.getState().activeIds).toContain('attune-fire');
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('DisciplineStore — reactivate after deactivate (bug #163)', () => {
|
||||
expect(useDisciplineStore.getState().activeIds).not.toContain('attune-fire');
|
||||
|
||||
useDisciplineStore.getState().activate('attune-fire', {
|
||||
elements: { fire: { unlocked: true, current: 100, max: 100 } },
|
||||
elements: { fire: { unlocked: true, current: 100, max: 100, baseMax: 100 } },
|
||||
});
|
||||
expect(useDisciplineStore.getState().activeIds).toContain('attune-fire');
|
||||
expect(useDisciplineStore.getState().disciplines['attune-fire'].paused).toBe(false);
|
||||
|
||||
@@ -35,14 +35,14 @@ describe('DisciplineStore', () => {
|
||||
it('should not activate capacity discipline when mana type is locked', () => {
|
||||
// capacity disciplines now require the mana type to be unlocked
|
||||
useDisciplineStore.getState().activate('attune-fire', {
|
||||
elements: { fire: { unlocked: false, current: 100, max: 100 } },
|
||||
elements: { fire: { unlocked: false, current: 100, max: 100, baseMax: 100 } },
|
||||
});
|
||||
expect(useDisciplineStore.getState().activeIds).not.toContain('attune-fire');
|
||||
});
|
||||
|
||||
it('should activate capacity discipline when mana type element is unlocked', () => {
|
||||
useDisciplineStore.getState().activate('attune-fire', {
|
||||
elements: { fire: { unlocked: true, current: 100, max: 100 } },
|
||||
elements: { fire: { unlocked: true, current: 100, max: 100, baseMax: 100 } },
|
||||
});
|
||||
expect(useDisciplineStore.getState().activeIds).toContain('attune-fire');
|
||||
});
|
||||
@@ -57,7 +57,7 @@ describe('DisciplineStore', () => {
|
||||
|
||||
it('should activate when required element is unlocked', () => {
|
||||
useDisciplineStore.getState().activate('attune-fire', {
|
||||
elements: { fire: { unlocked: true, current: 100, max: 100 } },
|
||||
elements: { fire: { unlocked: true, current: 100, max: 100, baseMax: 100 } },
|
||||
});
|
||||
expect(useDisciplineStore.getState().activeIds).toContain('attune-fire');
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Separate file to avoid exceeding the 400-line limit in craftingStore.ts.
|
||||
|
||||
import type { EquipmentCraftingProgress } from './types';
|
||||
import type { ElementState } from './types';
|
||||
import type { FabricatorRecipe } from './data/fabricator-recipes';
|
||||
import { FABRICATOR_RECIPES } from './data/fabricator-recipes';
|
||||
import { useManaStore } from './stores/manaStore';
|
||||
@@ -26,7 +27,7 @@ export function checkFabricatorCosts(
|
||||
recipe: FabricatorRecipe,
|
||||
materials: Record<string, number>,
|
||||
rawMana: number,
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
||||
elements: Record<string, ElementState>,
|
||||
): FabricatorCostCheck {
|
||||
const missingMaterials: Record<string, number> = {};
|
||||
let canCraft = true;
|
||||
@@ -52,13 +53,13 @@ export function checkFabricatorCosts(
|
||||
|
||||
export interface ManaDeduction {
|
||||
rawMana: number;
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||
elements: Record<string, ElementState>;
|
||||
}
|
||||
|
||||
export function deductFabricatorMana(
|
||||
recipe: FabricatorRecipe,
|
||||
rawMana: number,
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
||||
elements: Record<string, ElementState>,
|
||||
): ManaDeduction | null {
|
||||
if (recipe.manaType === 'raw') {
|
||||
if (rawMana < recipe.manaCost) return null;
|
||||
@@ -84,14 +85,14 @@ export function deductFabricatorMana(
|
||||
|
||||
export interface ManaRefund {
|
||||
rawMana: number;
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||
elements: Record<string, ElementState>;
|
||||
}
|
||||
|
||||
export function refundFabricatorMana(
|
||||
recipe: FabricatorRecipe,
|
||||
refundAmount: number,
|
||||
rawMana: number,
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
||||
elements: Record<string, ElementState>,
|
||||
): ManaRefund {
|
||||
if (recipe.manaType === 'raw') {
|
||||
return { rawMana: rawMana + refundAmount, elements };
|
||||
@@ -149,7 +150,7 @@ export interface CraftMaterialResult {
|
||||
success: boolean;
|
||||
newMaterials: Record<string, number>;
|
||||
newRawMana: number;
|
||||
newElements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||
newElements: Record<string, ElementState>;
|
||||
logMessage: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import type { ComputedEffects } from '../effects/upgrade-effects.types';
|
||||
|
||||
import { computeDisciplineEffects } from '../effects/discipline-effects';
|
||||
import { computeMaxMana, computeRegen, getMeditationBonus, getIncursionStrength, calcInsight } from '../utils';
|
||||
import { mergePerElementCapBonuses } from '../utils/element-cap-bonus';
|
||||
import { processPactRitual } from './pipelines/pact-ritual';
|
||||
import { useUIStore } from './uiStore';
|
||||
import { usePrestigeStore } from './prestigeStore';
|
||||
import { useManaStore } from './manaStore';
|
||||
@@ -204,40 +206,18 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
||||
}
|
||||
|
||||
// Pact ritual
|
||||
if (ctx.prestige.pactRitualFloor !== null) {
|
||||
const guardian = getGuardianForFloor(ctx.prestige.pactRitualFloor);
|
||||
if (guardian) {
|
||||
const pactAffinity = Math.min(0.9,
|
||||
(ctx.prestige.prestigeUpgrades.pactAffinity || 0) * 0.1 + (disciplineEffects.bonuses.pactAffinityBonus || 0));
|
||||
const requiredTime = guardian.pactTime * (1 - pactAffinity);
|
||||
|
||||
if (ctx.prestige.pactRitualProgress + HOURS_PER_TICK >= requiredTime) {
|
||||
addLog(`📜 Pact signed with ${guardian.name}! You have gained their boons.`);
|
||||
|
||||
// Unlock mana types granted by this guardian
|
||||
const manaStore = useManaStore.getState();
|
||||
for (const manaType of guardian.unlocksMana || []) {
|
||||
const result = manaStore.unlockElement(manaType, 0);
|
||||
if (result.success) {
|
||||
addLog(`✨ ${manaType.charAt(0).toUpperCase() + manaType.slice(1)} mana unlocked!`);
|
||||
}
|
||||
}
|
||||
|
||||
writes.prestige = {
|
||||
...(writes.prestige || {}),
|
||||
signedPacts: [...ctx.prestige.signedPacts, ctx.prestige.pactRitualFloor],
|
||||
defeatedGuardians: ctx.prestige.defeatedGuardians.filter(f => f !== ctx.prestige.pactRitualFloor),
|
||||
pactRitualFloor: null,
|
||||
pactRitualProgress: 0,
|
||||
};
|
||||
} else {
|
||||
writes.prestige = {
|
||||
...(writes.prestige || {}),
|
||||
pactRitualProgress: ctx.prestige.pactRitualProgress + HOURS_PER_TICK,
|
||||
};
|
||||
}
|
||||
}
|
||||
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));
|
||||
|
||||
// Discipline tick
|
||||
const disciplineResult = useDisciplineStore.getState().processTick({
|
||||
@@ -294,6 +274,21 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
||||
}
|
||||
}
|
||||
|
||||
// Apply per-element capacity bonuses from disciplines and equipment
|
||||
const perElementCapBonuses = mergePerElementCapBonuses(
|
||||
disciplineEffects.bonuses,
|
||||
equipmentEffects.bonuses,
|
||||
);
|
||||
useManaStore.getState().computeElementMaxWithBonuses(perElementCapBonuses);
|
||||
|
||||
// Sync updated max/baseMax from mana store into tick elements snapshot
|
||||
const manaStateAfter = useManaStore.getState();
|
||||
for (const [ek, es] of Object.entries(manaStateAfter.elements)) {
|
||||
if (elements[ek]) {
|
||||
elements[ek] = { ...elements[ek], max: es.max, baseMax: es.baseMax };
|
||||
}
|
||||
}
|
||||
|
||||
// Combat — delegate to combatStore
|
||||
if (ctx.combat.currentAction === 'climb') {
|
||||
const combatResult = useCombatStore.getState().processCombatTick(
|
||||
|
||||
@@ -39,6 +39,13 @@ export interface ManaActions {
|
||||
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;
|
||||
|
||||
@@ -64,6 +71,7 @@ export const useManaStore = create<ManaStore>()(
|
||||
{
|
||||
current: 0,
|
||||
max: 10,
|
||||
baseMax: 10,
|
||||
unlocked: BASE_UNLOCKED_ELEMENTS.includes(k),
|
||||
}
|
||||
])
|
||||
@@ -148,10 +156,27 @@ export const useManaStore = create<ManaStore>()(
|
||||
|
||||
setElementMax: (max: number) => {
|
||||
set((state) => ({
|
||||
elements: Object.fromEntries(Object.entries(state.elements).map(([k, v]) => [k, { ...v, max }])) as Record<string, ElementState>,
|
||||
elements: Object.fromEntries(Object.entries(state.elements).map(([k, v]) => [k, { ...v, max, baseMax: v.baseMax ?? max }])) as Record<string, ElementState>,
|
||||
}));
|
||||
},
|
||||
|
||||
computeElementMaxWithBonuses: (perElementBonuses: Record<string, number>) => {
|
||||
set((state) => {
|
||||
const newElements = { ...state.elements };
|
||||
let changed = false;
|
||||
for (const [element, bonus] of Object.entries(perElementBonuses)) {
|
||||
if (newElements[element] && bonus > 0) {
|
||||
const newMax = (newElements[element].baseMax ?? newElements[element].max) + bonus;
|
||||
if (newElements[element].max !== newMax) {
|
||||
newElements[element] = { ...newElements[element], max: newMax };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed ? { elements: newElements } : state;
|
||||
});
|
||||
},
|
||||
|
||||
craftComposite: (target: string, recipe: string[]) => {
|
||||
const state = get();
|
||||
const costs: Record<string, number> = {};
|
||||
@@ -162,12 +187,13 @@ export const useManaStore = create<ManaStore>()(
|
||||
}
|
||||
|
||||
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, unlocked: false }), current: (targetElem?.current || 0) + 1, unlocked: true };
|
||||
newElems[target] = { ...(targetElem || { current: 0, max: 10, baseMax: 10, unlocked: false }), current: (targetElem?.current || 0) + 1, unlocked: true, baseMax };
|
||||
set({ elements: newElems });
|
||||
return okVoid();
|
||||
},
|
||||
@@ -191,7 +217,7 @@ export const useManaStore = create<ManaStore>()(
|
||||
resetMana: (
|
||||
prestigeUpgrades: Record<string, number>,
|
||||
) => {
|
||||
const elementMax = 10 + (prestigeUpgrades.elemMax || 0) * 5;
|
||||
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) });
|
||||
},
|
||||
@@ -199,8 +225,19 @@ export const useManaStore = create<ManaStore>()(
|
||||
{
|
||||
storage: createSafeStorage(),
|
||||
name: 'mana-loop-mana',
|
||||
version: 1,
|
||||
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) {
|
||||
persistedState.elements[k].baseMax = persistedState.elements[k].max ?? 10;
|
||||
}
|
||||
}
|
||||
}
|
||||
return persistedState;
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -214,7 +251,7 @@ export function makeInitialElements(
|
||||
const elements: Record<string, ElementState> = {};
|
||||
for (const k of Object.keys(ELEMENTS)) {
|
||||
const isUnlocked = BASE_UNLOCKED_ELEMENTS.includes(k);
|
||||
elements[k] = { current: isUnlocked ? elemStart : 0, max: elementMax, unlocked: isUnlocked };
|
||||
elements[k] = { current: isUnlocked ? elemStart : 0, max: elementMax, baseMax: elementMax, unlocked: isUnlocked };
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// ─── Pact Ritual Pipeline Phase ───────────────────────────────────────────────
|
||||
// Processes pact ritual signing during the game tick.
|
||||
|
||||
import { useManaStore } from '../manaStore';
|
||||
import { getGuardianForFloor } from '../../data/guardian-encounters';
|
||||
import { HOURS_PER_TICK } from '../../constants';
|
||||
|
||||
export interface PactRitualResult {
|
||||
writes: {
|
||||
signedPacts?: number[];
|
||||
deficientGuardians?: number[];
|
||||
pactRitualFloor: number | null;
|
||||
pactRitualProgress: number;
|
||||
} | null;
|
||||
logs: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Process pact ritual progression. Advances progress and completes signing
|
||||
* when enough enough hours have accumulated.
|
||||
*/
|
||||
export function processPactRitual(pactRitualFloor: number | null, pactRitualProgress: number, signedPacts: number[], defeatedGuardians: number[], pactAffinityUpgrade: number, pactAffinityBonus: number): PactRitualResult {
|
||||
if (pactRitualFloor === null) return { writes: null, logs: [] };
|
||||
const logs: string[] = [];
|
||||
const guardian = getGuardianForFloor(pactRitualFloor);
|
||||
if (!guardian) return { writes: null, logs: [] };
|
||||
|
||||
const pactAffinity = Math.min(0.9, pactAffinityUpgrade * 0.1 + pactAffinityBonus);
|
||||
const requiredTime = guardian.pactTime * (1 - pactAffinity);
|
||||
|
||||
if (pactRitualProgress + HOURS_PER_TICK >= requiredTime) {
|
||||
logs.push(`📜 Pact signed with ${guardian.name}! You have gained their boons.`);
|
||||
const manaStore = useManaStore.getState();
|
||||
for (const manaType of guardian.unlocksMana || []) {
|
||||
const result = manaStore.unlockElement(manaType, 0);
|
||||
if (result.success) {
|
||||
logs.push(`✨ ${manaType.charAt(0).toUpperCase() + manaType.slice(1)} mana unlocked!`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
writes: {
|
||||
signedPacts: [...signedPacts, pactRitualFloor],
|
||||
defeatedGuardians: defeatedGuardians.filter(f => f !== pactRitualFloor),
|
||||
pactRitualFloor: null,
|
||||
pactRitualProgress: 0,
|
||||
},
|
||||
logs,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
writes: { pactRitualFloor, pactRitualProgress: pactRitualProgress + HOURS_PER_TICK, signedPacts, defeatedGuardians },
|
||||
logs,
|
||||
};
|
||||
}
|
||||
@@ -16,5 +16,8 @@ export interface ElementDef {
|
||||
export interface ElementState {
|
||||
current: number;
|
||||
max: number;
|
||||
/** Base max from prestige upgrades (elementalAttune), without discipline/equipment bonuses.
|
||||
* If not set, falls back to max (legacy behavior for backwards compat). */
|
||||
baseMax?: number;
|
||||
unlocked: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// ─── Element Capacity Bonus Utilities ─────────────────────────────────────────
|
||||
// Extracts and merges per-element capacity bonuses from discipline and equipment
|
||||
// effects for application during the game tick.
|
||||
|
||||
/**
|
||||
* Extract elementCap_* bonuses from a bonus record.
|
||||
* Returns a map of element name → bonus amount.
|
||||
*/
|
||||
export function extractElementCapBonuses(
|
||||
bonuses: Record<string, number>
|
||||
): Record<string, number> {
|
||||
const result: Record<string, number> = {};
|
||||
for (const [key, value] of Object.entries(bonuses)) {
|
||||
if (key.startsWith('elementCap_') && value > 0) {
|
||||
const element = key.replace('elementCap_', '');
|
||||
result[element] = (result[element] || 0) + value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge elementCap_* bonuses from discipline and equipment effects
|
||||
* into a single per-element bonus map.
|
||||
*/
|
||||
export function mergePerElementCapBonuses(
|
||||
disciplineBonuses: Record<string, number>,
|
||||
equipmentBonuses: Record<string, number>,
|
||||
): Record<string, number> {
|
||||
const merged: Record<string, number> = {};
|
||||
for (const [element, value] of Object.entries(extractElementCapBonuses(disciplineBonuses))) {
|
||||
merged[element] = (merged[element] || 0) + value;
|
||||
}
|
||||
for (const [element, value] of Object.entries(extractElementCapBonuses(equipmentBonuses))) {
|
||||
merged[element] = (merged[element] || 0) + value;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
Reference in New Issue
Block a user