@reldens/modifiers Architecture
How the @reldens/modifiers package calculates, limits, conditions and reverts value changes on object properties, with integration patterns and common pitfalls.
Overview
@reldens/modifiers applies and reverts value changes on object properties. Reldens uses it for item bonuses (@reldens/items-system), skill effects and level bonuses (@reldens/skills), and character stats, buffs and debuffs. It can also be used outside Reldens.
npm install @reldens/modifiers
Exports: Modifier, Condition, Calculator, PropertyManager and ModifierConst. The only dependency is @reldens/utils (ErrorManager, Logger and Shortcuts).
- Modifier - applies / reverts an operation on a target property, with conditions and limits.
- Condition - comparison that must pass before a modifier is applied.
- Calculator - the math for every operation and its inverse.
- PropertyManager - reads and writes nested properties using / separated paths.
- ModifierConst - operations, comparison operators, data types and state codes.
Modifier Properties
- key (required) - modifier identifier.
- propertyKey (required, property_key is also accepted) - target property path, for example stats/atk.
- operation (required) - one of ModifierConst.OPS; the value is converted with Number().
- value (required) - operation value, parsed according to type.
- type - ModifierConst.TYPES.INT (default) or ModifierConst.TYPES.STRING.
- basePropertyKey - alternative property used for base calculations (defaults to propertyKey).
- minValue, maxValue - fixed numeric limits.
- minProperty, maxProperty - limits read from target properties (for example, stats/maxHp).
- conditions - array of Condition instances.
- conditionsOnRevert - also enforce the conditions on revert (default false).
- target - optional default target object.
Execution Flow
apply(target, useBasePropertyToGetValue, applyOnBaseProperty) and revert(...) both call execute(target, revert, useBasePropertyToGetValue, applyOnBaseProperty), which runs these steps:
- If there is no target (neither the argument nor modifier.target), the state is set to MOD_UNDEFINED_TARGET and false is returned.
- If conditions are defined, all of them are validated against the target. A failure stops the apply. On revert, a failure only stops it when conditionsOnRevert is true.
- The target argument, when given, replaces modifier.target.
- getModifiedValue(revert, useBasePropertyToGetValue) calculates the new value:
- Reads the current value from basePropertyKey or propertyKey through the PropertyManager.
- Runs the Calculator with the operation and value.
- Handles the special operations: SET and SET_N return the value (or false on revert); METHOD calls the custom method.
- Applies the limits with applyModifierLimits().
- The result is written to basePropertyKey when applyOnBaseProperty is true, otherwise to propertyKey.
- The state changes to MOD_APPLIED or MOD_REVERTED and true is returned.
Base Property Operations
Two flags control where the value is read from and where the result is written:
- useBasePropertyToGetValue - true reads from basePropertyKey, false reads from propertyKey.
- applyOnBaseProperty - true writes to basePropertyKey, false writes to propertyKey.
The four combinations:
- false, false (default) - read propertyKey, write propertyKey. Normal modification.
- true, false - read basePropertyKey, write propertyKey. For example, a percentage of the max value applied to the current value.
- false, true - read propertyKey, write basePropertyKey. Current value to base value.
- true, true - read basePropertyKey, write basePropertyKey.
With useBasePropertyToGetValue the calculation starts from the base value, so the written result is "base value plus the operation" and it replaces the current value.
Example: equipment that changes the base stat
let player = {currentStrength: 50, baseStrength: 100};
let strengthRing = new Modifier({
key: 'strength-ring',
propertyKey: 'currentStrength',
basePropertyKey: 'baseStrength',
operation: ModifierConst.OPS.INC,
value: 25
});
// read currentStrength (50), add 25, write the result to baseStrength:
strengthRing.apply(player, false, true);
// player.baseStrength = 75
Calculator Operations
calculateNewValue(originalValue, operation, operationValue, revert) implements every operation and its inverse:
- INC (1) - apply a + b, revert a - b. Example: 100 + 25 = 125, reverted to 100.
- DEC (2) - apply a - b, revert a + b.
- DIV (3) - apply a / b, revert a * b.
- MUL (4) - apply a * b, revert a / b.
- INC_P (5) - apply a + round(a * b / 100), revert round(a / (1 + b / 100)).
- DEC_P (6) - apply a - round(a * b / 100), revert round(a / (1 - b / 100)).
- SET (7) and SET_N (9) - apply sets the value b, revert sets false.
- METHOD (8) - the Calculator returns the current value unchanged and the modifier calls the custom method (see below).
Percentage rounding
Percentage operations round the percentage amount so values stay integers and the revert returns to the original value:
- Apply INC_P 50 on 100: 100 + round(100 * 50 / 100) = 150.
- Revert INC_P 50 on 150: round(150 / 1.5) = 100.
- Apply INC_P 33 on 100: 100 + round(33) = 133; revert: round(133 / 1.33) = 100.
- Apply DEC_P 25 on 100: 100 - round(25) = 75; revert: round(75 / 0.75) = 100.
Modifier Limits
Limits are applied after the calculation and before the value is written to the target. They are checked in this order, and a value can be clamped more than once:
- minValue - only when it is a number.
- maxValue - only when it is a number.
- minProperty - read from the target; only applied when the property value is truthy (a 0 value is ignored).
- maxProperty - read from the target; same rule.
minValue and maxValue are not parsed, so pass numbers (not numeric strings) when building modifiers from configuration data. A limit property that does not exist on the target throws an error, like any missing property read.
let target = {health: 50, maxHealth: 120};
let modifier = new Modifier({
key: 'set-health',
propertyKey: 'health',
operation: ModifierConst.OPS.SET,
value: 200,
minValue: 0,
maxValue: 150,
maxProperty: 'maxHealth'
});
modifier.apply(target);
// 200 -> minValue: 200 -> maxValue: 150 -> maxProperty: 120
// target.health = 120
Conditions
A Condition compares a target property against a value. The constructor requires key, propertyKey, conditional and value, and throws an error when any of them is missing.
- conditional - one of ModifierConst.COMPARE: eq (===), ne (!==), lt (<), gt (>), le (<=), ge (>=).
- type - INT (default, the value is converted with Number()) or STRING (converted with String()).
- isValidOn(targetObject, overrideVal) - reads the target property through the PropertyManager (deep paths supported) and runs the comparison method. overrideVal replaces the condition value for that call.
When a modifier has several conditions, all of them must pass. Each condition must be a Condition instance; otherwise the state becomes MOD_MISSING_CONDITION_INSTANCE. A failed comparison sets MOD_INVALID_CONDITIONS.
let levelCondition = new Condition({
key: 'min-level',
propertyKey: 'stats/combat/level',
conditional: ModifierConst.COMPARE.GE,
value: 10
});
let healthCondition = new Condition({
key: 'min-health',
propertyKey: 'health',
conditional: ModifierConst.COMPARE.GT,
value: 50
});
let powerfulAttack = new Modifier({
key: 'powerful-attack',
propertyKey: 'damage',
operation: ModifierConst.OPS.MUL,
value: 2,
conditions: [levelCondition, healthCondition]
});
PropertyManager
Reads and writes nested properties using paths separated by /, for example stats/combat/attack.
- getPropertyValue(propertyOwner, propertyString) - returns the value; throws an error when the final property does not exist.
- setOwnerProperty(propertyOwner, propertyString, value) - writes the value; the final property is created when missing.
- extractChildPropertyOwner(propertyOwner, propertyPathParts) - returns the parent object that owns the final property, not the value. For ['stats', 'combat', 'attack'] it returns owner.stats.combat, and for a single-level path it returns the owner itself.
Every intermediate path segment must exist (both when reading and writing); a missing segment throws an error. Only the final property is validated on reads and created on writes.
const { PropertyManager } = require('@reldens/modifiers');
let propertyManager = new PropertyManager();
let player = {
stats: {
combat: {
attack: 50
}
}
};
propertyManager.getPropertyValue(player, 'stats/combat/attack'); // 50
propertyManager.setOwnerProperty(player, 'stats/combat/attack', 75); // player.stats.combat.attack = 75
Types and Value Parsing
- ModifierConst.TYPES.INT = 'int' - the default; the value is converted with Number(), so a string such as '25' from a database becomes 25.
- ModifierConst.TYPES.STRING = 'string' - the value is converted with String(); use it with SET to assign text values (for example, a status).
- The raw value is kept in originalValue.
State Management
The constructor sets the initial state, validating in this order:
- Missing key: MOD_MISSING_KEY.
- Missing property key: MOD_MISSING_PROPERTY_KEY.
- Missing operation: MOD_MISSING_OPERATION.
- Missing value: MOD_MISSING_VALUE.
- Otherwise: MOD_READY.
During execution the state can change to MOD_UNDEFINED_TARGET, MOD_INVALID_CONDITIONS, MOD_MISSING_CONDITION_INSTANCE, MOD_MODIFIER_ERROR (METHOD operation without a valid method), MOD_APPLIED or MOD_REVERTED. Compare modifier.state with the ModifierConst constants; the full list with their values is in the Quick Reference.
Custom METHOD Operations
With operation: ModifierConst.OPS.METHOD, the value is the name of a method that receives (modifier, currentValue) and returns the new value (the limits are applied to the result).
The method is looked up as an own property of the modifier instance, so a method declared only in the class body (on the prototype) is not found and the state becomes MOD_MODIFIER_ERROR. Assign it in the constructor:
const { Modifier, ModifierConst } = require('@reldens/modifiers');
class CriticalHitModifier extends Modifier
{
constructor(props)
{
super(props);
this.criticalCalculation = (modifier, currentValue) => {
let critChance = modifier.target.stats.critChance || 0.1;
let critMultiplier = modifier.target.stats.critMultiplier || 2;
if(Math.random() < critChance){
return Math.floor(currentValue * critMultiplier);
}
return currentValue;
};
}
}
let critModifier = new CriticalHitModifier({
key: 'crit-system',
propertyKey: 'calculatedDamage',
operation: ModifierConst.OPS.METHOD,
value: 'criticalCalculation'
});
critModifier.apply(combatContext);
Integration Patterns
With @reldens/items-system
Each item holds an object of modifiers indexed by modifier ID, built from the item modifiers table. When an item instance is created, the owner is set as the modifiers target. Equipping the item calls apply() on every modifier and unequipping calls revert(); usable items apply them when executed.
// equivalent of what an equipment item does on equip / unequip:
for(let modifierId of Object.keys(item.modifiers)){
item.modifiers[modifierId].apply(player);
}
for(let modifierId of Object.keys(item.modifiers)){
item.modifiers[modifierId].revert(player);
}
With @reldens/skills
- Each Level holds modifiers that are applied to the owner on level up and reverted on level down.
- Skills use Condition instances in ownerConditions to validate the owner before execution.
- Effect skills hold modifiers in targetEffects; skills can also apply ownerEffects to the caster. These are applied by the skill with getModifiedValue() and setOwnerProperty(), adding the critical bonus to the result.
Temporary buffs
Since every operation can be reverted, a timed buff is an apply followed by a delayed revert:
let rageAttack = new Modifier({
key: 'rage-attack',
propertyKey: 'stats/atk',
operation: ModifierConst.OPS.INC_P,
value: 50
});
rageAttack.apply(player);
setTimeout(() => rageAttack.revert(player), 10000);
Common Gotchas
1. Using the base property without setting basePropertyKey
basePropertyKey defaults to propertyKey, so apply(target, true, false) without it reads the same property. Set it explicitly:
let healingPotion = new Modifier({
key: 'healing-potion',
propertyKey: 'currentHealth',
basePropertyKey: 'maxHealth',
operation: ModifierConst.OPS.INC_P,
value: 50
});
2. Forgetting to revert
Modifiers change the target values permanently until reverted. Always pair apply() with revert() (for example, on unequip or when a buff ends).
3. Typos in property paths
Writes create a missing final property, so stats/combat/atk instead of stats/combat/attack silently creates a new atk property when the path is written (a read of a missing final property throws instead). Double-check paths and cover them in tests.
4. Condition type mismatches
Comparisons are strict. A condition with type: ModifierConst.TYPES.STRING and value '10' fails against a numeric level: 10, because 10 !== '10'. Use the INT type for numeric properties.
5. Percentages on zero
INC_P on a 0 value stays 0. Use INC for base increases, or set the base value before applying percentages.
6. SET revert
Reverting SET or SET_N writes false, not the previous value. Store the previous value yourself if you need to restore it.
Performance Considerations
- The PropertyManager does not cache lookups; every apply and revert navigates the full path.
- Conditions are validated on every apply, so keep them minimal on high-frequency modifiers.
- Modifier instances are reusable: the target passed to apply() / revert() replaces the stored target, so a single instance can be applied to several targets.
Testing
The package tests use the Node.js built-in test runner and are organized by class: tests/unit/test-calculator.js, test-condition.js, test-modifier.js, test-property-manager.js and test-constants.js, with shared mocks in tests/fixtures/test-helpers.js.
Key scenarios covered: every operation with apply and revert, state changes, simple / nested / deep property paths and invalid paths, all comparison operators and multiple conditions, type handling, limits, base property operations, and edge cases (zero, negative, large and decimal values).
npm test
npm test -- --filter=calculator
npm run test:watch
npm run test:coverage
Related Documentation
- @reldens/modifiers Quick Reference - Fast lookup for operations, constants and common mistakes.
- Level Modifiers Entity - Modifier fields configured per level.
- Item Entity - Item modifiers configuration.
- Stats Entity - The player stats modifiers usually target.
- @reldens/items-system Architecture - How items apply modifiers.
- @reldens/skills Architecture - How levels and skills apply modifiers.
reldens