Updating Minecraft Mod XpBeacons for 1.17 and new "xp drain" feature

A couple weeks ago, after completing my minecraft iron farm, I asked what I would do next for my singleplayer world. Since I was asked to update Epsilon-Carpet for 1.17, I decided to focus on modding. Updating Experience Beacons was going to be my way of getting familiar with updating a carpet extension — or any mod — to 1.17, because it is a small mod.

Struggles for Two Weeks

I thought this would be a quick operation, but as usual in programming and life there were some unforseen complications. I couldn't get any 1.17 development done.

The issue was fabric couldn't find the minecraft.jar at runtime, if I even got to that step. Sometimes it couldn't even find it at compile and everything using net.minecraft would be an error.

I tried everything to get it to work. I went as far as to get a fresh install of IntelliJ going in another OS. I tried different mods, I tried creating a new 1.17 mod, I tried updating existing 1.16 mods. But for some reason, I could not get it to work. I went through the setup instructions in the wiki exactly, even the optional steps. I needed a baseline; I needed anything to work, but nothing would.

I went to the fabric discord, to try to court some help, multiple times. No one seemed to know what was happening. Some people were even in disbelief that my log was actually that short. Yes, it really is that short, there's nothing else.

[20:20:23] [main/ERROR] (FabricLoader) Could not find valid game provider!
[20:20:23] [main/ERROR] (FabricLoader) - Minecraft
Exception in thread "main" java.lang.RuntimeException: Could not find valid game provider!
    at net.fabricmc.loader.launch.knot.Knot.init(Knot.java:104)
    at net.fabricmc.loader.launch.knot.KnotClient.main(KnotClient.java:28)
    at net.fabricmc.devlaunchinjector.Main.main(Main.java:86)
Yes, this is the entire log

Eventually, I found a gradle command hiding in a reddit thread:

gradlew --refresh-dependencies

After two weeks, I was finally on my way, but before that:

The Core Functionality of XpBeacons

Experience Beacons is "a fabric-carpet extension for experience-based amplitude on beacon status effects." This means that beacon effect strength on a player is dictated by how much xp they have, up to a point known as the "xp ceiling". In my implementation, this is a direct or linear relationship. Meaning that when the scale of xp to effect strength is 200:1, if a player has 200 levels the player will have effect amplitude 2 (because effect amplitude starts @ 1.) If the player has 400 levels they will have amplitude 3, and so on.

Tying the strength of beacon effects to xp is a way to justify increasing those effects; there's a cost to them. Here's an example scenario. At default settings, if you are at 8000 xp levels and select resistance in your beacon, you will have resistance 4 (or amplitude 3). With this you can survive a 100m fall with no armor at half heart. A 200m fall with armor can be survived easily. This may sound overpowered, but if you need 8000 levels to get there, I would argue it is justified.

But why is xpbeacons a carpet extension instead of its own standalone mod? This is because players involved enough in the game for this to be applicable likely already have Carpet, and if they don't, they probably would benefit from its features. Additionally, Carpet comes with a simple infrastructure for creating commands for configuration variables as a dependent mod, or "extension". Importantly, these variables can be made persistent on a world-by-world basis.

The functionality that I want to manipulate with these configurable variables is in package net.minecraft.block.entity.BeaconBlockEntity, according to yarn mappings in 1.17.

private static void applyPlayerEffects(World world, BlockPos pos, int beaconLevel, @Nullable StatusEffect primaryEffect, @Nullable StatusEffect secondaryEffect) {
    if (!world.isClient && primaryEffect != null) {
        double d = (double)(beaconLevel * 10 + 10);
        int i = 0;
        if (beaconLevel >= 4 && primaryEffect == secondaryEffect) {
            i = 1;
        }

        int j = (9 + beaconLevel * 2) * 20;
        Box box = (new Box(pos)).expand(d).stretch(0.0D, (double)world.getHeight(), 0.0D);
        List<PlayerEntity> list = world.getNonSpectatingEntities(PlayerEntity.class, box);
        Iterator var11 = list.iterator();

        PlayerEntity playerEntity2;
        while(var11.hasNext()) {
            playerEntity2 = (PlayerEntity)var11.next();
            playerEntity2.addStatusEffect(new StatusEffectInstance(primaryEffect, j, i, true, true));
        }

        if (beaconLevel >= 4 && primaryEffect != secondaryEffect && secondaryEffect != null) {
            var11 = list.iterator();

            while(var11.hasNext()) {
                playerEntity2 = (PlayerEntity)var11.next();
                playerEntity2.addStatusEffect(new StatusEffectInstance(secondaryEffect, j, 0, true, true));
            }
        }

    }
}
`net.minecraft.block.entity.BeaconBlockEntity$applyPlayerEffects` This can be used as a reference later.

