diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..9f7a1314 --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +org.gradle.unsafe.configuration-cache = true diff --git a/modules/block-interactions/src/main/java/net/hollowcube/blocks/ore/handler/OreBlockHandler.java b/modules/block-interactions/src/main/java/net/hollowcube/blocks/ore/handler/OreBlockHandler.java index d5711e8f..87603f26 100644 --- a/modules/block-interactions/src/main/java/net/hollowcube/blocks/ore/handler/OreBlockHandler.java +++ b/modules/block-interactions/src/main/java/net/hollowcube/blocks/ore/handler/OreBlockHandler.java @@ -7,6 +7,7 @@ import net.minestom.server.instance.Instance; import net.minestom.server.instance.block.Block; import net.minestom.server.instance.block.BlockHandler; +import net.minestom.server.thread.TickThread; import net.minestom.server.utils.NamespaceID; import net.minestom.server.utils.validate.Check; import org.jetbrains.annotations.NotNull; @@ -15,7 +16,6 @@ import net.hollowcube.blocks.ore.Ore; import net.hollowcube.blocks.ore.event.PlayerOreBreakEvent; import net.hollowcube.loot.LootContext; -import net.hollowcube.server.instance.TickTrackingInstance; import net.hollowcube.util.BlockUtil; import net.hollowcube.util.FutureUtil; @@ -118,8 +118,9 @@ public void tick(@NotNull Tick tick) { // Only tick once a second. // This could be adjusted in the future if necessary. final Instance instance = tick.getInstance(); - long currentTick = ((TickTrackingInstance) instance).getTick(); - if (currentTick % 20 != 0) + final TickThread thread = TickThread.current(); + Check.notNull(thread, "TickThread.current() returned null"); + if (thread.getTick() % 20 != 0) return; final Point pos = tick.getBlockPosition(); diff --git a/modules/build.gradle.kts b/modules/build.gradle.kts index 98be44d9..8e1aa4e6 100644 --- a/modules/build.gradle.kts +++ b/modules/build.gradle.kts @@ -27,7 +27,7 @@ subprojects { implementation("com.google.auto.service:auto-service-annotations:1.0.1") // Minestom - implementation("com.github.minestommmo:Minestom:c6c97162a6") + implementation("com.github.minestommmo:Minestom:ec6035d939") // Testing testImplementation(project(":modules:test")) diff --git a/modules/common/build.gradle.kts b/modules/common/build.gradle.kts index 1473429c..4a7f912e 100644 --- a/modules/common/build.gradle.kts +++ b/modules/common/build.gradle.kts @@ -8,6 +8,8 @@ dependencies { implementation("org.tinylog:tinylog-impl:2.4.1") + api("com.github.mworzala.mc_debug_renderer:minestom:3ed4c6e97536a19") + implementation("io.github.cdimascio:dotenv-java:2.2.4") // Optional components diff --git a/modules/common/src/main/java/com/mattworzala/debug/shape/OutlineBox.java b/modules/common/src/main/java/com/mattworzala/debug/shape/OutlineBox.java new file mode 100644 index 00000000..f3e545a6 --- /dev/null +++ b/modules/common/src/main/java/com/mattworzala/debug/shape/OutlineBox.java @@ -0,0 +1,88 @@ +package com.mattworzala.debug.shape; + +import com.mattworzala.debug.Layer; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.utils.binary.BinaryWriter; +import net.minestom.server.utils.validate.Check; +import org.jetbrains.annotations.NotNull; + +//todo remove me (this isn't present in mainline debug renderer currently) +public record OutlineBox( + Vec start, + Vec end, + int color, + Layer layer, + int colorLine, + Layer layerLine +) implements Shape { + private static final int ID = 3; + + @Override + public void write(@NotNull BinaryWriter buffer) { + buffer.writeVarInt(ID); + buffer.writeDouble(start.x()); + buffer.writeDouble(start.y()); + buffer.writeDouble(start.z()); + buffer.writeDouble(end.x()); + buffer.writeDouble(end.y()); + buffer.writeDouble(end.z()); + buffer.writeInt(color); + buffer.writeVarInt(layer.ordinal()); + buffer.writeInt(colorLine); + buffer.writeVarInt(layerLine.ordinal()); + } + + public static class Builder { + private Vec start; + private Vec end; + private int color = 0xFFFFFFFF; + private Layer layer = Layer.INLINE; + private int colorLine = 0xFFFFFFFF; + private Layer layerLine = Layer.INLINE; + + public Builder start(Vec start) { + this.start = start; + return this; + } + + public Builder end(Vec end) { + this.end = end; + return this; + } + + public Builder block(int x, int y, int z) { + return block(x, y, z, 0.05); + } + + public Builder block(int x, int y, int z, double expand) { + return start(new Vec(x - expand, y - expand, z - expand)) + .end(new Vec(x + 1 + expand, y + 1 + expand, z + 1 + expand)); + } + + public Builder color(int color) { + this.color = color; + return this; + } + + public Builder layer(Layer layer) { + this.layer = layer; + return this; + } + + public Builder colorLine(int color) { + this.colorLine = color; + return this; + } + + public Builder layerLine(Layer layer) { + this.layerLine = layer; + return this; + } + + public OutlineBox build() { + Check.notNull(start, "start"); + Check.notNull(end, "end"); + return new OutlineBox(start, end, color, layer, colorLine, layerLine); + } + } +} diff --git a/modules/common/src/main/java/net/hollowcube/registry/ResourceFactory.java b/modules/common/src/main/java/net/hollowcube/registry/ResourceFactory.java index a1eb5709..1a5dd746 100644 --- a/modules/common/src/main/java/net/hollowcube/registry/ResourceFactory.java +++ b/modules/common/src/main/java/net/hollowcube/registry/ResourceFactory.java @@ -15,6 +15,12 @@ public ResourceFactory(NamespaceID namespace, Class type, Codec type, Codec codec) { + this.namespace = NamespaceID.from(namespace); + this.type = type; + this.codec = codec; + } + @Override public @NotNull NamespaceID namespace() { return this.namespace; diff --git a/modules/common/src/main/java/net/hollowcube/server/instance/TickTrackingInstance.java b/modules/common/src/main/java/net/hollowcube/server/instance/TickTrackingInstance.java deleted file mode 100644 index df2deee1..00000000 --- a/modules/common/src/main/java/net/hollowcube/server/instance/TickTrackingInstance.java +++ /dev/null @@ -1,34 +0,0 @@ -package net.hollowcube.server.instance; - -import net.minestom.server.instance.IChunkLoader; -import net.minestom.server.instance.InstanceContainer; -import net.minestom.server.world.DimensionType; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.UUID; - -public class TickTrackingInstance extends InstanceContainer { - //todo i (matt) really dont think this should be in common, but not sure where - - private long tick = 0; - - public TickTrackingInstance(@NotNull UUID uniqueId, @NotNull DimensionType dimensionType, @Nullable IChunkLoader loader) { - super(uniqueId, dimensionType, loader); - } - - public TickTrackingInstance(@NotNull UUID uniqueId, @NotNull DimensionType dimensionType) { - super(uniqueId, dimensionType); - } - - @Override - public void tick(long time) { - tick++; - - super.tick(time); - } - - public long getTick() { - return tick; - } -} diff --git a/modules/common/src/main/java/net/hollowcube/util/StringUtil.java b/modules/common/src/main/java/net/hollowcube/util/StringUtil.java new file mode 100644 index 00000000..43c3033c --- /dev/null +++ b/modules/common/src/main/java/net/hollowcube/util/StringUtil.java @@ -0,0 +1,14 @@ +package net.hollowcube.util; + +import org.intellij.lang.annotations.Language; + +public final class StringUtil { + private StringUtil() {} + + private static final @Language("regexp") String CAMEL_TO_SNAKE_CASE_REGEX = "([a-z])([A-Z]+)"; + private static final String CAMEL_TO_SNAKE_CASE_REPLACEMENT = "$1_$2"; + + public static String camelCaseToSnakeCase(String str) { + return str.replaceAll(CAMEL_TO_SNAKE_CASE_REGEX, CAMEL_TO_SNAKE_CASE_REPLACEMENT).toLowerCase(); + } +} diff --git a/modules/development/build.gradle.kts b/modules/development/build.gradle.kts index 61c8c024..b65ab221 100644 --- a/modules/development/build.gradle.kts +++ b/modules/development/build.gradle.kts @@ -9,6 +9,7 @@ dependencies { implementation(project(":modules:item")) implementation(project(":modules:player")) implementation(project(":modules:quest")) + implementation(project(":modules:entity")) implementation("org.mongodb:mongodb-driver-sync:4.7.1") } diff --git a/modules/development/src/main/java/net/hollowcube/server/dev/Main.java b/modules/development/src/main/java/net/hollowcube/server/dev/Main.java index 0eef7c93..c5a1d1fa 100644 --- a/modules/development/src/main/java/net/hollowcube/server/dev/Main.java +++ b/modules/development/src/main/java/net/hollowcube/server/dev/Main.java @@ -2,9 +2,7 @@ import net.hollowcube.item.crafting.RecipeList; import net.hollowcube.item.crafting.ToolCraftingInventory; -import net.hollowcube.player.PlayerImpl; import net.hollowcube.server.dev.tool.DebugToolManager; -import net.hollowcube.server.instance.TickTrackingInstance; import net.minestom.server.MinecraftServer; import net.minestom.server.ServerProcess; import net.minestom.server.command.builder.Command; @@ -15,14 +13,12 @@ import net.minestom.server.event.GlobalEventHandler; import net.minestom.server.event.player.PlayerLoginEvent; import net.minestom.server.event.player.PlayerSpawnEvent; -import net.minestom.server.extras.MojangAuth; import net.minestom.server.instance.Instance; import net.minestom.server.instance.InstanceManager; import net.minestom.server.instance.block.Block; import net.minestom.server.instance.block.BlockHandler; import net.minestom.server.potion.Potion; import net.minestom.server.potion.PotionEffect; -import net.minestom.server.world.DimensionType; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import net.hollowcube.blocks.BlockInteracter; @@ -35,7 +31,6 @@ import java.util.HashMap; import java.util.Map; import java.util.ServiceLoader; -import java.util.UUID; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.TimeUnit; import java.util.function.Supplier; @@ -47,18 +42,14 @@ public static void main(String[] args) { MinecraftServer server = MinecraftServer.init(); - MojangAuth.init(); - MinecraftServer.getConnectionManager().setPlayerProvider(PlayerImpl::new); - InstanceManager instanceManager = MinecraftServer.getInstanceManager(); - - Instance instance = new TickTrackingInstance(UUID.randomUUID(), DimensionType.OVERWORLD); - instanceManager.registerInstance(instance); + Instance instance = instanceManager.createInstanceContainer(); instance.setGenerator(unit -> unit.modifier().fillHeight(0, 40, Block.STONE)); GlobalEventHandler eventHandler = MinecraftServer.getGlobalEventHandler(); eventHandler.addListener(PlayerLoginEvent.class, event -> { final Player player = event.getPlayer(); + player.setPermissionLevel(2); event.setSpawningInstance(instance); player.setRespawnPoint(new Pos(0, 42, 0)); }); @@ -78,6 +69,7 @@ public static void main(String[] args) { //todo a command for this player.getInventory().addItemStack(DebugToolManager.createTool("unnamed:hello")); + }); BaseCommandRegister.registerCommands(); //todo this should be in a facet? diff --git a/modules/development/src/main/java/net/hollowcube/server/dev/tool/DebugToolManager.java b/modules/development/src/main/java/net/hollowcube/server/dev/tool/DebugToolManager.java index 50a3689e..ae95c6ba 100644 --- a/modules/development/src/main/java/net/hollowcube/server/dev/tool/DebugToolManager.java +++ b/modules/development/src/main/java/net/hollowcube/server/dev/tool/DebugToolManager.java @@ -110,7 +110,7 @@ private void handAnimation(@NotNull PlayerHandAnimationEvent event) { if (tool == null || debounce(player)) return; final Point targetBlock = player.getTargetBlockPosition(3); - //todo ray trace for target entity + //todo ray trace for value entity final ItemStack newItemStack = tool.leftClicked(player, itemStack, targetBlock, null); if (itemStack != newItemStack) { diff --git a/modules/entity/README.md b/modules/entity/README.md new file mode 100644 index 00000000..6c0044d9 --- /dev/null +++ b/modules/entity/README.md @@ -0,0 +1,66 @@ +# Entities +Module for defining entities as well as their associated behavior. + +## Entity Definition +```json5 +{ + "namespace": "unnamed:test", + + // The model to use. For now this must be a vanilla entity type. + "model": "minecraft:pig", + // A reference to a behavior file + "behavior": "unnamed:test_behavior", + // (optional) The navigator to use for this entity. If not specified, the land navigator will be used. + "navigator": "minecraft:land", + + // (optional) The loot table to use when the entity dies + "loot_table": "unnamed:test_loot", + + // Stats + //todo need to be able to specify things like walk speed, jump height, etc. These are navigator parameters i suppose +} +``` + +## Behavior Definition +```json5 +{ + "namespace": "test_behavior", + + // Root behavior node + "type": "sequence", + "children": [/* ... */], + // (optional) If true, the task may be interrupted during execution by a parent sequence. Defaults to false. + "canInterrupt": false, +} +``` + +A behavior node is a JSON object with a type and some set of properties defined on the node itself. The basic primitives +are `sequence`s and `selector`s. They are documented below: + +```json5 +{ + // Sequence is a set of tasks to perform in order. If any task fails, the sequence fails. + "type": "unnamed:sequence", + // Each child is executed in order. If the sequence may be interrupted then any child task may be interrupted. + "children": [/* ... */], +} +``` + +```json5 +{ + // Selector is a set of tasks which will be performed based on their order and condition. + "type": "unnamed:selector", + // An array of stimuli definitions which will be active as long as this selector is active. This can be used for + // performance reasons (e.g. do not tick a stimuli source if you do not have to), but should not be used if there + // are two conflicting stimuli sources (e.g. do not have two targeting stimuli nested within each other). + "stimuli": [/* ... */], + // A set of mql expressions and tasks. Each expression is evaluated in order, executing the first task to completion. + // If the selected task may not be interrupted, it is executed to completion. If it may be interrupted, the + // conditions will be continuously evaluated, and the selected task will change if an earlier condition passes. + // An empty mql expression will always evaluate to true. + "children": { + "q.has_target": {/* ... */}, + "": {/* ... */}, + } +} +``` diff --git a/modules/entity/build.gradle.kts b/modules/entity/build.gradle.kts new file mode 100644 index 00000000..5612c744 --- /dev/null +++ b/modules/entity/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + `java-library` +} + +dependencies { + implementation(project(":modules:common")) +} diff --git a/modules/entity/src/main/java/net/hollowcube/entity/EntityType.java b/modules/entity/src/main/java/net/hollowcube/entity/EntityType.java new file mode 100644 index 00000000..69bdb9aa --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/entity/EntityType.java @@ -0,0 +1,27 @@ +package net.hollowcube.entity; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.hollowcube.dfu.ExtraCodecs; +import net.hollowcube.entity.task.Task; +import net.hollowcube.registry.Registry; +import net.hollowcube.registry.Resource; +import net.minestom.server.utils.NamespaceID; +import org.jetbrains.annotations.NotNull; + +public record EntityType( + @NotNull NamespaceID namespace, + @NotNull NamespaceID model, + @NotNull Task.Spec behavior +) implements Resource { + + public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group( + ExtraCodecs.NAMESPACE_ID.fieldOf("namespace").forGetter(EntityType::namespace), + ExtraCodecs.NAMESPACE_ID.fieldOf("model").forGetter(EntityType::model), + ExtraCodecs.NAMESPACE_ID.xmap(ns -> Task.Spec.REGISTRY.required(ns.asString()), Task.Spec::namespace) + .fieldOf("behavior").forGetter(EntityType::behavior) + ).apply(i, EntityType::new)); + + public static final Registry REGISTRY = Registry.codec("entities", CODEC); + +} diff --git a/modules/entity/src/main/java/net/hollowcube/entity/SmartEntity.java b/modules/entity/src/main/java/net/hollowcube/entity/SmartEntity.java new file mode 100644 index 00000000..e2aad9e8 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/entity/SmartEntity.java @@ -0,0 +1,84 @@ +package net.hollowcube.entity; + +import net.hollowcube.entity.motion.MotionNavigator; +import net.hollowcube.entity.navigator.Navigator; +import net.hollowcube.entity.task.Task; +import net.hollowcube.mql.MqlScript; +import net.hollowcube.mql.foreign.MqlForeignFunctions; +import net.hollowcube.mql.runtime.MqlScope; +import net.hollowcube.mql.runtime.MqlScopeImpl; +import net.hollowcube.mql.runtime.MqlScriptScope; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.LivingEntity; +import net.minestom.server.event.EventDispatcher; +import net.minestom.server.event.entity.EntityAttackEvent; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class SmartEntity extends LivingEntity { + + // Scripting + private final MqlScope queryScope = MqlForeignFunctions.create(SmartEntity.class, this); + private final MqlScope.Mutable actorScope = new MqlScopeImpl.Mutable(); + + // Brain + private final Navigator navigator; + private final Task rootTask; + + // Runtime state + private Entity target = null; + + public SmartEntity(@NotNull EntityType entityType) { + super(net.minestom.server.entity.EntityType.fromNamespaceId(entityType.model())); + + this.navigator = new MotionNavigator(this); + this.rootTask = entityType.behavior().create(); + } + + public @NotNull Navigator navigator() { + return navigator; + } + + public @Nullable Entity getTarget() { + return target; + } + + public void setTarget(@Nullable Entity target) { + this.target = target; + } + + @Override + public void update(long time) { + super.update(time); + + // Do not tick until it is in an instance. + if (!isActive()) return; + + navigator.tick(time); + switch (rootTask.getState()) { + case INIT, COMPLETE -> rootTask.start(this); + case RUNNING -> rootTask.tick(this, time); + case FAILED -> { + //todo this probably isnt the best way to handle this + remove(); + throw new RuntimeException("Entity root task failed: " + this); + } + } + } + + public void attack(@NotNull Entity target) { + swingMainHand(); + EntityAttackEvent attackEvent = new EntityAttackEvent(this, target); + EventDispatcher.call(attackEvent); + } + + // Scripting + + public double evalScript(@NotNull MqlScript script) { + return script.evaluate(new MqlScriptScope(queryScope, actorScope, MqlScope.EMPTY)); + } + + public boolean evalScriptBool(@NotNull MqlScript script) { + return script.evaluateToBool(new MqlScriptScope(queryScope, actorScope, MqlScope.EMPTY)); + } +} diff --git a/modules/entity/src/main/java/net/hollowcube/entity/motion/MotionNavigator.java b/modules/entity/src/main/java/net/hollowcube/entity/motion/MotionNavigator.java new file mode 100644 index 00000000..4609d838 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/entity/motion/MotionNavigator.java @@ -0,0 +1,226 @@ +package net.hollowcube.entity.motion; + +import com.mattworzala.debug.DebugMessage; +import com.mattworzala.debug.Layer; +import com.mattworzala.debug.shape.Box; +import com.mattworzala.debug.shape.Line; +import com.mattworzala.debug.shape.OutlineBox; +import net.hollowcube.entity.navigator.Navigator; +import net.minestom.server.attribute.Attribute; +import net.minestom.server.collision.CollisionUtils; +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.LivingEntity; +import net.minestom.server.instance.Chunk; +import net.minestom.server.instance.Instance; +import net.minestom.server.network.packet.server.play.EntityHeadLookPacket; +import net.minestom.server.network.packet.server.play.EntityRotationPacket; +import net.minestom.server.utils.NamespaceID; +import net.minestom.server.utils.PacketUtils; +import net.minestom.server.utils.position.PositionUtils; +import net.minestom.server.utils.time.Cooldown; +import net.minestom.server.utils.time.TimeUnit; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.time.Duration; +import java.util.ArrayList; + +/** + * Consumes paths from a {@link Pathfinder} to navigate an {@link net.minestom.server.entity.Entity} in an instance. + */ +public final class MotionNavigator implements Navigator { + private final Cooldown jumpCooldown = new Cooldown(Duration.of(40, TimeUnit.SERVER_TICK)); + private final Cooldown debugCooldown = new Cooldown(Duration.of(1, TimeUnit.SERVER_TICK)); + private final Entity entity; + + private Point goal = null; + private Path path = null; + private int index = 0; + + public MotionNavigator(@NotNull Entity entity) { + this.entity = entity; + } + + @Override + public boolean isActive() { + return path != null; + } + + public void reset() { + goal = null; + path = null; + index = 0; + } + + @Override + public synchronized boolean setPathTo(@Nullable Point point) { + // Providing a null point clears the navigation task + if (point == null) { + reset(); + return true; + } + + float minDistance = 0.8f; //todo move me + + // Early exit if trying to path to the same point as before, or the entity is already close enough + if (goal != null && path != null && point.samePoint(goal)) + return true; + if (entity.getPosition().distance(point) < minDistance) { + // In this case we reset because we are changing the path to the given point (which we are + // nearby already) so navigation should stop after this point. + reset(); + return true; + } + + // Ensure the entity is in an instance + final Instance instance = entity.getInstance(); + if (instance == null) + return false; + + // Cannot set a path outside the world border + if (!instance.getWorldBorder().isInside(point)) + return false; + + // Cannot path to an unloaded chunk + final Chunk chunk = instance.getChunkAt(point); + if (chunk == null || !chunk.isLoaded()) + return false; + + // Attempt to find a path + path = Pathfinder.A_STAR.findPath(PathGenerator.LAND, instance, + entity.getPosition(), point, entity.getBoundingBox()); + + boolean success = path != null; + goal = success ? point : null; + return success; + } + + @Override + public void tick(long time) { + if (goal == null || path == null) return; // No path + if (entity instanceof LivingEntity livingEntity && livingEntity.isDead()) return; + + // If we are close enough to the goal position, just stop + float minDistance = 0.8f; //todo move me + if (entity.getDistance(goal) < minDistance) { + reset(); + sendDebugData(); + return; + } + + if (debugCooldown.isReady(time)) { + debugCooldown.refreshLastUpdate(time); + sendDebugData(); + } + + Point current = index < path.size() ? path.get(index) : goal; + + float movementSpeed = 0.1f; + if (entity instanceof LivingEntity livingEntity) { + movementSpeed = livingEntity.getAttribute(Attribute.MOVEMENT_SPEED).getBaseValue(); + } + + // Move towards the current target, trying to jump if stuck + boolean isStuck = moveTowards(current, movementSpeed); + //todo jump if stuck + + // Move to next point if stuck + if (entity.getPosition().distanceSquared(current) < 0.4) { + index++; + } + } + + private boolean moveTowards(@NotNull Point direction, double speed) { + final Pos position = entity.getPosition(); + final double dx = direction.x() - position.x(); + final double dy = direction.y() - position.y(); + final double dz = direction.z() - position.z(); + // the purpose of these few lines is to slow down entities when they reach their destination + final double distSquared = dx * dx + dy * dy + dz * dz; + if (speed > distSquared) { + speed = distSquared; + } + final double radians = Math.atan2(dz, dx); + final double speedX = Math.cos(radians) * speed; + final double speedY = dy * speed; + final double speedZ = Math.sin(radians) * speed; + final float yaw = PositionUtils.getLookYaw(dx, dz); + final float pitch = PositionUtils.getLookPitch(dx, dy, dz); + + // Prevent ghosting + final var physicsResult = CollisionUtils.handlePhysics(entity, new Vec(speedX, speedY, speedZ)); + this.entity.refreshPosition(Pos.fromPoint(physicsResult.newPosition()).withView(yaw, pitch)); + return physicsResult.collisionX() || physicsResult.collisionY() || physicsResult.collisionZ(); + } + + + // SECTION: Debug rendering + // Eventually this should be only in the dev server. Just don't currently have a way to do a "mixin" here. + // Probably will have some way to set the entity provider somewhere. + + private @NotNull String debugNamespace(){ + return "debug_" + entity.getUuid(); + } + + private void sendDebugData() { + var builder = DebugMessage.builder() + .clear(debugNamespace()); + + addPathfinderDebugData(builder); + addTargetPoint(builder); + + // Send the server side view + + builder.set(NamespaceID.from(debugNamespace(), "view_dir"), new Line.Builder() + .point(entity.getPosition().asVec()) + .point(entity.getPosition().direction().mul(2).add(entity.getPosition().asVec())) + .color(0xFFFFFFFF) + .layer(Layer.TOP) + .build()); + EntityHeadLookPacket headLook = new EntityHeadLookPacket(entity.getEntityId(), entity.getPosition().yaw()); + PacketUtils.sendGroupedPacket(entity.getViewers(), headLook); + EntityRotationPacket rotation = new EntityRotationPacket(entity.getEntityId(), entity.getPosition().yaw(), entity.getPosition().pitch(), true); + PacketUtils.sendGroupedPacket(entity.getViewers(), rotation); + + + builder.build() + .sendTo(entity.getViewersAsAudience()); + } + + private void addPathfinderDebugData(DebugMessage.Builder builder) { + if (path == null) return; + var nodes = path.nodes(); + var linePoints = new ArrayList(); + + for (int i = index; i < nodes.size(); i++) { + var pos = Vec.fromPoint(nodes.get(i)); + builder.set( + debugNamespace() + ":pf_node_" + i, + new Box(pos.sub(0.4, 0.0, 0.4), pos.add(0.4, 0.1, 0.4), 0x331CB2F5, Layer.TOP) + ); + linePoints.add(pos.withY(y -> y + 0.05)); + } + builder.set( + debugNamespace() + ":pf_path", + new Line(linePoints, 10f, 0xFF1CB2F5, Layer.TOP) + ); + } + + private void addTargetPoint(DebugMessage.Builder builder) { + if (goal == null) return; + builder.set( + debugNamespace() + ":pf_target", + new OutlineBox.Builder() + .block(goal.blockX(), goal.blockY(), goal.blockZ(), 0) + .color(0x55FF0000) + .layer(Layer.TOP) + .colorLine(0xFFFF0000) + .layerLine(Layer.TOP) + .build() + ); + } + +} diff --git a/modules/entity/src/main/java/net/hollowcube/entity/motion/MotionNavigatorSlime.java b/modules/entity/src/main/java/net/hollowcube/entity/motion/MotionNavigatorSlime.java new file mode 100644 index 00000000..ef1d6d0e --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/entity/motion/MotionNavigatorSlime.java @@ -0,0 +1,148 @@ +package net.hollowcube.entity.motion; + +import net.hollowcube.entity.navigator.Navigator; +import net.minestom.server.attribute.Attribute; +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.LivingEntity; +import net.minestom.server.instance.Chunk; +import net.minestom.server.instance.Instance; +import net.minestom.server.utils.time.Cooldown; +import net.minestom.server.utils.time.TimeUnit; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.time.Duration; + +/** + * Consumes paths from a {@link Pathfinder} to navigate an {@link Entity} in an instance. + */ +public final class MotionNavigatorSlime implements Navigator { + private final Cooldown jumpCooldown = new Cooldown(Duration.of(40, TimeUnit.SERVER_TICK)); + private final Entity entity; + + private Point goal = null; + private Path path = null; + private int index = 0; + + public MotionNavigatorSlime(@NotNull Entity entity) { + this.entity = entity; + } + + @Override + public boolean isActive() { + return path != null; + } + + public void reset() { + goal = null; + path = null; + index = 0; + } + + @Override + public synchronized boolean setPathTo(@Nullable Point point) { + // Providing a null point clears the navigation task + if (point == null) { + reset(); + return true; + } + + float minDistance = 0.8f; //todo move me + + // Early exit if trying to path to the same point as before, or the entity is already close enough + if (goal != null && path != null && point.samePoint(goal)) + return true; + if (entity.getPosition().distance(point) < minDistance) { + // In this case we reset because we are changing the path to the given point (which we are + // nearby already) so navigation should stop after this point. + reset(); + return true; + } + + // Ensure the entity is in an instance + final Instance instance = entity.getInstance(); + if (instance == null) + return false; + + // Cannot set a path outside the world border + if (!instance.getWorldBorder().isInside(point)) + return false; + + // Cannot path to an unloaded chunk + final Chunk chunk = instance.getChunkAt(point); + if (chunk == null || !chunk.isLoaded()) + return false; + + // Attempt to find a path + path = Pathfinder.A_STAR.findPath(PathGenerator.LAND, instance, + entity.getPosition(), point, entity.getBoundingBox()); + + boolean success = path != null; + goal = success ? point : null; + return success; + } + + @Override + public void tick(long time) { + if (goal == null || path == null) return; // No path + if (entity instanceof LivingEntity livingEntity && livingEntity.isDead()) return; + + // If we are close enough to the goal position, just stop + float minDistance = 0.8f; //todo move me + if (entity.getDistance(goal) < minDistance) { + reset(); + return; + } + + Point current = index < path.size() ? path.get(index) : goal; + + float movementSpeed = 0.1f; + if (entity instanceof LivingEntity livingEntity) { + movementSpeed = livingEntity.getAttribute(Attribute.MOVEMENT_SPEED).getBaseValue(); + } + + // Alternative way to do this movement: + // - rotate towards the target pos each tick (interpolated probably, though the client interpolation might be enough) + // - jump in facing direction occasionally + + // Move towards the current target, trying to jump if stuck + if (jumpCooldown.isReady(time)) { + moveTowards(current, movementSpeed); + jumpCooldown.refreshLastUpdate(time); + } + + // Move to next point if stuck + if (entity.getPosition().distanceSquared(current) < 0.4) { + index++; + } + } + + private boolean moveTowards(@NotNull Point direction, double speed) { + final Pos position = entity.getPosition(); + final double dx = direction.x() - position.x(); + final double dy = direction.y() - position.y(); + final double dz = direction.z() - position.z(); + // the purpose of these few lines is to slow down entities when they reach their destination + final double distSquared = dx * dx + dy * dy + dz * dz; + if (speed > distSquared) { + speed = distSquared; + } + final double radians = Math.atan2(dz, dx); + final double speedX = Math.cos(radians) * speed * 10; + final double speedY = 8 + dy * speed * 2.5; + final double speedZ = Math.sin(radians) * speed * 10; + entity.setVelocity(new Vec(speedX, speedY, speedZ)); + return true; + +// final float yaw = PositionUtils.getLookYaw(dx, dz); +// final float pitch = PositionUtils.getLookPitch(dx, dy, dz); +// // Prevent ghosting +// final var physicsResult = CollisionUtils.handlePhysics(entity, new Vec(speedX, speedY, speedZ)); +// this.entity.refreshPosition(Pos.fromPoint(physicsResult.newPosition()).withView(yaw, pitch)); +// return physicsResult.collisionX() || physicsResult.collisionY() || physicsResult.collisionZ(); + } + +} diff --git a/modules/entity/src/main/java/net/hollowcube/entity/motion/Path.java b/modules/entity/src/main/java/net/hollowcube/entity/motion/Path.java new file mode 100644 index 00000000..7f5bfd01 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/entity/motion/Path.java @@ -0,0 +1,18 @@ +package net.hollowcube.entity.motion; + +import net.minestom.server.coordinate.Point; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +public record Path(@NotNull List nodes) { + + public Point get(int index) { + return nodes.get(index); + } + + public int size() { + return nodes.size(); + } + +} diff --git a/modules/entity/src/main/java/net/hollowcube/entity/motion/PathGenerator.java b/modules/entity/src/main/java/net/hollowcube/entity/motion/PathGenerator.java new file mode 100644 index 00000000..6708de06 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/entity/motion/PathGenerator.java @@ -0,0 +1,71 @@ +package net.hollowcube.entity.motion; + +import net.hollowcube.entity.motion.util.PhysicsUtil; +import net.minestom.server.collision.BoundingBox; +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.instance.block.Block; +import net.minestom.server.utils.Direction; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import static net.minestom.server.instance.block.Block.Getter.Condition; + +/** + * Generates paths to be consumed by a {@link Pathfinder}. + *

+ * Should take into account the entity capabilities, e.g. avoiding danger. + */ +public interface PathGenerator { + + @NotNull Collection generate(@NotNull Block.Getter world, @NotNull Point pos, @NotNull BoundingBox bb); + + + PathGenerator LAND = (world, pos, bb) -> { + pos = new Vec(pos.blockX() + 0.5, pos.blockY(), pos.blockZ() + 0.5); + List neighbors = new ArrayList<>(); + for (Direction direction : Direction.HORIZONTAL) { + for (int y = -1; y <= 1; y++) { + var neighbor = pos.add(direction.normalX(), direction.normalY() + y, direction.normalZ()); + // Block below must be solid, or we cannot move to it + try { + if (!world.getBlock(neighbor.add(0, -1, 0), Condition.TYPE).isSolid()) continue; + } catch (RuntimeException e) { + //todo need a better solution here. Instance throws an exception if the chunk is unloaded + // but that is kinda awful behavior here. Probably i will need to check if the chunk + // is loaded, but then i cant use a block getter + continue; + } + // Ensure the BB fits at that block + if (PhysicsUtil.testCollision(world, neighbor, bb)) continue; + + neighbors.add(neighbor); + } + } + return neighbors; + }; + + PathGenerator WATER = (world, pos, bb) -> { + pos = new Vec(pos.blockX() + 0.5, pos.blockY(), pos.blockZ() + 0.5); + List neighbors = new ArrayList<>(); + for (Direction direction : Direction.values()) { + var neighbor = pos.add(direction.normalX(), direction.normalY(), direction.normalZ()); + // Ensure the block is water, otherwise we cannot move to it + if (world.getBlock(neighbor, Condition.TYPE).id() != Block.WATER.id()) continue; + // Ensure the BB fits at that block + if (PhysicsUtil.testCollision(world, neighbor, bb)) continue; + + neighbors.add(neighbor); + } + return neighbors; + }; + + PathGenerator AIR = (world, pos, bb) -> { + //todo + return List.of(); + }; + +} diff --git a/modules/entity/src/main/java/net/hollowcube/entity/motion/PathOptimizer.java b/modules/entity/src/main/java/net/hollowcube/entity/motion/PathOptimizer.java new file mode 100644 index 00000000..63fd7560 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/entity/motion/PathOptimizer.java @@ -0,0 +1,46 @@ +package net.hollowcube.entity.motion; + +import net.hollowcube.entity.motion.util.PhysicsUtil; +import net.minestom.server.collision.BoundingBox; +import net.minestom.server.coordinate.Point; +import net.minestom.server.instance.block.Block; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.List; + +public interface PathOptimizer { + + @NotNull Path optimize(@NotNull Path path, @NotNull Block.Getter world, @NotNull BoundingBox bb); + + + /** Returns the input path with no modification. */ + PathOptimizer NOOP = (path, world, bb) -> path; + + /** + * Walks the path attempting to drop intermediate nodes and walk directly to the next one. + * If there is collision, add the current node and start again on the next one. + */ + PathOptimizer STRING_PULL = (path, world, bb) -> { + // On short paths there is nothing that can be shortened. + if (path.size() < 3) return path; + + List newPath = new ArrayList<>(); + newPath.add(path.get(0)); + + int current = 0; + int next = 1; + for (int i = 0; i < path.size() - 1; i++) { + boolean didCollide = PhysicsUtil.testCollisionSwept(world, bb, path.get(current), path.get(next)); + if (didCollide) { + newPath.add(path.get(next - 1)); + current = next; + } + next++; + } + + newPath.add(path.get(path.size() - 1)); + return new Path(newPath); + }; + +} diff --git a/modules/entity/src/main/java/net/hollowcube/entity/motion/Pathfinder.java b/modules/entity/src/main/java/net/hollowcube/entity/motion/Pathfinder.java new file mode 100644 index 00000000..cd25b2c5 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/entity/motion/Pathfinder.java @@ -0,0 +1,100 @@ +package net.hollowcube.entity.motion; + +import net.minestom.server.collision.BoundingBox; +import net.minestom.server.coordinate.Point; +import net.minestom.server.instance.block.Block; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.*; + +/** + * Consumes nodes from a {@link PathGenerator} to create a path. + */ +public interface Pathfinder { + + @Nullable Path findPath( + @NotNull PathGenerator pathGenerator, + @NotNull Block.Getter world, + @NotNull Point from, + @NotNull Point to, + @NotNull BoundingBox bb + ); + + /** + * Reference implementation of a* + * + * @see wikipedia + */ + Pathfinder A_STAR = (pathGenerator, world, start, goal, bb) -> { + //todo should only snap for land, otherwise it should target directly. perhaps the path generator will + // have to have a method to prep the start and end or something +// final Point start = PhysicsUtil.gravitySnap(world, from); +// final Point goal = PhysicsUtil.gravitySnap(world, to); +// if (start == null || goal == null) return null; + + // Min acceptable distance from the target. + //todo this should be a parameter somewhere i guess + // Also i am not sure this is necessary? could keep this internal and just add the target as the final point? + float minDistance = 0.8f; + + //todo should the bb be expanded? + + //todo maxSize + int maxSize = 100 * 7; + + Map minCost = new HashMap<>(); + minCost.put(start, 0f); + + //todo probably super slot to do a lookup for each of these + Queue open = new PriorityQueue<>((a, b) -> Float.compare(minCost.get(a), minCost.get(b))); + open.add(start); + + Map cameFrom = new HashMap<>(); + + + while (!open.isEmpty()) { + Point current = open.peek(); + if (current.distance(goal) < minDistance) + break; // Path was found + + // Safety stop if we are never actually going to find it + if (cameFrom.size() >= maxSize) { + //todo log + return null; + } + + open.poll(); + float currentCost = minCost.get(current); + for (var neighbor : pathGenerator.generate(world, current, bb)) { + float neighborCost = (float) (currentCost + current.distance(neighbor)); + if (neighborCost < minCost.getOrDefault(neighbor, Float.POSITIVE_INFINITY)) { + // New cheapest path, update indices + float neighborTotalCost = (float) (neighborCost + neighbor.distance(goal)); // Heuristic is just distance + cameFrom.put(neighbor, current); + minCost.put(neighbor, neighborTotalCost); + + if (!open.contains(neighbor)) { + open.add(neighbor); + } + } + } + } + + // Backtrack using cameFrom to find the best path + List result = new ArrayList<>(); + Point current = open.peek(); + if (current == null) return null; // Ran out of nodes + result.add(current); + while (cameFrom.containsKey(current)) { + current = cameFrom.get(current); + result.add(0, current); + } + + Path path = new Path(result); + //todo optimize the path + path = PathOptimizer.STRING_PULL.optimize(path, world, bb); + + return path; + }; +} diff --git a/modules/entity/src/main/java/net/hollowcube/entity/motion/util/PhysicsUtil.java b/modules/entity/src/main/java/net/hollowcube/entity/motion/util/PhysicsUtil.java new file mode 100644 index 00000000..437880ab --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/entity/motion/util/PhysicsUtil.java @@ -0,0 +1,149 @@ +package net.hollowcube.entity.motion.util; + +import net.minestom.server.collision.BoundingBox; +import net.minestom.server.collision.CollisionUtils; +import net.minestom.server.collision.PhysicsResult; +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.instance.Instance; +import net.minestom.server.instance.block.Block; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +import static net.minestom.server.instance.block.Block.Getter.Condition; + +/** + * Amalgamation of Minestom physics utility calls and a simpler static bounding box check in a world. + */ +@SuppressWarnings("UnstableApiUsage") +public final class PhysicsUtil { + private PhysicsUtil() {} + + private static final int MAX_SNAP_DISTANCE = 32; + + private static final MethodHandle handlePhysics; + + + /** + * Simplified check if a bounding box collides with a solid block. + *

+ * Currently, block shapes are ignored. Solid=full cube, empty=nothing + */ + public static boolean testCollision(@NotNull Block.Getter world, @NotNull Point pos, @NotNull BoundingBox bb) { + List blocks = new ArrayList<>(); + for (double x = bb.minX() + pos.x(); x <= bb.maxX() + pos.x(); x++) { + for (double y = bb.minY() + pos.y(); y <= bb.maxY() + pos.y(); y++) { + for (double z = bb.minZ() + pos.z(); z <= bb.maxZ() + pos.z(); z++) { + blocks.add(new Vec(Math.floor(x), Math.floor(y), Math.floor(z))); + } + blocks.add(new Vec(Math.floor(x), Math.floor(y), Math.floor(bb.maxX() + pos.z()))); + } + blocks.add(new Vec(Math.floor(x), Math.floor(bb.maxY() + pos.y()), Math.floor(bb.minZ() + pos.z()))); + } + blocks.add(new Vec(Math.floor(bb.maxX() + pos.x()), Math.floor(bb.maxY() + pos.y()), Math.floor(bb.maxZ() + pos.z()))); + + for (var block : blocks) { + if (world.getBlock(block, Block.Getter.Condition.TYPE).isSolid()) { + return true; + } + } + return false; + +// if (isInvalid) { +// boolean isInvalidUp = false; +// for (var block : blocks) { +// if (world.getBlock(block.add(0, 1, 0), Block.Getter.Condition.TYPE).isSolid()) { +// isInvalidUp = true; +// break; +// } +// } +// +// if (isInvalidUp) return true; +// } + //Collection overlapping = BoundingBoxUtilKt.getBlocks(expandedBoundingBox, point); +// +// boolean isInvalid = false; +// for (Point block : overlapping) { +// if (blockGetter.getBlock(block, Block.Getter.Condition.TYPE).isSolid()) { +// isInvalid = true; +// break; +// } +// } +// +// if (isInvalid) { +// // Check up 1 block +// boolean isInvalidUp = false; +// for (Point block : overlapping) { +// if (blockGetter.getBlock(block.add(0, 1, 0), Block.Getter.Condition.TYPE).isSolid()) { +// isInvalidUp = true; +// break; +// } +// } +// +// if (isInvalidUp) continue; +// point = point.add(0, 1, 0); +// } + } + + /** + * Collision check from a starting point to a given position. + *

+ * Uses Minestom internal physics check, which does account for block shapes. + */ + public static boolean testCollisionSwept(@NotNull Block.Getter world, @NotNull BoundingBox bb, @NotNull Point from, @NotNull Point to) { + PhysicsResult result; + if (world instanceof Instance instance) { + result = CollisionUtils.handlePhysics(instance, instance.getChunkAt(from), + bb, Pos.fromPoint(from), Vec.fromPoint(to.sub(from)), null); + } else { + // Not advisable in production, but acceptable to use in tests + try { + result = (PhysicsResult) handlePhysics.invoke(bb, Vec.fromPoint(to.sub(from)), Pos.fromPoint(from), world, null); + } catch (Throwable t) { + throw new RuntimeException(t); + } + } + + return result.collisionX() || result.collisionY() || result.collisionZ(); + } + + /** + * Snap the given point to the ground. If the point is the ground block, this moves it to + * the air block on top of the ground, if it is in the air, it snaps to the ground underneath. + *

+ * If there is no block within {@link #MAX_SNAP_DISTANCE}, null is returned. + */ + public static @Nullable Point gravitySnap(@NotNull Block.Getter world, @NotNull Point point) { + if (world.getBlock(point, Condition.TYPE).isSolid()) + return point.add(0, 1, 0); + + var ground = point.sub(0, 1, 0); + while (!world.getBlock(ground, Condition.TYPE).isSolid()) { + ground = ground.sub(0, 1, 0); + if (Math.abs(ground.blockY() - point.blockY()) > MAX_SNAP_DISTANCE) + return null; + } + + // Snap to the exact Y position + //todo need to take into account bounding box + return ground.withY(y -> Math.floor(y + 1)); + } + + static { + try { + Class blockCollision = Class.forName("net.minestom.server.collision.BlockCollision"); + Method rHandlePhysics = blockCollision.getDeclaredMethod("handlePhysics", BoundingBox.class, Vec.class, Pos.class, Block.Getter.class, PhysicsResult.class); + rHandlePhysics.setAccessible(true); + handlePhysics = MethodHandles.lookup().unreflect(rHandlePhysics); + } catch (Throwable t) { + throw new RuntimeException(t); + } + } +} diff --git a/modules/entity/src/main/java/net/hollowcube/entity/navigator/HydrazineNavigator.java b/modules/entity/src/main/java/net/hollowcube/entity/navigator/HydrazineNavigator.java new file mode 100644 index 00000000..a31b8329 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/entity/navigator/HydrazineNavigator.java @@ -0,0 +1,35 @@ +package net.hollowcube.entity.navigator; + +import com.extollit.gaming.ai.path.HydrazinePathFinder; +import net.minestom.server.coordinate.Point; +import net.minestom.server.entity.Entity; +import net.minestom.server.instance.Instance; +import org.jetbrains.annotations.NotNull; + +final class HydrazineNavigator implements Navigator { + private final net.minestom.server.entity.pathfinding.Navigator hydrazine; + + HydrazineNavigator(@NotNull Entity entity) { + this.hydrazine = new net.minestom.server.entity.pathfinding.Navigator(entity); + } + + @Override + public void setInstance(@NotNull Instance instance) { + hydrazine.setPathFinder(new HydrazinePathFinder(hydrazine.getPathingEntity(), instance.getInstanceSpace())); + } + + @Override + public boolean setPathTo(@NotNull Point point) { + return hydrazine.setPathTo(point); + } + + @Override + public boolean isActive() { + return hydrazine.getPathPosition() != null; + } + + @Override + public void tick(long time) { + hydrazine.tick(); + } +} diff --git a/modules/entity/src/main/java/net/hollowcube/entity/navigator/Navigator.java b/modules/entity/src/main/java/net/hollowcube/entity/navigator/Navigator.java new file mode 100644 index 00000000..14216e02 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/entity/navigator/Navigator.java @@ -0,0 +1,37 @@ +package net.hollowcube.entity.navigator; + +import net.minestom.server.coordinate.Point; +import net.minestom.server.entity.Entity; +import net.minestom.server.instance.Instance; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import net.hollowcube.entity.motion.MotionNavigator; +import net.hollowcube.entity.motion.MotionNavigatorSlime; + +public interface Navigator { + + static @NotNull Navigator hydrazine(@NotNull Entity entity) { + return new HydrazineNavigator(entity); + } + + static @NotNull Navigator custom(@NotNull Entity entity) { + return new CustomNavigator(entity); + } + + static @NotNull Navigator motion(@NotNull Entity entity) { + return new MotionNavigator(entity); + } + + static @NotNull Navigator motionSlime(@NotNull Entity entity) { + return new MotionNavigatorSlime(entity); + } + + default void setInstance(@NotNull Instance instance) {} + + boolean setPathTo(@Nullable Point point); + + boolean isActive(); + + void tick(long time); + +} diff --git a/modules/entity/src/main/java/net/hollowcube/entity/stimuli/NearbyEntityStimuliSource.java b/modules/entity/src/main/java/net/hollowcube/entity/stimuli/NearbyEntityStimuliSource.java new file mode 100644 index 00000000..a0ddf3f1 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/entity/stimuli/NearbyEntityStimuliSource.java @@ -0,0 +1,34 @@ +package net.hollowcube.entity.stimuli; + +import net.hollowcube.entity.SmartEntity; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.EntityTracker; +import net.minestom.server.instance.Instance; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.List; + +public class NearbyEntityStimuliSource implements StimuliSource { + + @Override + public void update(@NotNull SmartEntity entity) { + final Instance instance = entity.getInstance(); + + List nearby = new ArrayList<>(); + instance.getEntityTracker().nearbyEntities(entity.getPosition(), 5, EntityTracker.Target.PLAYERS, nearby::add); + + double minDistance = Double.MAX_VALUE; + Player closest = null; + for (Player player : nearby) { + double distance = player.getDistanceSquared(entity); + if (distance < minDistance) { + minDistance = distance; + closest = player; + } + } + + entity.setTarget(closest); + } + +} diff --git a/modules/entity/src/main/java/net/hollowcube/entity/stimuli/StimuliSource.java b/modules/entity/src/main/java/net/hollowcube/entity/stimuli/StimuliSource.java new file mode 100644 index 00000000..15422b56 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/entity/stimuli/StimuliSource.java @@ -0,0 +1,13 @@ +package net.hollowcube.entity.stimuli; + +import net.hollowcube.entity.SmartEntity; +import org.jetbrains.annotations.NotNull; + +public interface StimuliSource { + + void update(@NotNull SmartEntity entity); + + + + +} diff --git a/modules/entity/src/main/java/net/hollowcube/entity/task/AbstractTask.java b/modules/entity/src/main/java/net/hollowcube/entity/task/AbstractTask.java new file mode 100644 index 00000000..2380384e --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/entity/task/AbstractTask.java @@ -0,0 +1,23 @@ +package net.hollowcube.entity.task; + +import net.hollowcube.entity.SmartEntity; +import org.jetbrains.annotations.NotNull; + +public abstract non-sealed class AbstractTask implements Task { + private State state = State.INIT; + + @Override + public @NotNull State getState() { + return state; + } + + @Override + public void start(@NotNull SmartEntity entity) { + this.state = State.RUNNING; + } + + protected void end(boolean success) { + this.state = success ? State.COMPLETE : State.FAILED; + } + +} diff --git a/modules/entity/src/main/java/net/hollowcube/entity/task/FollowTargetTask.java b/modules/entity/src/main/java/net/hollowcube/entity/task/FollowTargetTask.java new file mode 100644 index 00000000..0161a405 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/entity/task/FollowTargetTask.java @@ -0,0 +1,63 @@ +package net.hollowcube.entity.task; + +import com.google.auto.service.AutoService; +import com.mojang.serialization.Codec; +import net.hollowcube.entity.SmartEntity; +import net.minestom.server.entity.Entity; +import net.minestom.server.thread.TickThread; +import net.minestom.server.utils.NamespaceID; +import net.minestom.server.utils.time.Cooldown; +import net.minestom.server.utils.time.TimeUnit; +import net.minestom.server.utils.validate.Check; +import org.jetbrains.annotations.NotNull; + +import java.time.Duration; + +public class FollowTargetTask extends AbstractTask { + + private Cooldown attackCooldown = new Cooldown(Duration.of(1, TimeUnit.SECOND)); + + @Override + public void tick(@NotNull SmartEntity entity, long time) { + + final Entity target = entity.getTarget(); + if (target == null) { + end(true); + return; + } + + TickThread thread = TickThread.current(); + Check.notNull(thread, "Task ticked outside of tick thread"); + if (thread.getTick() % 5 == 0) { + entity.navigator().setPathTo(target.getPosition()); + } + + double distance = entity.getDistanceSquared(target); + if (distance < 4 && attackCooldown.isReady(time)) { + attackCooldown.refreshLastUpdate(time); + entity.attack(target); + } + } + + + public record Spec() implements Task.Spec { + public static final Codec CODEC = Codec.unit(new Spec()); + + @Override + public @NotNull Task create() { + return new FollowTargetTask(); + } + + @Override + public @NotNull NamespaceID namespace() { + return NamespaceID.from("unnamed:follow_target"); + } + } + + @AutoService(Task.Factory.class) + public static class Factory extends Task.Factory { + public Factory() { + super("unnamed:follow_target", Spec.class, Spec.CODEC); + } + } +} diff --git a/modules/entity/src/main/java/net/hollowcube/entity/task/IdleTask.java b/modules/entity/src/main/java/net/hollowcube/entity/task/IdleTask.java new file mode 100644 index 00000000..63891655 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/entity/task/IdleTask.java @@ -0,0 +1,63 @@ +package net.hollowcube.entity.task; + +import com.google.auto.service.AutoService; +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.hollowcube.data.NumberSource; +import net.hollowcube.data.number.NumberProvider; +import net.hollowcube.entity.SmartEntity; +import net.minestom.server.utils.NamespaceID; +import org.jetbrains.annotations.NotNull; + +public class IdleTask extends AbstractTask { + private final Spec spec; + private int sleepTime = 0; + + public IdleTask(@NotNull Spec spec) { + this.spec = spec; + } + + @Override + public void start(@NotNull SmartEntity entity) { + super.start(entity); + + //todo get number source from entity or something, this is inconvenient to test + sleepTime = (int) spec.time().nextLong(NumberSource.threadLocalRandom()); + } + + @Override + public void tick(@NotNull SmartEntity entity, long time) { + sleepTime -= 1; + if (sleepTime < 1) { + end(true); + } + } + + + public record Spec( + @NotNull NumberProvider time + ) implements Task.Spec { + + public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group( + NumberProvider.CODEC.fieldOf("time").forGetter(Spec::time) + ).apply(i, Spec::new)); + + @Override + public @NotNull Task create() { + return new IdleTask(this); + } + + @Override + public @NotNull NamespaceID namespace() { + return NamespaceID.from("unnamed:idle"); + } + } + + @AutoService(Task.Factory.class) + public static final class Factory extends Task.Factory { + public Factory() { + super("unnamed:idle", Spec.class, Spec.CODEC); + } + } + +} diff --git a/modules/entity/src/main/java/net/hollowcube/entity/task/SelectorTask.java b/modules/entity/src/main/java/net/hollowcube/entity/task/SelectorTask.java new file mode 100644 index 00000000..ce20eb84 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/entity/task/SelectorTask.java @@ -0,0 +1,159 @@ +package net.hollowcube.entity.task; + +import com.google.auto.service.AutoService; +import com.mojang.datafixers.util.Pair; +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.hollowcube.entity.SmartEntity; +import net.hollowcube.entity.stimuli.NearbyEntityStimuliSource; +import net.hollowcube.entity.stimuli.StimuliSource; +import net.minestom.server.utils.NamespaceID; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import net.hollowcube.mql.MqlScript; + +import java.util.ArrayList; +import java.util.List; + +import static net.hollowcube.dfu.ExtraCodecs.lazy; + +public class SelectorTask extends AbstractTask { + private static final Logger LOGGER = LoggerFactory.getLogger(SelectorTask.class); + + private final Spec spec; + private final List children = new ArrayList<>(); + + private int activeTask = -1; + + //todo temp + private final StimuliSource stimuli = new NearbyEntityStimuliSource(); + + public SelectorTask(Spec spec) { + this.spec = spec; + for (var child : spec.children) { + children.add(child.create()); + } + } + + + @Override + public void start(@NotNull SmartEntity entity) { + super.start(entity); + + evaluate(entity); + } + + @Override + public void tick(@NotNull SmartEntity entity, long time) { + stimuli.update(entity); //todo not sure these should update every tick? + + // Try to tick the current task, if present + if (activeTask != -1) { + Task active = children.get(activeTask); + active.tick(entity, time); + + // Check if task is complete + switch (active.getState()) { + case FAILED -> end(false); + case COMPLETE -> { + // Current task finished with success, select a new task. + activeTask = -1; // Reset the active task so evaluate restarts it if relevant + evaluate(entity); + return; + } + // Otherwise do nothing and continue as normal + } + + // If the current task cannot be interrupted, do nothing else + Descriptor desc = spec.children.get(activeTask); + if (!desc.canInterrupt()) return; + } + + // Attempt to choose a new task. + //todo do not test for change every tick in the future + evaluate(entity); + } + + /** Evaluate each task in order, choosing the first matching one. */ + private void evaluate(@NotNull SmartEntity entity) { + for (int i = 0; i < children.size(); i++) { + Descriptor child = spec.children.get(i); + // Try to evaluate this child + if (!entity.evalScriptBool(child.script)) + continue; + + // If the selected task is also the current task, do nothing + if (activeTask == i) return; + + // Cancel the old task + //todo should do this better + entity.navigator().setPathTo(null); + + // Change task and start the new one + LOGGER.info("starting new task: {}", i); + activeTask = i; + Task newTask = children.get(i); + newTask.start(entity); + return; + } + } + + + + + public record Descriptor( + MqlScript script, + Task.Spec task, + boolean canInterrupt + ) implements Task.Spec { + @Override + public @NotNull Task create() { + return task.create(); + } + + @Override + public @NotNull NamespaceID namespace() { + return task.namespace(); + } + } + + public record Spec( + //todo stimuli + List children + ) implements Task.Spec { + private static final Codec> TASK_MIXIN = Codec.pair( + lazy(() -> Task.Spec.CODEC), + RecordCodecBuilder.create(i -> i.group( + Codec.BOOL.optionalFieldOf("interrupt", false).forGetter(b -> b) + ).apply(i, b -> b)) + ); + + private static final Codec> DESCRIPTOR_LIST = Codec.unboundedMap(MqlScript.CODEC, TASK_MIXIN) + .xmap(m -> m.entrySet().stream().map(entry -> new Descriptor(entry.getKey(), entry.getValue().getFirst(), entry.getValue().getSecond())).toList(), + d -> {throw new RuntimeException("not implemented");}); + + public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group( + DESCRIPTOR_LIST.fieldOf("children").forGetter(Spec::children) + ).apply(i, Spec::new)); + + + @Override + public @NotNull Task create() { + return new SelectorTask(this); + } + + @Override + public @NotNull NamespaceID namespace() { + return NamespaceID.from("unnamed:selector"); + } + } + + @AutoService(Task.Factory.class) + public static final class Factory extends Task.Factory { + public Factory() { + super("starlight:selector", Spec.class, Spec.CODEC); + } + } + +} diff --git a/modules/entity/src/main/java/net/hollowcube/entity/task/SequenceTask.java b/modules/entity/src/main/java/net/hollowcube/entity/task/SequenceTask.java new file mode 100644 index 00000000..b0d532f1 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/entity/task/SequenceTask.java @@ -0,0 +1,107 @@ +package net.hollowcube.entity.task; + +import com.google.auto.service.AutoService; +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.hollowcube.entity.SmartEntity; +import net.minestom.server.utils.NamespaceID; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +import static net.hollowcube.dfu.ExtraCodecs.lazy; + +public class SequenceTask extends AbstractTask { + + private final Spec spec; + private final List children; + private int current = 0; + + public SequenceTask(@NotNull Spec spec) { + this.spec = spec; + this.children = spec.children() + .stream() + .map(Task.Spec::create) + .toList(); + } + + @Override + public void start(@NotNull SmartEntity entity) { + super.start(entity); + + reset(); + if (!hasNext()) { + end(false); + return; + } + + current().start(entity); + } + + @Override + public void tick(@NotNull SmartEntity entity, long time) { + final Task current = current(); + current.tick(entity, time); + + // Do nothing if current task is still running + if (current.getState() == State.RUNNING) return; + + // If current task failed, fail the sequence + if (current.getState() == State.FAILED) { + end(false); + return; + } + + // If there are no more tasks, exit + if (!hasNext()) { + end(true); + return; + } + + // Next task + next().start(entity); + } + + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + private boolean hasNext() { + return current < children.size() - 1; + } + + private Task current() { + return children.get(current); + } + + private Task next() { + return children.get(++current); + } + + private void reset() { + this.current = 0; + } + + + public record Spec( + List children + ) implements Task.Spec { + public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group( + lazy(() -> Task.Spec.CODEC).listOf().fieldOf("children").forGetter(Spec::children) + ).apply(i, Spec::new)); + + @Override + public @NotNull Task create() { + return new SequenceTask(this); + } + + @Override + public @NotNull NamespaceID namespace() { + return NamespaceID.from("unnamed:sequence"); + } + } + + @AutoService(Task.Factory.class) + public static final class Factory extends Task.Factory { + public Factory() { + super("unnamed:sequence", Spec.class, Spec.CODEC); + } + } +} diff --git a/modules/entity/src/main/java/net/hollowcube/entity/task/Task.java b/modules/entity/src/main/java/net/hollowcube/entity/task/Task.java new file mode 100644 index 00000000..4d055674 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/entity/task/Task.java @@ -0,0 +1,46 @@ +package net.hollowcube.entity.task; + +import com.mojang.serialization.Codec; +import net.hollowcube.entity.SmartEntity; +import net.hollowcube.registry.Resource; +import org.jetbrains.annotations.NotNull; +import net.hollowcube.registry.Registry; +import net.hollowcube.registry.ResourceFactory; + +public sealed interface Task permits AbstractTask { + + @NotNull State getState(); + + void start(@NotNull SmartEntity entity); + + void tick(@NotNull SmartEntity entity, long time); + + enum State { + INIT, RUNNING, COMPLETE, FAILED + } + + interface Spec extends Resource { + Codec CODEC = Factory.CODEC.dispatch(Factory::from, Factory::codec); + + Registry REGISTRY = Registry.codec("behaviors", CODEC); + + @NotNull Task create(); + } + + class Factory extends ResourceFactory { + static final Registry REGISTRY = Registry.service("task_factory", Factory.class); + static final Registry.Index, Factory> TYPE_REGISTRY = REGISTRY.index(Factory::type); + + @SuppressWarnings("Convert2MethodRef") + //todo turn into Registry#required when updated + public static final Codec CODEC = Codec.STRING.xmap(ns -> REGISTRY.get(ns), Factory::name); + + public Factory(String namespace, Class type, Codec codec) { + super(namespace, type, codec); + } + + static @NotNull Factory from(@NotNull Spec spec) { + return TYPE_REGISTRY.get(spec.getClass()); + } + } +} diff --git a/modules/entity/src/main/java/net/hollowcube/entity/task/WanderInRegionTask.java b/modules/entity/src/main/java/net/hollowcube/entity/task/WanderInRegionTask.java new file mode 100644 index 00000000..ab23635b --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/entity/task/WanderInRegionTask.java @@ -0,0 +1,63 @@ +package net.hollowcube.entity.task; + +import com.google.auto.service.AutoService; +import com.mojang.serialization.Codec; +import net.hollowcube.entity.SmartEntity; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.utils.NamespaceID; +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.ThreadLocalRandom; + +public class WanderInRegionTask extends AbstractTask { + private final Spec spec; + + public WanderInRegionTask(@NotNull Spec spec) { + this.spec = spec; + } + + @Override + public void start(@NotNull SmartEntity entity) { + super.start(entity); + + var target = new Vec( + ThreadLocalRandom.current().nextInt(-10, 10), + 40, + ThreadLocalRandom.current().nextInt(-10, 10) + ); + System.out.println("PF to " + target); + + boolean result = entity.navigator().setPathTo(target); + System.out.println("Result: " + result); + + if (!result) end(false); + } + + @Override + public void tick(@NotNull SmartEntity entity, long time) { + if (entity.navigator().isActive()) return; + end(true); + } + + + public record Spec() implements Task.Spec { + public static final Codec CODEC = Codec.unit(new Spec()); + + @Override + public @NotNull Task create() { + return new WanderInRegionTask(this); + } + + @Override + public @NotNull NamespaceID namespace() { + return NamespaceID.from("unnamed:wander_in_region"); + } + } + + @AutoService(Task.Factory.class) + public static class Factory extends Task.Factory { + public Factory() { + super("unnamed:wander_in_region", Spec.class, Spec.CODEC); + } + } +} diff --git a/modules/entity/src/main/java/net/hollowcube/mql/MqlScript.java b/modules/entity/src/main/java/net/hollowcube/mql/MqlScript.java new file mode 100644 index 00000000..452ad168 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/mql/MqlScript.java @@ -0,0 +1,42 @@ +package net.hollowcube.mql; + +import com.mojang.datafixers.util.Either; +import com.mojang.serialization.Codec; +import net.hollowcube.dfu.DFUUtil; +import net.hollowcube.mql.parser.MqlParser; +import net.hollowcube.mql.runtime.MqlScope; +import net.hollowcube.mql.value.MqlNumberValue; +import org.jetbrains.annotations.NotNull; +import net.hollowcube.mql.tree.MqlExpr; +import net.hollowcube.mql.tree.MqlNumberExpr; +import net.hollowcube.mql.value.MqlValue; + +public record MqlScript(@NotNull MqlExpr expr) { + + public static final Codec CODEC = Codec.either(Codec.STRING, Codec.STRING.listOf()) + .xmap(either -> DFUUtil.value(either.mapRight(lines -> String.join("\n", lines))), Either::left) + //todo lossless parser + .xmap(MqlScript::parse, unused -> {throw new RuntimeException("cannot serialize an mql script");}); + + public static @NotNull MqlScript parse(@NotNull String script) { + if (script.trim().isEmpty()) // Empty script is always the number 1 + return new MqlScript(new MqlNumberExpr(1)); + MqlExpr expr = new MqlParser(script).parse(); + return new MqlScript(expr); + } + + public double evaluate(@NotNull MqlScope scope) { + MqlValue result = expr().evaluate(scope); + if (result instanceof MqlNumberValue num) + return num.value(); + return 0.0; + } + + public boolean evaluateToBool(@NotNull MqlScope scope) { + MqlValue result = expr().evaluate(scope); + if (result instanceof MqlNumberValue num) + return num.value() != 0; + return result != MqlValue.NULL; + } + +} diff --git a/modules/entity/src/main/java/net/hollowcube/mql/README.md b/modules/entity/src/main/java/net/hollowcube/mql/README.md new file mode 100644 index 00000000..209c0169 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/mql/README.md @@ -0,0 +1,21 @@ +# Minecraft Query Language (mql) + +A subset of MoLang (may eventually be a full implementation). Currently implemented as a basic tree-walk interpreter, +but may eventually be refactored to something more performant in the future. + +## Syntax + +`mql` supports the following syntax +* [Query functions](#query-functions) + +## Query Functions + +`mql` implements a subset of the query functions in MoLang. They are described below. + +| Function | Status | Description | +|---------------|--------|------------------------------------------------------------------------------------------------------------| +| q.time_of_day | + | Gets the current time of day in the world as a decimal: midnight=0.0, sunrise=0.25, noon=0.5, sunset=0.75. | +| q.has_target | + | Returns true if the entity has a target, false otherwise. | +| q.is_alive | + | Returns true if the entity is alive, false otherwise | +| | | | +| | | | diff --git a/modules/entity/src/main/java/net/hollowcube/mql/foreign/MqlForeignFunctions.java b/modules/entity/src/main/java/net/hollowcube/mql/foreign/MqlForeignFunctions.java new file mode 100644 index 00000000..904d1371 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/mql/foreign/MqlForeignFunctions.java @@ -0,0 +1,207 @@ +package net.hollowcube.mql.foreign; + +import net.hollowcube.mql.runtime.MqlRuntimeError; +import net.hollowcube.mql.runtime.MqlScope; +import net.hollowcube.mql.value.MqlCallable; +import net.hollowcube.mql.value.MqlValue; +import net.hollowcube.util.StringUtil; +import net.minestom.server.utils.validate.Check; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.UnknownNullability; + +import java.lang.invoke.CallSite; +import java.lang.invoke.LambdaMetafactory; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@SuppressWarnings({"rawtypes", "unchecked"}) +public class MqlForeignFunctions { + + public static @NotNull MqlScope create(@NotNull Class type, @Nullable T instance) { + // Construct a map with all static and non static @Query methods in the class + Map functions = new HashMap<>(); + for (Method method : type.getMethods()) { + Query annotation = method.getAnnotation(Query.class); + if (annotation == null) continue; + + boolean isStatic = (method.getModifiers() & Modifier.STATIC) != 0; + MqlCallable callable = createForeign(method, isStatic ? null : instance); + + String name = annotation.value(); + if (name.isEmpty()) name = StringUtil.camelCaseToSnakeCase(method.getName()); + + functions.put(name, callable); + } + + // Return the scope using the functions map + return name -> { + MqlCallable function = functions.get(name); + if (function == null) + throw new MqlRuntimeError("No such function: " + name); + + // 0 arg functions do not need an explicit call + if (function.arity() == 0) + return function.call(List.of()); + return function; + }; + } + + /** + * @param method The method to bind. Must be public, may be static. + * @param bindTo The instance of the method's class to bind to. If the method is static, this must be null. + * @return An {@link MqlCallable} representing an mql accessible function. + * + * @see FastInvokerFactory + */ + public static @NotNull MqlCallable createForeign(@NotNull Method method, @UnknownNullability Object bindTo) { + Check.argCondition((method.getModifiers() & Modifier.PUBLIC) == 0, "method must be public"); + Check.argCondition(bindTo != null && !method.getDeclaringClass().isInstance(bindTo), "bindTo must be an instance of the method class"); + + try { + MethodHandles.Lookup lookup = MethodHandles.lookup(); + + Class[] erasedTypes = new Class[method.getParameterCount()]; + Arrays.fill(erasedTypes, Object.class); + Class erasedReturnType = method.getReturnType(); + if (erasedReturnType != void.class) { + erasedReturnType = Object.class; + } + + Class[] fixedTypes = new Class[method.getParameterCount()]; + int index = 0; + for (Class clazz : method.getParameterTypes()) { + fixedTypes[index++] = convertPrimitive(clazz); + } + Class fixedReturnType = method.getReturnType(); + if (fixedReturnType != void.class) { + fixedReturnType = convertPrimitive(fixedReturnType); + } + + boolean isVoid = erasedReturnType == void.class; + boolean isStatic = (method.getModifiers() & Modifier.STATIC) != 0; + + CallSite callsite = LambdaMetafactory.metafactory( + lookup, + "accept" + method.getParameterCount() + (isVoid ? "V" : "R"), + MethodType.methodType(NConsumer.class, isStatic ? new Class[0] : new Class[]{method.getDeclaringClass()}), + MethodType.methodType(erasedReturnType, erasedTypes), + lookup.unreflect(method), + MethodType.methodType(fixedReturnType, fixedTypes) + ); + + var handle = (NConsumer) (isStatic ? callsite.getTarget().invoke() : callsite.getTarget().bindTo(bindTo).invoke()); + + return new ForeignCallable(handle, fixedTypes, fixedReturnType); + } catch (Throwable e) { + throw new RuntimeException(e); + } + } + + private static Class convertPrimitive(Class in) { + if (in == int.class) { + return Integer.class; + } else if (in == float.class) { + return Float.class; + } else if (in == boolean.class) { + return Boolean.class; + } else if (in == double.class) { + return Double.class; + } else if (in == long.class) { + return Long.class; + } else { + return in; + } + } + + private record ForeignCallable( + NConsumer handle, + Class[] parameterTypes, + Class returnType + ) implements MqlCallable { + + @Override + public int arity() { + return parameterTypes.length; + } + + @Override + public @NotNull MqlValue call(@NotNull List args) { + if (args.size() != parameterTypes.length) { + //todo mql exception + throw new IllegalArgumentException("Expected " + parameterTypes.length + " arguments, got " + args.size()); + } + + Object[] javaArgs = new Object[args.size()]; + for (int i = 0; i < args.size(); i++) { + javaArgs[i] = MqlForeignTypes.fromMql(args.get(i), parameterTypes[i]); + } + + boolean isVoid = returnType == void.class; + if (isVoid) { + switch (args.size()) { + case 0 -> handle.accept0V(); + case 1 -> handle.accept1V(javaArgs[0]); + case 2 -> handle.accept2V(javaArgs[0], javaArgs[1]); + case 3 -> handle.accept3V(javaArgs[0], javaArgs[1], javaArgs[2]); + case 4 -> handle.accept4V(javaArgs[0], javaArgs[1], javaArgs[2], javaArgs[3]); + case 5 -> handle.accept5V(javaArgs[0], javaArgs[1], javaArgs[2], javaArgs[3], javaArgs[4]); + case 6 -> handle.accept6V(javaArgs[0], javaArgs[1], javaArgs[2], javaArgs[3], javaArgs[4], javaArgs[5]); + case 7 -> handle.accept7V(javaArgs[0], javaArgs[1], javaArgs[2], javaArgs[3], javaArgs[4], javaArgs[5], javaArgs[6]); + case 8 -> handle.accept8V(javaArgs[0], javaArgs[1], javaArgs[2], javaArgs[3], javaArgs[4], javaArgs[5], javaArgs[6], javaArgs[7]); + case 9 -> handle.accept9V(javaArgs[0], javaArgs[1], javaArgs[2], javaArgs[3], javaArgs[4], javaArgs[5], javaArgs[6], javaArgs[7], javaArgs[8]); + } + } else { + Object result = switch (args.size()) { + case 0 -> handle.accept0R(); + case 1 -> handle.accept1R(javaArgs[0]); + case 2 -> handle.accept2R(javaArgs[0], javaArgs[1]); + case 3 -> handle.accept3R(javaArgs[0], javaArgs[1], javaArgs[2]); + case 4 -> handle.accept4R(javaArgs[0], javaArgs[1], javaArgs[2], javaArgs[3]); + case 5 -> handle.accept5R(javaArgs[0], javaArgs[1], javaArgs[2], javaArgs[3], javaArgs[4]); + case 6 -> handle.accept6R(javaArgs[0], javaArgs[1], javaArgs[2], javaArgs[3], javaArgs[4], javaArgs[5]); + case 7 -> handle.accept7R(javaArgs[0], javaArgs[1], javaArgs[2], javaArgs[3], javaArgs[4], javaArgs[5], javaArgs[6]); + case 8 -> handle.accept8R(javaArgs[0], javaArgs[1], javaArgs[2], javaArgs[3], javaArgs[4], javaArgs[5], javaArgs[6], javaArgs[7]); + case 9 -> handle.accept9R(javaArgs[0], javaArgs[1], javaArgs[2], javaArgs[3], javaArgs[4], javaArgs[5], javaArgs[6], javaArgs[7], javaArgs[8]); + default -> throw new MqlRuntimeError("unreachable arg error"); + }; + + return MqlForeignTypes.toMql(result); + } + + return MqlValue.NULL; + } + } + + public interface NConsumer { + + R accept0R(); + R accept1R(P1 p1); + R accept2R(P1 p1, P2 p2); + R accept3R(P1 p1, P2 p2, P3 p3); + R accept4R(P1 p1, P2 p2, P3 p3, P4 p4); + R accept5R(P1 p1, P2 p2, P3 p3, P4 p4, P5 p5); + R accept6R(P1 p1, P2 p2, P3 p3, P4 p4, P5 p5, P6 p6); + R accept7R(P1 p1, P2 p2, P3 p3, P4 p4, P5 p5, P6 p6, P7 p7); + R accept8R(P1 p1, P2 p2, P3 p3, P4 p4, P5 p5, P6 p6, P7 p7, P8 p8); + R accept9R(P1 p1, P2 p2, P3 p3, P4 p4, P5 p5, P6 p6, P7 p7, P8 p8, P9 p9); + + void accept0V(); + void accept1V(P1 p1); + void accept2V(P1 p1, P2 p2); + void accept3V(P1 p1, P2 p2, P3 p3); + void accept4V(P1 p1, P2 p2, P3 p3, P4 p4); + void accept5V(P1 p1, P2 p2, P3 p3, P4 p4, P5 p5); + void accept6V(P1 p1, P2 p2, P3 p3, P4 p4, P5 p5, P6 p6); + void accept7V(P1 p1, P2 p2, P3 p3, P4 p4, P5 p5, P6 p6, P7 p7); + void accept8V(P1 p1, P2 p2, P3 p3, P4 p4, P5 p5, P6 p6, P7 p7, P8 p8); + void accept9V(P1 p1, P2 p2, P3 p3, P4 p4, P5 p5, P6 p6, P7 p7, P8 p8, P9 p9); + + } +} diff --git a/modules/entity/src/main/java/net/hollowcube/mql/foreign/MqlForeignTypes.java b/modules/entity/src/main/java/net/hollowcube/mql/foreign/MqlForeignTypes.java new file mode 100644 index 00000000..45c8d459 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/mql/foreign/MqlForeignTypes.java @@ -0,0 +1,58 @@ +package net.hollowcube.mql.foreign; + +import net.hollowcube.mql.value.MqlNumberValue; +import net.hollowcube.mql.value.MqlValue; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.UnknownNullability; + +/** + * Conversion utility between java types and MQL types. + *

+ * todo not sure if other types should be convertable. I think just doubles is fine. + */ +public class MqlForeignTypes { + + public static @NotNull MqlValue toMql(@UnknownNullability Object javaValue) { + if (javaValue == null) return MqlValue.NULL; + if (javaValue instanceof MqlValue value) + return value; + if (javaValue instanceof Double value) + return new MqlNumberValue(value); +// if (javaValue instanceof Float value) +// return new MqlNumberValue(value); +// if (javaValue instanceof Long value) +// return new MqlNumberValue(value); +// if (javaValue instanceof Integer value) +// return new MqlNumberValue(value); +// if (javaValue instanceof Short value) +// return new MqlNumberValue(value); +// if (javaValue instanceof Byte value) +// return new MqlNumberValue(value); + if (javaValue instanceof Boolean value) + return MqlValue.from(value); + throw new RuntimeException("cannot convert " + javaValue.getClass().getSimpleName() + " to mql value"); + } + + public static @UnknownNullability Object fromMql(@NotNull MqlValue value, @NotNull Class targetType) { + if (value instanceof MqlNumberValue numberValue) { + if (Double.class.equals(targetType) || double.class.equals(targetType)) { + return numberValue.value(); +// } else if (Float.class.equals(targetType) || float.class.equals(targetType)) { +// return (float) numberValue.value(); +// } else if (Long.class.equals(targetType) || long.class.equals(targetType)) { +// return (long) numberValue.value(); +// } else if (Integer.class.equals(targetType) || int.class.equals(targetType)) { +// return (int) numberValue.value(); +// } else if (Short.class.equals(targetType) || short.class.equals(targetType)) { +// return (short) numberValue.value(); +// } else if (Byte.class.equals(targetType) || byte.class.equals(targetType)) { +// return (byte) numberValue.value(); + } else if (Boolean.class.equals(targetType) || boolean.class.equals(targetType)) { + return numberValue.value() != 0; + } + throw new RuntimeException("cannot convert number " + targetType.getSimpleName()); + } + throw new RuntimeException("cannot convert " + value.getClass().getSimpleName() + " to " + targetType.getSimpleName()); + } + +} diff --git a/modules/entity/src/main/java/net/hollowcube/mql/foreign/Query.java b/modules/entity/src/main/java/net/hollowcube/mql/foreign/Query.java new file mode 100644 index 00000000..90b4dcf9 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/mql/foreign/Query.java @@ -0,0 +1,12 @@ +package net.hollowcube.mql.foreign; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Query { + String value() default ""; +} diff --git a/modules/entity/src/main/java/net/hollowcube/mql/parser/MqlLexer.java b/modules/entity/src/main/java/net/hollowcube/mql/parser/MqlLexer.java new file mode 100644 index 00000000..ea4a8bce --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/mql/parser/MqlLexer.java @@ -0,0 +1,125 @@ +package net.hollowcube.mql.parser; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class MqlLexer { + private final String source; + + private int start = 0; + private int cursor = 0; + + public MqlLexer(@NotNull String source) { + this.source = source; + } + + /** + * Returns the next token in the input, or null if the end of file was reached. + * @throws MqlParseError if there is an unexpected token. + */ + public @Nullable MqlToken next() { + start = cursor; + + if (atEnd()) return null; + + consumeWhitespace(); + + char c = advance(); + if (isAlpha(c)) + return ident(); + if (isDigit(c)) + return number(); + + return symbol(c); + } + + /** + * Returns the next token without stepping to the next token in the input, + * or null if the end of file was reached. + * @throws MqlParseError if there is an unexpected token. + */ + public @Nullable MqlToken peek() { + var result = next(); + cursor = start; // Reset to where it was before the call to next. + return result; + } + + public @NotNull String span(@NotNull MqlToken token) { + return source.substring(start, cursor); + } + + private void consumeWhitespace() { + while (true) { + switch (peek0()) { + case ' ', '\t', '\r', '\n' -> advance(); + default -> { + return; + } + } + } + } + + private MqlToken ident() { + while (isAlpha(peek0()) || isDigit(peek0())) { + advance(); + } + + return new MqlToken(MqlToken.Type.IDENT, start, cursor); + } + + private MqlToken number() { + // Pre decimal + while (isDigit(peek0())) + advance(); + + // Decimal, if present + if (match('.')) { + while (isDigit(peek0())) + advance(); + } + + return new MqlToken(MqlToken.Type.NUMBER, start, cursor); + } + + private MqlToken symbol(char c) { + var tokenType = switch (c) { + // @formatter:off + case '+' -> MqlToken.Type.PLUS; + case '.' -> MqlToken.Type.DOT; + default -> throw new MqlParseError( + String.format("unexpected token '%s' at %d.", c, cursor)); + // @formatter:on + }; + return new MqlToken(tokenType, start, cursor); + } + + private boolean atEnd() { + return cursor >= source.length(); + } + + private char peek0() { + if (atEnd()) + return '\u0000'; + return source.charAt(cursor); + } + + private char advance() { + if (atEnd()) throw new MqlParseError("unexpected end of input"); + return source.charAt(cursor++); + } + + private boolean match(char c) { + if (atEnd()) return false; + if (peek0() != c) return false; + advance(); + return true; + } + + private boolean isAlpha(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'; + } + + private boolean isDigit(char c) { + return c >= '0' && c <= '9'; + } +} diff --git a/modules/entity/src/main/java/net/hollowcube/mql/parser/MqlParseError.java b/modules/entity/src/main/java/net/hollowcube/mql/parser/MqlParseError.java new file mode 100644 index 00000000..7a74b5b0 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/mql/parser/MqlParseError.java @@ -0,0 +1,11 @@ +package net.hollowcube.mql.parser; + +import org.jetbrains.annotations.NotNull; + +public class MqlParseError extends RuntimeException { + + public MqlParseError(@NotNull String message) { + super(message); + } + +} diff --git a/modules/entity/src/main/java/net/hollowcube/mql/parser/MqlParser.java b/modules/entity/src/main/java/net/hollowcube/mql/parser/MqlParser.java new file mode 100644 index 00000000..f8974727 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/mql/parser/MqlParser.java @@ -0,0 +1,84 @@ +package net.hollowcube.mql.parser; + +import net.hollowcube.mql.tree.*; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import net.hollowcube.mql.tree.*; + +public class MqlParser { + private final MqlLexer lexer; + + public MqlParser(@NotNull String source) { + this.lexer = new MqlLexer(source); + } + + public @NotNull MqlExpr parse() { + return expr(0); + } + + private @NotNull MqlExpr expr(int minBindingPower) { + MqlExpr lhs = lhs(); + + while (true) { + InfixOp op = infixOp(); + if (op == null) break; + + // Stop if operator left binding power is less than the current min + if (op.lbp < minBindingPower) break; + + lexer.next(); // Operator token + + // Parse right side expression + MqlExpr rhs = expr(op.rbp); + lhs = switch (op) { + case MEMBER_ACCESS -> { + if (!(rhs instanceof MqlIdentExpr ident)) + throw new MqlParseError("rhs of member access must be an ident, was " + rhs); + yield new MqlAccessExpr(lhs, ident.value()); + } + default -> new MqlBinaryExpr(op.op, lhs, rhs); + }; + } + + return lhs; + } + + /** Parses a possible left side expression. */ + private @NotNull MqlExpr lhs() { + MqlToken token = lexer.next(); + if (token == null) throw new MqlParseError("unexpected end of input"); + + return switch (token.type()) { + case NUMBER -> new MqlNumberExpr(Double.parseDouble(lexer.span(token))); + case IDENT -> new MqlIdentExpr(lexer.span(token)); + //todo better error handling + default -> throw new MqlParseError("unexpected token " + token); + }; + } + + private @Nullable InfixOp infixOp() { + MqlToken token = lexer.peek(); + if (token == null) return null; + return switch (token.type()) { + case PLUS -> InfixOp.PLUS; + case DOT -> InfixOp.MEMBER_ACCESS; + default -> null; + }; + } + + private enum InfixOp { + PLUS(25, 26, MqlBinaryExpr.Op.PLUS), + MEMBER_ACCESS(35, 36, null); + + private final int lbp; + private final int rbp; + private final MqlBinaryExpr.Op op; + + InfixOp(int lbp, int rbp, MqlBinaryExpr.Op op) { + this.lbp = lbp; + this.rbp = rbp; + this.op = op; + } + } + +} diff --git a/modules/entity/src/main/java/net/hollowcube/mql/parser/MqlToken.java b/modules/entity/src/main/java/net/hollowcube/mql/parser/MqlToken.java new file mode 100644 index 00000000..c2c99eb5 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/mql/parser/MqlToken.java @@ -0,0 +1,13 @@ +package net.hollowcube.mql.parser; + +import org.jetbrains.annotations.NotNull; + +record MqlToken(@NotNull Type type, int start, int end) { + + enum Type { + PLUS, + DOT, + NUMBER, IDENT; + } + +} diff --git a/modules/entity/src/main/java/net/hollowcube/mql/runtime/MqlMath.java b/modules/entity/src/main/java/net/hollowcube/mql/runtime/MqlMath.java new file mode 100644 index 00000000..55c065e1 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/mql/runtime/MqlMath.java @@ -0,0 +1,201 @@ +package net.hollowcube.mql.runtime; + +import net.hollowcube.mql.foreign.MqlForeignFunctions; +import net.hollowcube.mql.foreign.Query; + +import java.util.concurrent.ThreadLocalRandom; + +public class MqlMath { + + public static final MqlScope INSTANCE = MqlForeignFunctions.create(MqlMath.class, null); + + private MqlMath() {} + + /** Absolute value of value */ + @Query + public static double abs(double value) { + return Math.abs(value); + } + + /** arccos of value */ + @Query + public static double acos(double value) { + return Math.acos(value); + } + + /** arcsin of value */ + @Query + public static double asin(double value) { + return Math.asin(value); + } + + /** arctan of value */ + @Query + public static double atan(double value) { + return Math.atan(value); + } + + /** arctan of y/x. NOTE: the order of arguments! */ + @Query + public static double atan2(double y, double x) { + return Math.atan2(y, x); + } + + /** Round value up to nearest integral number */ + @Query + public static double ceil(double value) { + return Math.ceil(value); + } + + /** Clamp value to between min and max inclusive */ + @Query + public static double clamp(double value, double min, double max) { + return Math.min(Math.max(value, min), max); + } + + /** Cosine (in degrees) of value */ + @Query + public static double cos(double value) { + return Math.cos(value); + } + + /** Returns the sum of 'num' random numbers, each with a value from low to high. Note: the generated random numbers are not integers like normal dice. For that, use math.die_roll_integer. */ + @Query + public static double dieRoll(double num, double low, double high) { + double total = 0; + for (int i = 0; i < num; i++) + total += random(low, high); + return total; + } + + /** Returns the sum of 'num' random integer numbers, each with a value from low to high. Note: the generated random numbers are integers like normal dice. */ + @Query + public static double dieRollInteger(double num, double low, double high) { + double total = 0; + for (int i = 0; i < num; i++) + total += randomInteger(low, high); + return total; + } + + /** Calculates e to the value 'nth' power */ + @Query + public static double exp(double value) { + return Math.exp(value); + } + + /** Round value down to nearest integral number */ + @Query + public static double floor(double value) { + return Math.floor(value); + } + + /** Useful for simple smooth curve interpolation using one of the Hermite Basis functions: 3t^2 - 2t^3. Note that while any valid float is a valid input, this function works best in the range [0,1]. */ + @Query + public static double hermiteBlend(double value) { + //todo: implement me + throw new MqlRuntimeError("hermite_blend not implemented"); + } + + /** Lerp from start to end via zeroToOne */ + @Query + public static double lerp(double start, double end, double zeroToOne) { + //todo test me + zeroToOne = clamp(zeroToOne, 0, 1); + return start * zeroToOne + end * (1D - zeroToOne); + } + + /** Lerp the shortest direction around a circle from start degrees to end degrees via zeroToOne */ + @Query + public static double lerprotate(double start, double end, double zeroToOne) { + //todo test me + zeroToOne = clamp(zeroToOne, 0, 1); + double diff = end - start; + if (diff > 180) diff -= 360; + else if (diff < -180) diff += 360; + return start + diff * zeroToOne; + } + + /** Natural logarithm of value */ + @Query + public static double ln(double value) { + return Math.log(value); + } + + /** Return highest value of A or B */ + @Query + public static double max(double a, double b) { + return Math.max(a, b); + } + + /** Return lowest value of A or B */ + @Query + public static double min(double a, double b) { + return Math.min(a, b); + } + + /** Minimize angle magnitude (in degrees) into the range [-180, 180) */ + @Query + public static double minAngle(double value) { + //todo: implement me + throw new MqlRuntimeError("hermite_blend not implemented"); + } + + /** Return the remainder of value / denominator */ + @Query + public static double mod(double value, double denominator) { + return value % denominator; + } + + /** Returns the float representation of the constant pi. */ + @Query + public static double pi() { + return Math.PI; + } + + /** Elevates base to the exponent'th power */ + @Query + public static double pow(double base, double exponent) { + return Math.pow(base, exponent); + } + + /** + * Random value between low (inclusive) and high (exclusive) + *

+ * Note: The original molang spec says that the range is inclusive, but this high end is exclusive. + */ + @Query + public static double random(double low, double high) { + return ThreadLocalRandom.current().nextDouble(low, high); + } + + /** Random integer value between low and high (inclusive) */ + @Query + public static double randomInteger(double low, double high) { + return ThreadLocalRandom.current().nextInt((int) low, (int) high + 1); + } + + /** Round value to nearest integral number */ + @Query + public static double round(double value) { + return Math.round(value); + } + + /** Sine (in degrees) of value */ + @Query + public static double sin(double value) { + return Math.sin(value); + } + + /** Square root of value */ + @Query + public static double sqrt(double value) { + return Math.sqrt(value); + } + + /** Round value towards zero */ + @Query + public static double trunc(double value) { + return value < 0 ? Math.ceil(value) : Math.floor(value); + } + +} diff --git a/modules/entity/src/main/java/net/hollowcube/mql/runtime/MqlRuntimeError.java b/modules/entity/src/main/java/net/hollowcube/mql/runtime/MqlRuntimeError.java new file mode 100644 index 00000000..f7f63e28 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/mql/runtime/MqlRuntimeError.java @@ -0,0 +1,9 @@ +package net.hollowcube.mql.runtime; + +import org.jetbrains.annotations.NotNull; + +public class MqlRuntimeError extends RuntimeException { + public MqlRuntimeError(@NotNull String message) { + super(message); + } +} diff --git a/modules/entity/src/main/java/net/hollowcube/mql/runtime/MqlScope.java b/modules/entity/src/main/java/net/hollowcube/mql/runtime/MqlScope.java new file mode 100644 index 00000000..00fb9f26 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/mql/runtime/MqlScope.java @@ -0,0 +1,19 @@ +package net.hollowcube.mql.runtime; + +import net.hollowcube.mql.value.MqlHolder; +import org.jetbrains.annotations.NotNull; +import net.hollowcube.mql.value.MqlValue; + +public interface MqlScope extends MqlHolder { + + MqlScope EMPTY = unused -> MqlValue.NULL; + + @NotNull MqlValue get(@NotNull String name); + + interface Mutable extends MqlScope { + + void set(@NotNull String name, @NotNull MqlValue value); + + } + +} diff --git a/modules/entity/src/main/java/net/hollowcube/mql/runtime/MqlScopeImpl.java b/modules/entity/src/main/java/net/hollowcube/mql/runtime/MqlScopeImpl.java new file mode 100644 index 00000000..701d2c56 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/mql/runtime/MqlScopeImpl.java @@ -0,0 +1,27 @@ +package net.hollowcube.mql.runtime; + +import net.hollowcube.mql.value.MqlValue; +import org.jetbrains.annotations.NotNull; + +import java.util.HashMap; +import java.util.Map; + +public class MqlScopeImpl implements MqlScope { + protected final Map data = new HashMap<>(); + + @Override + public @NotNull MqlValue get(@NotNull String name) { + return data.getOrDefault(name, MqlValue.NULL); + } + + + public static class Mutable extends MqlScopeImpl implements MqlScope.Mutable { + + @Override + public void set(@NotNull String name, @NotNull MqlValue value) { + data.put(name, value); + } + + } + +} diff --git a/modules/entity/src/main/java/net/hollowcube/mql/runtime/MqlScriptScope.java b/modules/entity/src/main/java/net/hollowcube/mql/runtime/MqlScriptScope.java new file mode 100644 index 00000000..c6a64bc7 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/mql/runtime/MqlScriptScope.java @@ -0,0 +1,30 @@ +package net.hollowcube.mql.runtime; + +import net.hollowcube.mql.value.MqlValue; +import org.jetbrains.annotations.NotNull; + +public class MqlScriptScope implements MqlScope { + + private final MqlScope query; + private final MqlScope.Mutable actor; + private final MqlScope context; + private final MqlScope.Mutable temp = new MqlScopeImpl.Mutable(); + + public MqlScriptScope(@NotNull MqlScope query, @NotNull Mutable actor, @NotNull MqlScope context) { + this.query = query; + this.actor = actor; + this.context = context; + } + + @Override + public @NotNull MqlValue get(@NotNull String name) { + return switch (name) { + case "math", "m" -> MqlMath.INSTANCE; + case "query", "q" -> query; + case "temp", "t" -> temp; + case "variable", "v" -> actor; + case "context", "c" -> context; + default -> throw new MqlRuntimeError("unknown environment object: " + name); + }; + } +} diff --git a/modules/entity/src/main/java/net/hollowcube/mql/tree/MqlAccessExpr.java b/modules/entity/src/main/java/net/hollowcube/mql/tree/MqlAccessExpr.java new file mode 100644 index 00000000..9f7712e1 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/mql/tree/MqlAccessExpr.java @@ -0,0 +1,23 @@ +package net.hollowcube.mql.tree; + +import net.hollowcube.mql.runtime.MqlScope; +import net.hollowcube.mql.value.MqlHolder; +import net.hollowcube.mql.value.MqlValue; +import org.jetbrains.annotations.NotNull; + +public record MqlAccessExpr( + @NotNull MqlExpr lhs, + @NotNull String target +) implements MqlExpr { + + @Override + public MqlValue evaluate(@NotNull MqlScope scope) { + var lhs = lhs().evaluate(scope).cast(MqlHolder.class); + return lhs.get(target()); + } + + @Override + public R visit(@NotNull MqlVisitor visitor, P p) { + return visitor.visitAccessExpr(this, p); + } +} diff --git a/modules/entity/src/main/java/net/hollowcube/mql/tree/MqlBinaryExpr.java b/modules/entity/src/main/java/net/hollowcube/mql/tree/MqlBinaryExpr.java new file mode 100644 index 00000000..7facdb8d --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/mql/tree/MqlBinaryExpr.java @@ -0,0 +1,31 @@ +package net.hollowcube.mql.tree; + +import net.hollowcube.mql.runtime.MqlScope; +import net.hollowcube.mql.value.MqlNumberValue; +import net.hollowcube.mql.value.MqlValue; +import org.jetbrains.annotations.NotNull; + +public record MqlBinaryExpr( + @NotNull Op operator, + @NotNull MqlExpr lhs, + @NotNull MqlExpr rhs +) implements MqlExpr { + public enum Op { + PLUS + } + + @Override + public MqlValue evaluate(@NotNull MqlScope scope) { + var lhs = lhs().evaluate(scope).cast(MqlNumberValue.class); + var rhs = lhs().evaluate(scope).cast(MqlNumberValue.class); + + return switch (operator()) { + case PLUS -> new MqlNumberValue(lhs.value() + rhs.value()); + }; + } + + @Override + public R visit(@NotNull MqlVisitor visitor, P p) { + return visitor.visitBinaryExpr(this, p); + } +} diff --git a/modules/entity/src/main/java/net/hollowcube/mql/tree/MqlExpr.java b/modules/entity/src/main/java/net/hollowcube/mql/tree/MqlExpr.java new file mode 100644 index 00000000..4427cd12 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/mql/tree/MqlExpr.java @@ -0,0 +1,12 @@ +package net.hollowcube.mql.tree; + +import net.hollowcube.mql.runtime.MqlScope; +import net.hollowcube.mql.value.MqlValue; +import org.jetbrains.annotations.NotNull; + +public sealed interface MqlExpr permits MqlAccessExpr, MqlBinaryExpr, MqlNumberExpr, MqlIdentExpr { + + MqlValue evaluate(@NotNull MqlScope scope); + + R visit(@NotNull MqlVisitor visitor, P p); +} diff --git a/modules/entity/src/main/java/net/hollowcube/mql/tree/MqlIdentExpr.java b/modules/entity/src/main/java/net/hollowcube/mql/tree/MqlIdentExpr.java new file mode 100644 index 00000000..7aa668fe --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/mql/tree/MqlIdentExpr.java @@ -0,0 +1,18 @@ +package net.hollowcube.mql.tree; + +import net.hollowcube.mql.runtime.MqlScope; +import net.hollowcube.mql.value.MqlValue; +import org.jetbrains.annotations.NotNull; + +public record MqlIdentExpr(@NotNull String value) implements MqlExpr { + + @Override + public MqlValue evaluate(@NotNull MqlScope scope) { + return scope.get(value); + } + + @Override + public R visit(@NotNull MqlVisitor visitor, P p) { + return visitor.visitRefExpr(this, p); + } +} diff --git a/modules/entity/src/main/java/net/hollowcube/mql/tree/MqlNumberExpr.java b/modules/entity/src/main/java/net/hollowcube/mql/tree/MqlNumberExpr.java new file mode 100644 index 00000000..73dd847d --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/mql/tree/MqlNumberExpr.java @@ -0,0 +1,23 @@ +package net.hollowcube.mql.tree; + +import net.hollowcube.mql.runtime.MqlScope; +import net.hollowcube.mql.value.MqlNumberValue; +import net.hollowcube.mql.value.MqlValue; +import org.jetbrains.annotations.NotNull; + +public record MqlNumberExpr(@NotNull MqlNumberValue value) implements MqlExpr { + + public MqlNumberExpr(double value) { + this(new MqlNumberValue(value)); + } + + @Override + public MqlValue evaluate(@NotNull MqlScope scope) { + return value; + } + + @Override + public R visit(@NotNull MqlVisitor visitor, P p) { + return visitor.visitNumberExpr(this, p); + } +} diff --git a/modules/entity/src/main/java/net/hollowcube/mql/tree/MqlPrinter.java b/modules/entity/src/main/java/net/hollowcube/mql/tree/MqlPrinter.java new file mode 100644 index 00000000..5cb42b6d --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/mql/tree/MqlPrinter.java @@ -0,0 +1,42 @@ +package net.hollowcube.mql.tree; + +import org.jetbrains.annotations.NotNull; + +public class MqlPrinter implements MqlVisitor { + + @Override + public String visitBinaryExpr(@NotNull MqlBinaryExpr expr, Void unused) { + return String.format( + "(%s %s %s)", + switch (expr.operator()) { + case PLUS -> "+"; + }, + visit(expr.lhs(), null), + visit(expr.rhs(), null) + ); + } + + @Override + public String visitAccessExpr(@NotNull MqlAccessExpr expr, Void unused) { + return String.format( + "(. %s %s)", + visit(expr.lhs(), null), + expr.target() + ); + } + + @Override + public String visitNumberExpr(@NotNull MqlNumberExpr expr, Void unused) { + return String.valueOf(expr.value()); + } + + @Override + public String visitRefExpr(@NotNull MqlIdentExpr expr, Void unused) { + return expr.value(); + } + + @Override + public String defaultValue() { + return "##Error"; + } +} diff --git a/modules/entity/src/main/java/net/hollowcube/mql/tree/MqlVisitor.java b/modules/entity/src/main/java/net/hollowcube/mql/tree/MqlVisitor.java new file mode 100644 index 00000000..4e14a246 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/mql/tree/MqlVisitor.java @@ -0,0 +1,26 @@ +package net.hollowcube.mql.tree; + +import org.jetbrains.annotations.NotNull; + +// @formatter:off +public interface MqlVisitor { + + default R visitBinaryExpr(@NotNull MqlBinaryExpr expr, P p) { return defaultValue(); } + + default R visitAccessExpr(@NotNull MqlAccessExpr expr, P p) { return defaultValue(); } + + default R visitNumberExpr(@NotNull MqlNumberExpr expr, P p) { return defaultValue(); } + + default R visitRefExpr(@NotNull MqlIdentExpr expr, P p) { return defaultValue(); } + + + + + default R visit(@NotNull MqlExpr expr, P p) { + return expr.visit(this, p); + } + + R defaultValue(); + +} +// @formatter:on diff --git a/modules/entity/src/main/java/net/hollowcube/mql/value/MqlCallable.java b/modules/entity/src/main/java/net/hollowcube/mql/value/MqlCallable.java new file mode 100644 index 00000000..3255bdb2 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/mql/value/MqlCallable.java @@ -0,0 +1,15 @@ +package net.hollowcube.mql.value; + +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +@FunctionalInterface +public non-sealed interface MqlCallable extends MqlValue { + + /** Returns the arity of the function, or -1 if it is variadic/otherwise unknown */ + default int arity() { return -1; } + + @NotNull MqlValue call(@NotNull List args); + +} diff --git a/modules/entity/src/main/java/net/hollowcube/mql/value/MqlHolder.java b/modules/entity/src/main/java/net/hollowcube/mql/value/MqlHolder.java new file mode 100644 index 00000000..356fc7f0 --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/mql/value/MqlHolder.java @@ -0,0 +1,9 @@ +package net.hollowcube.mql.value; + +import org.jetbrains.annotations.NotNull; + +public non-sealed interface MqlHolder extends MqlValue { + + @NotNull MqlValue get(@NotNull String name); + +} diff --git a/modules/entity/src/main/java/net/hollowcube/mql/value/MqlIdentValue.java b/modules/entity/src/main/java/net/hollowcube/mql/value/MqlIdentValue.java new file mode 100644 index 00000000..6cf892ee --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/mql/value/MqlIdentValue.java @@ -0,0 +1,8 @@ +package net.hollowcube.mql.value; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public record MqlIdentValue(@NotNull String value) implements MqlValue { +} diff --git a/modules/entity/src/main/java/net/hollowcube/mql/value/MqlNumberValue.java b/modules/entity/src/main/java/net/hollowcube/mql/value/MqlNumberValue.java new file mode 100644 index 00000000..d0e499af --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/mql/value/MqlNumberValue.java @@ -0,0 +1,9 @@ +package net.hollowcube.mql.value; + +public record MqlNumberValue(double value) implements MqlValue { + + @Override + public String toString() { + return String.valueOf(value); + } +} diff --git a/modules/entity/src/main/java/net/hollowcube/mql/value/MqlValue.java b/modules/entity/src/main/java/net/hollowcube/mql/value/MqlValue.java new file mode 100644 index 00000000..7cabfa3c --- /dev/null +++ b/modules/entity/src/main/java/net/hollowcube/mql/value/MqlValue.java @@ -0,0 +1,27 @@ +package net.hollowcube.mql.value; + +import net.hollowcube.mql.runtime.MqlRuntimeError; +import org.jetbrains.annotations.NotNull; + +/** + * Mutable marker for any possible mql value. + */ +public sealed interface MqlValue permits MqlCallable, MqlHolder, MqlIdentValue, MqlNumberValue { + MqlValue NULL = new MqlNumberValue(0.0); + + static @NotNull MqlValue from(boolean bool) { + return new MqlNumberValue(bool ? 1 : 0); + } + + static @NotNull MqlValue from(double dbl) { + return new MqlNumberValue(dbl); + } + + default Target cast(@NotNull Class targetType) { + if (targetType.isInstance(this)) + //noinspection unchecked + return (Target) this; + throw new MqlRuntimeError("cannot cast " + this.getClass().getSimpleName() + " to " + targetType.getSimpleName()); + } + +} diff --git a/modules/entity/src/main/resources/data/behaviors.json b/modules/entity/src/main/resources/data/behaviors.json new file mode 100644 index 00000000..7c5bd75f --- /dev/null +++ b/modules/entity/src/main/resources/data/behaviors.json @@ -0,0 +1,32 @@ +[ + { + "namespace": "hostile_generic", + "type": "unnamed:selector", + "stimuli": [ + { + "type": "unnamed:target_entity_in_range", + "entity_type": "player", + "range": 16, + "line_of_sight": true + } + ], + "children": { + "q.has_target": { + "type": "unnamed:follow_target" + }, + "": { + "type": "unnamed:sequence", + "children": [ + { + "type": "unnamed:idle_time", + "time": { "min": 20, "max": 60 } + }, + { + "type": "unnamed:wander_in_region" + } + ], + "interrupt": true + } + } + } +] \ No newline at end of file diff --git a/modules/entity/src/main/resources/data/entities.json b/modules/entity/src/main/resources/data/entities.json new file mode 100644 index 00000000..aa0a77c6 --- /dev/null +++ b/modules/entity/src/main/resources/data/entities.json @@ -0,0 +1,7 @@ +[ + { + "id": "unnamed:slime", + "model": "minecraft:slime", + "behavior": "unnamed:slime_generic" + } +] \ No newline at end of file diff --git a/modules/entity/src/main/resources/entity_reference.json5 b/modules/entity/src/main/resources/entity_reference.json5 new file mode 100644 index 00000000..e69de29b diff --git a/modules/entity/src/main/resources/fisherman_brain.json5 b/modules/entity/src/main/resources/fisherman_brain.json5 new file mode 100644 index 00000000..84441615 --- /dev/null +++ b/modules/entity/src/main/resources/fisherman_brain.json5 @@ -0,0 +1,49 @@ +{ + "namespace": "unnamed:fisherman_npc", + "task": { + "type": "selector", + "children": { + "world.time < 700": { + // sleep + "type": "sleep_in_bed", + "bed": [5, 5, 5], + + // Allow all to interrupt the other actions (since they should change when time changes) + "canInterrupt": true, + }, + "q.time_of_day < 0.25": { + // Idle walk around in house + "type": "sequence", + "children": [ + { + "type": "idle_time", + "time": {"min": 5, "max": 100} + }, + { + "type": "wander_in_region", + "region": [[1, 1, 1], [10, 10, 10]] + } + ], + + "canInterrupt": true + }, + "q.time_of_day < 0.5": { + // Walk to fishing spot then play animation forever + "type": "sequence", + "children": [ + { + "type": "walk_to_fixed", + "point": [2, 2, 2] + }, + { + "type": "play_animation", + "animation": "fisherman_fishing", + "repeat": "forever", + } + ], + + "canInterrupt": true + }, + } + } +} \ No newline at end of file diff --git a/modules/entity/src/main/resources/sniffing.json5 b/modules/entity/src/main/resources/sniffing.json5 new file mode 100644 index 00000000..86131f2f --- /dev/null +++ b/modules/entity/src/main/resources/sniffing.json5 @@ -0,0 +1,93 @@ +{ + "namespace": "unnamed:sniffing", + "task": { + "type": "selector", + "stimuli": [ + { + // Target closest player that it has line of sight to + "type": "target_closest", + "range": 25 + }, + // Below is the stimuli for the sniffing value + { + // Probably not super reusable, also we dont want to track this for every region (would be expensive) + // so maybe it would be even more specific. + "type": "ticks_in_region", + "ref": "forest", + + // Not sure about this, but need some way to have multiple targets here + "into": "sniffTarget" + }, + { + // An alternative to above with some kind of weighting system + "type": "weighted_target", + "children": [ + { + "type": "target_closest_no_los", + "entityType": "player", + "range": { + "type": "region", + "ref": "forest" + }, + + "weight": 0.1 + }, + { + "type": "ticks_in_region", + "ref": "forest", + + "weight": 1 + } + ] + } + ], + "children": { + "target != null": { + // Move towards the closest player + "type": "follow_target", + + //todo attack when close enough + }, + "sniffTarget != null": { + // Move towards the player, stopping every once in a while for a bit + "type": "sequence", + "children": [ + { + "type": "timeout", + "length": 100, + "child": { + "type": "follow_target", + "navigator": { + "movementSpeed": 0.5 + } + }, + }, + { + "type": "play_animation", + "animation": "bear_sniff" + } + ], + + // This should be interruptable in case it gets line of sight to somebody + "canInterrupt": true + }, + // Default case, matches everything + "": { + // Idle wander in the center of the forest + "type": "sequence", + "children": [ + { + "type": "wander_in_region", + "ref": "forest_center" + }, + { + "type": "idle_time", + "time": 5 + } + ], + + "canInterrupt": true + }, + } + } +} \ No newline at end of file diff --git a/modules/entity/src/main/resources/test_brain.json5 b/modules/entity/src/main/resources/test_brain.json5 new file mode 100644 index 00000000..d76a6bf7 --- /dev/null +++ b/modules/entity/src/main/resources/test_brain.json5 @@ -0,0 +1,62 @@ +{ + "namespace": "unnamed:attack_enemy", + "task": { + "type": "selector", + // Define the stimuli for this task. They can provide context such as value entities/positions + "stimuli": [ + { + "type": "target_closest_entity", + "entityType": "player", + "range": 25, + // If true, it will keep looking and update accordingly, rather than continuing to value + // the same entity until it is no longer in range. + "dynamic": false, + }, + { + // Will value the last attacker always + "type": "target_last_attacker", + //todo how will it decide how long until it forgets? Entity setting for memory? + //todo min time until it can change value (in case two people are back and forth hitting it) + } + + // Various necessary behaviors + // - smelling: value without line of sight then pause (do an animation) and continue targeting + // - hearing: warden-like + // - slime jump on head: jumps on your head and you have to hit it off + + // - interrupts: Be able to interrupt an action if a condition changes. + // this works in one of 2 ways: + // - specifying that certain actions may interrupt others + // - specifying that certain can be interrupted + ], + // Children are evaluated in order. + "children": { + // If there is a value (from stimuli) + "target != null": { + "type": "parallel", + // Follow the value and attempt to attack it + children: [ + { + "type": "follow_target" + }, + { + "type": "attack_target" + } + ] + }, + // No expression always matches + "": { + "type": "sequence", + "children": [ + { + "type": "idle_time", + "time": 5 + }, + { + "type": "wander_in_region" + } + ] + } + } + } +} \ No newline at end of file diff --git a/modules/entity/src/main/resources/test_fish.json5 b/modules/entity/src/main/resources/test_fish.json5 new file mode 100644 index 00000000..2d13d516 --- /dev/null +++ b/modules/entity/src/main/resources/test_fish.json5 @@ -0,0 +1,26 @@ +{ + "namespace": "unnamed:attack_enemy", + "task": { + "type": "selector", + // No stimuli, the fish will have its value set programmatically + "stimuli": [], + "children": { + // If we have a value, move towards it + "target != null": { + "type": "follow_target" + }, + "": { + "type": "sequence", + "children": [ + { + "type": "idle_time", + "time": 5 + }, + { + "type": "wander_in_region" + } + ] + } + } + } +} \ No newline at end of file diff --git a/modules/entity/src/test/java/net/hollowcube/entity/brain/navigator/TestNavigatorBasicIntegration.java b/modules/entity/src/test/java/net/hollowcube/entity/brain/navigator/TestNavigatorBasicIntegration.java new file mode 100644 index 00000000..07730e2a --- /dev/null +++ b/modules/entity/src/test/java/net/hollowcube/entity/brain/navigator/TestNavigatorBasicIntegration.java @@ -0,0 +1,75 @@ +package net.hollowcube.entity.brain.navigator; + +import net.hollowcube.entity.navigator.Navigator; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.EntityType; +import net.minestom.server.instance.block.Block; +import net.minestom.server.test.Env; +import net.minestom.server.test.EnvTest; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.time.Duration; +import java.util.function.Function; + +import static com.google.common.truth.Truth.assertThat; + +@EnvTest +public class TestNavigatorBasicIntegration { + + @ParameterizedTest(name = "{0}") + @MethodSource("net.hollowcube.entity.brain.navigator.TestUtil#navigators") + public void testBasicMovement(String name, Function newNavigator, Env env) { + var entity = new Entity(EntityType.ZOMBIE); + var navigator = newNavigator.apply(entity); + + var instance = env.createFlatInstance(); + instance.loadChunk(0, 0).join(); + + entity.setInstance(instance, new Pos(5, 40, 5)).join(); + navigator.setInstance(instance); + + var pathFound = navigator.setPathTo(new Pos(0, 40, 0)); + var result = env.tickWhile(() -> { + System.out.println(entity.getPosition()); + navigator.tick(System.currentTimeMillis()); + return navigator.isActive(); + }, Duration.ofMillis(100)); + + assertThat(result).isTrue(); + assertThat(entity.getPosition().distance(new Vec(0, 40, 0))).isAtMost(1); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("net.hollowcube.entity.brain.navigator.TestUtil#navigators") + public void testBasicMovementAroundBlock(String name, Function newNavigator, Env env) { + var entity = new Entity(EntityType.ZOMBIE); + var navigator = newNavigator.apply(entity); + + var instance = env.createFlatInstance(); + for (int x = -1; x <= 1; x++) { + for (int y = -1; y <= 1; y++) { + instance.loadChunk(x, y).join(); + } + } + + // Block the direct path + instance.setBlock(3, 41, 0, Block.STONE); + instance.setBlock(3, 42, 0, Block.STONE); + + entity.setInstance(instance, new Pos(5, 40, 0)).join(); + navigator.setInstance(instance); + + navigator.setPathTo(new Pos(0, 40, 0)); + var result = env.tickWhile(() -> { + System.out.println(entity.getPosition()); + navigator.tick(System.currentTimeMillis()); + return navigator.isActive(); + }, Duration.ofMillis(200)); + + assertThat(result).isTrue(); + assertThat(entity.getPosition().distance(new Vec(0, 40, 0))).isAtMost(1); + } +} diff --git a/modules/entity/src/test/java/net/hollowcube/entity/brain/navigator/TestUtil.java b/modules/entity/src/test/java/net/hollowcube/entity/brain/navigator/TestUtil.java new file mode 100644 index 00000000..3163a35b --- /dev/null +++ b/modules/entity/src/test/java/net/hollowcube/entity/brain/navigator/TestUtil.java @@ -0,0 +1,20 @@ +package net.hollowcube.entity.brain.navigator; + +import net.hollowcube.entity.navigator.Navigator; +import net.minestom.server.entity.Entity; +import org.junit.jupiter.params.provider.Arguments; +import net.hollowcube.entity.motion.MotionNavigator; + +import java.util.function.Function; +import java.util.stream.Stream; + +public class TestUtil { + + public static Stream navigators() { + return Stream.of( +// Arguments.of("hydrazine", (Function) HydrazineNavigator::new), +// Arguments.of("custom", (Function) CustomNavigator::new), + Arguments.of("motion", (Function) MotionNavigator::new) + ); + } +} diff --git a/modules/entity/src/test/java/net/hollowcube/entity/brain/task/TestFollowTargetTaskIntegration.java b/modules/entity/src/test/java/net/hollowcube/entity/brain/task/TestFollowTargetTaskIntegration.java new file mode 100644 index 00000000..dfc72d2c --- /dev/null +++ b/modules/entity/src/test/java/net/hollowcube/entity/brain/task/TestFollowTargetTaskIntegration.java @@ -0,0 +1,20 @@ +package net.hollowcube.entity.brain.task; + +import net.minestom.server.test.Env; +import net.minestom.server.test.EnvTest; +import org.junit.jupiter.api.Test; + +@EnvTest +public class TestFollowTargetTaskIntegration { + + // No value + @Test + public void testNoTarget(Env env) { + + } + + // Static value + + // Moving value + +} diff --git a/modules/entity/src/test/java/net/hollowcube/entity/brain/task/TestIdleTask.java b/modules/entity/src/test/java/net/hollowcube/entity/brain/task/TestIdleTask.java new file mode 100644 index 00000000..b0a720e1 --- /dev/null +++ b/modules/entity/src/test/java/net/hollowcube/entity/brain/task/TestIdleTask.java @@ -0,0 +1,29 @@ +package net.hollowcube.entity.brain.task; + +import net.hollowcube.data.number.NumberProvider; +import net.hollowcube.entity.brain.task.test.MockBrain; +import net.hollowcube.entity.task.IdleTask; +import net.hollowcube.entity.task.Task; +import org.junit.jupiter.api.Test; + +import static com.google.common.truth.Truth.assertThat; + +public class TestIdleTask { + + @Test + public void testHappyCase() { + var spec = new IdleTask.Spec(NumberProvider.constant(5)); + var task = new IdleTask(spec); + // Entity should do nothing, so we can happily use any old entity. + var brain = new MockBrain(); + + assertThat(task.getState()).isEqualTo(Task.State.INIT); + task.start(brain); + for (int i = 0; i < 5; i++) { + assertThat(task.getState()).isEqualTo(Task.State.RUNNING); + task.tick(brain, 0); + } + assertThat(task.getState()).isEqualTo(Task.State.COMPLETE); + } + +} diff --git a/modules/entity/src/test/java/net/hollowcube/entity/brain/task/TestSequenceTask.java b/modules/entity/src/test/java/net/hollowcube/entity/brain/task/TestSequenceTask.java new file mode 100644 index 00000000..c9adbc44 --- /dev/null +++ b/modules/entity/src/test/java/net/hollowcube/entity/brain/task/TestSequenceTask.java @@ -0,0 +1,60 @@ +package net.hollowcube.entity.brain.task; + +import net.hollowcube.entity.brain.task.test.MockBrain; +import net.hollowcube.entity.brain.task.test.MockTask; +import net.hollowcube.entity.brain.task.test.TaskSubject; +import net.hollowcube.entity.task.SequenceTask; +import net.hollowcube.entity.task.Task; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class TestSequenceTask { + @Test + public void testEmptySequence() { + var spec = new SequenceTask.Spec(List.of()); + var task = new SequenceTask(spec); + var brain = new MockBrain(); + + task.start(brain); + + //todo not really sure this should fail on empty, but the problem with passing is that it may be instantly + // started again, pass again, start again, etc. Which would not be good. + assertEquals(Task.State.FAILED, task.getState()); + } + + @Test + public void testSingleTaskSuccess() { + var brain = new MockBrain(); + var mock1 = new MockTask(true); + var spec = new SequenceTask.Spec(List.of(mock1.spec())); + var task = new SequenceTask(spec); + + task.start(brain); + task.tick(brain, System.currentTimeMillis()); + + // Should have passed and mock1 should also have been run (passed) + TaskSubject.assertThat(mock1).isComplete(); + TaskSubject.assertThat(task).isComplete(); + } + + @Test + public void testMultiTaskSuccess() { + var brain = new MockBrain(); + var mock1 = new MockTask(true); + var mock2 = new MockTask(true); + var spec = new SequenceTask.Spec(List.of(mock1.spec(), mock2.spec())); + var task = new SequenceTask(spec); + task.start(brain); + + task.tick(brain, System.currentTimeMillis()); + TaskSubject.assertThat(mock1).isComplete(); + + task.tick(brain, System.currentTimeMillis()); + TaskSubject.assertThat(mock2).isComplete(); + + TaskSubject.assertThat(task).isComplete(); + } +} diff --git a/modules/entity/src/test/java/net/hollowcube/entity/brain/task/test/MockBrain.java b/modules/entity/src/test/java/net/hollowcube/entity/brain/task/test/MockBrain.java new file mode 100644 index 00000000..769ef104 --- /dev/null +++ b/modules/entity/src/test/java/net/hollowcube/entity/brain/task/test/MockBrain.java @@ -0,0 +1,29 @@ +package net.hollowcube.entity.brain.task.test; + +import net.hollowcube.entity.navigator.Navigator; +import net.minestom.server.coordinate.Point; +import net.minestom.server.entity.Entity; +import org.jetbrains.annotations.NotNull; + +public class MockBrain implements Brain { + + @Override + public @NotNull Entity entity() { + return null; + } + + @Override + public @NotNull Navigator navigator() { + return null; + } + + @Override + public boolean setPathTo(@NotNull Point point) { + return false; + } + + @Override + public void tick(long time) { + + } +} diff --git a/modules/entity/src/test/java/net/hollowcube/entity/brain/task/test/MockTask.java b/modules/entity/src/test/java/net/hollowcube/entity/brain/task/test/MockTask.java new file mode 100644 index 00000000..3c2c6b33 --- /dev/null +++ b/modules/entity/src/test/java/net/hollowcube/entity/brain/task/test/MockTask.java @@ -0,0 +1,26 @@ +package net.hollowcube.entity.brain.task.test; + +import net.hollowcube.entity.task.AbstractTask; +import org.jetbrains.annotations.NotNull; + +public class MockTask extends AbstractTask { + private final Boolean pass; + + public MockTask(Boolean pass) { + this.pass = pass; + } + + @Override + public void tick(@NotNull Brain brain, long time) { + if (pass != null) + end(pass); + } + + public @NotNull Spec spec() { + return () -> MockTask.this; + } + + + + +} diff --git a/modules/entity/src/test/java/net/hollowcube/entity/brain/task/test/TaskSubject.java b/modules/entity/src/test/java/net/hollowcube/entity/brain/task/test/TaskSubject.java new file mode 100644 index 00000000..fcdeadee --- /dev/null +++ b/modules/entity/src/test/java/net/hollowcube/entity/brain/task/test/TaskSubject.java @@ -0,0 +1,33 @@ +package net.hollowcube.entity.brain.task.test; + +import com.google.common.truth.Fact; +import com.google.common.truth.FailureMetadata; +import com.google.common.truth.Subject; +import com.google.common.truth.Truth; +import net.hollowcube.entity.task.Task; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class TaskSubject extends Subject { + private final Task actual; + + public static @NotNull TaskSubject assertThat(@Nullable Task actual) { + return Truth.assertAbout(tasks()).that(actual); + } + + protected TaskSubject(FailureMetadata metadata, @Nullable Task actual) { + super(metadata, actual); + this.actual = actual; + } + + public void isComplete() { + if (actual.getState() != Task.State.COMPLETE) { + failWithActual(Fact.simpleFact("Expected task to be complete, but it was " + actual.getState())); + } + } + + + private static final Factory tasks() { + return TaskSubject::new; + } +} diff --git a/modules/entity/src/test/java/net/hollowcube/entity/motion/TestPathGeneratorLand.java b/modules/entity/src/test/java/net/hollowcube/entity/motion/TestPathGeneratorLand.java new file mode 100644 index 00000000..704e1d4b --- /dev/null +++ b/modules/entity/src/test/java/net/hollowcube/entity/motion/TestPathGeneratorLand.java @@ -0,0 +1,96 @@ +package net.hollowcube.entity.motion; + +import net.minestom.server.collision.BoundingBox; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.instance.block.Block; +import org.junit.jupiter.api.Test; +import net.hollowcube.test.MockBlockGetter; + +import static com.google.common.truth.Truth.assertThat; + +public class TestPathGeneratorLand { + + @Test + public void testEmpty() { + var bb = new BoundingBox(0.1, 0.1, 0.1); + var world = MockBlockGetter.empty(); + var start = new Vec(0, 1, 0); + + var result = PathGenerator.LAND.generate(world, start, bb); + assertThat(result).isEmpty(); + } + + @Test + public void testSingleNeighbor() { + var bb = new BoundingBox(0.1, 0.1, 0.1); + var world = MockBlockGetter.range( + 0, 0, 0, + 1, 0, 0, + Block.STONE); + var start = new Vec(0, 1, 0); + + var result = PathGenerator.LAND.generate(world, start, bb); + assertThat(result).containsExactly( + new Vec(1.5, 1, 0.5) + ); + } + + @Test + public void testAllNeighborsFlat() { + var bb = new BoundingBox(0.1, 0.1, 0.1); + var world = MockBlockGetter.range( + -1, 0, -1, + 1, 0, 1, + Block.STONE); + var start = new Vec(0, 1, 0); + + var result = PathGenerator.LAND.generate(world, start, bb); + assertThat(result).containsExactly( + new Vec(1.5, 1, 0.5), + new Vec(-0.5, 1, 0.5), + new Vec(0.5, 1, 1.5), + new Vec(0.5, 1, -0.5) + ); + } + + @Test + public void testSingleNeighborBigBB() { + var bb = new BoundingBox(0.6, 1.95, 0.6); + var world = MockBlockGetter.range( + 0, 0, 0, + 1, 0, 0, + Block.STONE) + .set(1, 2, 0, Block.STONE); + var start = new Vec(0, 1, 0); + + var result = PathGenerator.LAND.generate(world, start, bb); + assertThat(result).isEmpty(); + } + + @Test + public void testSingleStepUp() { + var bb = new BoundingBox(0.1, 0.1, 0.1); + var world = MockBlockGetter.empty() + .set(1, 1, 0, Block.STONE); + var start = new Vec(0, 1, 0); + + var result = PathGenerator.LAND.generate(world, start, bb); + assertThat(result).containsExactly( + new Vec(1.5, 2, 0.5) + ); + } + + @Test + public void testSingleStepDown() { + var bb = new BoundingBox(0.1, 0.1, 0.1); + var world = MockBlockGetter.empty() + .set(1, -1, 0, Block.STONE); + var start = new Vec(0, 1, 0); + + var result = PathGenerator.LAND.generate(world, start, bb); + assertThat(result).containsExactly( + new Vec(1.5, 0, 0.5) + ); + } + +} diff --git a/modules/entity/src/test/java/net/hollowcube/entity/motion/TestPathGeneratorWater.java b/modules/entity/src/test/java/net/hollowcube/entity/motion/TestPathGeneratorWater.java new file mode 100644 index 00000000..619aedb1 --- /dev/null +++ b/modules/entity/src/test/java/net/hollowcube/entity/motion/TestPathGeneratorWater.java @@ -0,0 +1,87 @@ +package net.hollowcube.entity.motion; + +import net.minestom.server.collision.BoundingBox; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.instance.block.Block; +import org.junit.jupiter.api.Test; +import net.hollowcube.test.MockBlockGetter; + +import static com.google.common.truth.Truth.assertThat; + +public class TestPathGeneratorWater { + + @Test + public void testSingleWaterBlock() { + var bb = new BoundingBox(0.1, 0.1, 0.1); + var world = MockBlockGetter.block(0, 0, 0, Block.WATER); + var start = new Vec(0, 0, 0); + + var result = PathGenerator.WATER.generate(world, start, bb); + assertThat(result).isEmpty(); + } + + @Test + public void testSingleNeighbor() { + var bb = new BoundingBox(0.1, 0.1, 0.1); + var world = MockBlockGetter.range( + 0, 0, 0, + 1, 0, 0, + Block.WATER + ); + var start = new Vec(0, 0, 0); + + var result = PathGenerator.WATER.generate(world, start, bb); + assertThat(result).containsExactly(new Vec(1.5, 0, 0.5)); + } + + @Test + public void testAllNeighbors() { + var bb = new BoundingBox(0.1, 0.1, 0.1); + var world = MockBlockGetter.range( + -1, -1, -1, + 1, 1, 1, + Block.WATER + ); + var start = new Vec(0, 0, 0); + + var result = PathGenerator.WATER.generate(world, start, bb); + assertThat(result).containsExactly( + new Vec(-0.5, 0, 0.5), + new Vec(1.5, 0, 0.5), + new Vec(0.5, -1, 0.5), + new Vec(0.5, 1, 0.5), + new Vec(0.5, 0, -0.5), + new Vec(0.5, 0, 1.5) + ); + } + + @Test + public void testSingleNeighbor2() { + var bb = new BoundingBox(0.5, 0.5, 0.5); + var world = MockBlockGetter.range( + -2, -2, -2, + 2, 2, 2, + Block.STONE + ).set(0, 0, 0, Block.WATER).set(1, 0, 0, Block.WATER); + var start = new Vec(0.5, 0, 0.5); + + var result = PathGenerator.WATER.generate(world, start, bb); + assertThat(result).containsExactly(new Vec(1.5, 0, 0.5)); + } + + @Test + public void testSingleNeighborBigBB() { + // Single neighbor surrounded by stone, but the BB is too big to fit + var bb = new BoundingBox(1.5, 1.5, 1.5); + var world = MockBlockGetter.range( + -2, -2, -2, + 2, 2, 2, + Block.STONE + ).set(0, 0, 0, Block.WATER).set(1, 0, 0, Block.WATER); + var start = new Vec(0.5, 0, 0.5); + + var result = PathGenerator.WATER.generate(world, start, bb); + assertThat(result).isEmpty(); + } + +} diff --git a/modules/entity/src/test/java/net/hollowcube/entity/motion/TestPathOptimizerStringPull.java b/modules/entity/src/test/java/net/hollowcube/entity/motion/TestPathOptimizerStringPull.java new file mode 100644 index 00000000..88e78d14 --- /dev/null +++ b/modules/entity/src/test/java/net/hollowcube/entity/motion/TestPathOptimizerStringPull.java @@ -0,0 +1,111 @@ +package net.hollowcube.entity.motion; + +import net.minestom.server.collision.BoundingBox; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.instance.block.Block; +import org.junit.jupiter.api.Test; +import net.hollowcube.test.MockBlockGetter; + +import java.util.List; + +import static com.google.common.truth.Truth.assertThat; + +public class TestPathOptimizerStringPull { + + @Test + public void testEmpty() { + var bb = new BoundingBox(0.1, 0.1, 0.1); + var world = MockBlockGetter.empty(); + var path = new Path(List.of()); + + var result = PathOptimizer.STRING_PULL.optimize(path, world, bb); + assertThat(result.nodes()).isEmpty(); + } + + @Test + public void testTwoPoints() { + var bb = new BoundingBox(0.1, 0.1, 0.1); + var world = MockBlockGetter.empty(); + var path = new Path(List.of( + new Vec(0, 0, 0), + new Vec(1, 0, 0) + )); + + var result = PathOptimizer.STRING_PULL.optimize(path, world, bb); + assertThat(result.nodes()).isEqualTo(path.nodes()); + } + + @Test + public void testThreePointsNoObstacles() { + var bb = new BoundingBox(0.1, 0.1, 0.1); + var world = MockBlockGetter.empty(); + var path = new Path(List.of( + new Vec(0, 0, 0), + new Vec(1, 0, 0), + new Vec(1, 0, 1) + )); + + var result = PathOptimizer.STRING_PULL.optimize(path, world, bb); + assertThat(result.nodes()).containsExactly( + new Vec(0, 0, 0), + new Vec(1, 0, 1) + ); + } + + @Test + public void testFivePointsNoObstacles() { + var bb = new BoundingBox(0.1, 0.1, 0.1); + var world = MockBlockGetter.empty(); + var path = new Path(List.of( + new Vec(0, 0, 0), + new Vec(1, 0, 0), + new Vec(1, 0, 1), + new Vec(1, 0, 2), + new Vec(1, 0, 3) + )); + + var result = PathOptimizer.STRING_PULL.optimize(path, world, bb); + assertThat(result.nodes()).containsExactly( + new Vec(0, 0, 0), + new Vec(1, 0, 3) + ); + } + + @Test + public void testThreePointsWithObstacle() { + var bb = new BoundingBox(0.1, 0.1, 0.1); + var world = MockBlockGetter.block(1, 0, 1, Block.STONE); + var path = new Path(List.of( + new Vec(0, 0, 0), + new Vec(2, 0, 0), + new Vec(2, 0, 2) + )); + + var result = PathOptimizer.STRING_PULL.optimize(path, world, bb); + assertThat(result.nodes()).containsExactly( + new Vec(0, 0, 0), + new Vec(2, 0, 0), + new Vec(2, 0, 2) + ); + } + + @Test + public void testFivePointsWithObstacle() { + var bb = new BoundingBox(0.1, 0.1, 0.1); + var world = MockBlockGetter.block(1, 0, 1, Block.STONE); + var path = new Path(List.of( + new Vec(0, 0, 0), + new Vec(2, 0, 0), + new Vec(2, 0, 2), + new Vec(2, 0, 4), + new Vec(2, 0, 6) + )); + + var result = PathOptimizer.STRING_PULL.optimize(path, world, bb); + assertThat(result.nodes()).containsExactly( + new Vec(0, 0, 0), + new Vec(2, 0, 0), + new Vec(2, 0, 6) + ); + } +} diff --git a/modules/entity/src/test/java/net/hollowcube/entity/motion/TestPathfinderAStar.java b/modules/entity/src/test/java/net/hollowcube/entity/motion/TestPathfinderAStar.java new file mode 100644 index 00000000..9763cb53 --- /dev/null +++ b/modules/entity/src/test/java/net/hollowcube/entity/motion/TestPathfinderAStar.java @@ -0,0 +1,78 @@ +package net.hollowcube.entity.motion; + +import net.minestom.server.collision.BoundingBox; +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.instance.block.Block; +import net.minestom.server.utils.Direction; +import org.junit.jupiter.api.Test; +import net.hollowcube.test.MockBlockGetter; + +import java.util.ArrayList; +import java.util.List; + +import static com.google.common.truth.Truth.assertThat; +import static net.minestom.server.instance.block.Block.Getter.Condition; + +public class TestPathfinderAStar { + + @Test + public void testSamePoint() { + var bb = new BoundingBox(0.1, 0.1, 0.1); + var world = MockBlockGetter.empty(); + var start = new Vec(0, 0, 0); + var goal = new Vec(0, 0, 0); + + var result = Pathfinder.A_STAR.findPath(ALL, world, start, goal, bb); + assertThat(result).isNotNull(); + assertThat(result.nodes()).containsExactly(new Vec(0, 0, 0)); + } + + @Test + public void testBasicLine() { + var bb = new BoundingBox(0.1, 0.1, 0.1); + var world = MockBlockGetter.empty(); + var start = new Vec(0.5, 0, 0.5); + var goal = new Vec(3, 0, 0); + + var result = Pathfinder.A_STAR.findPath(ALL, world, start, goal, bb); + assertThat(result).isNotNull(); + assertThat(result.nodes()).containsExactly( + new Vec(0.5, 0, 0.5), + new Vec(1.5, 0, 0.5), + new Vec(2.5, 0, 0.5) + ); + } + + @Test + public void testBasicAvoidance() { + var bb = new BoundingBox(0.1, 0.1, 0.1); + var world = MockBlockGetter.block(1, 0, 0, Block.STONE); + var start = new Vec(0.5, 0, 0.5); + var goal = new Vec(2.5, 0, 0.5); + + var result = Pathfinder.A_STAR.findPath(ALL, world, start, goal, bb); + assertThat(result).isNotNull(); + assertThat(result.nodes()).containsExactly( + new Vec(0.5, 0, 0.5), + new Vec(0.5, 0, 1.5), + new Vec(1.5, 0, 1.5), + new Vec(2.5, 0, 1.5), + new Vec(2.5, 0, 0.5) + ); + } + + + // A path generator which returns any solid block in a direction (up/down/nsew) + private static final PathGenerator ALL = (world, pos, bb) -> { + pos = new Vec(pos.blockX() + 0.5, pos.blockY(), pos.blockZ() + 0.5); + List neighbors = new ArrayList<>(); + for (Direction direction : Direction.values()) { + var neighbor = pos.add(direction.normalX(), direction.normalY(), direction.normalZ()); + if (world.getBlock(neighbor, Condition.TYPE).isSolid()) continue; + neighbors.add(neighbor); + } + return neighbors; + }; + +} diff --git a/modules/entity/src/test/java/net/hollowcube/entity/motion/util/TestPhysicsUtil.java b/modules/entity/src/test/java/net/hollowcube/entity/motion/util/TestPhysicsUtil.java new file mode 100644 index 00000000..bae0680f --- /dev/null +++ b/modules/entity/src/test/java/net/hollowcube/entity/motion/util/TestPhysicsUtil.java @@ -0,0 +1,109 @@ +package net.hollowcube.entity.motion.util; + +import net.minestom.server.collision.BoundingBox; +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.instance.block.Block; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import net.hollowcube.test.MockBlockGetter; + +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +public class TestPhysicsUtil { + + public static class TestCollision { + @Test + public void testEmpty() { + var bb = new BoundingBox(100, 100, 100); + var world = MockBlockGetter.empty(); + var pos = new Vec(0, 0, 0); + + var result = PhysicsUtil.testCollision(world, pos, bb); + + assertFalse(result); + } + + @Test + public void testSmallInsideBlock() { + var bb = new BoundingBox(0.5, 0.5, 0.5); + var world = MockBlockGetter.block(0, 0, 0, Block.STONE); + var pos = new Vec(0, 0, 0); + + var result = PhysicsUtil.testCollision(world, pos, bb); + + assertTrue(result); + } + + @Test + public void testBetweenBlocks() { + var bb = new BoundingBox(0.6, 1.95, 0.6); + var world = MockBlockGetter.block(1, 1, 0, Block.STONE).set(-1, 1, 0, Block.STONE); + var pos = new Vec(0, 0, 0); + + var result = PhysicsUtil.testCollision(world, pos, bb); + + assertTrue(result); + } + + @Test + public void testUnderBlock() { + var bb = new BoundingBox(0.6, 1.95, 0.6); + var world = MockBlockGetter.block(0, 1, 0, Block.STONE); + var pos = new Vec(0.5, 0, 0.5); + + var result = PhysicsUtil.testCollision(world, pos, bb); + + assertTrue(result); + } + + @Test + public void testUnderBlock2() { + var bb = new BoundingBox(0.6, 1.95, 0.6); + var world = MockBlockGetter.block(0, 2, 0, Block.STONE); + var pos = new Vec(0.5, 0, 0.5); + + var result = PhysicsUtil.testCollision(world, pos, bb); + + assertFalse(result); + } + + @Test + public void testWaterNoCollision() { + var bb = new BoundingBox(0.6, 0.6, 0.6); + var world = MockBlockGetter.range(-1, -1, -1, 1, 1, 1, Block.WATER); + var pos = new Vec(0.5, 0, 0.5); + + var result = PhysicsUtil.testCollision(world, pos, bb); + + assertFalse(result); + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("gravitySnapCases") + public void testGravitySnap(String name, Point start, boolean expected) { + var world = MockBlockGetter.block(0, 50, 0, Block.STONE); + var result = PhysicsUtil.gravitySnap(world, start); + if (expected) { + assertEquals(new Vec(0, 51, 0), result); + } else { + assertNull(result); + } + } + + private static Stream gravitySnapCases() { + // Each case has a block at 0, 50, 0 + return Stream.of( + Arguments.of("test correct place already", new Vec(0, 51, 0), true), + Arguments.of("inside block", new Vec(0, 50, 0), true), + Arguments.of("above block", new Vec(0, 58, 0), true), + Arguments.of("below block (fail to find)", new Vec(0, 40, 0), false) + ); + } + +} diff --git a/modules/entity/src/test/java/net/hollowcube/mql/MqlTest.java b/modules/entity/src/test/java/net/hollowcube/mql/MqlTest.java new file mode 100644 index 00000000..0e96dd7c --- /dev/null +++ b/modules/entity/src/test/java/net/hollowcube/mql/MqlTest.java @@ -0,0 +1,38 @@ +package net.hollowcube.mql; + +import net.hollowcube.mql.parser.MqlParser; +import net.hollowcube.mql.runtime.MqlRuntimeError; +import net.hollowcube.mql.runtime.MqlScope; +import net.hollowcube.mql.value.MqlHolder; +import net.hollowcube.mql.value.MqlNumberValue; +import net.hollowcube.mql.value.MqlValue; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class MqlTest { + + @Test + public void basicQueryCall() { + var source = "q.is_alive"; + var expr = new MqlParser(source).parse(); + + var scope = new MqlScope() { + @Override + public @NotNull MqlValue get(@NotNull String name) { + if (!name.equals("q") && !name.equals("query")) + throw new MqlRuntimeError("unknown environment object: " + name); + return (MqlHolder) queryFunction -> switch (queryFunction) { + case "is_alive" -> new MqlNumberValue(1); + default -> throw new MqlRuntimeError("no such query function: " + queryFunction); + }; + } + }; + var result = expr.evaluate(scope); + assertTrue(result instanceof MqlNumberValue); + assertEquals(1, ((MqlNumberValue) result).value()); + } + +} diff --git a/modules/entity/src/test/java/net/hollowcube/mql/foreign/TestMqlForeignFunctions.java b/modules/entity/src/test/java/net/hollowcube/mql/foreign/TestMqlForeignFunctions.java new file mode 100644 index 00000000..dcec1391 --- /dev/null +++ b/modules/entity/src/test/java/net/hollowcube/mql/foreign/TestMqlForeignFunctions.java @@ -0,0 +1,75 @@ +package net.hollowcube.mql.foreign; + +import net.hollowcube.mql.value.MqlCallable; +import net.hollowcube.mql.value.MqlValue; +import org.junit.jupiter.api.Test; +import org.testcontainers.shaded.com.google.common.util.concurrent.AtomicDouble; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import static com.google.common.truth.Truth.assertThat; + +public class TestMqlForeignFunctions { + + private static final AtomicBoolean test1Called = new AtomicBoolean(false); + + public static void test1() { + test1Called.set(true); + } + + @Test + public void emptyVoidFunction() throws Exception { + Method method = getClass().getMethod("test1"); + MqlCallable function = MqlForeignFunctions.createForeign(method, null); + assertThat(function.arity()).isEqualTo(0); + assertThat(function.call(List.of())).isEqualTo(MqlValue.NULL); + assertThat(test1Called.get()).isTrue(); + } + + private static final AtomicDouble test2Value = new AtomicDouble(0); + + public static void test2(double value) { + test2Value.set(value); + } + + @Test + public void singleArgVoidFunction() throws Exception { + Method method = getClass().getMethod("test2", double.class); + MqlCallable function = MqlForeignFunctions.createForeign(method, null); + MqlValue result = function.call(List.of(MqlValue.from(10.5))); + + assertThat(function.arity()).isEqualTo(1); + assertThat(result).isEqualTo(MqlValue.NULL); + assertThat(test2Value.get()).isEqualTo(10.5); + } + + public static double test3() { + return 10.5; + } + + @Test + public void emptyNonVoidFunction() throws Exception { + Method method = getClass().getMethod("test3"); + MqlCallable function = MqlForeignFunctions.createForeign(method, null); + MqlValue result = function.call(List.of()); + + assertThat(function.arity()).isEqualTo(0); + assertThat(result).isEqualTo(MqlValue.from(10.5)); + } + + public static double test4(double a, double b) { + return a + b; + } + + @Test + public void multiParamNonVoidFunction() throws Exception { + Method method = getClass().getMethod("test4", double.class, double.class); + MqlCallable function = MqlForeignFunctions.createForeign(method, null); + MqlValue result = function.call(List.of(MqlValue.from(10.5), MqlValue.from(20.5))); + + assertThat(function.arity()).isEqualTo(2); + assertThat(result).isEqualTo(MqlValue.from(31)); + } +} diff --git a/modules/entity/src/test/java/net/hollowcube/mql/parser/TestMqlLexer.java b/modules/entity/src/test/java/net/hollowcube/mql/parser/TestMqlLexer.java new file mode 100644 index 00000000..0962039c --- /dev/null +++ b/modules/entity/src/test/java/net/hollowcube/mql/parser/TestMqlLexer.java @@ -0,0 +1,41 @@ +package net.hollowcube.mql.parser; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +public class TestMqlLexer { + + @ParameterizedTest + @MethodSource("individualSymbols") + public void testIndividualSymbols(String input, MqlToken.Type expected) { + var lexer = new MqlLexer(input); + + var token = lexer.next(); + assertNotNull(token); + assertEquals(expected, token.type()); + + var eof = lexer.next(); + assertNull(eof); + } + + private static Stream individualSymbols() { + return Stream.of( + Arguments.of("+", MqlToken.Type.PLUS), + Arguments.of(".", MqlToken.Type.DOT), + + Arguments.of("123", MqlToken.Type.NUMBER), + Arguments.of("123.", MqlToken.Type.NUMBER), + Arguments.of("123.456", MqlToken.Type.NUMBER), + + Arguments.of("abc", MqlToken.Type.IDENT), + Arguments.of("aBc", MqlToken.Type.IDENT), + Arguments.of("aBc1", MqlToken.Type.IDENT) + ); + } + +} diff --git a/modules/entity/src/test/java/net/hollowcube/mql/parser/TestMqlParser.java b/modules/entity/src/test/java/net/hollowcube/mql/parser/TestMqlParser.java new file mode 100644 index 00000000..65cbeeaf --- /dev/null +++ b/modules/entity/src/test/java/net/hollowcube/mql/parser/TestMqlParser.java @@ -0,0 +1,39 @@ +package net.hollowcube.mql.parser; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import net.hollowcube.mql.tree.MqlPrinter; + +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class TestMqlParser { + + @MethodSource("inputPairs") + @ParameterizedTest(name = "{0}") + public void testInputPairs(String name, String input, String expected) { + var expr = new MqlParser(input).parse(); + var actual = new MqlPrinter().visit(expr, null); + + assertEquals(expected, actual); + } + + private static Stream inputPairs() { + return Stream.of( + Arguments.of("basic number", + "1", "1.0"), + Arguments.of("basic ref", + "abc", "abc"), + Arguments.of("basic add", + "1 + 2", "(+ 1.0 2.0)"), + Arguments.of("nested add", + "1 + 2 + 3", "(+ (+ 1.0 2.0) 3.0)"), + Arguments.of("basic access", + "a.b", "(. a b)"), + Arguments.of("access/add precedence", + "a.b + 1", "(+ (. a b) 1.0)") + ); + } +} diff --git a/modules/entity/src/test/java/net/hollowcube/mql/runtime/TestMqlMath.java b/modules/entity/src/test/java/net/hollowcube/mql/runtime/TestMqlMath.java new file mode 100644 index 00000000..b9879cd6 --- /dev/null +++ b/modules/entity/src/test/java/net/hollowcube/mql/runtime/TestMqlMath.java @@ -0,0 +1,26 @@ +package net.hollowcube.mql.runtime; + +import net.hollowcube.mql.value.MqlCallable; +import net.hollowcube.mql.value.MqlNumberValue; +import net.hollowcube.mql.value.MqlValue; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static com.google.common.truth.Truth.assertThat; + +public class TestMqlMath { + + @Test + public void testForeignMath() { + MqlValue result = MqlMath.INSTANCE.get("sqrt").cast(MqlCallable.class) + .call(List.of(MqlValue.from(4))); + + assertThat(result).isEqualTo(MqlValue.from(2)); + } + + // hermiteBlend + // lerp + // lerprotate + +} diff --git a/modules/entity/src/test/java/net/hollowcube/test/MockBlockGetter.java b/modules/entity/src/test/java/net/hollowcube/test/MockBlockGetter.java new file mode 100644 index 00000000..04d10088 --- /dev/null +++ b/modules/entity/src/test/java/net/hollowcube/test/MockBlockGetter.java @@ -0,0 +1,51 @@ +package net.hollowcube.test; + +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.instance.block.Block; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.UnknownNullability; + +import java.util.HashMap; +import java.util.Map; + +public class MockBlockGetter implements Block.Getter { + private final Map data; + + public static MockBlockGetter empty() { + return new MockBlockGetter(new HashMap<>()); + } + + public static MockBlockGetter block(int x, int y, int z, Block block) { + Map data = new HashMap<>(); + data.put(new Vec(x, y, z), block); + return new MockBlockGetter(data); + } + + public static MockBlockGetter range(int startX, int startY, int startZ, int endX, int endY, int endZ, Block block) { + Map data = new HashMap<>(); + for (int x = startX; x <= endX; x++) { + for (int y = startY; y <= endY; y++) { + for (int z = startZ; z <= endZ; z++) { + data.put(new Vec(x, y, z), block); + } + } + } + return new MockBlockGetter(data); + } + + public MockBlockGetter(Map data) { + this.data = data; + } + + @Override + public @UnknownNullability Block getBlock(int x, int y, int z, @NotNull Condition condition) { + return data.getOrDefault(new Vec(x, y, z), Block.AIR); + } + + public MockBlockGetter set(int x, int y, int z, Block block) { + data.put(new Vec(x, y, z), block); + return this; + } + +} diff --git a/settings.gradle.kts b/settings.gradle.kts index c5b0e5d5..7a0ef52e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,4 +9,5 @@ include(":modules:block-interactions") include(":modules:loot-table") include(":modules:player") include("modules:quest") +include(":modules:entity") include(":modules:development")