fix various issues

This commit is contained in:
2026-07-20 21:07:14 +02:00
parent 34260431f2
commit 3b150bcf7d
9 changed files with 279 additions and 48 deletions
@@ -297,7 +297,10 @@ public final class AdaptiveGoalInjector {
|| mob instanceof Hoglin || mob instanceof Zoglin) {
return AMConfig.ENABLE_NETHER_MOB_AI.get() && AMConfig.AI_PIGLIN_FAMILY.get();
}
if (mob instanceof Vindicator || mob instanceof Ravager) {
if (mob instanceof Vindicator) {
return AMConfig.ENABLE_RAID_MOB_AI.get() && AMConfig.AI_VINDICATOR.get();
}
if (mob instanceof Ravager) {
return AMConfig.ENABLE_RAID_MOB_AI.get() && AMConfig.AI_RAVAGER.get();
}
if (mob instanceof Silverfish) {
@@ -7,6 +7,9 @@ import net.minecraft.world.entity.Mob;
import net.minecraft.world.level.ClipContext;
import net.minecraft.world.phys.HitResult;
import net.minecraft.world.phys.Vec3;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.core.Direction;
import net.minecraft.world.phys.AABB;
public final class AdaptivePositioningUtils {
@@ -64,6 +67,9 @@ public final class AdaptivePositioningUtils {
if (target == null) {
return false;
}
if (!chunksLoaded(mob, mob.getEyePosition(), target.getEyePosition())) {
return false;
}
HitResult hit = mob.level().clip(new ClipContext(mob.getEyePosition(), target.getEyePosition(),
ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, mob));
return hit.getType() == HitResult.Type.MISS;
@@ -71,9 +77,25 @@ public final class AdaptivePositioningUtils {
public static boolean isReasonableGround(Mob mob, Vec3 pos) {
BlockPos block = BlockPos.containing(pos);
return mob.level().getWorldBorder().isWithinBounds(block)
&& !mob.level().getBlockState(block).liquid()
&& mob.level().getBlockState(block).getCollisionShape(mob.level(), block).isEmpty();
BlockPos below = block.below();
AABB destinationBox = mob.getBoundingBox().move(pos.subtract(mob.position()));
if (!mob.level().getWorldBorder().isWithinBounds(block)
|| !chunksLoaded(mob, new Vec3(destinationBox.minX, destinationBox.minY, destinationBox.minZ),
new Vec3(destinationBox.maxX, destinationBox.maxY, destinationBox.maxZ))
|| !mob.level().hasChunkAt(below)) {
return false;
}
var state = mob.level().getBlockState(block);
var support = mob.level().getBlockState(below);
if (state.liquid() || !state.getCollisionShape(mob.level(), block).isEmpty()
|| !support.isFaceSturdy(mob.level(), below, Direction.UP)
|| support.is(Blocks.CACTUS) || support.is(Blocks.MAGMA_BLOCK)
|| support.is(Blocks.CAMPFIRE) || support.is(Blocks.SOUL_CAMPFIRE)
|| support.is(Blocks.POWDER_SNOW) || support.is(Blocks.FIRE) || support.is(Blocks.SOUL_FIRE)
|| !mob.level().noCollision(mob, destinationBox)) {
return false;
}
return mob.getNavigation().createPath(block, 0) != null;
}
public static boolean isOpenToSky(Mob mob) {
@@ -81,7 +103,8 @@ public final class AdaptivePositioningUtils {
}
public static int localLight(Mob mob, Vec3 pos) {
return mob.level().getMaxLocalRawBrightness(BlockPos.containing(pos));
BlockPos block = BlockPos.containing(pos);
return mob.level().hasChunkAt(block) ? mob.level().getMaxLocalRawBrightness(block) : Integer.MAX_VALUE;
}
public static Vec3 elevatedPosition(Mob mob, LivingEntity target, double radius) {
@@ -94,4 +117,19 @@ public final class AdaptivePositioningUtils {
}
return base;
}
private static boolean chunksLoaded(Mob mob, Vec3 from, Vec3 to) {
int minChunkX = ((int) Math.floor(Math.min(from.x, to.x))) >> 4;
int maxChunkX = ((int) Math.floor(Math.max(from.x, to.x))) >> 4;
int minChunkZ = ((int) Math.floor(Math.min(from.z, to.z))) >> 4;
int maxChunkZ = ((int) Math.floor(Math.max(from.z, to.z))) >> 4;
for (int chunkX = minChunkX; chunkX <= maxChunkX; chunkX++) {
for (int chunkZ = minChunkZ; chunkZ <= maxChunkZ; chunkZ++) {
if (!mob.level().hasChunkAt(new BlockPos(chunkX << 4, 0, chunkZ << 4))) {
return false;
}
}
}
return true;
}
}
@@ -2,6 +2,7 @@ package net.fretux.adaptivemobs.ai;
import net.fretux.adaptivemobs.config.AMConfig;
import net.fretux.adaptivemobs.difficulty.AdaptiveDifficultyManager;
import net.fretux.adaptivemobs.data.TemporaryBlockSavedData;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerLevel;
@@ -66,7 +67,6 @@ public final class AdaptiveSpecialAbilities {
private static final String COOLDOWN_PREFIX = "am_special_cd_";
private static final String SPLIT_FIREBALL_KEY = "am_split_fireball";
private static final List<TemporaryBlock> TEMPORARY_BLOCKS = new ArrayList<>();
private static final List<DelayedReappear> DELAYED_REAPPEARS = new ArrayList<>();
private AdaptiveSpecialAbilities() {
@@ -136,17 +136,20 @@ public final class AdaptiveSpecialAbilities {
if (event.phase != TickEvent.Phase.END || event.getServer() == null) {
return;
}
Iterator<TemporaryBlock> blockIterator = TEMPORARY_BLOCKS.iterator();
TemporaryBlockSavedData temporaryBlocks = TemporaryBlockSavedData.get(event.getServer().overworld());
Iterator<TemporaryBlockSavedData.Entry> blockIterator = temporaryBlocks.entries();
while (blockIterator.hasNext()) {
TemporaryBlock block = blockIterator.next();
ServerLevel level = block.server == event.getServer() ? event.getServer().getLevel(block.dimension) : null;
if (level == null || level.getGameTime() < block.expireTick) {
TemporaryBlockSavedData.Entry block = blockIterator.next();
ServerLevel level = event.getServer().getLevel(net.minecraft.resources.ResourceKey.create(
net.minecraft.core.registries.Registries.DIMENSION, block.dimension()));
if (level == null || level.getGameTime() < block.expireTick() || !level.hasChunkAt(block.pos())) {
continue;
}
if (level.getBlockState(block.pos).equals(block.placedState)) {
block.replaced.restore(true, false);
if (level.getBlockState(block.pos()).is(Blocks.COBWEB)) {
level.setBlock(block.pos(), Blocks.AIR.defaultBlockState(), 3);
}
blockIterator.remove();
temporaryBlocks.changed();
}
Iterator<DelayedReappear> reappearIterator = DELAYED_REAPPEARS.iterator();
@@ -348,8 +351,7 @@ public final class AdaptiveSpecialAbilities {
if (!level.setBlock(pos, placedState, 3)) {
return;
}
TEMPORARY_BLOCKS.add(new TemporaryBlock(level.getServer(), level.dimension(), pos.immutable(),
expireTick, replaced, placedState));
TemporaryBlockSavedData.get(level).add(level, pos, expireTick);
level.playSound(null, pos, SoundEvents.SPIDER_AMBIENT, SoundSource.HOSTILE, 0.6F, 1.25F);
}
@@ -389,34 +391,14 @@ public final class AdaptiveSpecialAbilities {
}
public static void invalidateTemporaryBlock(ServerLevel level, BlockPos pos) {
TEMPORARY_BLOCKS.removeIf(block -> block.server == level.getServer()
&& block.dimension == level.dimension() && block.pos.equals(pos));
TemporaryBlockSavedData.get(level).remove(level, pos);
}
public static void clear(ServerLevel level) {
TEMPORARY_BLOCKS.removeIf(block -> {
if (block.server != level.getServer() || block.dimension != level.dimension()) {
return false;
}
if (level.getBlockState(block.pos).equals(block.placedState)) {
block.replaced.restore(true, false);
}
return true;
});
DELAYED_REAPPEARS.removeIf(pending -> pending.server == level.getServer() && pending.dimension == level.dimension());
}
public static void clear(net.minecraft.server.MinecraftServer server) {
TEMPORARY_BLOCKS.removeIf(block -> {
if (block.server != server) {
return false;
}
ServerLevel level = server.getLevel(block.dimension);
if (level != null && level.getBlockState(block.pos).equals(block.placedState)) {
block.replaced.restore(true, false);
}
return true;
});
DELAYED_REAPPEARS.removeIf(pending -> pending.server == server);
}
@@ -424,11 +406,6 @@ public final class AdaptiveSpecialAbilities {
return AMConfig.enabled && AMConfig.AI_ENABLED.get() && AMConfig.ENABLE_ADVANCED_AI.get();
}
private record TemporaryBlock(net.minecraft.server.MinecraftServer server,
net.minecraft.resources.ResourceKey<net.minecraft.world.level.Level> dimension,
BlockPos pos, long expireTick, BlockSnapshot replaced, BlockState placedState) {
}
private record DelayedReappear(net.minecraft.server.MinecraftServer server,
net.minecraft.resources.ResourceKey<net.minecraft.world.level.Level> dimension,
long tick, UUID mobId, UUID targetId, int tier) {
@@ -7,6 +7,7 @@ import java.util.function.IntSupplier;
/** Keeps the creeper fuse synchronized with the current config-gated adaptive tier. */
public final class AdaptiveCreeperFuseGoal extends Goal {
private static final String ORIGINAL_FUSE_KEY = "am_original_fuse";
private final Creeper creeper;
private final IntSupplier tierSupplier;
private int updateCooldown;
@@ -33,6 +34,17 @@ public final class AdaptiveCreeperFuseGoal extends Goal {
}
updateCooldown = 20;
int tier = tierSupplier.getAsInt();
creeper.maxSwell = tier <= 0 ? 30 : Math.max(18, 30 - Math.max(0, tier - 1) * 3);
if (tier <= 0) {
if (creeper.getPersistentData().contains(ORIGINAL_FUSE_KEY)) {
creeper.maxSwell = creeper.getPersistentData().getInt(ORIGINAL_FUSE_KEY);
creeper.getPersistentData().remove(ORIGINAL_FUSE_KEY);
}
return;
}
if (!creeper.getPersistentData().contains(ORIGINAL_FUSE_KEY)) {
creeper.getPersistentData().putInt(ORIGINAL_FUSE_KEY, creeper.maxSwell);
}
int original = creeper.getPersistentData().getInt(ORIGINAL_FUSE_KEY);
creeper.maxSwell = Math.max(18, original - Math.max(0, tier - 1) * 3);
}
}
@@ -17,15 +17,23 @@ import java.util.function.IntSupplier;
public class AdaptiveSlimeTacticsGoal extends Goal {
private static final String RECOMBINE_READY_AT_KEY = "am_recombine_ready_at";
private static final String RECOMBINE_CLAIM_KEY = "am_recombine_claim";
private static final String RECOMBINE_CLAIM_EXPIRES_KEY = "am_recombine_claim_expires";
private static final int MAX_RECOMBINE_SIZE = 4;
private static final double RECOMBINE_SEARCH_RADIUS = 12.0D;
private static final double RECOMBINE_DISTANCE_SQR = 3.24D;
private static final double MAX_CHASE_DISTANCE_SQR = 256.0D;
private static final int MAX_CHASE_TICKS = 200;
private static final int MAX_STUCK_TICKS = 40;
private final Slime slime;
private final IntSupplier tierSupplier;
private int cooldown;
private int recombineCheckCooldown;
private Slime recombinePartner;
private int chaseTicks;
private int stuckTicks;
private Vec3 lastChasePosition;
public AdaptiveSlimeTacticsGoal(Slime slime, IntSupplier tierSupplier) {
this.slime = slime;
@@ -48,9 +56,10 @@ public class AdaptiveSlimeTacticsGoal extends Goal {
&& --recombineCheckCooldown <= 0) {
recombineCheckCooldown = 10;
recombinePartner = findRecombinePartner();
if (recombinePartner != null) {
if (recombinePartner != null && claim(recombinePartner)) {
return true;
}
recombinePartner = null;
}
return AdaptiveAIGoalUtils.isValidAdaptiveTarget(slime.getTarget()) && --cooldown <= 0;
}
@@ -59,6 +68,9 @@ public class AdaptiveSlimeTacticsGoal extends Goal {
public void start() {
int tier = tierSupplier.getAsInt();
if (recombinePartner != null && recombinePartner.isAlive()) {
chaseTicks = 0;
stuckTicks = 0;
lastChasePosition = slime.position();
if (tryRecombine(recombinePartner)) {
recombinePartner = null;
}
@@ -87,13 +99,20 @@ public class AdaptiveSlimeTacticsGoal extends Goal {
@Override
public boolean canContinueToUse() {
return recombinePartner != null && recombinePartner.isAlive()
&& slime.isAlive() && tierSupplier.getAsInt() >= 5
&& slime.getSize() < MAX_RECOMBINE_SIZE;
return validPartner(recombinePartner, true) && chaseTicks < MAX_CHASE_TICKS
&& stuckTicks < MAX_STUCK_TICKS
&& slime.distanceToSqr(recombinePartner) <= MAX_CHASE_DISTANCE_SQR;
}
@Override
public void tick() {
chaseTicks++;
if (lastChasePosition != null && slime.position().distanceToSqr(lastChasePosition) < 0.0025D) {
stuckTicks++;
} else {
stuckTicks = 0;
lastChasePosition = slime.position();
}
if (recombinePartner != null && tryRecombine(recombinePartner)) {
recombinePartner = null;
}
@@ -101,6 +120,7 @@ public class AdaptiveSlimeTacticsGoal extends Goal {
@Override
public void stop() {
releaseClaim(recombinePartner);
recombinePartner = null;
}
@@ -108,13 +128,17 @@ public class AdaptiveSlimeTacticsGoal extends Goal {
boolean magma = slime instanceof MagmaCube;
List<Slime> candidates = slime.level().getEntitiesOfClass(Slime.class,
slime.getBoundingBox().inflate(RECOMBINE_SEARCH_RADIUS),
other -> other != slime && other.isAlive() && other.getSize() == slime.getSize()
other -> validPartner(other, false)
&& (other instanceof MagmaCube) == magma
&& other.level().getGameTime() >= other.getPersistentData().getLong(RECOMBINE_READY_AT_KEY));
&& claimAvailable(other));
return candidates.stream().min(Comparator.comparingDouble(slime::distanceToSqr)).orElse(null);
}
private boolean tryRecombine(Slime partner) {
if (!validPartner(partner, true) || slime.distanceToSqr(partner) > MAX_CHASE_DISTANCE_SQR) {
releaseClaim(partner);
return false;
}
if (slime.distanceToSqr(partner) > RECOMBINE_DISTANCE_SQR) {
slime.getLookControl().setLookAt(partner, 30.0F, 30.0F);
slime.getMoveControl().setWantedPosition(partner.getX(), partner.getY(), partner.getZ(), 1.2D);
@@ -122,8 +146,10 @@ public class AdaptiveSlimeTacticsGoal extends Goal {
}
LivingEntity inheritedTarget = AdaptiveAIGoalUtils.isValidAdaptiveTarget(slime.getTarget())
? slime.getTarget() : partner.getTarget();
float healthFraction = slime.getHealth() / slime.getMaxHealth();
partner.discard();
slime.setSize(Math.min(MAX_RECOMBINE_SIZE, slime.getSize() * 2), true);
slime.setSize(Math.min(MAX_RECOMBINE_SIZE, slime.getSize() * 2), false);
slime.setHealth(Math.max(1.0F, Math.min(slime.getMaxHealth(), slime.getMaxHealth() * healthFraction)));
if (AdaptiveAIGoalUtils.isValidAdaptiveTarget(inheritedTarget)) {
slime.setTarget(inheritedTarget);
}
@@ -133,6 +159,47 @@ public class AdaptiveSlimeTacticsGoal extends Goal {
return true;
}
private boolean validPartner(Slime partner, boolean requireClaim) {
if (partner == null || partner == slime || !partner.isAlive() || !slime.isAlive()
|| tierSupplier.getAsInt() < 5 || slime.getSize() >= MAX_RECOMBINE_SIZE
|| partner.getSize() != slime.getSize()
|| (partner instanceof MagmaCube) != (slime instanceof MagmaCube)) {
return false;
}
long now = slime.level().getGameTime();
if (now < slime.getPersistentData().getLong(RECOMBINE_READY_AT_KEY)
|| now < partner.getPersistentData().getLong(RECOMBINE_READY_AT_KEY)) {
return false;
}
return !requireClaim || (partner.getPersistentData().hasUUID(RECOMBINE_CLAIM_KEY)
&& partner.getPersistentData().getUUID(RECOMBINE_CLAIM_KEY).equals(slime.getUUID())
&& partner.getPersistentData().getLong(RECOMBINE_CLAIM_EXPIRES_KEY) >= now);
}
private boolean claimAvailable(Slime partner) {
long now = slime.level().getGameTime();
return !partner.getPersistentData().hasUUID(RECOMBINE_CLAIM_KEY)
|| partner.getPersistentData().getLong(RECOMBINE_CLAIM_EXPIRES_KEY) < now;
}
private boolean claim(Slime partner) {
if (!validPartner(partner, false) || !claimAvailable(partner)) {
return false;
}
partner.getPersistentData().putUUID(RECOMBINE_CLAIM_KEY, slime.getUUID());
partner.getPersistentData().putLong(RECOMBINE_CLAIM_EXPIRES_KEY,
slime.level().getGameTime() + MAX_CHASE_TICKS + 1L);
return true;
}
private void releaseClaim(Slime partner) {
if (partner != null && partner.getPersistentData().hasUUID(RECOMBINE_CLAIM_KEY)
&& partner.getPersistentData().getUUID(RECOMBINE_CLAIM_KEY).equals(slime.getUUID())) {
partner.getPersistentData().remove(RECOMBINE_CLAIM_KEY);
partner.getPersistentData().remove(RECOMBINE_CLAIM_EXPIRES_KEY);
}
}
private void scheduleRecombine(int delayTicks) {
if (slime.getPersistentData().getLong(RECOMBINE_READY_AT_KEY) == 0L) {
slime.getPersistentData().putLong(RECOMBINE_READY_AT_KEY, slime.level().getGameTime() + delayTicks);
@@ -199,6 +199,7 @@ public final class AMConfig {
public static final ForgeConfigSpec.BooleanValue AI_GUARDIAN = BUILDER.define("ai.guardians", true);
public static final ForgeConfigSpec.BooleanValue AI_PHANTOM = BUILDER.define("ai.phantoms", true);
public static final ForgeConfigSpec.BooleanValue AI_RAVAGER = BUILDER.define("ai.ravagers", true);
public static final ForgeConfigSpec.BooleanValue AI_VINDICATOR = BUILDER.define("ai.vindicators", true);
public static final ForgeConfigSpec.BooleanValue AI_EVOKER = BUILDER.define("ai.evokers", true);
public static final ForgeConfigSpec.BooleanValue AI_VEX = BUILDER.define("ai.vexes", true);
public static final ForgeConfigSpec.BooleanValue AI_PIGLIN_FAMILY = BUILDER.define("ai.piglinFamily", true);
@@ -0,0 +1,75 @@
package net.fretux.adaptivemobs.data;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.nbt.Tag;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.saveddata.SavedData;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
/** Crash-safe registry for temporary blocks. Cobwebs only replace air, so restoration is unambiguous. */
public final class TemporaryBlockSavedData extends SavedData {
private static final String DATA_NAME = "adaptivemobs_temporary_blocks";
private final List<Entry> entries = new ArrayList<>();
public static TemporaryBlockSavedData get(ServerLevel level) {
ServerLevel overworld = level.getServer().overworld();
return overworld.getDataStorage().computeIfAbsent(TemporaryBlockSavedData::load,
TemporaryBlockSavedData::new, DATA_NAME);
}
public void add(ServerLevel level, BlockPos pos, long expireTick) {
entries.add(new Entry(level.dimension().location(), pos.immutable(), expireTick));
setDirty();
}
public void remove(ServerLevel level, BlockPos pos) {
if (entries.removeIf(entry -> entry.dimension.equals(level.dimension().location()) && entry.pos.equals(pos))) {
setDirty();
}
}
public Iterator<Entry> entries() {
return entries.iterator();
}
public void changed() {
setDirty();
}
public static TemporaryBlockSavedData load(CompoundTag tag) {
TemporaryBlockSavedData data = new TemporaryBlockSavedData();
ListTag list = tag.getList("Blocks", Tag.TAG_COMPOUND);
for (Tag raw : list) {
CompoundTag entry = (CompoundTag) raw;
ResourceLocation dimension = ResourceLocation.tryParse(entry.getString("Dimension"));
if (dimension != null) {
data.entries.add(new Entry(dimension, BlockPos.of(entry.getLong("Pos")), entry.getLong("Expires")));
}
}
return data;
}
@Override
public CompoundTag save(CompoundTag tag) {
ListTag list = new ListTag();
for (Entry entry : entries) {
CompoundTag encoded = new CompoundTag();
encoded.putString("Dimension", entry.dimension.toString());
encoded.putLong("Pos", entry.pos.asLong());
encoded.putLong("Expires", entry.expireTick);
list.add(encoded);
}
tag.put("Blocks", list);
return tag;
}
public record Entry(ResourceLocation dimension, BlockPos pos, long expireTick) {
}
}
@@ -65,6 +65,9 @@ public class AdaptiveMobsEvents {
return;
}
int tier = AdaptiveDifficultyManager.getMobTier(level, victim.getType());
if (!attacker.isAlliedTo(victim) && !victim.isAlliedTo(attacker)) {
return;
}
if (tier < 2 || !AMConfig.isMobEnabled(victim.getType())) {
return;
}
@@ -0,0 +1,55 @@
package net.fretux.adaptivemobs.gametest;
import net.fretux.adaptivemobs.ai.goals.AdaptiveSlimeTacticsGoal;
import net.fretux.adaptivemobs.data.TemporaryBlockSavedData;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.monster.Slime;
import net.minecraftforge.gametest.GameTestHolder;
import java.util.Iterator;
@GameTestHolder("adaptivemobs")
public final class AdaptiveServerBehaviorGameTests {
private AdaptiveServerBehaviorGameTests() {
}
@GameTest(template = "empty")
public static void temporaryBlocksSurviveSavedDataReload(GameTestHelper helper) {
TemporaryBlockSavedData original = new TemporaryBlockSavedData();
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
long expires = helper.getLevel().getGameTime() + 100L;
original.add(helper.getLevel(), pos, expires);
TemporaryBlockSavedData reloaded = TemporaryBlockSavedData.load(original.save(new CompoundTag()));
Iterator<TemporaryBlockSavedData.Entry> entries = reloaded.entries();
helper.assertTrue(entries.hasNext(), "temporary block record was lost during save/reload");
TemporaryBlockSavedData.Entry entry = entries.next();
helper.assertTrue(entry.dimension().equals(helper.getLevel().dimension().location()), "dimension was not preserved");
helper.assertTrue(entry.pos().equals(pos), "position was not preserved");
helper.assertTrue(entry.expireTick() == expires, "expiration was not preserved");
helper.assertTrue(!entries.hasNext(), "unexpected duplicate temporary block record");
helper.succeed();
}
@GameTest(template = "empty")
public static void slimeRecombinationCancelsWhenStuck(GameTestHelper helper) {
Slime slime = helper.spawn(EntityType.SLIME, new BlockPos(1, 2, 1));
Slime partner = helper.spawn(EntityType.SLIME, new BlockPos(8, 2, 1));
slime.setSize(1, true);
partner.setSize(1, true);
AdaptiveSlimeTacticsGoal goal = new AdaptiveSlimeTacticsGoal(slime, () -> 5);
helper.assertTrue(goal.canUse(), "slime did not select its eligible partner");
goal.start();
for (int i = 0; i <= 40; i++) {
goal.tick();
}
helper.assertTrue(!goal.canContinueToUse(), "stuck recombination chase did not time out");
goal.stop();
helper.succeed();
}
}