Rapid Development

Once I figured this one command out I actually did a lot more than I had initially planned, which was to just update the mod for 1.17. I looked at the mixin that provides the functionality for the mod, and decided it needed a rewrite.

Deconstructing the Old Mixin

@Mixin(BeaconBlockEntity.class)
public abstract class BeaconBlockEntity_xpBeaconTileMixin extends BlockEntity {

    @Shadow private StatusEffect primary;

    @Shadow private int level;

    @Shadow private StatusEffect secondary;

    public BeaconBlockEntity_xpBeaconTileMixin(BlockEntityType<?> type, BlockPos pos, BlockState state) {
        super(type, pos, state);
    }

    @Inject(method="applyPlayerEffects", at=@At("RETURN"))
    private static void applyXpBasedStatusEffects(World world, BlockPos pos, int beaconLevel, StatusEffect primaryEffect, StatusEffect secondaryEffect, CallbackInfo ci) {

        if (XpBeaconsSimpleSettings.xpBeacons && primaryEffect != null && !world.isClient) {
            int r = (int)Math.pow(2, beaconLevel + 3);
            int x1 = pos.getX() - r, x2 = pos.getX() + r,
                    z1 = pos.getZ() - r, z2 = pos.getZ() + r,
                    y1 = Math.max(pos.getY() - r, 0), y2 = Math.min(pos.getY() + r, world.getHeight());
            Box range = new Box(x1, y1, z1, x2, y2, z2);

            double statusMultiplier = getEffectSpecificAmplificationMultiplier(primaryEffect, primaryEffect);

            if (secondaryEffect == null) {
                statusMultiplier /= 2; // Use the secondary to unlock FULL POWA
            } else if (secondaryEffect.equals(StatusEffects.REGENERATION)) {
                statusMultiplier /= 2;  // If secondary is regen apply xp-based regen
                applyEffectToAllPlayers(secondaryEffect, range, getEffectSpecificAmplificationMultiplier(secondaryEffect, primaryEffect), world);
            }

            applyEffectToAllPlayers(primaryEffect, range, statusMultiplier, world);
        }
    }
    private static double getEffectSpecificAmplificationMultiplier(StatusEffect se, StatusEffect primaryEffect) {
        double statusMultiplier = 0.0;
        double[] multipliersInOrder = // UGGGGG JAVA WON"T DO SWITCHES ON OBJECTS
                {
                        XpBeaconsCategorySettings.hasteMultiplier,
                        XpBeaconsCategorySettings.speedMultiplier,
                        XpBeaconsCategorySettings.resistanceMultiplier,
                        XpBeaconsCategorySettings.regenMultiplier,
                        XpBeaconsCategorySettings.jumpMultiplier,
                        XpBeaconsCategorySettings.strengthMultiplier
                };
        StatusEffect[] effectsInOrder =
                {
                        StatusEffects.HASTE,
                        StatusEffects.SPEED,
                        StatusEffects.RESISTANCE,
                        StatusEffects.REGENERATION,
                        StatusEffects.JUMP_BOOST,
                        StatusEffects.STRENGTH
                };
        for (int i = 0; i<effectsInOrder.length; i++) {
            if (effectsInOrder[i].equals(primaryEffect)) {
                statusMultiplier = multipliersInOrder[i];
                break;
            }
        }
        return statusMultiplier;
    }
    private static void applyEffectToAllPlayers(StatusEffect se, Box range, double statusMultiplier, World world) {
        for (PlayerEntity player : world.getEntitiesByClass(PlayerEntity.class, range, null)) {
            int amplifier = (int)(Math.min((int)((double)player.experienceLevel / XpBeaconsCategorySettings.xpBeaconsMax * 255), 255) * statusMultiplier);
            player.addStatusEffect(new StatusEffectInstance(se, 400, amplifier, true, true));
        }
    }
}
The old mixin where I made some questionable decisions.

If you don't know, in programming mixins are generally a construct to add functionality to an existing class, without being a parent or super class. When you add a mixin to a class, its functionality is essentially "mixed in". This is a useful concept in modding because you can think of mods as "mixing in" new functionality to existing classes at runtime. You can read more about mixins here.

The @Shadow annotations expose properties that are a part of BeaconBlockEntity. After all, if the mixin is essentially a part of BeaconBlockEntity, then it should be able to access its fields. When the code is patched at runtime the mixin library sees those annotations and does the work for us.

