Registering power types
A power type is a Java class that describes one kind of power: how its JSON is parsed (its codec), and what it does when it is added, removed or ticked. Registering it makes "type": "yourmod:whatever" valid in any data pack.
The shape of a power type
Extend PowerType<C>, where C is a config record holding the parsed fields. The one method you must implement is configCodec():
public final class GravityPower extends PowerType<GravityPower.Cfg> {
public record Cfg(double multiplier) {}
private static final MapCodec<Cfg> CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
Codec.DOUBLE.optionalFieldOf("multiplier", 1.0).forGetter(Cfg::multiplier)
).apply(i, Cfg::new));
@Override
public MapCodec<Cfg> configCodec() {
return CODEC;
}
}optionalFieldOf("multiplier", 1.0) is exactly the multiplier field a data pack author writes, with its default. The codec is the JSON schema, and there is no separate declaration.
Lifecycle hooks
Override the hooks you need. They all receive the power ID, the parsed config and the power container the power is attached to, and the add and remove hooks also receive the source that granted it:
@Override
public void onAdded(ResourceLocation id, Cfg cfg, PowerContainer holder, ResourceLocation source) {
}
@Override
public void onRemoved(ResourceLocation id, Cfg cfg, PowerContainer holder, ResourceLocation source) {
if (holder.hasPower(id)) return;
}
@Override
public void onSuppressed(ResourceLocation id, Cfg cfg, PowerContainer holder) { }
@Override
public void onUnsuppressed(ResourceLocation id, Cfg cfg, PowerContainer holder) { }
@Override
public void tick(ResourceLocation id, Cfg cfg, PowerContainer holder) {
}
@Override
public boolean ticksNonLivingEntities() {
return true;
}onAddedis where the effect is applied.onRemovedruns whenever a source is removed. Whileholder.hasPower(id)is still true, another source holds the power and the effect has to stay, so only undo it once that check fails.tickruns every tick while the power is active.ticksNonLivingEntities()returnsfalseby default. Returntrueto opt in to ticking on projectiles, items and armour stands.
The full set is onAdded, onRemoved, onSuppressed, onUnsuppressed, tick and tickStored, plus the readResource and writeResource family for types that behave like a resource. Override only what you need, since every one has a default that does nothing.
tick is a hot path. It runs every tick for every holder of the power. Don't allocate, don't do registry lookups, don't use streams and don't build a ResourceLocation. A careless tick turns straight into server lag.
onAdded doesn't run on login. It only runs from addPower, when the first source is added, and a container loaded from NBT fills itself through its codec without passing through it. Any static set or map you fill in onAdded is therefore empty for every player after a restart: it works in the session the power was granted in and never again. Keep per-entity state on the container, and get "everyone who has this power" from PoweredEntities.forEach(...), which is kept up to date on load as well.
Suppression doesn't undo onAdded. A suppressed power keeps whatever onAdded applied unless you implement onSuppressed to take it back. If your type adds an attribute modifier or registers a render flag, it needs both hooks.
Cooldowns and HUD bars
A type with a cooldown or a value of its own exposes it through the resource hooks, and gets a HUD bar by giving its config a HudRender. Nothing else has to know about the type: the HUD, the Resource condition, apoli:modify_resource, apoli:trigger_cooldown, expressions and /apoli:resource all go through the same hooks.
public final class BlinkPower extends PowerType<BlinkPower.Cfg> {
public record Cfg(Expression cooldown, HudRender hudRender) implements HudRendered {}
@Override
public boolean isCooldown() {
return true;
}
@Override
public OptionalInt readResource(ResourceLocation id, Cfg cfg, PowerContainer holder) {
return PowerResources.readDeadline(holder, id);
}
@Override
public OptionalInt writeResource(ResourceLocation id, Cfg cfg, PowerContainer holder, int value) {
return PowerResources.writeDeadline(holder, id, value, PowerResources.cooldownTicks(cfg.cooldown(), holder));
}
@Override
public OptionalInt resourceBound(ResourceLocation id, Cfg cfg, PowerContainer holder, boolean max) {
return OptionalInt.of(max ? PowerResources.cooldownTicks(cfg.cooldown(), holder) : 0);
}
}HudRenderedis a one-method interface,HudRender hudRender(). A config record with ahudRendercomponent already has that method, soimplements HudRenderedis all it takes. A type whoseHudRenderlives somewhere else overridesPowerType#hudRender(cfg)instead.isCooldown()picks the bar style. Withtrue, the bar draws1 - value / max, filling as the power recovers, and hides at0, when it is ready. Withfalse, it always draws the value between theminandmaxfromresourceBound.readResourcehere returns the ticks left.resourceBound(…, holder, …)can be called with anullholder, for command tab completion. Return a constant when you can.readDeadlineandwriteDeadlinestore one absolute game time in the power's aux int. The value counts down by itself, so the container is written, and synced, once per trigger instead of once per tick.
Powers applied on the client
A power that changes what one player sees or hears, such as the camera, the field of view or how far sounds carry, is applied on that player's client, but its condition may need state that only the server has. Return true from resolvesForClient() and Apoli evaluates the condition on the server every tick, for every player holding the type, and sends the player the set of their powers that are active whenever it changes.
@Override
public boolean resolvesForClient() {
return true;
}On the client, read the result instead of evaluating the condition again, and apply each power that passes:
for (ResourceLocation id : container.powersOfType(MY_TYPE)) {
if (!ClientResolvedPowers.isActive(id)) continue;
apply(id);
}ClientResolvedPowers.age(id, partialTick) is the time since that power last turned on, for animations that should start with it. A type that needs the server to pick an entity for the client, like the selector of apoli:modify_camera, overrides clientTarget(id, cfg, player, current) to return that entity's network ID, and the client reads it with ClientResolvedPowers.target(id). It runs every tick, so only resolve again when current is no longer valid, or on an interval.
Registering it
During mod initialisation, hand the type to PowerTypeRegistry under your own ID:
PowerTypeRegistry.register(MyMod.id("gravity"), new GravityPower());Now this loads:
{
"type":"mymod:gravity",
"multiplier":0.4
}Aliases and legacy fields
If you are porting powers from another mod, you can accept old type IDs and old field names without changing your codec:
PowerTypeRegistry.register(
MyMod.id("gravity"),
new GravityPower(),
AliasingOptions.builder()
.addTypeAlias(MyMod.id("low_gravity"))
.build()
);Apoli uses the same mechanism to keep apoli:conditioned_attribute working as an alias of apoli:attribute, and to rename legacy fields to their current names before parsing. See Aliasing.