Skip to main content

Actions & conditions

Actions and conditions are smaller than power types and follow the same recipe: a class with a codec and one method, registered under an ID. Once registered, they are available in every power's JSON, yours and everyone else's.

The two interfaces

public interface ActionType<CTX, C> {
    MapCodec<C> codec();
    void run(C config, CTX ctx);
}

public interface ConditionType<CTX, C> {
    MapCodec<C> codec();
    boolean test(C config, CTX ctx);
}

CTX is the context it acts on, and it is the only thing that decides the flavour. C is the parsed config. Use EmptyCfg when there are no fields.

Actions

public final class ExtinguishAction implements ActionType<EntityCtx, EmptyCfg> {

    @Override
    public MapCodec<EmptyCfg> codec() {
        return MapCodec.unit(EmptyCfg.INSTANCE);
    }

    @Override
    public void run(EmptyCfg cfg, EntityCtx ctx) {
        ctx.raw().clearFire();
    }
}

A configured action reads fields from its codec. Declare the config as a record nested in the action. That is the convention across the whole codebase, and it keeps the schema next to the code that reads it:

public final class HealAction implements ActionType<EntityCtx, HealAction.Cfg> {

    public record Cfg(float amount) {}

    @Override
    public MapCodec<Cfg> codec() {
        return RecordCodecBuilder.mapCodec(i -> i.group(
            Codec.FLOAT.optionalFieldOf("amount", 1.0F).forGetter(Cfg::amount)
        ).apply(i, Cfg::new));
    }

    @Override
    public void run(Cfg cfg, EntityCtx ctx) {
        LivingEntity living = ctx.living();
        if (living != null) living.heal(cfg.amount());
    }
}

The codec is the JSON schema. optionalFieldOf("amount", 1.0F) is exactly the field a pack author writes, with its default, and fieldOf("amount") would make it required.

The action registries

ActionTypes.ENTITY.register(MyMod.id("extinguish"), new ExtinguishAction());
ActionTypes.BI_ENTITY.register(MyMod.id("yank"), new YankAction());
ActionTypes.BLOCK.register(MyMod.id("sprout"), new SproutAction());
ActionTypes.ITEM.register(MyMod.id("scorch"), new ScorchAction());
Field on ActionTypesContextJSON field it lands in
ENTITYEntityCtxentity_action
BI_ENTITYBiEntityCtxbientity_action
BLOCKBlockCtxblock_action
ITEMItemCtxitem_action

Registering the same ID twice in one registry throws Duplicate <group> action at startup, so a copy-pasted registration fails loudly instead of silently winning.

Conditions

Conditions have the same shape, but test returns a boolean:

public final class OnFireCondition implements ConditionType<EntityCtx, EmptyCfg> {

    @Override
    public MapCodec<EmptyCfg> codec() {
        return MapCodec.unit(EmptyCfg.INSTANCE);
    }

    @Override
    public boolean test(EmptyCfg cfg, EntityCtx ctx) {
        return ctx.raw().isOnFire();
    }
}

The condition registries

There are eight, four more than there are action registries:

ConditionTypes.ENTITY.register(MyMod.id("on_fire"), new OnFireCondition());
ConditionTypes.BI_ENTITY.register(MyMod.id("same_team"), new SameTeamCondition());
ConditionTypes.BLOCK.register(MyMod.id("mossy"), new MossyCondition());
ConditionTypes.ITEM.register(MyMod.id("heavy"), new HeavyCondition());
ConditionTypes.DAMAGE.register(MyMod.id("explosive"), new ExplosiveCondition());
ConditionTypes.FLUID.register(MyMod.id("hot"), new HotFluidCondition());
ConditionTypes.BIOME.register(MyMod.id("snowy"), new SnowyCondition());
ConditionTypes.STATIC.register(MyMod.id("full_moon"), new FullMoonCondition());
Field on ConditionTypesContextJSON field it lands in
ENTITYEntityCtxcondition, entity_condition
BI_ENTITYBiEntityCtxbientity_condition
BLOCKBlockCtxblock_condition
ITEMItemCtxitem_condition
DAMAGEDamageCtxdamage_condition
FLUIDFluidCtxfluid_condition
BIOMEBiomeCtxbiome_condition
STATICStaticCtxcondition on the types that take one

inverted is handled for you

Apoli adds the universal inverted field in a wrapper, so return the plain answer without inverting it. EntityCondition.test applies inverted != result after calling you, and inverting it yourself would cancel it out.

The same wrapper holds the null guards. A condition whose context has no entity returns inverted without calling your test, and an unknown type ID returns true, so one bad field never silently disables a whole power.

Running them from your own code

A power type that exposes entity_action, bientity_condition and similar fields doesn't call the registries directly. It holds the parsed wrapper record and calls it:

public record Cfg(Optional<EntityAction> entityAction,
                  Optional<EntityCondition> condition,
                  Optional<BiEntityAction> bientityAction) {}

@Override
public MapCodec<Cfg> codec() {
    return RecordCodecBuilder.mapCodec(i -> i.group(
        LoggedOptionalField.of("entity_action", EntityAction.CODEC).forGetter(Cfg::entityAction),
        LoggedOptionalField.strict("condition", EntityCondition.CODEC).forGetter(Cfg::condition),
        LoggedOptionalField.of("bientity_action", BiEntityAction.CODEC).forGetter(Cfg::bientityAction)
    ).apply(i, Cfg::new));
}

Then, at the point the thing happens:

if (cfg.condition().isEmpty() || cfg.condition().get().test(ctx)) {
    cfg.entityAction().ifPresent(action -> action.run(ctx));
}

EntityAction, BiEntityAction, BlockAction, ItemAction and the eight *Condition records all have a public CODEC and a single run or test method. That is the whole API, and you never look a type up by ID at runtime.

LoggedOptionalField vs optionalFieldOf

Use LoggedOptionalField for action and condition fields instead of plain optionalFieldOf. Otherwise DFU drops a malformed nested action silently, and the power loads looking fine while doing nothing.

HelperOn a malformed value
Codec.optionalFieldOfDrops the field and says nothing.
LoggedOptionalField.ofDrops the field and logs a warning naming the power and the field.
LoggedOptionalField.strictFails the whole enclosing object, with a warning.

strict is right for a field the type can't work without. A condition that silently vanishes turns a gated power into an ungated one, which is worse than a load error.

Aliases and legacy IDs

To accept an old ID without changing your codec:

ConditionTypes.ITEM.register(
    MyMod.id("fireproof"),
    new FireproofItemCondition(),
    AliasingOptions.builder().addTypeAlias(MyMod.id("fire_resistant")).build()
);

AliasingOptions also renames fields (renameField("old", "new")) and adds defaults for one alias (addTypeAlias(oldId, Map.of("field", "value"))) that only apply when the legacy ID was used. See aliasing.

Performance

test and run are called wherever the containing power fires, which can be every tick, every hit, or once per entity in a selector. Keep them free of allocations and O(1): no streams, no Optional chains in the body, no ResourceLocation construction and no registry lookups. Read the performance rules before shipping anything that runs in a loop.