diff --git a/src/main/java/net/goldenstack/loot/LootPredicate.java b/src/main/java/net/goldenstack/loot/LootPredicate.java index c2dc23c..18cfe5a 100644 --- a/src/main/java/net/goldenstack/loot/LootPredicate.java +++ b/src/main/java/net/goldenstack/loot/LootPredicate.java @@ -209,9 +209,7 @@ record EntityProperties(@Nullable EntityPredicate predicate, @NotNull RelevantEn @Override public boolean test(@NotNull LootContext context) { Entity entity = context.get(this.entity.key()); - Point origin = context.get(LootContext.ORIGIN); - - return predicate == null || predicate.test(context.require(LootContext.WORLD), origin, entity); + return predicate == null || predicate.test(entity, context); } @Override diff --git a/src/main/java/net/goldenstack/loot/util/predicate/EntityPredicate.java b/src/main/java/net/goldenstack/loot/util/predicate/EntityPredicate.java index bb3687d..14de054 100644 --- a/src/main/java/net/goldenstack/loot/util/predicate/EntityPredicate.java +++ b/src/main/java/net/goldenstack/loot/util/predicate/EntityPredicate.java @@ -1,20 +1,241 @@ package net.goldenstack.loot.util.predicate; +import net.goldenstack.loot.LootContext; import net.minestom.server.codec.Codec; +import net.minestom.server.codec.StructCodec; import net.minestom.server.coordinate.Point; import net.minestom.server.entity.Entity; -import net.minestom.server.instance.Instance; -import net.minestom.server.utils.Unit; +import net.minestom.server.entity.EntityCreature; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.EquipmentSlot; +import net.minestom.server.entity.LivingEntity; +import net.minestom.server.instance.block.predicate.DataComponentPredicates; +import net.minestom.server.potion.PotionEffect; +import net.minestom.server.registry.Registries; +import net.minestom.server.registry.RegistryKey; +import net.minestom.server.registry.RegistryTag; +import net.minestom.server.utils.Range; + +import java.util.Map; + import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -// TODO: Incomplete +// TODO: nbt, slots, stepping_on, fall_distance in movement @SuppressWarnings("UnstableApiUsage") -public interface EntityPredicate { +public record EntityPredicate( + @Nullable RegistryTag type, + @NotNull DataComponentPredicates predicate, + @Nullable DistancePredicate distance, + @NotNull Map, EffectCondition> effects, + @NotNull Map equipment, + @Nullable FlagsPredicate flags, + @Nullable LocationPredicate location, + @Nullable EntityPredicate passanger, + @Nullable LocationPredicate movementAffectedBy, + @Nullable String team, + @Nullable EntityPredicate targetedEntity, + @Nullable EntityPredicate vehicle, + @Nullable MovementPredicate movement, + @Nullable Integer periodicTick +) { + // Distance + public static record DistancePredicate( + @Nullable Range.Double absolute, + @Nullable Range.Double horizontal, + @Nullable Range.Double x, + @Nullable Range.Double y, + @Nullable Range.Double z + ) { + public static final @NotNull StructCodec CODEC = StructCodec.struct( + "absolute", DataComponentPredicates.DOUBLE_RANGE_CODEC.optional(), DistancePredicate::absolute, + "horizontal", DataComponentPredicates.DOUBLE_RANGE_CODEC.optional(), DistancePredicate::horizontal, + "x", DataComponentPredicates.DOUBLE_RANGE_CODEC.optional(), DistancePredicate::x, + "y", DataComponentPredicates.DOUBLE_RANGE_CODEC.optional(), DistancePredicate::y, + "z", DataComponentPredicates.DOUBLE_RANGE_CODEC.optional(), DistancePredicate::z, + DistancePredicate::new + ); + + public boolean test(Point delta) { + if (absolute != null && !absolute.inRange(delta.distance(0.0, 0.0, 0.0))) return false; + if (horizontal != null && !horizontal.inRange(delta.mul(1.0, 0.0, 1.0).distance(0.0, 0.0, 0.0))) return false; + if (x != null && !x.inRange(Math.abs(delta.x()))) return false; + if (y != null && !y.inRange(Math.abs(delta.y()))) return false; + if (z != null && !z.inRange(Math.abs(delta.z()))) return false; + return true; + } + } + + // Effects + public record EffectCondition( + @Nullable Range.Int amplifier, + @Nullable Range.Int duration, + @Nullable Boolean ambient, + @Nullable Boolean visible + ) { + public static final @NotNull StructCodec CODEC = StructCodec.struct( + "amplifier", DataComponentPredicates.INT_RANGE_CODEC.optional(), EffectCondition::amplifier, + "duration", DataComponentPredicates.INT_RANGE_CODEC.optional(), EffectCondition::duration, + "ambient", StructCodec.BOOLEAN.optional(), EffectCondition::ambient, + "visible", StructCodec.BOOLEAN.optional(), EffectCondition::visible, + EffectCondition::new + ); + + public boolean test(@NotNull net.minestom.server.potion.TimedPotion effect, @NotNull Entity entity) { + final var remaining = effect.potion().duration() - (int)(entity.getAliveTicks() - effect.startingTicks()); + if (remaining < 0) return false; + if (amplifier != null && !amplifier.inRange(effect.potion().amplifier())) return false; + if (duration != null && !duration.inRange(remaining)) return false; + if (ambient != null && ambient != effect.potion().isAmbient()) return false; + if (visible != null && visible != effect.potion().hasParticles()) return false; + return true; + } + } + + // Flags + public record FlagsPredicate( + @Nullable Boolean isBaby, + @Nullable Boolean isOnFire, + @Nullable Boolean isSneaking, + @Nullable Boolean isSprinting, + @Nullable Boolean isSwimming, + @Nullable Boolean isOnGround, + @Nullable Boolean isFlying, + @Nullable Boolean isFallFlying + ) { + public static final @NotNull StructCodec CODEC = StructCodec.struct( + "is_baby", StructCodec.BOOLEAN.optional(), FlagsPredicate::isBaby, + "is_on_fire", StructCodec.BOOLEAN.optional(), FlagsPredicate::isOnFire, + "is_sneaking", StructCodec.BOOLEAN.optional(), FlagsPredicate::isSneaking, + "is_sprinting", StructCodec.BOOLEAN.optional(), FlagsPredicate::isSprinting, + "is_swimming", StructCodec.BOOLEAN.optional(), FlagsPredicate::isSwimming, + "is_on_ground", StructCodec.BOOLEAN.optional(), FlagsPredicate::isOnGround, + "is_flying", StructCodec.BOOLEAN.optional(), FlagsPredicate::isFlying, + "is_fall_flying", StructCodec.BOOLEAN.optional(), FlagsPredicate::isFallFlying, + FlagsPredicate::new + ); + + public boolean test(@NotNull Entity entity) { + if (isBaby != null) { + if (!(entity.getEntityMeta() instanceof net.minestom.server.entity.metadata.AgeableMobMeta meta)) return false; + if (meta.isBaby() != isBaby) return false; + } + if (isOnFire != null && entity.isOnFire() != isOnFire) return false; + if (isSneaking != null && entity.isSneaking() != isSneaking) return false; + if (isSprinting != null && entity.isSprinting() != isSprinting) return false; + if (isSwimming != null && entity.getEntityMeta().isSwimming() != isSwimming) return false; + if (isOnGround != null && entity.isOnGround() != isOnGround) return false; + if (isFlying != null) { + var flying = entity.getEntityMeta().isFlyingWithElytra(); + if (entity instanceof net.minestom.server.entity.Player player) + flying = flying || player.isFlying(); + if (flying != isFlying) return false; + } + if (isFallFlying != null && entity.getEntityMeta().isFlyingWithElytra() != isFallFlying) return false; + return true; + } + } + + // Movement + public record MovementPredicate( + @Nullable Range.Double x, + @Nullable Range.Double y, + @Nullable Range.Double z, + @Nullable Range.Double speed, + @Nullable Range.Double horizontalSpeed, + @Nullable Range.Double verticalSpeed, + @Nullable Range.Double fallDistance + ) { + public static final @NotNull StructCodec CODEC = StructCodec.struct( + "x", DataComponentPredicates.DOUBLE_RANGE_CODEC.optional(), MovementPredicate::x, + "y", DataComponentPredicates.DOUBLE_RANGE_CODEC.optional(), MovementPredicate::y, + "z", DataComponentPredicates.DOUBLE_RANGE_CODEC.optional(), MovementPredicate::z, + "speed", DataComponentPredicates.DOUBLE_RANGE_CODEC.optional(), MovementPredicate::speed, + "horizontal_speed", DataComponentPredicates.DOUBLE_RANGE_CODEC.optional(), MovementPredicate::horizontalSpeed, + "vertical_speed", DataComponentPredicates.DOUBLE_RANGE_CODEC.optional(), MovementPredicate::verticalSpeed, + "fall_distance", DataComponentPredicates.DOUBLE_RANGE_CODEC.optional(), MovementPredicate::fallDistance, + MovementPredicate::new + ); + + public boolean test(@NotNull Entity entity) { + final var velocity = entity.getVelocity(); + if (x != null && !x.inRange(velocity.x())) return false; + if (y != null && !y.inRange(velocity.y())) return false; + if (z != null && !z.inRange(velocity.z())) return false; + if (speed != null && !speed.inRange(velocity.distance(0.0, 0.0, 0.0))) return false; + if (horizontalSpeed != null && !horizontalSpeed.inRange(velocity.mul(1.0, 0.0, 1.0).distance(0.0, 0.0, 0.0))) return false; + if (verticalSpeed != null && !verticalSpeed.inRange(velocity.y())) return false; + return true; + } + } + + // Implementation + public static final @NotNull Codec CODEC = Codec.Recursive(codec -> StructCodec.struct( + "type", RegistryTag.codec(Registries::entityType).optional(), EntityPredicate::type, + StructCodec.INLINE, DataComponentPredicates.CODEC, EntityPredicate::predicate, + "distance", DistancePredicate.CODEC.optional(), EntityPredicate::distance, + "effects", RegistryKey.codec(Registries::potionEffect).mapValue(EffectCondition.CODEC).optional(Map.of()), EntityPredicate::effects, + "equipment", EquipmentSlot.CODEC.mapValue(ItemPredicate.CODEC).optional(Map.of()), EntityPredicate::equipment, + "flags", FlagsPredicate.CODEC.optional(), EntityPredicate::flags, + "location", LocationPredicate.CODEC.optional(), EntityPredicate::location, + "passanger", codec.optional(), EntityPredicate::passanger, + "movement_affected_by", LocationPredicate.CODEC.optional(), EntityPredicate::movementAffectedBy, + "team", Codec.STRING.optional(), EntityPredicate::team, + "targeted_entity", codec.optional(), EntityPredicate::targetedEntity, + "vehicle", codec.optional(), EntityPredicate::vehicle, + "movement", MovementPredicate.CODEC.optional(), EntityPredicate::movement, + "periodic_tick", Codec.INT.optional(), EntityPredicate::periodicTick, + EntityPredicate::new + )); + + public boolean test(@Nullable Entity entity, @NotNull LootContext context) { + if (entity == null) return false; // Not sure about that + final var origin = context.get(LootContext.ORIGIN); + final var instance = context.require(LootContext.WORLD); + + if (type != null && !type.contains(entity.getEntityType())) return false; + if (!predicate.test(entity)) return false; + if (distance != null && (origin == null || entity == null || !distance.test(entity.getPosition().sub(origin)))) return false; + + for (final var entry : effects.entrySet()) { + final var effect = entity.getEffect(PotionEffect.staticRegistry().get(entry.getKey())); + if (!entry.getValue().test(effect, entity)) return false; + } + + for (final var entry : equipment.entrySet()) { + if (!(entity instanceof LivingEntity living)) return false; + if (!entry.getValue().test(living.getEquipment(entry.getKey()), context)) return false; + } + + if (flags != null && !flags.test(entity)) return false; + if (location != null && !location.test(instance, origin)) return false; + if (passanger != null) { + var passed = false; + for (final var candidate : entity.getPassengers()) { + if (passanger.test(candidate, context)) { + passed = true; + break; + } + } + + if (!passed) return false; + } - @NotNull Codec CODEC = Codec.UNIT.transform(a -> (instance, pos, entity) -> false, a -> Unit.INSTANCE); + if (movementAffectedBy != null && !movementAffectedBy.test(instance, origin.sub(0.0, 0.5, 0.0))) return false; + if (team != null) { + if (!(entity instanceof LivingEntity living)) return false; + if (!living.getTeam().getTeamName().equals(team)) return false; + } - boolean test(@NotNull Instance instance, @Nullable Point pos, @Nullable Entity entity); + if (targetedEntity != null) { + if (!(entity instanceof EntityCreature creature)) return false; + if (!targetedEntity.test(creature.getTarget(), context)) return false; + } + if (vehicle != null && !vehicle.test(entity.getVehicle(), context)) return false; + if (movement != null && !movement.test(entity)) return false; + if (periodicTick != null && entity.getAliveTicks() % periodicTick != 0) return false; + return true; + } } diff --git a/src/main/java/net/goldenstack/loot/util/predicate/TypeSpecificPredicate.java b/src/main/java/net/goldenstack/loot/util/predicate/TypeSpecificPredicate.java new file mode 100644 index 0000000..edfe875 --- /dev/null +++ b/src/main/java/net/goldenstack/loot/util/predicate/TypeSpecificPredicate.java @@ -0,0 +1,267 @@ +package net.goldenstack.loot.util.predicate; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import net.kyori.adventure.key.Key; +import net.minestom.server.MinecraftServer; +import net.minestom.server.codec.Codec; +import net.minestom.server.codec.StructCodec; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.GameMode; +import net.minestom.server.entity.Player; +import net.minestom.server.entity.metadata.animal.SheepMeta; +import net.minestom.server.entity.metadata.other.SlimeMeta; +import net.minestom.server.instance.block.predicate.DataComponentPredicates; +import net.minestom.server.registry.DynamicRegistry; +import net.minestom.server.registry.Registry; +import net.minestom.server.statistic.PlayerStatistic; +import net.minestom.server.statistic.StatisticCategory; +import net.minestom.server.statistic.StatisticType; +import net.minestom.server.utils.Either; +import net.minestom.server.utils.Range; + +// TODO: fishing_hook, lightning, looking_at in player, advancements in player, recipes in player, raider + +@SuppressWarnings("UnstableApiUsage") +public sealed interface TypeSpecificPredicate permits + TypeSpecificPredicate.FishingHookPredicate, + TypeSpecificPredicate.LightningPredicate, + TypeSpecificPredicate.PlayerPredicate, + TypeSpecificPredicate.RaiderPredicate, + TypeSpecificPredicate.SheepPredicate, + TypeSpecificPredicate.SlimePredicate { + + public static final Registry> REGISTRY = DynamicRegistry.fromMap( + Key.key("type"), + Map.entry(Key.key("fishing_hook"), FishingHookPredicate.CODEC), + Map.entry(Key.key("lightning"), LightningPredicate.CODEC), + Map.entry(Key.key("player"), PlayerPredicate.CODEC), + Map.entry(Key.key("raider"), RaiderPredicate.CODEC), + Map.entry(Key.key("sheep"), SheepPredicate.CODEC), + Map.entry(Key.key("slime"), SlimePredicate.CODEC)); + public static final StructCodec CODEC = Codec.RegistryTaggedUnion(REGISTRY, TypeSpecificPredicate::codec); + + boolean test(@NotNull Entity entity); + StructCodec codec(); + + + public static record FishingHookPredicate(@Nullable Boolean inOpenWater) implements TypeSpecificPredicate { + public static final @NotNull StructCodec CODEC = StructCodec.struct( + "in_open_water", Codec.BOOLEAN.optional(), FishingHookPredicate::inOpenWater, + FishingHookPredicate::new + ); + + @Override public boolean test(@NotNull Entity entity) { + if (!entity.getEntityType().equals(EntityType.FISHING_BOBBER)) return false; + return inOpenWater == null; // TODO + } + + @Override + public StructCodec codec() { + return CODEC; + } + } + + public record LightningPredicate(@Nullable Range.Int blocksSetOnFire, @Nullable EntityPredicate entityStruck) implements TypeSpecificPredicate { + public static final @NotNull StructCodec CODEC = StructCodec.struct( + "blocks_set_on_fire", DataComponentPredicates.INT_RANGE_CODEC.optional(), LightningPredicate::blocksSetOnFire, + "entity_struck", EntityPredicate.CODEC.optional(), LightningPredicate::entityStruck, + LightningPredicate::new + ); + + @Override public boolean test(@NotNull Entity entity) { + if (!entity.getEntityType().equals(EntityType.LIGHTNING_BOLT)) return false; + return blocksSetOnFire == null && entityStruck == null; // TODO + } + + @Override + public StructCodec codec() { + return CODEC; + } + } + + public record PlayerPredicate( + @Nullable EntityPredicate lookingAt, + @NotNull Map>> advancements, + @Nullable Set gamemode, + @Nullable Range.Int level, + @NotNull Map recipes, + @NotNull List stats, + @Nullable InputPredicate input, + @Nullable FoodPredicate food + ) implements TypeSpecificPredicate { + public static final @NotNull StructCodec CODEC = StructCodec.struct( + "looking_at", EntityPredicate.CODEC.optional(), PlayerPredicate::lookingAt, + "advancements", Codec.KEY.mapValue(Codec.Either(Codec.BOOLEAN, Codec.KEY.mapValue(Codec.BOOLEAN))).optional(Map.of()), PlayerPredicate::advancements, + "gamemode", Codec.Enum(GameMode.class).set().optional(), PlayerPredicate::gamemode, + "level", DataComponentPredicates.INT_RANGE_CODEC.optional(), PlayerPredicate::level, + "recipes", Codec.KEY.mapValue(Codec.BOOLEAN).optional(Map.of()), PlayerPredicate::recipes, + "stats", Statistic.CODEC.list().optional(List.of()), PlayerPredicate::stats, + "input", InputPredicate.CODEC.optional(), PlayerPredicate::input, + "food", FoodPredicate.CODEC.optional(), PlayerPredicate::food, + PlayerPredicate::new + ); + + @Override public boolean test(@NotNull Entity entity) { + if (!(entity instanceof Player player)) return false; + if (!advancements.isEmpty()) return false; // TODO + if (gamemode != null && !gamemode.contains(player.getGameMode())) return false; + if (level != null && !level.inRange(player.getLevel())) return false; + if (!recipes.isEmpty()) return false; // TODO + for (final var stat : stats) { + if (!stat.test(player)) return false; + } + + if (input != null && !input.test(player)) return false; + if (food != null && !food.test(player)) return false; + return true; + } + + @Override + public StructCodec codec() { + return CODEC; + } + + public record Statistic(@NotNull StatisticCategory type, @NotNull Key stat, @NotNull Range.Int value) { + public static final @NotNull StructCodec CODEC = StructCodec.struct( + "type", Codec.Enum(StatisticCategory.class), Statistic::type, + "stat", Codec.KEY, Statistic::stat, + "value", DataComponentPredicates.INT_RANGE_CODEC, Statistic::value, + Statistic::new + ); + + public PlayerStatistic getPlayerStatistic() { + final var registries = MinecraftServer.process(); + var id = 0; + if (type == StatisticCategory.MINED) + id = registries.blocks().getId(registries.blocks().getKey(stat)); + else if (type == StatisticCategory.CRAFTED) + id = registries.material().getId(registries.material().getKey(stat)); + else if (type == StatisticCategory.USED) + id = registries.material().getId(registries.material().getKey(stat)); + else if (type == StatisticCategory.BROKEN) + id = registries.material().getId(registries.material().getKey(stat)); + else if (type == StatisticCategory.PICKED_UP) + id = registries.material().getId(registries.material().getKey(stat)); + else if (type == StatisticCategory.DROPPED) + id = registries.material().getId(registries.material().getKey(stat)); + else if (type == StatisticCategory.KILLED) + id = registries.entityType().getId(registries.entityType().getKey(stat)); + else if (type == StatisticCategory.KILLED_BY) + id = registries.entityType().getId(registries.entityType().getKey(stat)); + else if (type == StatisticCategory.CUSTOM) + id = StatisticType.fromKey(stat).id(); + else throw new RuntimeException("Unknown statistic category: " + type); + return new PlayerStatistic(type, id); + } + + public boolean test(@NotNull Player player) { + final var statValue = player.getStatisticValueMap().getOrDefault(getPlayerStatistic(), 0); + return value.inRange(statValue); + } + } + + public record InputPredicate( + @Nullable Boolean forward, @Nullable Boolean backward, + @Nullable Boolean left, @Nullable Boolean right, + @Nullable Boolean jump, @Nullable Boolean sneak, @Nullable Boolean sprint + ) { + public static final @NotNull StructCodec CODEC = StructCodec.struct( + "forward", Codec.BOOLEAN.optional(), InputPredicate::forward, + "backward", Codec.BOOLEAN.optional(), InputPredicate::backward, + "left", Codec.BOOLEAN.optional(), InputPredicate::left, + "right", Codec.BOOLEAN.optional(), InputPredicate::right, + "jump", Codec.BOOLEAN.optional(), InputPredicate::jump, + "sneak", Codec.BOOLEAN.optional(), InputPredicate::sneak, + "sprint", Codec.BOOLEAN.optional(), InputPredicate::sprint, + InputPredicate::new + ); + + public boolean test(@NotNull Player player) { + final var inputs = player.inputs(); + if (forward != null && inputs.forward() != forward) return false; + if (backward != null && inputs.backward() != backward) return false; + if (left != null && inputs.left() != left) return false; + if (right != null && inputs.right() != right) return false; + if (jump != null && inputs.jump() != jump) return false; + if (sneak != null && inputs.shift() != sneak) return false; + if (sprint != null && inputs.sprint() != sprint) return false; + return true; + } + } + + public record FoodPredicate(@Nullable Range.Int level, @Nullable Range.Double saturation) { + public static final @NotNull StructCodec CODEC = StructCodec.struct( + "level", DataComponentPredicates.INT_RANGE_CODEC.optional(), FoodPredicate::level, + "saturation", DataComponentPredicates.DOUBLE_RANGE_CODEC.optional(), FoodPredicate::saturation, + FoodPredicate::new + ); + + public boolean test(@NotNull Player player) { + if (level != null && !level.inRange(player.getFood())) return false; + if (saturation != null && !saturation.inRange(player.getFoodSaturation())) return false; + return true; + } + } + } + + public record RaiderPredicate(@Nullable Boolean isCaptain, @Nullable Boolean hasRaid) implements TypeSpecificPredicate { + public static final @NotNull StructCodec CODEC = StructCodec.struct( + "is_captain", Codec.BOOLEAN.optional(), RaiderPredicate::isCaptain, + "has_raid", Codec.BOOLEAN.optional(), RaiderPredicate::hasRaid, + RaiderPredicate::new + ); + + @Override public boolean test(@NotNull Entity entity) { + return isCaptain == null && hasRaid == null; // TODO + } + + @Override + public StructCodec codec() { + return CODEC; + } + } + + public record SheepPredicate(@Nullable Boolean sheared) implements TypeSpecificPredicate { + public static final @NotNull StructCodec CODEC = StructCodec.struct( + "sheared", Codec.BOOLEAN.optional(), SheepPredicate::sheared, + SheepPredicate::new + ); + + @Override public boolean test(@NotNull Entity entity) { + if (!entity.getEntityType().equals(EntityType.SHEEP)) return false; + if (!(entity.getEntityMeta() instanceof SheepMeta meta)) return false; + if (sheared != null && meta.isSheared() != sheared) return false; + return true; + } + + @Override + public StructCodec codec() { + return CODEC; + } + } + + public record SlimePredicate(@Nullable Range.Int size) implements TypeSpecificPredicate { + public static final @NotNull StructCodec CODEC = StructCodec.struct( + "size", DataComponentPredicates.INT_RANGE_CODEC.optional(), SlimePredicate::size, + SlimePredicate::new + ); + @Override public boolean test(@NotNull Entity entity) { + if (!entity.getEntityType().equals(EntityType.SLIME) && !entity.getEntityType().equals(EntityType.MAGMA_CUBE)) return false; + if (!(entity.getEntityMeta() instanceof SlimeMeta meta)) return false; + return size == null || size.inRange(meta.getSize()); + } + + @Override + public StructCodec codec() { + return CODEC; + } + } +} +