The next part is the actual functionality. In applyXpBasedStatusEffects, there is an @Inject annotation. This inject annotation tells us that a call to this method will be placed just before the return. If you were able to see the injected code at runtime it would look something like this:

private static void applyPlayerEffects(World world, BlockPos pos, int beaconLevel, @Nullable StatusEffect primaryEffect, @Nullable StatusEffect secondaryEffect) {
    if (!world.isClient && primaryEffect != null) {
        double d = (double)(beaconLevel * 10 + 10);
				......
				......
				......
							playerEntity2.addStatusEffect(new StatusEffectInstance(secondaryEffect, j, 0, true, true));
            }
        }
+   applyXpBasedStatusEffects(world, pos, beaconLevel, primaryEffect, secondaryEffect);
}
`applyXpBasedStatusEffects` injected into `applyPlayerEffects`, signified by a "+". Notice how the injected function gets all the parameters of `applyPlayerEffects`

The main reason I don't like the way this was done is because instead of being targeted with how the features are implemented, applyXpBasedStatusEffects is essentially just a rewrite of applyPlayerEffects. So it relies on the behavior of minecraft effects that effects are only applied to a player if the amplitude is higher than what the player currently has. It also wastes CPU cycles because none of applyPlayerEffects is even needed or wanted for that matter. Not to mention that the way each configuration variable is mapped to its status effect is kind of disgusting, with those two arrays. Additionally, the whole concept of the "status multiplier" is kind of confusing as there is no analog or easy sense to the player for how manipulating this changes the status amplitude.

Furthermore, there were some functional choices and bugs that had to be addressed, and I decided rewriting the mixin completely would be the way to go.

Upgrading to 1.17

Before I decided to rewrite, I wanted to just update the mod to work in 1.17. This ended up being pretty easy because there are only two mixins. It's pretty much just a remap, but you can see the commit here if you want.

https://i.imgur.com/yvEBTvj.png

A sample of the git diff of the beacon mixin. The biggest change was probably the switch from an instance method to static method.

I pretty much scrapped the old mixin. The new mixin was still a mixin for BeaconBlockEntity, but the injection became a redirect of the 2 function calls of PlayerEntity.addStatusEffect in applyPlayerEffects.

@Redirect(method = "applyPlayerEffects", at = @At(value = "INVOKE", target = "Lnet/minecraft/entity/player/PlayerEntity;addStatusEffect(Lnet/minecraft/entity/effect/StatusEffectInstance;)Z"))
private static boolean applyXpBasedEffects(PlayerEntity player, StatusEffectInstance oldEffect) {

    StatusEffect effectType = oldEffect.getEffectType();
    Map<StatusEffect, Double> effectMultiplierMap = Map.of(
            StatusEffects.HASTE, XpBeaconsCategorySettings.hasteMultiplier,
            StatusEffects.SPEED, XpBeaconsCategorySettings.speedMultiplier,
            StatusEffects.RESISTANCE, XpBeaconsCategorySettings.resistanceMultiplier,
            StatusEffects.REGENERATION, XpBeaconsCategorySettings.regenMultiplier,
            StatusEffects.JUMP_BOOST, XpBeaconsCategorySettings.jumpMultiplier,
            StatusEffects.STRENGTH, XpBeaconsCategorySettings.strengthMultiplier
    );
    double amplifierMultiplier = effectMultiplierMap.get(effectType);
    int amplifier = (int)(Math.min((int)((double)(((PlayerEntity)player).experienceLevel) / XpBeaconsCategorySettings.xpBeaconsMax * 255), 255) * amplifierMultiplier);

    StatusEffectInstance newEffect = new StatusEffectInstance(
            effectType,
            oldEffect.getDuration(),
            amplifier, oldEffect.isAmbient(),
            oldEffect.shouldShowParticles(),
            oldEffect.shouldShowIcon()
    );
    return player.addStatusEffect(newEffect);
}
The new mixin function.

You can see that I moved from having two arrays of StatusEffect and double, to a map of <StatusEffect, Double>. This seems a bit cleaner, and it can find the correct multiplier with one call to Map.get. Overall, this mixin is much shorter than the previous.

So now the code injection could be represented like this:

