Stats & Modifiers
AstralStats models every player stat as a StatInstance keyed by a namespaced astralstats:<key> id. A stat's value is computed each update from a base value plus any active modifiers, and — for stats backed by a vanilla Attribute — the computed value is mirrored onto that attribute so it actually affects gameplay. Stats without an attribute mapping are tracked but never applied on their own; something else (a listener, a placeholder, another plugin) has to read them explicitly. See Resources for the shield resource and Combat Mechanics for how critical strikes and damage reduction consume their stats.
Stat catalog
StatType (an enum implementing Keyed) lists every stat AstralStats ships with, all namespaced under astralstats:. A StatType existing here only means the key is known to the plugin — the stat is only active for a player once a matching blueprint is loaded, and it only affects gameplay if it also has a handler (see Attribute-backed stats).
Group | Keys |
|---|---|
Attributes |
|
Resources |
|
Resource regeneration |
|
Critical strikes |
|
Damage mitigation |
|
That's 34 keys in total. Three of the "Attributes" group — health_regeneration, movement_speed, and speed_malus_reduction — have no registered handler anywhere in AstralStats; like max_shield, shield_regeneration, critical_chance, critical_damage, and damage_reduction, they fall back to DummyStatHandler (see below) unless a handler is bound for them via StatRegistry.
Attribute-backed stats
StatRegistry is a static registry (Map<Key, StatHandler>) that wires a StatType key to a StatHandler. At class-init it registers 26 of the 34 keys to an AttributeStatHandler(Attribute attribute, double playerBaseValue) — a record pairing a vanilla Bukkit Attribute with the value that attribute has for a fresh player. These are the shipped defaults:
Stat key | Vanilla | Player base value |
|---|---|---|
|
| 0 |
|
| 0 |
|
| 1 |
|
| 4 |
|
| 0 |
|
| 0 |
|
| 20 |
|
| 0 |
|
| 1 |
|
| 4.5 |
|
| 3 |
|
| 1 |
|
| 0.08 |
|
| 0.42 |
|
| 3 |
|
| 1 |
|
| 0.6 |
|
| 1 |
|
| 0 |
|
| 0 |
|
| 0 |
|
| 0 |
|
| 0.3 |
|
| 0.2 |
|
| 0 |
|
| 0 |
StatRegistry.register throws IllegalArgumentException if a handler is already registered for a key — each key can only be bound once per server run.
AttributeStatHandler behavior
On apply(player, instance, value), the handler:
Fetches the player's
AttributeInstancefor itsattribute, registering the attribute on the player first if it isn't present yet.Removes any previously applied modifier keyed
astralstats:mainfrom that attribute.If
valuediffers fromplayerBaseValueby more than0.0001, adds a single newAttributeModifierkeyedastralstats:mainwith operationADD_NUMBERand amountvalue - playerBaseValue.
In other words, AstralStats never touches the vanilla base value directly — it always applies exactly one additive modifier that makes the attribute read as the freshly computed stat value, and re-creates that modifier (removing the old one first) every time the stat is dirtied and re-applied.
Unregistered stats
A stat key with no entry in StatRegistry is registered with DummyStatHandler instead (see StatContainer.register), whose apply(...) is a no-op. The stat's value is still computed and readable through StatService, but nothing pushes it onto the player automatically — consumers must read it explicitly, as DamageReductionListener and CriticalDamageListener do for damage_reduction, critical_chance, and critical_damage (see Combat Mechanics).
Blueprints and StatContainer
A StatType key only becomes an active stat for players once a blueprint for it is loaded. StatContainer.register(StatBlueprint blueprint) builds a StatInstance for the blueprint's key, resolving its handler via StatRegistry.findHandlerByKey(blueprint.key()) and falling back to new DummyStatHandler() when nothing is registered. Only blueprinted keys end up in a player's StatContainer — defining a StatType in code is not enough on its own.
StatService.load(player) builds a fresh StatContainer for the player and registers one StatInstance per blueprint loaded by BlueprintService, then performs an initial update(player).
Blueprints are YAML files under plugins/AstralStats/blueprints/ (sub-folders are scanned recursively). Each file deserializes to a StatBlueprint record:
Field | Type | Description |
|---|---|---|
| Component (MiniMessage) | Human-readable name for the stat. |
|
| The |
| double | Starting |
| double | Clamp bounds for the computed value, applied only when |
AstralStats ships four blueprints out of the box: attack-damage.yml and attack-speed.yml at the root of blueprints/, plus max-shield.yml and shield-regeneration.yml under blueprints/shield/. Every other StatType key needs a blueprint added before it does anything for players.
Modifier computation
StatInstance.compute(base, context) folds every active StatModifier on the instance into the base value:
ConditionalStatModifiers whose test(context) returns false are skipped entirely before their type is even inspected. Once every modifier has been folded in, if the instance's blueprint hasBonds() the result is clamped to [blueprint.min(), blueprint.max()].
Because FLAT modifiers act on base directly while RELATIVE and PERCENTAGE modifiers accumulate into two separate scalars applied at the end, all flat modifiers effectively apply before all relative/percentage modifiers, regardless of add order — the two families don't interleave.
Type | Effect |
|---|---|
| Adds |
| Adds |
| Multiplies a multiplicative scalar starting at |
StatModifier shape
Every modifier applied to a stat is a StatModifier (extends InstanceModifier → PlayerModifier), carrying:
Field | Type | Description |
|---|---|---|
|
| Identity used to remove a specific modifier instance; auto-generated if not supplied. |
|
| The modifier's own id (e.g. an item, effect, or command-created id). |
|
| Which |
|
| Where the modifier originates. |
|
| The equipment slot associated with the modifier's source. |
| double | The modifier's magnitude, interpreted per |
|
|
|
ModifierSource values: MAIN_HAND, OFF_HAND, ARMOR, OTHER, VOID.
Modifiers live on a StatInstance (which extends SimpleModifierHolder<StatModifier>), keyed by their uniqueId in a ConcurrentHashMap. Adding or removing a modifier immediately marks the instance dirty (see Update loop). removeModifier(Key key) removes every modifier whose own key matches, which is how a source (e.g. an item being unequipped) clears all the modifiers it previously granted.
Conditional modifiers
ConditionalStatModifier<T> is an abstract StatModifier subclass that only applies when an arbitrary context object matches: its test(Object context) checks the context is non-null and an instance of contextType(), then delegates to the abstract applies(T context). Modifiers that don't match are skipped in StatInstance.compute before their ModifierType is even read.
EnumBasedStatModifier<T extends Enum<?>> is the concrete implementation shipped — it applies only when the supplied context equals a fixed enum constant chosen at construction (context == value), and derives its contextType() from that constant's class.
The context is supplied by the caller of StatService.statValue(...)/StatInstance.compute(base, context) — it isn't tied to any single concept, so any enum (equipment type, biome category, damage cause, etc.) can gate a modifier as long as the modifier's T matches what's passed in.
Value lookup
StatService exposes several statValue overloads, all resolving through the player's StatContainer:
Method | Behavior when the stat has no blueprint |
|---|---|
| Returns |
| Returns |
| Returns the supplied |
| Returns the supplied |
When the stat is registered, the base-value overloads compute from the value you pass in rather than the stat's own stored baseValue() — useful for one-off computations (e.g. rolling an item's own numeric stat through the player's modifiers) without touching the player's persistent stat state.
Update loop
StatService schedules a StatsUpdateTask with Bukkit.getScheduler().runTaskTimer(plugin, task, 0L, 20L) — it runs once per second (every 20 ticks) and calls StatContainer.update(player) for every online player with a loaded container, which in turn calls StatInstance.update(player) on each of their stats.
Each StatInstance tracks its own dirty flag (AtomicBoolean, initially true). update(player) only re-runs the handler when the flag is set:
The flag is set whenever a modifier is added/removed or the base value changes, so a stat with no changes between ticks skips its handler call entirely. /stats set calls StatService.setStatBaseValue, which sets a new baseValue (dirtying the instance) and immediately calls update(player) rather than waiting for the next tick; /stats reset (resetStatBaseValue) does the same but restores blueprint().base(). See Commands for the full /stats command reference.
Adding a custom stat
To make a new stat exist for players, drop a blueprint YAML file under plugins/AstralStats/blueprints/ (any filename, any sub-folder):
If key matches an existing StatType that has an AttributeStatHandler registered in StatRegistry (see Attribute-backed stats), the new blueprint activates that handler and the stat drives the corresponding vanilla attribute immediately. Any other key — including a brand-new one not in StatType at all — is registered with DummyStatHandler: the value is tracked, computed, and readable via StatService.statValue(...), but nothing applies it to the player unless you bind a handler through StatRegistry.register(...) yourself. See Developer API for registering a custom StatHandler.