Astral Realms Documentation Help

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

attack_speed, attack_damage, max_health, luck, health_regeneration, movement_speed, speed_malus_reduction, knockback_resistance, armor, armor_toughness, max_absorption, block_break_speed, block_interaction_range, entity_interaction_range, fall_damage_multiplier, gravity, jump_strength, safe_fall_distance, scale, step_height, burning_time, explosion_knockback_resistance, mining_efficiency, movement_efficiency, oxygen_bonus, sneaking_speed, submerged_mining_speed, sweeping_damage_ratio, water_movement_efficiency

Resources

max_shield

Resource regeneration

shield_regeneration

Critical strikes

critical_chance, critical_damage

Damage mitigation

damage_reduction

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 Attribute

Player base value

armor

ARMOR

0

armor_toughness

ARMOR_TOUGHNESS

0

attack_damage

ATTACK_DAMAGE

1

attack_speed

ATTACK_SPEED

4

knockback_resistance

KNOCKBACK_RESISTANCE

0

luck

LUCK

0

max_health

MAX_HEALTH

20

max_absorption

MAX_ABSORPTION

0

block_break_speed

BLOCK_BREAK_SPEED

1

block_interaction_range

BLOCK_INTERACTION_RANGE

4.5

entity_interaction_range

ENTITY_INTERACTION_RANGE

3

fall_damage_multiplier

FALL_DAMAGE_MULTIPLIER

1

gravity

GRAVITY

0.08

jump_strength

JUMP_STRENGTH

0.42

safe_fall_distance

SAFE_FALL_DISTANCE

3

scale

SCALE

1

step_height

STEP_HEIGHT

0.6

burning_time

BURNING_TIME

1

explosion_knockback_resistance

EXPLOSION_KNOCKBACK_RESISTANCE

0

mining_efficiency

MINING_EFFICIENCY

0

movement_efficiency

MOVEMENT_EFFICIENCY

0

oxygen_bonus

OXYGEN_BONUS

0

sneaking_speed

SNEAKING_SPEED

0.3

submerged_mining_speed

SUBMERGED_MINING_SPEED

0.2

sweeping_damage_ratio

SWEEPING_DAMAGE_RATIO

0

water_movement_efficiency

WATER_MOVEMENT_EFFICIENCY

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:

  1. Fetches the player's AttributeInstance for its attribute, registering the attribute on the player first if it isn't present yet.

  2. Removes any previously applied modifier keyed astralstats:main from that attribute.

  3. If value differs from playerBaseValue by more than 0.0001, adds a single new AttributeModifier keyed astralstats:main with operation ADD_NUMBER and amount value - 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:

display-name: "<blue>Attack Damage" key: "astralstats:attack_damage" base: 50 min: 0 max: 500

Field

Type

Description

display-name

Component (MiniMessage)

Human-readable name for the stat.

key

Key

The StatType-style namespaced id this blueprint activates.

base

double

Starting baseValue for a new StatInstance and the value /stats reset restores.

min/max

double

Clamp bounds for the computed value, applied only when min < max (StatBlueprint.hasBonds()). Set min equal to (or greater than) max to leave the stat unclamped.

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:

double addScalar = 1d; double multiplyScalar = 1d; for (StatModifier modifier : modifiers()) { switch (modifier.type()) { case FLAT -> base += modifier.value(); case RELATIVE -> addScalar += modifier.value() / 100d; case PERCENTAGE -> multiplyScalar *= 1 + (modifier.value() / 100d); } } double processed = base * addScalar * multiplyScalar;

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

FLAT

Adds value straight onto the running base.

RELATIVE

Adds value / 100 onto an additive scalar starting at 1 (i.e. contributes value% of the base, stacking additively with other RELATIVE modifiers).

PERCENTAGE

Multiplies a multiplicative scalar starting at 1 by 1 + value / 100 (stacks multiplicatively — compounds with other PERCENTAGE modifiers rather than adding).

StatModifier shape

Every modifier applied to a stat is a StatModifier (extends InstanceModifierPlayerModifier), carrying:

Field

Type

Description

uniqueId

UUID

Identity used to remove a specific modifier instance; auto-generated if not supplied.

key

Key

The modifier's own id (e.g. an item, effect, or command-created id).

statKey

Key

Which StatType /blueprint key this modifier targets.

source

ModifierSource

Where the modifier originates.

slot

EquipmentSlot

The equipment slot associated with the modifier's source.

value

double

The modifier's magnitude, interpreted per type.

type

ModifierType

FLAT, RELATIVE, or PERCENTAGE — see Modifier computation.

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

statValue(player, statType)

Returns -1.0.

statValue(player, statType, context)

Returns -1.0.

statValue(player, statType, baseValue)

Returns the supplied baseValue unchanged.

statValue(player, statType, baseValue, context)

Returns the supplied baseValue unchanged.

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:

public void update(Player player) { if (this.dirty.getAndSet(false)) this.handler.apply(player, this, compute(baseValue())); }

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):

display-name: "<blue>Custom Stat" key: "astralstats:custom_stat" base: 0 min: 0 max: 100

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.

Last modified: 25 July 2026