private static void applyPlayerEffects(World world, BlockPos pos, int beaconLevel, @Nullable StatusEffect primaryEffect, @Nullable StatusEffect secondaryEffect) {
        if (!world.isClient && primaryEffect != null) {
            .....
						.....

            PlayerEntity playerEntity2;
            while(var11.hasNext()) {
                playerEntity2 = (PlayerEntity)var11.next();
-               playerEntity2.addStatusEffect(new StatusEffectInstance(primaryEffect, j, i, true, true));
+								applyXpBasedEffects(playerEntity2, new StatusEffectInstance(primaryEffect, j, i, true, true));
            }

            if (beaconLevel >= 4 && primaryEffect != secondaryEffect && secondaryEffect != null) {
                var11 = list.iterator();

                while(var11.hasNext()) {
                   playerEntity2 = (PlayerEntity)var11.next();
-                  playerEntity2.addStatusEffect(new StatusEffectInstance(secondaryEffect, j, 0, true, true));
+                  applyXpBasedEffects(playerEntity2, new StatusEffectInstance(secondaryEffect, j, 0, true, true));
                }
            }

        }
    }

In this version, it replaces the calls to playerEntity2.addStatusEffect with BeaconBlockEntity_xpbeaconsMixin.applyXpBasedEffects. While redirects are not preferred for compatibility with other mods, this seems like a clean way to do it because applyPlayerEffects can do all the complicated stuff like selecting players and determining secondary effects, while not relying on the game mechanic that only applies effects if they are of equal or higher value. If there are any mods that conflict with this redirect in the future, I can always just patch it.

Then I released the first of four updates. I wasn't nearly done, but I wanted to just get something out there for 1.17, even if it felt incomplete.

Reconfiguring Settings

At this point each effect had a multiplier setting, but I knew I wanted a few more settings for each effect. I made an abstract class for the effects' settings.

public abstract class EffectSettings {
    protected StatusEffect EffectType;
    public StatusEffect getEffect() {
        return EffectType;
    }

    public abstract int getEffectAmplitudeCeiling();
    public abstract int getEffectXpCeiling();
    public abstract boolean getModdedBehaviorToggle();
}
`EffectSettings.java`, which exposes an identifier and 3 settings.

The amplitude ceiling is replacing the effect multiplier, which will be discussed more later. The xp ceiling is not new, but before it was a blanket xp ceiling for all effects. Now, each effect can have a different xp ceiling. Lastly, the xp-based effect amplitude has a toggle for each effect.

