diff --git a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/SuperMarioClient.java b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/SuperMarioClient.java index 300110c97..ff734ae2b 100644 --- a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/SuperMarioClient.java +++ b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/SuperMarioClient.java @@ -1,6 +1,7 @@ package fr.hugman.mubble.super_mario.client; import fr.hugman.mubble.super_mario.client.gui.screens.inventory.BumpableScreen; +import fr.hugman.mubble.super_mario.client.keybind.FreezeStruggleHandler; import fr.hugman.mubble.super_mario.client.model.SuperMarioModelLayers; import fr.hugman.mubble.super_mario.client.particle.SuperMarioParticleResources; import fr.hugman.mubble.super_mario.client.renderer.SuperMarioRenderPipelines; @@ -9,6 +10,7 @@ import com.google.common.reflect.Reflection; import fr.hugman.mubble.super_mario.world.inventory.SuperMarioMenuTypes; import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; import net.fabricmc.api.EnvType; import net.fabricmc.api.Environment; import net.minecraft.client.gui.screens.MenuScreens; @@ -25,6 +27,7 @@ public void onInitializeClient() { SuperMarioRenderers.registerEntities(); SuperMarioRenderers.registerBlockEntities(); SuperMarioParticleResources.register(); + ClientTickEvents.END_CLIENT_TICK.register(FreezeStruggleHandler::tick); } private static void registerHandledScreens() { diff --git a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/keybind/FreezeStruggleHandler.java b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/keybind/FreezeStruggleHandler.java new file mode 100644 index 000000000..d17a94664 --- /dev/null +++ b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/keybind/FreezeStruggleHandler.java @@ -0,0 +1,45 @@ +package fr.hugman.mubble.super_mario.client.keybind; + +import fr.hugman.mubble.super_mario.network.protocol.common.custom.StruggleFreePayload; +import fr.hugman.mubble.super_mario.world.entity.freeze.Freezing; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.minecraft.client.KeyMapping; +import net.minecraft.client.Minecraft; + +/** + * Lets a frozen player smash their way out of the ice a little sooner by hammering the movement + * keys. + *
+ * Only the presses themselves count, never the keys being held down: holding a direction is what a
+ * player does anyway when they run into the ice ball that froze them.
+ */
+@Environment(EnvType.CLIENT)
+public class FreezeStruggleHandler {
+ public static void tick(Minecraft client) {
+ var player = client.player;
+ var options = client.options;
+ if (player == null) {
+ return;
+ }
+
+ int presses = consumeClicks(options.keyUp) + consumeClicks(options.keyDown)
+ + consumeClicks(options.keyLeft) + consumeClicks(options.keyRight);
+ // the keys are consumed either way: a press held over from before the freeze is not a struggle
+ if (presses == 0 || !Freezing.isFrozen(player)) {
+ return;
+ }
+ for (int i = 0; i < presses; i++) {
+ ClientPlayNetworking.send(StruggleFreePayload.INSTANCE);
+ }
+ }
+
+ private static int consumeClicks(KeyMapping key) {
+ int presses = 0;
+ while (key.consumeClick()) {
+ presses++;
+ }
+ return presses;
+ }
+}
diff --git a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/mixin/FrozenEntityRendererMixin.java b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/mixin/FrozenEntityRendererMixin.java
new file mode 100644
index 000000000..a5c4118f9
--- /dev/null
+++ b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/mixin/FrozenEntityRendererMixin.java
@@ -0,0 +1,66 @@
+package fr.hugman.mubble.super_mario.client.mixin;
+
+import com.mojang.blaze3d.vertex.PoseStack;
+import fr.hugman.mubble.super_mario.client.references.SuperMarioRenderStateDataKeys;
+import fr.hugman.mubble.super_mario.client.renderer.entity.state.FreezeRenderData;
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.minecraft.client.renderer.SubmitNodeCollector;
+import net.minecraft.client.renderer.entity.EntityRenderer;
+import net.minecraft.client.renderer.entity.state.EntityRenderState;
+import net.minecraft.client.renderer.state.level.CameraRenderState;
+import net.minecraft.world.entity.Entity;
+import net.minecraft.world.phys.Vec3;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
+
+/**
+ * Wraps a frozen entity in the block of ice holding it, and holds its animations still while it is
+ * in there.
+ */
+@Mixin(EntityRenderer.class)
+@Environment(EnvType.CLIENT)
+public class FrozenEntityRendererMixin
+ * The offset is added here rather than around the ice cube below, because this is the one the
+ * whole entity is drawn from: the ice and whatever is caught inside it shudder as the one thing.
+ */
+ @Inject(method = "getRenderOffset", at = @At("RETURN"), cancellable = true)
+ private void super_mario$rattleTheIce(S state, CallbackInfoReturnable
+ * Vanilla reads them off a walk animation that runs itself down as soon as the entity stops
+ * moving, so a mob frozen mid-stride would ease into a resting pose over the next half second.
+ */
+ @Inject(method = "extractRenderState(Lnet/minecraft/world/entity/LivingEntity;Lnet/minecraft/client/renderer/entity/state/LivingEntityRenderState;F)V", at = @At("TAIL"))
+ private void super_mario$holdThePoseWhileFrozen(T entity, S state, float partialTicks, CallbackInfo ci) {
+ if (state.getData(SuperMarioRenderStateDataKeys.FREEZE) == null) {
+ return;
+ }
+ var snapshot = (FreezeSnapshot) entity;
+ state.walkAnimationPos = snapshot.frozenWalkPos();
+ state.walkAnimationSpeed = snapshot.frozenWalkSpeed();
+ }
+
+ /**
+ * Draws a frozen entity through the ice shader, which remaps it onto the ice block's palette.
+ *
+ * Whatever vanilla settled on is kept when it decided not to draw the entity at all, so an
+ * invisible mob stays invisible in there.
+ */
+ @ModifyReturnValue(method = "getRenderType", at = @At("RETURN"))
+ private @Nullable RenderType super_mario$iceRenderTypeWhileFrozen(@Nullable RenderType original, S state, boolean isBodyVisible, boolean forceTransparent, boolean appearGlowing) {
+ if (original == null || state.getData(SuperMarioRenderStateDataKeys.FREEZE) == null) {
+ return original;
+ }
+ return SuperMarioRenderTypes.getFrozenEntity(this.getTextureLocation(state));
+ }
+}
diff --git a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/references/SuperMarioRenderStateDataKeys.java b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/references/SuperMarioRenderStateDataKeys.java
index a052f0975..15733e984 100644
--- a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/references/SuperMarioRenderStateDataKeys.java
+++ b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/references/SuperMarioRenderStateDataKeys.java
@@ -1,5 +1,6 @@
package fr.hugman.mubble.super_mario.client.references;
+import fr.hugman.mubble.super_mario.client.renderer.entity.state.FreezeRenderData;
import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment;
import net.fabricmc.fabric.api.client.rendering.v1.RenderStateDataKey;
@@ -7,6 +8,9 @@
@Environment(EnvType.CLIENT)
public class SuperMarioRenderStateDataKeys {
+ /** The block of ice around the entity, {@code null} whenever it is not frozen. */
+ public static final RenderStateDataKey
+ * It is synced to every client and not only to the frozen entity itself: whoever is looking at it
+ * has to see the ice cube around it, and the frozen entity is very much not the only one looking.
+ */
+ public static final AttachmentType
+ * It is caught in the one method every sound an entity makes of its own accord goes through,
+ * rather than in {@code isSilent()} right below it: that flag is written back out when the entity
+ * is saved, and a mob that happened to be frozen at the time would come back mute for good.
+ */
+ @Inject(method = "playSound(Lnet/minecraft/sounds/SoundEvent;FF)V", at = @At("HEAD"), cancellable = true)
+ private void super_mario$muteWhileFrozen(SoundEvent sound, float volume, float pitch, CallbackInfo ci) {
+ if (Freezing.isFrozen((Entity) (Object) this)) {
+ ci.cancel();
+ }
+ }
+
+ /**
+ * Keeps a frozen entity from catching fire. Putting one out is left to
+ * {@link Freezing#freezeFor}, which does it the moment the ice takes hold.
+ */
+ @Inject(method = "setRemainingFireTicks", at = @At("HEAD"), cancellable = true)
+ private void super_mario$stayUnburntWhileFrozen(int ticks, CallbackInfo ci) {
+ if (ticks > 0 && Freezing.isFrozen((Entity) (Object) this)) {
+ ci.cancel();
+ }
+ }
+}
diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/mixin/FrozenLivingEntityMixin.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/mixin/FrozenLivingEntityMixin.java
new file mode 100644
index 000000000..1643e07b4
--- /dev/null
+++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/mixin/FrozenLivingEntityMixin.java
@@ -0,0 +1,111 @@
+package fr.hugman.mubble.super_mario.mixin;
+
+import com.llamalad7.mixinextras.injector.ModifyExpressionValue;
+import fr.hugman.mubble.super_mario.world.entity.freeze.FreezeSnapshot;
+import fr.hugman.mubble.super_mario.world.entity.freeze.Freezing;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.damagesource.DamageSource;
+import net.minecraft.world.entity.LivingEntity;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.phys.Vec3;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Unique;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
+
+/**
+ * Everything a block of ice changes about a living entity: the say it has over where it goes, the
+ * pose it holds while it is in there, what reaches it through the ice — and the footing it gives
+ * whoever climbs on top of it.
+ *
+ * @see Freezing
+ */
+@Mixin(LivingEntity.class)
+public class FrozenLivingEntityMixin implements FreezeSnapshot {
+ @Unique
+ private float super_mario$frozenWalkPos;
+ @Unique
+ private float super_mario$frozenWalkSpeed;
+
+ @Inject(method = "tick", at = @At("HEAD"))
+ private void super_mario$rememberThePose(CallbackInfo ci) {
+ LivingEntity this_ = (LivingEntity) (Object) this;
+ if (Freezing.isFrozen(this_)) {
+ return;
+ }
+ this.super_mario$frozenWalkPos = this_.walkAnimation.position();
+ this.super_mario$frozenWalkSpeed = this_.walkAnimation.speed();
+ }
+
+ @Override
+ public float frozenWalkPos() {
+ return this.super_mario$frozenWalkPos;
+ }
+
+ @Override
+ public float frozenWalkSpeed() {
+ return this.super_mario$frozenWalkSpeed;
+ }
+
+ @Inject(method = "isImmobile", at = @At("HEAD"), cancellable = true)
+ private void super_mario$immobileWhileFrozen(CallbackInfoReturnable
+ * The lift is the one thing vanilla only adds to a knockback when the target is standing on
+ * something, so telling it the ice is mid-air leaves the horizontal shove untouched and the
+ * vertical speed exactly as it was.
+ */
+ @ModifyExpressionValue(
+ method = "knockback(DDDLnet/minecraft/world/damagesource/DamageSource;FZ)V",
+ at = @At(value = "INVOKE", target = "Lnet/minecraft/world/entity/LivingEntity;onGround()Z"))
+ private boolean super_mario$noLiftWhileFrozen(boolean onGround) {
+ return onGround && !Freezing.isFrozen((LivingEntity) (Object) this);
+ }
+
+ @Inject(method = "hurtServer", at = @At("HEAD"))
+ private void super_mario$shieldWhileFrozen(ServerLevel level, DamageSource source, float amount, CallbackInfoReturnable
+ * Friction is a property of the block underfoot, and there is no block underfoot here: standing on
+ * an entity leaves vanilla reading the air below it and handing out ordinary ground. Reading the
+ * ice off the entity instead is what makes the top of a frozen mob as slippery as it looks.
+ */
+ @ModifyExpressionValue(
+ method = "travelInAir",
+ at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/block/Block;getFriction()F"))
+ private float super_mario$slipperyOnTopOfIce(float friction) {
+ return Freezing.isStandingOnFrozen((LivingEntity) (Object) this) ? Blocks.ICE.getFriction() : friction;
+ }
+}
diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/network/protocol/SuperMarioServerReceivers.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/network/protocol/SuperMarioServerReceivers.java
new file mode 100644
index 000000000..5fd3b3cb9
--- /dev/null
+++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/network/protocol/SuperMarioServerReceivers.java
@@ -0,0 +1,12 @@
+package fr.hugman.mubble.super_mario.network.protocol;
+
+import fr.hugman.mubble.super_mario.network.protocol.common.custom.SuperMarioPayloadTypes;
+import fr.hugman.mubble.super_mario.world.entity.freeze.Freezing;
+import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
+
+public class SuperMarioServerReceivers {
+ public static void register() {
+ ServerPlayNetworking.registerGlobalReceiver(SuperMarioPayloadTypes.STRUGGLE_FREE, (payload, context) ->
+ context.server().execute(() -> Freezing.struggle(context.player())));
+ }
+}
diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/network/protocol/common/custom/StruggleFreePayload.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/network/protocol/common/custom/StruggleFreePayload.java
new file mode 100644
index 000000000..32daa8143
--- /dev/null
+++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/network/protocol/common/custom/StruggleFreePayload.java
@@ -0,0 +1,19 @@
+package fr.hugman.mubble.super_mario.network.protocol.common.custom;
+
+import net.minecraft.network.RegistryFriendlyByteBuf;
+import net.minecraft.network.codec.StreamCodec;
+import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
+
+/**
+ * Sent every time a frozen player smashes one of their movement keys, to melt a bit of the ice they
+ * are stuck in.
+ */
+public class StruggleFreePayload implements CustomPacketPayload {
+ public static final StruggleFreePayload INSTANCE = new StruggleFreePayload();
+ public static final StreamCodec
+ * Being unable to move is not much of a punishment for someone who can still mine the block in front
+ * of them, so the whole of mining, placing, using and hitting goes with it. The callbacks fire on the
+ * client as much as on the server, which is what keeps a frozen player from watching a block crack
+ * open on their own screen before the server tells them otherwise.
+ *
+ * @see Freezing
+ */
+public final class FreezeEvents {
+ private FreezeEvents() {
+ }
+
+ public static void register() {
+ AttackBlockCallback.EVENT.register((player, level, hand, pos, direction) -> refuseWhileFrozen(player));
+ PlayerBlockBreakEvents.BEFORE.register((level, player, pos, state, blockEntity) -> !Freezing.isFrozen(player));
+ UseBlockCallback.EVENT.register((player, level, hand, hitResult) -> refuseWhileFrozen(player));
+ UseItemCallback.EVENT.register((player, level, hand) -> refuseWhileFrozen(player));
+ AttackEntityCallback.EVENT.register((player, level, hand, entity, hitResult) -> refuseWhileFrozen(player));
+ UseEntityCallback.EVENT.register((player, level, hand, entity, hitResult) -> refuseWhileFrozen(player));
+ }
+
+ private static InteractionResult refuseWhileFrozen(Entity player) {
+ return Freezing.isFrozen(player) ? InteractionResult.FAIL : InteractionResult.PASS;
+ }
+}
diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/freeze/FreezeResistance.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/freeze/FreezeResistance.java
new file mode 100644
index 000000000..7b746d02a
--- /dev/null
+++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/freeze/FreezeResistance.java
@@ -0,0 +1,14 @@
+package fr.hugman.mubble.super_mario.world.entity.freeze;
+
+/**
+ * How well an entity holds up against being frozen, which is what tells apart the three outcomes an
+ * ice ball can have on it.
+ */
+public enum FreezeResistance {
+ /** Trapped for the full duration. */
+ NONE,
+ /** Big enough to crack the ice open well before it melts. */
+ TOUGH,
+ /** Not to be trapped at all: the ice shatters on impact and leaves nothing behind. */
+ IMMUNE
+}
diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/freeze/FreezeSnapshot.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/freeze/FreezeSnapshot.java
new file mode 100644
index 000000000..5dda3d6d8
--- /dev/null
+++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/freeze/FreezeSnapshot.java
@@ -0,0 +1,14 @@
+package fr.hugman.mubble.super_mario.world.entity.freeze;
+
+/**
+ * The pose an entity was caught in, implemented by every living entity through the mixin on
+ * {@code LivingEntity}. The walk animation keeps running down to a standstill however immobile the
+ * entity is, so the limbs are read back from here rather than from it.
+ */
+public interface FreezeSnapshot {
+ /** @return how far into its walk cycle the entity was when it froze */
+ float frozenWalkPos();
+
+ /** @return how wide the entity was swinging its limbs when it froze */
+ float frozenWalkSpeed();
+}
diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/freeze/FreezeState.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/freeze/FreezeState.java
new file mode 100644
index 000000000..2901722e0
--- /dev/null
+++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/freeze/FreezeState.java
@@ -0,0 +1,76 @@
+package fr.hugman.mubble.super_mario.world.entity.freeze;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+import io.netty.buffer.ByteBuf;
+import net.minecraft.network.codec.ByteBufCodecs;
+import net.minecraft.network.codec.StreamCodec;
+
+/**
+ * The block of ice an entity is trapped in, attached to it for as long as it lasts.
+ *
+ * Both ends are stored as absolute game times rather than as a countdown, so that the whole freeze
+ * only has to be sent to the clients once: they hold the very same clock and can work out on their
+ * own how far along it is on any given frame. A countdown would have to be synced every single tick.
+ *
+ * @param startedAt the game time the entity was frozen at
+ * @param endsAt the game time the entity thaws at, unless it breaks free sooner, or
+ * {@link #NEVER} for a freeze that never runs out on its own
+ */
+public record FreezeState(long startedAt, long endsAt) {
+ public static final Codec
+ * A frozen entity is one carrying a {@link FreezeState} attachment and nothing else, which is what
+ * makes any living entity freezable without each of them having to know about it.
+ *
+ * @see FreezeState
+ */
+public final class Freezing {
+ /** How long a regular entity stays trapped, in ticks. */
+ public static final int DURATION = 260;
+ /** How long a {@link FreezeResistance#TOUGH} entity stays trapped, in ticks. */
+ public static final int TOUGH_DURATION = 80;
+ /** How much of the remaining freeze a single struggle from a frozen player melts away, in ticks. */
+ public static final int STRUGGLE_RELIEF = 15;
+ /** How much of the remaining freeze a single point of damage melts away, in ticks. */
+ public static final int MELT_PER_DAMAGE = 20;
+ /** How long the ice is left alone after a hit, in ticks, so that nothing grinds it away at once. */
+ public static final int CRACK_COOLDOWN = 10;
+ /** How long before the end the block of ice starts rattling, in ticks. */
+ public static final int RATTLE_DURATION = 40;
+
+ /** Hitbox volume, in cubic blocks, from which an entity counts as big: above a horse, below an iron golem. */
+ public static final double BIG_HITBOX_VOLUME = 2.0D;
+
+ /** Horizontal speed a shoved block of ice sets off at, in blocks per tick. */
+ public static final double SLIDE_SPEED = 0.4D;
+ /** Horizontal speed, in blocks per tick, from which running into a wall shatters the ice outright. */
+ public static final double SHATTER_SPEED = 0.25D;
+ /** How much horizontal speed a sliding block of ice keeps every tick while on the ground. */
+ private static final double GROUND_DRAG = 0.94D;
+ /** How much horizontal speed a falling block of ice keeps every tick. */
+ private static final double AIR_DRAG = 0.98D;
+ /** How much horizontal speed a block of ice keeps every tick while it is in water. */
+ private static final double WATER_DRAG = 0.8D;
+ /** How much vertical speed a block of ice keeps every tick while it is in water. */
+ private static final double WATER_BOB_DRAG = 0.7D;
+ /**
+ * How much of a floating block of ice comes to rest below the waterline, as a fraction of its
+ * height.
+ *
+ * Real ice rides with almost all of itself under, which would leave whoever is inside it out of
+ * air. Sitting this high keeps their head clear and the cube plainly in view.
+ */
+ private static final double FLOAT_SUBMERSION = 0.6D;
+ /** Horizontal speeds below this are rounded down to a standstill, so that ice does not creep. */
+ private static final double SLIDE_EPSILON = 1.0e-3D;
+ /** How far around the block of ice a player counts as pushing it: being solid, it is never overlapped. */
+ private static final double PUSH_REACH = 0.2D;
+ /** How far below the top of the ice a player has to stand to shove it rather than ride it. */
+ private static final double PUSH_HEADROOM = 0.1D;
+ /** How far below its feet an entity looks for the block of ice it might be standing on. */
+ private static final double STANDING_REACH = 1.0e-3D;
+
+ private static final int THAW_PARTICLE_COUNT = 24;
+ private static final double THAW_PARTICLE_SPEED = 0.15D;
+ private static final int CRACK_PARTICLE_COUNT = 6;
+ private static final double CRACK_PARTICLE_SPEED = 0.05D;
+
+ private Freezing() {
+ }
+
+ @Nullable
+ public static FreezeState getState(Entity entity) {
+ return entity.getAttached(SuperMarioAttachmentTypes.FREEZE);
+ }
+
+ public static boolean isFrozen(Entity entity) {
+ return getState(entity) != null;
+ }
+
+ /**
+ * @return how much longer the entity stays frozen, in ticks, or {@code 0} when it is not frozen
+ */
+ public static int getRemainingTicks(Entity entity) {
+ var state = getState(entity);
+ return state == null ? 0 : state.remaining(entity.level().getGameTime());
+ }
+
+ /**
+ * Whether no block of ice can hold this entity, whatever put it there — a command included.
+ *
+ * Narrower than {@link #resistanceOf}: a creative player shrugs an ice ball off but can still be
+ * frozen by hand, so creative is not in here.
+ */
+ public static boolean isUnfreezable(Entity entity) {
+ return !(entity instanceof LivingEntity)
+ || entity.isSpectator()
+ || entity.is(SuperMarioEntityTypeTags.FREEZE_IMMUNE);
+ }
+
+ /** How well the entity holds up against being frozen by an ice ball. */
+ public static FreezeResistance resistanceOf(Entity entity) {
+ if (isUnfreezable(entity)) {
+ return FreezeResistance.IMMUNE;
+ }
+ // a creative player is busy building, and is not there to be caught out by a stray ice ball
+ if (entity instanceof Player player && player.isCreative()) {
+ return FreezeResistance.IMMUNE;
+ }
+ return isBig(entity) ? FreezeResistance.TOUGH : FreezeResistance.NONE;
+ }
+
+ /** Whether the entity is standing on top of a block of ice someone else is trapped in. */
+ public static boolean isStandingOnFrozen(Entity entity) {
+ // the sweep is not free, so it is kept behind the cheap tell: on the ground with no block holding it up
+ if (!entity.onGround() || entity.mainSupportingBlockPos.isPresent()) {
+ return false;
+ }
+ var feet = entity.getBoundingBox();
+ var underfoot = new AABB(feet.minX, feet.minY - STANDING_REACH, feet.minZ, feet.maxX, feet.minY, feet.maxZ);
+ return !entity.level().getEntities(entity, underfoot, Freezing::isFrozen).isEmpty();
+ }
+
+ public static boolean isBig(Entity entity) {
+ double width = entity.getBbWidth();
+ return width * width * entity.getBbHeight() >= BIG_HITBOX_VOLUME;
+ }
+
+ /** @return how long the entity would stay trapped, in ticks, were it frozen right now */
+ public static int durationFor(Entity entity) {
+ return resistanceOf(entity) == FreezeResistance.TOUGH ? TOUGH_DURATION : DURATION;
+ }
+
+ /**
+ * Traps an entity in a block of ice, unless it is one of those nothing can hold. The freeze
+ * itself costs no health, on the way in or on the way out.
+ *
+ * @return how the entity took it, which tells whether it ended up frozen at all
+ */
+ public static FreezeResistance freeze(ServerLevel level, LivingEntity entity) {
+ var resistance = resistanceOf(entity);
+ if (resistance == FreezeResistance.IMMUNE) {
+ return resistance;
+ }
+ freezeFor(level, entity, resistance == FreezeResistance.TOUGH ? TOUGH_DURATION : DURATION);
+ return resistance;
+ }
+
+ /** Traps an entity in a block of ice for a set number of ticks, whatever it is. */
+ public static void freezeFor(ServerLevel level, LivingEntity entity, int ticks) {
+ freezeWith(level, entity, FreezeState.lasting(level.getGameTime(), ticks));
+ }
+
+ /** Traps an entity in a block of ice that never runs out on its own. */
+ public static void freezeEndlessly(ServerLevel level, LivingEntity entity) {
+ freezeWith(level, entity, FreezeState.endless(level.getGameTime()));
+ }
+
+ private static void freezeWith(ServerLevel level, LivingEntity entity, FreezeState state) {
+ entity.setAttached(SuperMarioAttachmentTypes.FREEZE, state);
+ entity.setDeltaMovement(Vec3.ZERO);
+ entity.clearFire();
+ level.playSound(null, entity.getX(), entity.getY(), entity.getZ(), SoundEvents.GLASS_PLACE, SoundSource.NEUTRAL, 0.8F, 1.2F);
+ }
+
+ /**
+ * Ticks the freeze of a single entity, thawing it once its time is up. Server side only: the
+ * clients hold the same {@link FreezeState} and work out where it is at on their own.
+ */
+ public static void tick(Entity entity) {
+ var state = getState(entity);
+ if (state == null || !(entity.level() instanceof ServerLevel level)) {
+ return;
+ }
+ // an entity that has since turned unfreezable — a player gone to spectator, say — is let out
+ if (state.hasExpired(level.getGameTime()) || !entity.isAlive() || isUnfreezable(entity)) {
+ thaw(level, entity);
+ return;
+ }
+ shoveAroundBy(level, entity);
+ }
+
+ /** @return whether the entity was frozen in the first place */
+ public static boolean thaw(ServerLevel level, Entity entity) {
+ if (entity.removeAttached(SuperMarioAttachmentTypes.FREEZE) == null) {
+ return false;
+ }
+ level.playSound(null, entity.getX(), entity.getY(), entity.getZ(), SoundEvents.GLASS_BREAK, SoundSource.NEUTRAL, 0.8F, 1.2F);
+ level.sendParticles(new BlockParticleOption(ParticleTypes.BLOCK, Blocks.ICE.defaultBlockState()),
+ entity.getX(), entity.getY(0.5D), entity.getZ(),
+ THAW_PARTICLE_COUNT,
+ entity.getBbWidth() / 2.0D, entity.getBbHeight() / 2.0D, entity.getBbWidth() / 2.0D,
+ THAW_PARTICLE_SPEED);
+ return true;
+ }
+
+ /**
+ * Whether the block of ice takes this hit in place of whoever is inside it. It takes everything
+ * but fire, which melts it, and what nothing is ever safe from — the void and {@code /kill}.
+ */
+ public static boolean shields(Entity entity, DamageSource source) {
+ return isFrozen(entity)
+ && !source.is(SuperMarioDamageTypeTags.MELTS_FROZEN_ENTITIES)
+ && !source.is(DamageTypeTags.BYPASSES_INVULNERABILITY);
+ }
+
+ /**
+ * Puts a hit into the block of ice rather than into whoever is inside it. A hit that empties the
+ * ice does not thaw it here — the entity has to still count as frozen for the rest of this hit
+ * to be turned away, so {@link #tick} lets it out on the next tick.
+ */
+ public static void absorb(ServerLevel level, Entity entity, DamageSource source, float amount) {
+ var state = getState(entity);
+ if (state == null) {
+ return;
+ }
+ if (source.is(SuperMarioDamageTypeTags.MELTS_FROZEN_ENTITIES)) {
+ // thawed right away, so that the fire that broke the ice still reaches what was inside it
+ thaw(level, entity);
+ return;
+ }
+ if (source.is(DamageTypeTags.BYPASSES_INVULNERABILITY) || entity.invulnerableTime > CRACK_COOLDOWN) {
+ return;
+ }
+ entity.invulnerableTime = CRACK_COOLDOWN * 2;
+ entity.setAttached(SuperMarioAttachmentTypes.FREEZE, state.shortenedBy(Math.max((int) (amount * MELT_PER_DAMAGE), 1)));
+ level.playSound(null, entity.getX(), entity.getY(), entity.getZ(), SoundEvents.GLASS_HIT, SoundSource.NEUTRAL, 0.9F, 1.4F);
+ level.sendParticles(new BlockParticleOption(ParticleTypes.BLOCK, Blocks.ICE.defaultBlockState()),
+ entity.getX(), entity.getY(0.5D), entity.getZ(),
+ CRACK_PARTICLE_COUNT,
+ entity.getBbWidth() / 2.0D, entity.getBbHeight() / 2.0D, entity.getBbWidth() / 2.0D,
+ CRACK_PARTICLE_SPEED);
+ shoveAwayFrom(entity, source);
+ }
+
+ /**
+ * Sends the block of ice skidding away from whatever just hit it. Vanilla knocks back only once a
+ * hit has landed, and a hit the ice turns away never does, so the shove is dealt out here.
+ */
+ private static void shoveAwayFrom(Entity entity, DamageSource source) {
+ var from = source.getSourcePosition();
+ // a hit with nowhere to come from — drowning, starvation — says nothing about where to send it
+ if (from != null) {
+ shove(entity, entity.position().subtract(from));
+ }
+ }
+
+ /**
+ * Melts a slice off the remaining freeze, which is what a frozen player smashing their movement
+ * keys buys them.
+ *
+ * @return whether the entity was frozen in the first place
+ */
+ public static boolean struggle(Entity entity) {
+ var state = getState(entity);
+ if (state == null) {
+ return false;
+ }
+ entity.setAttached(SuperMarioAttachmentTypes.FREEZE, state.shortenedBy(STRUGGLE_RELIEF));
+ return true;
+ }
+
+ /**
+ * Moves a frozen entity for the tick: it only falls, floats and slides. A slide that meets a wall
+ * before it has run itself out shatters against it.
+ */
+ public static void travelFrozen(LivingEntity entity) {
+ var movement = entity.getDeltaMovement();
+ double submerged = submergedFraction(entity);
+ double rise = movement.y() - entity.getGravity();
+ double drag;
+ if (submerged > 0.0D) {
+ // Archimedes: the lift is what the ice displaces, scaled so that it exactly cancels
+ // gravity at FLOAT_SUBMERSION. A block riding lower than that is pushed up, one riding
+ // higher falls back, and it comes to rest at the surface.
+ rise = (rise + entity.getGravity() * submerged / FLOAT_SUBMERSION) * WATER_BOB_DRAG;
+ drag = WATER_DRAG;
+ } else {
+ drag = entity.onGround() ? GROUND_DRAG : AIR_DRAG;
+ }
+ entity.setDeltaMovement(movement.x() * drag, rise, movement.z() * drag);
+
+ double speed = entity.getDeltaMovement().horizontalDistance();
+ entity.move(MoverType.SELF, entity.getDeltaMovement());
+
+ if (entity.horizontalCollision && speed >= SHATTER_SPEED && entity.level() instanceof ServerLevel level) {
+ thaw(level, entity);
+ return;
+ }
+
+ // rounding the last of a slide down keeps blocks of ice from drifting forever
+ var slowed = entity.getDeltaMovement();
+ if (Math.abs(slowed.x()) < SLIDE_EPSILON && Math.abs(slowed.z()) < SLIDE_EPSILON) {
+ entity.setDeltaMovement(0.0D, slowed.y(), 0.0D);
+ }
+ }
+
+ /** @return how much of the entity's height is under water, from 0 to 1 */
+ private static double submergedFraction(Entity entity) {
+ double height = entity.getBbHeight();
+ if (height <= 0.0D) {
+ return 0.0D;
+ }
+ return Math.min(entity.getFluidHeight(FluidTags.WATER) / height, 1.0D);
+ }
+
+ /** Sends the block of ice sliding whenever a player walks into its side. */
+ private static void shoveAroundBy(ServerLevel level, Entity entity) {
+ var hitBox = entity.getBoundingBox();
+ var reach = hitBox.inflate(PUSH_REACH, 0.0D, PUSH_REACH);
+
+ for (Player player : level.getEntitiesOfClass(Player.class, reach, EntitySelector.NO_SPECTATORS)) {
+ // whoever stands on top of the ice rides it, they do not push it
+ if (player.getBoundingBox().minY >= hitBox.maxY - PUSH_HEADROOM) {
+ continue;
+ }
+ var heading = flatten(player.getKnownMovement());
+ if (heading == null) {
+ continue;
+ }
+ // ...and only when they are heading into the ice, rather than away from it
+ if (hitBox.getCenter().subtract(player.position()).dot(heading) <= 0.0D) {
+ continue;
+ }
+ // a player chasing the ice they just shoved must not keep resetting its speed
+ if (entity.getDeltaMovement().dot(heading) >= SLIDE_SPEED - SLIDE_EPSILON) {
+ return;
+ }
+ shove(entity, heading);
+ return;
+ }
+ }
+
+ /**
+ * Sends the block of ice sliding along a heading, keeping whatever vertical motion it had. The
+ * heading is taken as it comes, so a shove that lands at an angle sends the ice off at that angle.
+ */
+ public static void shove(Entity entity, Vec3 heading) {
+ var flat = flatten(heading);
+ if (flat == null) {
+ return;
+ }
+ var push = flat.scale(SLIDE_SPEED);
+ entity.setDeltaMovement(push.x(), entity.getDeltaMovement().y(), push.z());
+ // a frozen player moves itself: the server has to tell it where it is being sent
+ if (entity instanceof ServerPlayer player) {
+ player.connection.send(new ClientboundSetEntityMotionPacket(player));
+ }
+ }
+
+ /** @see #shove(Entity, Vec3) */
+ public static void shove(Entity entity, Direction direction) {
+ shove(entity, direction.getUnitVec3());
+ }
+
+ /**
+ * @return {@code heading} flattened onto the ground and brought down to unit length, or
+ * {@code null} when there is not enough of it left to point anywhere
+ */
+ @Nullable
+ private static Vec3 flatten(Vec3 heading) {
+ if (heading.horizontalDistanceSqr() < SLIDE_EPSILON * SLIDE_EPSILON) {
+ return null;
+ }
+ return new Vec3(heading.x(), 0.0D, heading.z()).normalize();
+ }
+}
diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/Iceball.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/Iceball.java
index 40272dc50..9c5aa2e54 100644
--- a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/Iceball.java
+++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/Iceball.java
@@ -5,6 +5,7 @@
import fr.hugman.mubble.super_mario.sounds.SuperMarioSounds;
import fr.hugman.mubble.super_mario.world.attribute.SuperMarioEnvironmentAttributes;
import fr.hugman.mubble.super_mario.world.entity.SuperMarioEntityTypes;
+import fr.hugman.mubble.super_mario.world.entity.freeze.Freezing;
import fr.hugman.mubble.world.attribute.BlockTransform;
import fr.hugman.mubble.world.entity.projectile.Ball;
import net.minecraft.core.BlockPos;
@@ -13,10 +14,10 @@
import net.minecraft.core.Holder;
import net.minecraft.core.particles.ParticleOptions;
import net.minecraft.core.particles.ParticleTypes;
+import net.minecraft.server.level.ServerLevel;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.sounds.SoundSource;
-import net.minecraft.world.effect.MobEffectInstance;
-import net.minecraft.world.effect.MobEffects;
+import net.minecraft.world.damagesource.DamageSource;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.LivingEntity;
@@ -26,9 +27,17 @@
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.EntityHitResult;
+import net.minecraft.world.phys.Vec3;
public class Iceball extends Ball {
private static final ClientAsset.ResourceTexture TEXTURE = new ClientAsset.ResourceTexture(SuperMario.id("entity/iceball"));
+ /**
+ * How far the target may have moved during the hit and still be trapped by it, in blocks.
+ *
+ * Anything further has not been knocked about, it has left: an enderman teleports on being hit by
+ * a projectile, and it lands well clear of this.
+ */
+ private static final double DODGE_LEEWAY = 1.0D;
public Iceball(EntityType extends Iceball> type, Level level) {
super(type, level);
@@ -61,20 +70,29 @@ protected ParticleOptions getTrailParticle() {
protected void onHitEntity(EntityHitResult result) {
super.onHitEntity(result);
Entity entity = result.getEntity();
+ // an entity already in a block of ice is a wall as far as the next ice ball is concerned:
+ // it shatters against it, leaving whoever is inside no better and no worse off
+ if (Freezing.isFrozen(entity)) {
+ this.finalHit();
+ return;
+ }
Entity owner = this.getOwner();
float damage = entity instanceof SnowGolem ? 1.0F : 3.0F;
+ DamageSource source = this.damageSources().source(SuperMarioDamageTypeIds.ICEBALL, this, owner);
if (owner instanceof LivingEntity livingEntity) {
livingEntity.setLastHurtMob(entity);
}
- if (!this.level().isClientSide()) {
- if (!(entity instanceof SnowGolem) && entity instanceof LivingEntity) {
- LivingEntity livingEntity = (LivingEntity) entity;
- livingEntity.addEffect(new MobEffectInstance(MobEffects.SLOWNESS, 40, 1));
- }
- }
- entity.hurt(this.damageSources().source(SuperMarioDamageTypeIds.ICEBALL, this, this.getOwner()), damage);
+ Vec3 struckAt = entity.position();
+ entity.hurt(source, damage);
+ // snow golems are made of the stuff: an ice ball is no more to them than the hit itself. And
+ // whatever blinked out of the way — an enderman — is no longer there for the ice to close on.
+ if (this.level() instanceof ServerLevel level && !(entity instanceof SnowGolem)
+ && entity instanceof LivingEntity living && living.isAlive()
+ && living.distanceToSqr(struckAt) < DODGE_LEEWAY * DODGE_LEEWAY) {
+ Freezing.freeze(level, living);
+ }
this.finalHit(SuperMarioSounds.ICEBALL_HIT_ENTITY);
}
diff --git a/mubble-super_mario/src/main/resources/super_mario.mixins.json b/mubble-super_mario/src/main/resources/super_mario.mixins.json
index 3c80d4dd5..cc2718da5 100644
--- a/mubble-super_mario/src/main/resources/super_mario.mixins.json
+++ b/mubble-super_mario/src/main/resources/super_mario.mixins.json
@@ -4,6 +4,8 @@
"compatibilityLevel": "JAVA_21",
"mixins": [
"EntityMixin",
+ "FrozenEntityMixin",
+ "FrozenLivingEntityMixin",
"LivingEntityMixin"
],
"injectors": {
diff --git a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/PowerUpCommandGameTest.java b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/PowerUpCommandGameTest.java
index 3798d58f5..d1b9a8ee3 100644
--- a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/PowerUpCommandGameTest.java
+++ b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/PowerUpCommandGameTest.java
@@ -4,12 +4,10 @@
import fr.hugman.mubble.test.gametest.datapack.PowerUpFixtures;
import fr.hugman.mubble.test.gametest.support.TestPlayers;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
-import net.minecraft.commands.CommandSource;
-import net.minecraft.commands.CommandSourceStack;
-import net.minecraft.network.chat.Component;
-import net.minecraft.server.permissions.PermissionSet;
import net.minecraft.gametest.framework.GameTestHelper;
-import net.minecraft.server.level.ServerPlayer;
+
+import static fr.hugman.mubble.test.gametest.support.TestCommands.run;
+import static fr.hugman.mubble.test.gametest.support.TestCommands.succeeds;
/**
* {@code /powerup}, the way a power-up is handed out without an item. It is also the only user of
@@ -84,59 +82,4 @@ public void anUnknownPowerUpIsRefused(GameTestHelper helper) {
helper.succeed();
}
-
- private static void run(GameTestHelper helper, ServerPlayer player, String command) {
- var outcome = perform(helper, player, command);
- helper.assertTrue(outcome.succeeded, "`/" + command + "` failed: " + outcome.message);
- }
-
- private static boolean succeeds(GameTestHelper helper, ServerPlayer player, String command) {
- return perform(helper, player, command).succeeded;
- }
-
- /** Runs {@code command} as the server, on behalf of {@code player}, keeping whatever it answered. */
- private static Outcome perform(GameTestHelper helper, ServerPlayer player, String command) {
- var server = helper.getLevel().getServer();
- var outcome = new Outcome();
-
- CommandSourceStack source = new CommandSourceStack(
- new CommandSource() {
- @Override
- public void sendSystemMessage(Component message) {
- outcome.message = outcome.message + " | " + message.getString();
- }
-
- @Override
- public boolean acceptsSuccess() {
- return true;
- }
-
- @Override
- public boolean acceptsFailure() {
- return true;
- }
-
- @Override
- public boolean shouldInformAdmins() {
- return false;
- }
- },
- player.position(),
- player.getRotationVector(),
- helper.getLevel(),
- PermissionSet.ALL_PERMISSIONS,
- "gametest",
- Component.literal("gametest"),
- server,
- player
- );
-
- server.getCommands().performPrefixedCommand(source.withCallback((success, result) -> outcome.succeeded = success), command);
- return outcome;
- }
-
- private static final class Outcome {
- boolean succeeded;
- String message = "";
- }
}
diff --git a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/BallGameTest.java b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/BallGameTest.java
index b6abc4bf3..55b0dd1ae 100644
--- a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/BallGameTest.java
+++ b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/BallGameTest.java
@@ -1,11 +1,12 @@
package fr.hugman.mubble.test.gametest.super_mario;
import fr.hugman.mubble.super_mario.world.entity.SuperMarioEntityTypes;
+import fr.hugman.mubble.super_mario.world.entity.freeze.Freezing;
+import fr.hugman.mubble.super_mario.world.entity.projectile.Iceball;
import fr.hugman.mubble.world.entity.projectile.Ball;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTestHelper;
-import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.entity.animal.pig.Pig;
@@ -68,21 +69,42 @@ public void fireballSetsWhatItHitsOnFire(GameTestHelper helper) {
}
@GameTest(maxTicks = 100)
- public void iceballSlowsDownWhatItHits(GameTestHelper helper) {
+ public void iceballFreezesWhatItHits(GameTestHelper helper) {
buildFloor(helper);
Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET_POS);
shootAt(helper, SuperMarioEntityTypes.ICEBALL, TARGET_POS);
helper.succeedWhen(() -> {
- // Checked first: the vanilla assertion right below reports failures without any context.
- helper.assertTrue(pig.hasEffect(MobEffects.SLOWNESS), "the iceball did not slow the pig down");
- helper.assertLivingEntityHasMobEffect(pig, MobEffects.SLOWNESS, 1);
+ helper.assertTrue(Freezing.isFrozen(pig), "the iceball did not freeze the pig");
helper.assertTrue(pig.getHealth() < pig.getMaxHealth(), "the iceball did not hurt the pig");
helper.assertTrue(pig.getRemainingFireTicks() <= 0, "the iceball set the pig on fire");
});
}
+ @GameTest(maxTicks = 100)
+ public void iceballShattersOnWhatIsAlreadyFrozen(GameTestHelper helper) {
+ buildFloor(helper);
+ Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET_POS);
+
+ Freezing.freezeFor(helper.getLevel(), pig, 200);
+ float health = pig.getHealth();
+ // the game time the ice is due to break: unlike the remaining ticks it only moves if something
+ // shortens the freeze, so it tells a chipped block of ice apart from one merely counting down
+ long endsAt = Freezing.getState(pig).endsAt();
+
+ shootAt(helper, SuperMarioEntityTypes.ICEBALL, TARGET_POS);
+
+ // a block of ice is a wall to the next ice ball: it bursts against it and leaves it as it was
+ helper.succeedWhen(() -> {
+ helper.assertTrue(helper.getLevel().getEntitiesOfClass(Iceball.class, helper.getBounds()).isEmpty(),
+ "the iceball did not burst on the frozen pig");
+ helper.assertTrue(pig.getHealth() == health, "the iceball hurt a pig that was already frozen");
+ helper.assertTrue(Freezing.getState(pig) != null && Freezing.getState(pig).endsAt() == endsAt,
+ "the iceball chipped away at the ice it burst on");
+ });
+ }
+
/** Fills the bottom layer of the arena with stone, so that nothing falls out of the test. */
private static void buildFloor(GameTestHelper helper) {
for (int x = 0; x < ARENA_SIZE; x++) {
diff --git a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/FreezeCommandGameTest.java b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/FreezeCommandGameTest.java
new file mode 100644
index 000000000..68caa73cc
--- /dev/null
+++ b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/FreezeCommandGameTest.java
@@ -0,0 +1,150 @@
+package fr.hugman.mubble.test.gametest.super_mario;
+
+import fr.hugman.mubble.super_mario.world.entity.freeze.Freezing;
+import fr.hugman.mubble.test.gametest.support.Arena;
+import fr.hugman.mubble.test.gametest.support.TestPlayers;
+import net.fabricmc.fabric.api.gametest.v1.GameTest;
+import net.minecraft.core.BlockPos;
+import net.minecraft.gametest.framework.GameTestHelper;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.entity.EntityTypes;
+import net.minecraft.world.entity.animal.pig.Pig;
+
+import static fr.hugman.mubble.test.gametest.support.TestCommands.perform;
+import static fr.hugman.mubble.test.gametest.support.TestCommands.run;
+import static fr.hugman.mubble.test.gametest.support.TestCommands.succeeds;
+
+/**
+ * {@code /freeze}, the way an entity is put in a block of ice by hand.
+ *
+ * Every target is named by its UUID rather than picked with a type selector: game tests share one
+ * level, and {@code @e[type=pig]} would just as happily reach into whatever another test is running.
+ */
+public class FreezeCommandGameTest {
+ private static final BlockPos TARGET = new BlockPos(4, Arena.FLOOR_Y + 1, 3);
+
+ @GameTest
+ public void aTimeFreezesTheTarget(GameTestHelper helper) {
+ var pig = target(helper);
+
+ run(helper, operator(helper), "freeze set " + pig.getUUID() + " 100");
+
+ helper.assertTrue(Freezing.isFrozen(pig), "the command left the pig unfrozen");
+ helper.succeed();
+ }
+
+ @GameTest
+ public void aTimeOfZeroThawsTheTarget(GameTestHelper helper) {
+ var pig = target(helper);
+ var operator = operator(helper);
+ run(helper, operator, "freeze set " + pig.getUUID() + " 100");
+
+ run(helper, operator, "freeze set " + pig.getUUID() + " 0");
+
+ helper.assertFalse(Freezing.isFrozen(pig), "the command left the pig in the ice");
+ helper.succeed();
+ }
+
+ @GameTest
+ public void omittingTheValueFails(GameTestHelper helper) {
+ var pig = target(helper);
+ var operator = operator(helper);
+
+ helper.assertFalse(succeeds(helper, operator, "freeze set " + pig.getUUID()),
+ "the time is mandatory: leaving it off must not quietly flip the target");
+ helper.assertFalse(Freezing.isFrozen(pig), "and the pig should have been left alone");
+
+ helper.succeed();
+ }
+
+ @GameTest
+ public void aDurationIsHonoured(GameTestHelper helper) {
+ var pig = target(helper);
+
+ run(helper, operator(helper), "freeze set " + pig.getUUID() + " 7");
+
+ helper.assertTrue(Freezing.getRemainingTicks(pig) == 7,
+ "the freeze should last exactly the number of ticks asked for");
+ helper.succeed();
+ }
+
+ @GameTest
+ public void anInfiniteFreezeNeverRunsOut(GameTestHelper helper) {
+ var pig = target(helper);
+
+ run(helper, operator(helper), "freeze set " + pig.getUUID() + " infinite");
+
+ var state = Freezing.getState(pig);
+ helper.assertTrue(state != null && state.isEndless(), "the freeze should have been endless");
+ // an endless freeze outlasts anything a countdown could hold
+ helper.assertFalse(state.hasExpired(pig.level().getGameTime() + 1_000_000L),
+ "an endless freeze ran out anyway");
+ helper.succeed();
+ }
+
+ @GameTest
+ public void aNewTimeReplacesTheOldOne(GameTestHelper helper) {
+ var pig = target(helper);
+ var operator = operator(helper);
+
+ run(helper, operator, "freeze set " + pig.getUUID() + " 200");
+ run(helper, operator, "freeze set " + pig.getUUID() + " 20");
+
+ helper.assertTrue(Freezing.isFrozen(pig), "re-setting the time let the pig out");
+ helper.assertTrue(Freezing.getRemainingTicks(pig) == 20,
+ "the second time should have replaced the first, not been turned down");
+ helper.succeed();
+ }
+
+ @GameTest
+ public void thawingWhatIsNotFrozenFails(GameTestHelper helper) {
+ var pig = target(helper);
+
+ helper.assertFalse(succeeds(helper, operator(helper), "freeze set " + pig.getUUID() + " 0"),
+ "thawing a pig that is not frozen should fail");
+ helper.succeed();
+ }
+
+ @GameTest
+ public void aCreativePlayerCanBeFrozenByHand(GameTestHelper helper) {
+ // the framework hands out creative players and nothing else, which is exactly what is needed here
+ var player = TestPlayers.inLevel(helper);
+
+ // an ice ball leaves a creative player alone, but the command is an operator's tool and does not
+ helper.assertTrue(succeeds(helper, player, "freeze set @s 100"),
+ "the command turned a creative player down");
+ helper.assertTrue(Freezing.isFrozen(player), "and left them out of the ice");
+
+ // and the freeze has to survive the tick that lets genuinely unfreezable entities out
+ Freezing.tick(player);
+ helper.assertTrue(Freezing.isFrozen(player), "the next tick thawed them again");
+
+ helper.succeed();
+ }
+
+ @GameTest
+ public void queryAnswersWithTheUsualOneOrZero(GameTestHelper helper) {
+ var pig = target(helper);
+ var operator = operator(helper);
+
+ helper.assertTrue(perform(helper, operator, "freeze query " + pig.getUUID()).result == 0,
+ "a query on an unfrozen entity should answer 0, so that `execute if` reads it as a no");
+
+ run(helper, operator, "freeze set " + pig.getUUID() + " 100");
+ helper.assertTrue(perform(helper, operator, "freeze query " + pig.getUUID()).result == 1,
+ "a query on a frozen entity should answer 1");
+
+ helper.succeed();
+ }
+
+ /** Something freezable standing in the arena, for the command to be pointed at. */
+ private static Pig target(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ return helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET);
+ }
+
+ /** Whoever runs the command. Only its position and its level matter. */
+ private static ServerPlayer operator(GameTestHelper helper) {
+ return TestPlayers.inLevel(helper);
+ }
+}
diff --git a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/FreezeGameTest.java b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/FreezeGameTest.java
new file mode 100644
index 000000000..8c9d8c62e
--- /dev/null
+++ b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/FreezeGameTest.java
@@ -0,0 +1,380 @@
+package fr.hugman.mubble.test.gametest.super_mario;
+
+import fr.hugman.mubble.super_mario.references.SuperMarioDamageTypeIds;
+import fr.hugman.mubble.super_mario.world.entity.freeze.FreezeResistance;
+import fr.hugman.mubble.super_mario.world.entity.freeze.Freezing;
+import fr.hugman.mubble.test.gametest.support.Arena;
+import fr.hugman.mubble.test.gametest.support.TestPlayers;
+import net.fabricmc.fabric.api.gametest.v1.GameTest;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.Direction;
+import net.minecraft.gametest.framework.GameTestHelper;
+import net.minecraft.tags.FluidTags;
+import net.minecraft.world.entity.EntityTypes;
+import net.minecraft.world.entity.LivingEntity;
+import net.minecraft.world.entity.MoverType;
+import net.minecraft.world.entity.animal.pig.Pig;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.phys.Vec3;
+
+/**
+ * Entities caught in a block of ice: how long they stay in there, what it takes out of them, and
+ * what the block of ice itself behaves like while it lasts.
+ *
+ * @see Freezing
+ */
+public class FreezeGameTest {
+ private static final BlockPos TARGET = new BlockPos(4, Arena.FLOOR_Y + 1, 3);
+ /** Where a mob about to be shoved stands, with room to slide east from there. */
+ private static final BlockPos SHOVE_START = new BlockPos(1, Arena.FLOOR_Y + 1, 3);
+ /** What a sliding mob is aimed at, for the tests about running into something. */
+ private static final BlockPos WALL = new BlockPos(5, Arena.FLOOR_Y + 1, 3);
+ /** The waterline of the pool the floating tests fill, in structure-relative coordinates. */
+ private static final int POOL_SURFACE_Y = Arena.FLOOR_Y + 5;
+
+ @GameTest(maxTicks = 140)
+ public void aRegularMobStaysFrozenForTheWholeDuration(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET);
+
+ helper.assertTrue(freeze(helper, pig) == FreezeResistance.NONE, "a pig is small enough to be frozen outright");
+
+ helper.startSequence()
+ // well past the point a big mob would have broken out of the ice
+ .thenIdle(Freezing.TOUGH_DURATION + 20)
+ .thenExecute(() -> {
+ helper.assertTrue(Freezing.isFrozen(pig), "the pig thawed long before its freeze was up");
+ helper.assertTrue(pig.getHealth() == pig.getMaxHealth(), "being frozen hurt the pig by itself");
+ })
+ .thenSucceed();
+ }
+
+ @GameTest(maxTicks = 140)
+ public void aBigMobBreaksOutOfTheIceUnharmed(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ var golem = helper.spawnWithNoFreeWill(EntityTypes.IRON_GOLEM, TARGET);
+
+ helper.assertTrue(freeze(helper, golem) == FreezeResistance.TOUGH, "an iron golem is big enough to break out of the ice");
+ helper.assertTrue(Freezing.isFrozen(golem), "a big mob is still frozen, only not for long");
+
+ helper.startSequence()
+ .thenIdle(Freezing.TOUGH_DURATION + 5)
+ .thenExecute(() -> {
+ helper.assertFalse(Freezing.isFrozen(golem), "the iron golem never broke out of the ice");
+ helper.assertTrue(golem.getHealth() == golem.getMaxHealth(), "breaking out of the ice should cost the iron golem nothing");
+ })
+ .thenSucceed();
+ }
+
+ @GameTest(maxTicks = 20)
+ public void aBossIsLeftAloneRatherThanFrozen(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ var wither = helper.spawnWithNoFreeWill(EntityTypes.WITHER, TARGET);
+ float before = wither.getHealth();
+
+ helper.assertTrue(freeze(helper, wither) == FreezeResistance.IMMUNE, "a boss cannot be frozen at all");
+ helper.assertFalse(Freezing.isFrozen(wither), "the wither ended up in a block of ice anyway");
+ helper.assertTrue(wither.getHealth() == before, "the freeze hurt the wither on its own, on top of whatever threw it");
+ helper.succeed();
+ }
+
+ @GameTest(maxTicks = 20)
+ public void frozenEntitiesCanBeStoodOn(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET);
+
+ helper.assertFalse(pig.canBeCollidedWith(null), "a pig is walked through, not into");
+ freeze(helper, pig);
+ helper.assertTrue(pig.canBeCollidedWith(null), "the block of ice is not solid enough to stand on");
+ helper.succeed();
+ }
+
+ @GameTest(maxTicks = 60)
+ public void shovedIceSlidesStraightAhead(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, SHOVE_START);
+ freeze(helper, pig);
+
+ double startX = pig.getX();
+ double startZ = pig.getZ();
+ Freezing.shove(pig, Direction.EAST);
+
+ helper.startSequence()
+ .thenIdle(10)
+ .thenExecute(() -> {
+ helper.assertTrue(pig.getX() > startX + 1.0D, "the shoved block of ice barely moved");
+ helper.assertTrue(Math.abs(pig.getZ() - startZ) < 0.1D, "the shoved block of ice veered off its axis");
+ })
+ .thenSucceed();
+ }
+
+ @GameTest(maxTicks = 60)
+ public void aShoveOffTheAxesSendsTheIceOffAtThatAngle(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, SHOVE_START);
+ freeze(helper, pig);
+
+ double startX = pig.getX();
+ double startZ = pig.getZ();
+ // straight between east and south, which snapping to the nearest way round would have flattened
+ Freezing.shove(pig, new Vec3(1.0D, 0.0D, 1.0D));
+
+ helper.startSequence()
+ .thenIdle(10)
+ .thenExecute(() -> {
+ double east = pig.getX() - startX;
+ double south = pig.getZ() - startZ;
+ helper.assertTrue(east > 0.5D && south > 0.5D, "the block of ice went off along an axis rather than the corner");
+ helper.assertTrue(Math.abs(east - south) < 0.2D, "and it favoured one of the two over the other");
+ })
+ .thenSucceed();
+ }
+
+ @GameTest(maxTicks = 20)
+ public void theTopOfABlockOfIceIsSlippery(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ Pig ice = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET);
+ Pig rider = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET.above(2));
+ freeze(helper, ice);
+
+ // dropped the last inch onto the ice, which is the move that works out what is holding it up
+ rider.setPos(ice.getX(), ice.getBoundingBox().maxY + 0.05D, ice.getZ());
+ rider.move(MoverType.SELF, new Vec3(0.0D, -0.2D, 0.0D));
+
+ helper.assertTrue(rider.onGround(), "the rider never came to rest on the block of ice");
+ helper.assertTrue(rider.mainSupportingBlockPos.isEmpty(), "and it found a block under it rather than the ice");
+ helper.assertTrue(Freezing.isStandingOnFrozen(rider), "standing on a frozen mob should count as standing on ice");
+ helper.assertFalse(Freezing.isStandingOnFrozen(ice), "the block of ice is not standing on itself");
+ helper.succeed();
+ }
+
+ @GameTest(maxTicks = 60)
+ public void aSlideRunsItselfOut(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, SHOVE_START);
+ freeze(helper, pig);
+ // gently, so that the arena wall is never reached and only the friction can stop it
+ pig.setDeltaMovement(Freezing.SHATTER_SPEED * 0.8D, 0.0D, 0.0D);
+
+ helper.startSequence()
+ .thenIdle(30)
+ .thenExecute(() -> {
+ helper.assertTrue(pig.getDeltaMovement().horizontalDistance() < 0.05D,
+ "the block of ice was still going as fast as ever");
+ helper.assertTrue(Freezing.isFrozen(pig), "and it fell apart rather than coming to a stop");
+ })
+ .thenSucceed();
+ }
+
+ @GameTest(maxTicks = 60)
+ public void aFastSlideIntoAWallShattersTheIce(GameTestHelper helper) {
+ Pig pig = walledIn(helper);
+ Freezing.shove(pig, Direction.EAST);
+
+ helper.startSequence()
+ .thenIdle(10)
+ .thenExecute(() -> helper.assertFalse(Freezing.isFrozen(pig), "the ice held up against a wall at full tilt"))
+ .thenSucceed();
+ }
+
+ @GameTest(maxTicks = 60)
+ public void aSpentSlideIntoAWallLeavesTheIceStanding(GameTestHelper helper) {
+ Pig pig = walledIn(helper);
+ pig.setDeltaMovement(Freezing.SHATTER_SPEED * 0.6D, 0.0D, 0.0D);
+
+ helper.startSequence()
+ .thenIdle(25)
+ .thenExecute(() -> helper.assertTrue(Freezing.isFrozen(pig), "a slide that had run its course still broke the ice"))
+ .thenSucceed();
+ }
+
+ @GameTest(maxTicks = 20)
+ public void smashingTheKeysMeltsTheIceFaster(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET);
+ freeze(helper, pig);
+
+ int before = Freezing.getRemainingTicks(pig);
+ helper.assertTrue(Freezing.struggle(pig), "struggling did nothing to a frozen entity");
+ helper.assertTrue(Freezing.getRemainingTicks(pig) == before - Freezing.STRUGGLE_RELIEF,
+ "struggling did not melt its share of the ice");
+ helper.succeed();
+ }
+
+ @GameTest(maxTicks = 20)
+ public void strugglingDoesNothingWhenNotFrozen(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET);
+
+ helper.assertFalse(Freezing.struggle(pig), "an entity that is not frozen has nothing to struggle out of");
+ helper.succeed();
+ }
+
+ @GameTest(maxTicks = 20)
+ public void theIceTakesTheHitInsteadOfTheEntity(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET);
+ freeze(helper, pig);
+
+ float health = pig.getHealth();
+ int before = Freezing.getRemainingTicks(pig);
+
+ helper.assertFalse(pig.hurtServer(helper.getLevel(), helper.getLevel().damageSources().generic(), 4.0F),
+ "the hit got through to the pig");
+ helper.assertTrue(pig.getHealth() == health, "and took health off it");
+ helper.assertTrue(Freezing.getRemainingTicks(pig) == before - 4 * Freezing.MELT_PER_DAMAGE,
+ "the hit went nowhere: it should have melted its share of the ice");
+ helper.succeed();
+ }
+
+ @GameTest(maxTicks = 20)
+ public void theIceIsNoShieldAgainstTheThingsNothingIsSafeFrom(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET);
+ freeze(helper, pig);
+
+ helper.assertTrue(pig.hurtServer(helper.getLevel(), helper.getLevel().damageSources().genericKill(), Float.MAX_VALUE),
+ "a block of ice turned `/kill` away");
+ helper.assertFalse(pig.isAlive(), "and left the pig standing");
+ helper.succeed();
+ }
+
+ @GameTest(maxTicks = 20)
+ public void aBurnBreaksTheIceOpenAtOnce(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET);
+ freeze(helper, pig);
+
+ pig.hurtServer(helper.getLevel(), helper.getLevel().damageSources().inFire(), 1.0F);
+
+ helper.assertFalse(Freezing.isFrozen(pig), "fire left the block of ice standing");
+ helper.succeed();
+ }
+
+ @GameTest(maxTicks = 20)
+ public void aFireballBreaksTheIceOpenToo(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET);
+ freeze(helper, pig);
+
+ // the mod's own fireballs are fire in everything but the vanilla tag, hence `super_mario:melts_frozen_entities`
+ pig.hurtServer(helper.getLevel(), helper.getLevel().damageSources().source(SuperMarioDamageTypeIds.FIREBALL), 1.0F);
+
+ helper.assertFalse(Freezing.isFrozen(pig), "a fireball left the block of ice standing");
+ helper.succeed();
+ }
+
+ @GameTest(maxTicks = 20)
+ public void frozenMobsAreNotSetAlight(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET);
+ pig.igniteForSeconds(8.0F);
+
+ freeze(helper, pig);
+ helper.assertTrue(pig.getRemainingFireTicks() <= 0, "freezing a burning mob left it burning");
+
+ pig.igniteForSeconds(8.0F);
+ helper.assertTrue(pig.getRemainingFireTicks() <= 0, "a mob caught fire while sitting in a block of ice");
+ helper.succeed();
+ }
+
+ @GameTest(maxTicks = 60)
+ public void aHitTheIceTurnsAwayStillSendsItSkidding(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, SHOVE_START);
+ freeze(helper, pig);
+ var attacker = TestPlayers.at(helper, SHOVE_START.west(1));
+
+ double startX = pig.getX();
+ double liftBefore = pig.getDeltaMovement().y();
+ pig.hurtServer(helper.getLevel(), helper.getLevel().damageSources().playerAttack(attacker), 1.0F);
+
+ helper.assertTrue(pig.getDeltaMovement().y() == liftBefore, "the hit lifted the block of ice off the floor");
+
+ helper.startSequence()
+ .thenIdle(10)
+ .thenExecute(() -> helper.assertTrue(pig.getX() > startX + 1.0D,
+ "a hit the ice turned away left it standing there rather than skidding out of reach"))
+ .thenSucceed();
+ }
+
+ @GameTest(maxTicks = 20)
+ public void knockbackNeverLiftsABlockOfIce(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET);
+ freeze(helper, pig);
+ pig.setOnGround(true);
+
+ double liftBefore = pig.getDeltaMovement().y();
+ pig.knockback(0.5D, 1.0D, 0.0D, helper.getLevel().damageSources().generic(), 0.5F);
+
+ helper.assertTrue(pig.getDeltaMovement().y() == liftBefore, "the punch sent the block of ice into the air");
+ helper.assertTrue(pig.getDeltaMovement().horizontalDistanceSqr() > 0.0D, "and it did not send it skidding either");
+ helper.succeed();
+ }
+
+ /** A frozen pig two blocks short of a wall, with room to build up speed on the way there. */
+ @GameTest(maxTicks = 160)
+ public void frozenIceFloatsUpToTheWaterSurface(GameTestHelper helper) {
+ Pig pig = inThePool(helper);
+ freeze(helper, pig);
+
+ double startY = pig.getY();
+ // the pig's position is absolute, the pool was built in structure-relative coordinates
+ double waterline = helper.absolutePos(new BlockPos(0, POOL_SURFACE_Y, 0)).getY();
+
+ helper.startSequence()
+ .thenIdle(120)
+ .thenExecute(() -> {
+ helper.assertTrue(pig.getY() > startY + 1.0D,
+ "the block of ice sank instead of floating up");
+ // it rides the surface rather than popping out of the water and landing back in
+ helper.assertTrue(pig.getY() < waterline + 0.5D,
+ "the block of ice was pushed clear of the water");
+ helper.assertTrue(pig.getY() > waterline - pig.getBbHeight(),
+ "the block of ice settled below the surface instead of on it");
+ helper.assertTrue(Math.abs(pig.getDeltaMovement().y()) < 0.05D,
+ "the block of ice never settled, it is still bobbing");
+ })
+ .thenSucceed();
+ }
+
+ @GameTest(maxTicks = 160)
+ public void aFloatingBlockOfIceKeepsItsRiderOutOfTheWater(GameTestHelper helper) {
+ Pig pig = inThePool(helper);
+ freeze(helper, pig);
+
+ helper.startSequence()
+ .thenIdle(120)
+ .thenExecute(() -> helper.assertFalse(pig.isEyeInFluid(FluidTags.WATER),
+ "a floating block of ice left the head of whoever is inside it under water"))
+ .thenSucceed();
+ }
+
+ /**
+ * A pig sitting on the bottom of a pool deep enough that it has somewhere to float up to.
+ */
+ private static Pig inThePool(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ for (int x = 0; x < Arena.SIZE; x++) {
+ for (int z = 0; z < Arena.SIZE; z++) {
+ for (int y = Arena.FLOOR_Y + 1; y < POOL_SURFACE_Y; y++) {
+ helper.setBlock(new BlockPos(x, y, z), Blocks.WATER);
+ }
+ }
+ }
+ return helper.spawnWithNoFreeWill(EntityTypes.PIG, new BlockPos(4, Arena.FLOOR_Y + 1, 3));
+ }
+
+ private static Pig walledIn(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ helper.setBlock(WALL, Blocks.STONE);
+ Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, WALL.west(2));
+ freeze(helper, pig);
+ return pig;
+ }
+
+ private static FreezeResistance freeze(GameTestHelper helper, LivingEntity entity) {
+ return Freezing.freeze(helper.getLevel(), entity);
+ }
+}
diff --git a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/support/TestCommands.java b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/support/TestCommands.java
new file mode 100644
index 000000000..0d98e59e4
--- /dev/null
+++ b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/support/TestCommands.java
@@ -0,0 +1,80 @@
+package fr.hugman.mubble.test.gametest.support;
+
+import net.minecraft.commands.CommandSource;
+import net.minecraft.commands.CommandSourceStack;
+import net.minecraft.gametest.framework.GameTestHelper;
+import net.minecraft.network.chat.Component;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.server.permissions.PermissionSet;
+
+/**
+ * Running a command the way a test wants it: as the server, on behalf of a player, with whatever it
+ * answered kept rather than printed.
+ */
+public final class TestCommands {
+ private TestCommands() {
+ }
+
+ /** Runs {@code command} and fails the test when it does not go through. */
+ public static void run(GameTestHelper helper, ServerPlayer player, String command) {
+ var outcome = perform(helper, player, command);
+ helper.assertTrue(outcome.succeeded, "`/" + command + "` failed: " + outcome.message);
+ }
+
+ /** @return whether {@code command} went through, without minding what it answered */
+ public static boolean succeeds(GameTestHelper helper, ServerPlayer player, String command) {
+ return perform(helper, player, command).succeeded;
+ }
+
+ /** Runs {@code command} as the server, on behalf of {@code player}, keeping whatever it answered. */
+ public static Outcome perform(GameTestHelper helper, ServerPlayer player, String command) {
+ var server = helper.getLevel().getServer();
+ var outcome = new Outcome();
+
+ CommandSourceStack source = new CommandSourceStack(
+ new CommandSource() {
+ @Override
+ public void sendSystemMessage(Component message) {
+ outcome.message = outcome.message + " | " + message.getString();
+ }
+
+ @Override
+ public boolean acceptsSuccess() {
+ return true;
+ }
+
+ @Override
+ public boolean acceptsFailure() {
+ return true;
+ }
+
+ @Override
+ public boolean shouldInformAdmins() {
+ return false;
+ }
+ },
+ player.position(),
+ player.getRotationVector(),
+ helper.getLevel(),
+ PermissionSet.ALL_PERMISSIONS,
+ "gametest",
+ Component.literal("gametest"),
+ server,
+ player
+ );
+
+ server.getCommands().performPrefixedCommand(source.withCallback((success, result) -> {
+ outcome.succeeded = success;
+ outcome.result = result;
+ }), command);
+ return outcome;
+ }
+
+ /** What a command left behind: whether it went through, what it returned, and what it said. */
+ public static final class Outcome {
+ public boolean succeeded;
+ /** The number the command returned, which is what {@code execute if} hangs off. */
+ public int result;
+ public String message = "";
+ }
+}
diff --git a/mubble-test/src/gametest/resources/fabric.mod.json b/mubble-test/src/gametest/resources/fabric.mod.json
index 5763a9917..96534b412 100644
--- a/mubble-test/src/gametest/resources/fabric.mod.json
+++ b/mubble-test/src/gametest/resources/fabric.mod.json
@@ -25,6 +25,8 @@
"fr.hugman.mubble.test.gametest.super_mario.KoopaShellGameTest",
"fr.hugman.mubble.test.gametest.super_mario.CloudPlatformGameTest",
"fr.hugman.mubble.test.gametest.super_mario.SpawnCloudPlatformActionGameTest",
+ "fr.hugman.mubble.test.gametest.super_mario.FreezeGameTest",
+ "fr.hugman.mubble.test.gametest.super_mario.FreezeCommandGameTest",
"fr.hugman.mubble.test.gametest.super_mario.BlockTransformGameTest",
"fr.hugman.mubble.test.gametest.collectible.CollectibleEntityGameTest",
"fr.hugman.mubble.test.gametest.super_mario.BumpableBlockGameTest",