I wanted to do subcategories of settings, so players could use commands like /xpbeacons haste xp_ceiling 1000, but I couldn't find a mechanism for creating these types of rules in carpet. This meant I had to settled with prefixing each rule with the effect, so it looks like /xpbeacons haste_xp_ceiling 1000. This means I have to program a bunch of individual rules, but having an abstract class to follow made it easier. Here's a sample (doesn't include all the rules/method implementations) class that exposes rules for the haste effect:

public static class HasteSettings extends EffectSettings {
    private final static String HASTE = "haste";
    public HasteSettings() {
        EffectType = StatusEffects.HASTE;
    }

    @Rule(
            desc="effect amplitude ceiling for haste. default maybe a bit OP, play around with this one; mining obsidian is like mining stone without enchants or haste",
            validate = {Validator.NONNEGATIVE_NUMBER.class},
            category = {xpbeaconsCategory, HASTE}
    )
    public static int haste_amplitude_ceiling = 10;

    public int getEffectAmplitudeCeiling() {
        return haste_amplitude_ceiling;
    }
}
A sample of `HasteSettings` in `XpBeaconsCategorySettings.java`

The @Rule annotation tells carpet to add the variable as a command, and includes useful features like description, defaults, and validators. Then the mixin function was rewritten to consume these settings.

private static final EffectSettings[] effectsSettings = new EffectSettings[] {
        new XpBeaconsCategorySettings.StrengthSettings(),
        new XpBeaconsCategorySettings.HasteSettings(),
        new XpBeaconsCategorySettings.SpeedSettings(),
        new XpBeaconsCategorySettings.ResistanceSettings(),
        new XpBeaconsCategorySettings.RegenerationSettings(),
        new XpBeaconsCategorySettings.JumpBoostSettings()
};

@Redirect(method = "applyPlayerEffects", at = @At(value = "INVOKE", target = "Lnet/minecraft/entity/player/PlayerEntity;addStatusEffect(Lnet/minecraft/entity/effect/StatusEffectInstance;)Z"))
private static boolean applyXpBasedEffects(PlayerEntity player, StatusEffectInstance oldEffect) {
    StatusEffect effectType = oldEffect.getEffectType();
    EffectSettings effectSettings = Arrays.stream(effectsSettings).filter(es -> es.getEffect() == effectType).findFirst().get();

    if (XpBeaconsSimpleSettings.xpbeacons && effectSettings.getModdedBehaviorToggle()) {

        int amplifier = (int)(effectSettings.getEffectAmplitudeCeiling() * ((double)Math.min(player.experienceLevel, effectSettings.getEffectXpCeiling()) / effectSettings.getEffectXpCeiling()));

        StatusEffectInstance newEffect = new StatusEffectInstance(
                effectType,
                oldEffect.getDuration(),
                amplifier,
                oldEffect.isAmbient(),
                oldEffect.shouldShowParticles(),
                oldEffect.shouldShowIcon()
        );
        return player.addStatusEffect(newEffect);
    } else {
        return player.addStatusEffect(oldEffect);
    }
}
part of `BeaconBlockEntity_xpbeaconsMixin` in the 4.0 update

You can see that since each setting class is an EffectSettings, I just need to find the correct group of settings by calling getEffect and comparing it with the input effect. Then, I can treat it like an EffectSettings instead of a SpeedSetting, ResistanceSettings or so forth.

Initially, there is an if statement to determine if the settings are such that the player wants vanilla behavior. If this is the case, the function returns early in the else clause.

Then, the most consequential change of this particular update is that the effect amplitude is determined by the amplitude ceiling, according to this line:

int amplifier = (int)(effectSettings.getEffectAmplitudeCeiling() * ((double)Math.min(player.experienceLevel, effectSettings.getEffectXpCeiling()) / effectSettings.getEffectXpCeiling()));

In English this says "The effect amplitude is the floor of the percent of the player's xp level up to 100% times the amplitude ceiling." This means that the amplitude or effect strength scales linearly with the player's xp level up to the amplitude ceiling. Therefore, if a player's xp level is at or above their xp ceiling, the effect strength will be the amplitude ceiling. If the player's xp level is half the xp ceiling, the effect strength will be half the amplitude ceiling.

This is much easier for players to grasp than an arbitrary multiplier because they have the metric of minecraft effect strength. Many players have played around with effect strength values and have a good idea of how they behave. If they don't, it is easy to find out by messing around with commands.

This work became 4.0 and a few hours later I released 4.1 to patch a bug related to the carpet rules.

Xp Drain

Version 5.0 ended up becoming the new feature that I wanted to try out, xp drain. I thought that if the beacon consumed xp while you were using it, that would be a cool dynamic. So not only do you have to reach a certain xp level to get the full effects, but you also have to maintain that level. Since the values calculating xp drain are rounded down, this won't really apply to lower levels. But if you have hundreds of levels, the beacon will slowly suck away your xp. This effect stacks according to how many beacons you are using. I think the default values for this could be toned down a bit, perhaps I will do that next update.

I decided that I wanted to make this feature optional and the drain rate configurable on an effect-by-effect basis. Fortunately, I already had the infrastructure in place to add new carpet rules.

public static class RegenerationSettings extends EffectSettings {
        @Rule(
                desc="xp drain feature toggle for regeneration",
                category = {xpbeaconsCategory, REGENERATION}
        )
        public static boolean regeneration_xp_drain = true;

        @Rule(
                desc="xp drain rate for regeneration. each beacon tick takes away (haste_xp_drain_rate * xp level) xp POINTS",
                validate = {Validator.NONNEGATIVE_NUMBER.class},
                category = {xpbeaconsCategory, REGENERATION},
                strict = false
        )
        public static double regeneration_xp_drain_rate = (double)1/40;

        public double getXpDrainRate() {
            return regeneration_xp_drain_rate;
        }

        public boolean getShouldDrainXp() {
            return regeneration_xp_drain;
        }
    }
A sample of `XpBeaconsCategorySettings.RegenerationSettings`

All I had to do was add these 3 lines before the lines applying the amplitude feature.

if (effectSettings.getShouldDrainXp()) {
    player.addExperience((int)(-player.experienceLevel * effectSettings.getXpDrainRate()));
}
The inner workings of the xp drain feature.

Here we run into almost the same problem as the amplitude feature has. That is, it's an arbitrary multiplier. I don't really know what kind of metric to convert this to that is easily understandable. Maybe "percent xp bar per beacon tick"? But maybe it shouldn't scale 1:1 with how many levels you have, because 1% can become hundreds or thousands of xp points. I'm going to have a think about it.

https://i.imgur.com/JwqklQp.mp4

xp being drained in range of a beacon

Next Steps

I have a few ideas for this mod, but that might come later next month.

At this point my mods and all their dependencies installed for my minecraft 1.17 survival world have reached over 100 total. But there's still about a dozen that I'm waiting on. I might end up updating them myself and posting it here. I have a few projects lined up so I don't know if that will be the next post. However, expect a second post this week because I missed last week's post.

See you next week

-Fractured