aboutsummaryrefslogtreecommitdiff
path: root/mod/src/main/java/moe/ymc
diff options
context:
space:
mode:
Diffstat (limited to 'mod/src/main/java/moe/ymc')
-rw-r--r--mod/src/main/java/moe/ymc/acron/Acron.java32
-rw-r--r--mod/src/main/java/moe/ymc/acron/MinecraftServerHolder.java28
-rw-r--r--mod/src/main/java/moe/ymc/acron/auth/Action.java10
-rw-r--r--mod/src/main/java/moe/ymc/acron/auth/Client.java9
-rw-r--r--mod/src/main/java/moe/ymc/acron/auth/PolicyChecker.java42
-rw-r--r--mod/src/main/java/moe/ymc/acron/auth/Rule.java10
-rw-r--r--mod/src/main/java/moe/ymc/acron/c2s/ReqCmd.java51
-rw-r--r--mod/src/main/java/moe/ymc/acron/c2s/ReqSetConfig.java63
-rw-r--r--mod/src/main/java/moe/ymc/acron/c2s/Request.java6
-rw-r--r--mod/src/main/java/moe/ymc/acron/cmd/CmdOut.java53
-rw-r--r--mod/src/main/java/moe/ymc/acron/cmd/CmdQueue.java17
-rw-r--r--mod/src/main/java/moe/ymc/acron/cmd/CmdResConsumer.java33
-rw-r--r--mod/src/main/java/moe/ymc/acron/cmd/CmdSrc.java34
-rw-r--r--mod/src/main/java/moe/ymc/acron/common/Vec2f.java25
-rw-r--r--mod/src/main/java/moe/ymc/acron/common/Vec3d.java34
-rw-r--r--mod/src/main/java/moe/ymc/acron/common/WorldKey.java35
-rw-r--r--mod/src/main/java/moe/ymc/acron/config/Config.java30
-rw-r--r--mod/src/main/java/moe/ymc/acron/config/ConfigReloadCmd.java41
-rw-r--r--mod/src/main/java/moe/ymc/acron/config/json/Client.java45
-rw-r--r--mod/src/main/java/moe/ymc/acron/config/json/Config.java103
-rw-r--r--mod/src/main/java/moe/ymc/acron/config/json/ConfigDeserializationException.java18
-rw-r--r--mod/src/main/java/moe/ymc/acron/config/json/ConfigDeserializer.java27
-rw-r--r--mod/src/main/java/moe/ymc/acron/config/json/ConfigJsonObject.java7
-rw-r--r--mod/src/main/java/moe/ymc/acron/config/json/Rule.java35
-rw-r--r--mod/src/main/java/moe/ymc/acron/jvav/Pair.java4
-rw-r--r--mod/src/main/java/moe/ymc/acron/mixin/CommandManagerMixin.java38
-rw-r--r--mod/src/main/java/moe/ymc/acron/mixin/LivingEntityMixin.java41
-rw-r--r--mod/src/main/java/moe/ymc/acron/mixin/MinecraftDedicatedServerMixin.java21
-rw-r--r--mod/src/main/java/moe/ymc/acron/mixin/MinecraftServerMixin.java33
-rw-r--r--mod/src/main/java/moe/ymc/acron/mixin/ServerLoginNetworkHandlerMixin.java47
-rw-r--r--mod/src/main/java/moe/ymc/acron/mixin/ServerNetworkIoMixin.java65
-rw-r--r--mod/src/main/java/moe/ymc/acron/mixin/ServerPlayNetworkHandlerMixin.java43
-rw-r--r--mod/src/main/java/moe/ymc/acron/mixin/ServerPlayerEntityMixin.java32
-rw-r--r--mod/src/main/java/moe/ymc/acron/net/AcronInitializer.java25
-rw-r--r--mod/src/main/java/moe/ymc/acron/net/Attributes.java13
-rw-r--r--mod/src/main/java/moe/ymc/acron/net/AuthHandler.java98
-rw-r--r--mod/src/main/java/moe/ymc/acron/net/ClientConfiguration.java20
-rw-r--r--mod/src/main/java/moe/ymc/acron/net/ClientIdentification.java11
-rw-r--r--mod/src/main/java/moe/ymc/acron/net/HandshakeComplete.java7
-rw-r--r--mod/src/main/java/moe/ymc/acron/net/WSFrameHandler.java174
-rw-r--r--mod/src/main/java/moe/ymc/acron/s2c/Entity.java26
-rw-r--r--mod/src/main/java/moe/ymc/acron/s2c/Event.java4
-rw-r--r--mod/src/main/java/moe/ymc/acron/s2c/EventQueue.java28
-rw-r--r--mod/src/main/java/moe/ymc/acron/s2c/event/EventDisconnected.java12
-rw-r--r--mod/src/main/java/moe/ymc/acron/s2c/event/EventEntityDeath.java12
-rw-r--r--mod/src/main/java/moe/ymc/acron/s2c/event/EventLagging.java9
-rw-r--r--mod/src/main/java/moe/ymc/acron/s2c/event/EventPlayerJoined.java10
-rw-r--r--mod/src/main/java/moe/ymc/acron/s2c/event/EventPlayerMessage.java12
-rw-r--r--mod/src/main/java/moe/ymc/acron/s2c/response/EventCmdOut.java13
-rw-r--r--mod/src/main/java/moe/ymc/acron/s2c/response/EventCmdRes.java10
-rw-r--r--mod/src/main/java/moe/ymc/acron/s2c/response/EventError.java22
-rw-r--r--mod/src/main/java/moe/ymc/acron/s2c/response/EventOk.java8
-rw-r--r--mod/src/main/java/moe/ymc/acron/serialization/Serializer.java60
53 files changed, 1686 insertions, 0 deletions
diff --git a/mod/src/main/java/moe/ymc/acron/Acron.java b/mod/src/main/java/moe/ymc/acron/Acron.java
new file mode 100644
index 0000000..d6f6214
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/Acron.java
@@ -0,0 +1,32 @@
+package moe.ymc.acron;
+
+import moe.ymc.acron.config.Config;
+import moe.ymc.acron.config.json.ConfigDeserializer;
+import net.fabricmc.api.ModInitializer;
+import net.fabricmc.loader.api.FabricLoader;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import java.io.IOException;
+import java.nio.file.Path;
+
+public class Acron implements ModInitializer {
+ private static final Logger LOGGER = LogManager.getLogger();
+
+ @Override
+ public void onInitialize() {
+ LOGGER.debug("onInitialize");
+ try {
+ final Path config = FabricLoader
+ .getInstance().getConfigDir()
+ .resolve("acron.json");
+ if (!config.toFile().exists()) {
+ throw new IllegalStateException("Cannot find config/acron.json.");
+ }
+ final Config cfg = ConfigDeserializer.deserialize(config.toFile(), true);
+ Config.setGlobalConfig(cfg);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/mod/src/main/java/moe/ymc/acron/MinecraftServerHolder.java b/mod/src/main/java/moe/ymc/acron/MinecraftServerHolder.java
new file mode 100644
index 0000000..f522884
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/MinecraftServerHolder.java
@@ -0,0 +1,28 @@
+package moe.ymc.acron;
+
+import net.minecraft.server.dedicated.MinecraftDedicatedServer;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.jetbrains.annotations.NotNull;
+
+public class MinecraftServerHolder {
+ private static final Logger LOGGER = LogManager.getLogger();
+ public static MinecraftDedicatedServer server;
+
+ public static void setServer(@NotNull MinecraftDedicatedServer server) {
+ if (MinecraftServerHolder.server != null) {
+ throw new IllegalStateException();
+ }
+ LOGGER.debug("Got MinecraftDedicatedServer on thread {}.",
+ Thread.currentThread().getName());
+ MinecraftServerHolder.server = server;
+ }
+
+ public static @NotNull MinecraftDedicatedServer getServer() {
+ if (server == null) {
+ throw new IllegalStateException(String.format("[%s] getServer() called before a server is ready.",
+ Thread.currentThread().getName()));
+ }
+ return server;
+ }
+}
diff --git a/mod/src/main/java/moe/ymc/acron/auth/Action.java b/mod/src/main/java/moe/ymc/acron/auth/Action.java
new file mode 100644
index 0000000..17d29a3
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/auth/Action.java
@@ -0,0 +1,10 @@
+package moe.ymc.acron.auth;
+
+import com.google.gson.annotations.SerializedName;
+
+public enum Action {
+ @SerializedName("allow")
+ ALLOW,
+ @SerializedName("deny")
+ DENY
+}
diff --git a/mod/src/main/java/moe/ymc/acron/auth/Client.java b/mod/src/main/java/moe/ymc/acron/auth/Client.java
new file mode 100644
index 0000000..2124ad4
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/auth/Client.java
@@ -0,0 +1,9 @@
+package moe.ymc.acron.auth;
+
+import org.jetbrains.annotations.NotNull;
+
+public record Client(@NotNull String id,
+ @NotNull String token,
+ @NotNull Action policyMode,
+ @NotNull Rule[] rules) {
+}
diff --git a/mod/src/main/java/moe/ymc/acron/auth/PolicyChecker.java b/mod/src/main/java/moe/ymc/acron/auth/PolicyChecker.java
new file mode 100644
index 0000000..2ab7b97
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/auth/PolicyChecker.java
@@ -0,0 +1,42 @@
+package moe.ymc.acron.auth;
+
+import moe.ymc.acron.jvav.Pair;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.jetbrains.annotations.NotNull;
+
+public class PolicyChecker {
+ private static final Logger LOGGER = LogManager.getLogger();
+
+ public static Pair<Action, Boolean> check(@NotNull Client client,
+ @NotNull String command) {
+ final String commandToMatch = command.startsWith("/") ?
+ command.substring(1) :
+ command;
+ for (int i = 0; i < client.rules().length; i++) {
+ final Rule rule = client.rules()[i];
+ if (rule.cmdPattern().matcher(commandToMatch).matches()) {
+ if (rule.action() == Action.DENY) {
+ LOGGER.warn("The command from client {}, `{}`, was " +
+ "explicitly denied by rule #{} (starting from 1).",
+ client.id(),
+ command,
+ i + 1);
+ } else {
+ LOGGER.warn("The command from client {}, `{}`, was " +
+ "explicitly allowed by rule #{} (starting from 1).",
+ client.id(),
+ command,
+ i + 1);
+ }
+ return new Pair<>(rule.action(), rule.display());
+ }
+ }
+ LOGGER.warn("The command from client {}, `{}`, was " +
+ "implicitly {} by the default policy mode.",
+ client.id(),
+ command,
+ client.policyMode() == Action.ALLOW ? "allowed" : "denied");
+ return new Pair<>(client.policyMode() == Action.ALLOW ? Action.ALLOW : Action.DENY, false);
+ }
+}
diff --git a/mod/src/main/java/moe/ymc/acron/auth/Rule.java b/mod/src/main/java/moe/ymc/acron/auth/Rule.java
new file mode 100644
index 0000000..55ad0d7
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/auth/Rule.java
@@ -0,0 +1,10 @@
+package moe.ymc.acron.auth;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.regex.Pattern;
+
+public record Rule(@NotNull Pattern cmdPattern,
+ @NotNull Action action,
+ boolean display) {
+}
diff --git a/mod/src/main/java/moe/ymc/acron/c2s/ReqCmd.java b/mod/src/main/java/moe/ymc/acron/c2s/ReqCmd.java
new file mode 100644
index 0000000..6f34b07
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/c2s/ReqCmd.java
@@ -0,0 +1,51 @@
+package moe.ymc.acron.c2s;
+
+import com.google.gson.*;
+import com.google.gson.annotations.SerializedName;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.lang.reflect.Type;
+
+public record ReqCmd(@SerializedName("id") int id,
+ @SerializedName("cmd") @NotNull String cmd,
+ @SerializedName("config") @Nullable ReqSetConfig config)
+ implements Request {
+ @Override
+ public void validate() {
+ if (cmd == null) {
+ throw new IllegalArgumentException("Property 'cmd' cannot be null.");
+ }
+ }
+
+ @Override
+ public int getId() {
+ return id;
+ }
+
+ public static class ReqCmdDeserializer implements JsonDeserializer<ReqCmd> {
+ @Override
+ public ReqCmd deserialize(JsonElement json,
+ Type typeOfT,
+ JsonDeserializationContext context) throws JsonParseException {
+ final JsonObject object = json.getAsJsonObject();
+ final int id = object.has("id") ?
+ object.get("id").getAsInt() :
+ -1;
+ final String cmd = object.has("cmd") ?
+ object.get("cmd").getAsString() :
+ null;
+ // We cannot use context#deserialize here
+ // because RuntimeTypeAdapterFactory keeps kicking in
+ // and asking for the 'type' property
+ // which is obviously redundant for an inner field.
+ // Thus, I pass it directly to the deserializer
+ // to bypass the RuntimeTypeAdapterFactory.
+ final ReqSetConfig reqSetConfig = object.has("config") ?
+ new ReqSetConfig.ReqSetConfigDeserializer()
+ .deserialize(object.get("config"), ReqSetConfig.class, context) :
+ null;
+ return new ReqCmd(id, cmd, reqSetConfig);
+ }
+ }
+}
diff --git a/mod/src/main/java/moe/ymc/acron/c2s/ReqSetConfig.java b/mod/src/main/java/moe/ymc/acron/c2s/ReqSetConfig.java
new file mode 100644
index 0000000..fcddf35
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/c2s/ReqSetConfig.java
@@ -0,0 +1,63 @@
+package moe.ymc.acron.c2s;
+
+import com.google.gson.*;
+import com.google.gson.annotations.SerializedName;
+import moe.ymc.acron.common.Vec2f;
+import moe.ymc.acron.common.Vec3d;
+import moe.ymc.acron.common.WorldKey;
+import org.jetbrains.annotations.Nullable;
+
+import java.lang.reflect.Type;
+
+public record ReqSetConfig(@SerializedName("id") int id,
+ @SerializedName("world") @Nullable WorldKey world,
+ @SerializedName("pos") @Nullable Vec3d pos,
+ @SerializedName("rot") @Nullable Vec2f rot,
+ @SerializedName("name") @Nullable String name)
+ implements Request {
+ @Override
+ public void validate() {
+ }
+
+ @Override
+ public int getId() {
+ return id;
+ }
+
+ public static class ReqSetConfigDeserializer implements JsonDeserializer<ReqSetConfig> {
+ @Override
+ public ReqSetConfig deserialize(JsonElement json,
+ Type typeOfT,
+ JsonDeserializationContext context) throws JsonParseException {
+ final JsonObject object = json.getAsJsonObject();
+ final int id = object.has("id") ?
+ object.get("id").getAsInt() :
+ -1;
+ final WorldKey world;
+ if (object.has("world")) {
+ world = context.deserialize(object.get("world"), WorldKey.class);
+ // https://stackoverflow.com/a/49574019
+ if (world == null) {
+ throw new JsonParseException("Invalid world");
+ }
+ } else {
+ world = null;
+ }
+ final Vec3d pos = object.has("pos") ?
+ context.deserialize(object.get("pos"), Vec3d.class) :
+ null;
+ final Vec2f rot = object.has("rot") ?
+ context.deserialize(object.get("rot"), Vec2f.class) :
+ null;
+ final String name = object.has("name") ?
+ object.get("name").getAsString() :
+ null;
+ return new ReqSetConfig(id,
+ world,
+ pos,
+ rot,
+ name);
+ }
+ }
+
+} \ No newline at end of file
diff --git a/mod/src/main/java/moe/ymc/acron/c2s/Request.java b/mod/src/main/java/moe/ymc/acron/c2s/Request.java
new file mode 100644
index 0000000..af81705
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/c2s/Request.java
@@ -0,0 +1,6 @@
+package moe.ymc.acron.c2s;
+
+public interface Request {
+ int getId();
+ void validate() throws IllegalArgumentException;
+}
diff --git a/mod/src/main/java/moe/ymc/acron/cmd/CmdOut.java b/mod/src/main/java/moe/ymc/acron/cmd/CmdOut.java
new file mode 100644
index 0000000..55eadf1
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/cmd/CmdOut.java
@@ -0,0 +1,53 @@
+package moe.ymc.acron.cmd;
+
+import io.netty.channel.Channel;
+import moe.ymc.acron.s2c.response.EventCmdOut;
+import moe.ymc.acron.serialization.Serializer;
+import net.minecraft.server.command.CommandOutput;
+import net.minecraft.text.Text;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.UUID;
+
+public class CmdOut implements CommandOutput {
+ private static final Logger LOGGER = LogManager.getLogger();
+
+ private final @NotNull Channel channel;
+ private final int id;
+ private final boolean display;
+
+ public CmdOut(@NotNull Channel channel,
+ int id,
+ boolean display) {
+ this.channel = channel;
+ this.id = id;
+ this.display = display;
+ }
+
+ @Override
+ public void sendSystemMessage(Text message, UUID sender) {
+ LOGGER.debug("sendSystemMessage[{}]: {}",
+ id,
+ message.getString());
+ channel.writeAndFlush(
+ Serializer.serialize(new EventCmdOut(id, sender, message.getString()))
+ );
+ }
+
+ @Override
+ public boolean shouldReceiveFeedback() {
+ return true;
+ }
+
+ @Override
+ public boolean shouldTrackOutput() {
+ return true;
+ }
+
+ @Override
+ public boolean shouldBroadcastConsoleToOps() {
+ return display;
+ }
+}
diff --git a/mod/src/main/java/moe/ymc/acron/cmd/CmdQueue.java b/mod/src/main/java/moe/ymc/acron/cmd/CmdQueue.java
new file mode 100644
index 0000000..3c49143
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/cmd/CmdQueue.java
@@ -0,0 +1,17 @@
+package moe.ymc.acron.cmd;
+
+import io.netty.channel.Channel;
+import moe.ymc.acron.MinecraftServerHolder;
+import moe.ymc.acron.net.ClientConfiguration;
+import org.jetbrains.annotations.NotNull;
+
+public class CmdQueue {
+ public static void enqueue(int id,
+ boolean display,
+ @NotNull final Channel channel,
+ @NotNull final ClientConfiguration configuration,
+ @NotNull final String command) {
+ MinecraftServerHolder.getServer().enqueueCommand(command,
+ new CmdSrc(channel, id, display, configuration, MinecraftServerHolder.getServer()));
+ }
+}
diff --git a/mod/src/main/java/moe/ymc/acron/cmd/CmdResConsumer.java b/mod/src/main/java/moe/ymc/acron/cmd/CmdResConsumer.java
new file mode 100644
index 0000000..d22b77e
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/cmd/CmdResConsumer.java
@@ -0,0 +1,33 @@
+package moe.ymc.acron.cmd;
+
+import com.mojang.brigadier.ResultConsumer;
+import com.mojang.brigadier.context.CommandContext;
+import io.netty.channel.Channel;
+import moe.ymc.acron.s2c.response.EventCmdRes;
+import moe.ymc.acron.serialization.Serializer;
+import net.minecraft.server.command.ServerCommandSource;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.jetbrains.annotations.NotNull;
+
+public class CmdResConsumer implements ResultConsumer<ServerCommandSource> {
+ private static final Logger LOGGER = LogManager.getLogger();
+
+ private final @NotNull Channel channel;
+ private final int id;
+
+ public CmdResConsumer(@NotNull Channel channel,
+ int id) {
+ this.channel = channel;
+ this.id = id;
+ }
+
+ @Override
+ public void onCommandComplete(CommandContext<ServerCommandSource> context, boolean success, int result) {
+ LOGGER.debug("onCommandComplete[{}]: {} {}",
+ id,
+ success,
+ result);
+ channel.writeAndFlush(Serializer.serialize(new EventCmdRes(id, success, result)));
+ }
+}
diff --git a/mod/src/main/java/moe/ymc/acron/cmd/CmdSrc.java b/mod/src/main/java/moe/ymc/acron/cmd/CmdSrc.java
new file mode 100644
index 0000000..983b4ed
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/cmd/CmdSrc.java
@@ -0,0 +1,34 @@
+package moe.ymc.acron.cmd;
+
+import io.netty.channel.Channel;
+import moe.ymc.acron.net.ClientConfiguration;
+import net.minecraft.command.argument.EntityAnchorArgumentType;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.server.command.ServerCommandSource;
+import net.minecraft.text.LiteralText;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.jetbrains.annotations.NotNull;
+
+class CmdSrc extends ServerCommandSource {
+ private static final Logger LOGGER = LogManager.getLogger();
+
+ public CmdSrc(@NotNull Channel channel,
+ int id,
+ boolean display,
+ @NotNull ClientConfiguration configuration,
+ @NotNull MinecraftServer server) {
+ super(new CmdOut(channel, id, display),
+ configuration.pos(),
+ configuration.rot(),
+ configuration.world(),
+ 4,
+ configuration.name(),
+ new LiteralText(configuration.name()),
+ server,
+ null,
+ false,
+ new CmdResConsumer(channel, id),
+ EntityAnchorArgumentType.EntityAnchor.FEET);
+ }
+}
diff --git a/mod/src/main/java/moe/ymc/acron/common/Vec2f.java b/mod/src/main/java/moe/ymc/acron/common/Vec2f.java
new file mode 100644
index 0000000..5ab3dfd
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/common/Vec2f.java
@@ -0,0 +1,25 @@
+package moe.ymc.acron.common;
+
+import com.google.gson.*;
+import com.google.gson.annotations.SerializedName;
+
+import java.lang.reflect.Type;
+
+public record Vec2f(@SerializedName("x") float x,
+ @SerializedName("y") float y) {
+ public static class Vec2fDeserializer implements JsonDeserializer<Vec2f> {
+ @Override
+ public Vec2f deserialize(JsonElement json,
+ Type typeOfT,
+ JsonDeserializationContext context) throws JsonParseException {
+ final JsonObject object = json.getAsJsonObject();
+ final float x = object.has("x") ?
+ object.get("x").getAsFloat() :
+ 0.0f;
+ final float y = object.has("y") ?
+ object.get("y").getAsFloat() :
+ 0.0f;
+ return new Vec2f(x, y);
+ }
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/java/moe/ymc/acron/common/Vec3d.java b/mod/src/main/java/moe/ymc/acron/common/Vec3d.java
new file mode 100644
index 0000000..593019f
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/common/Vec3d.java
@@ -0,0 +1,34 @@
+package moe.ymc.acron.common;
+
+import com.google.gson.*;
+import com.google.gson.annotations.SerializedName;
+import org.jetbrains.annotations.NotNull;
+
+import java.lang.reflect.Type;
+
+public record Vec3d(@SerializedName("x") double x,
+ @SerializedName("y") double y,
+ @SerializedName("z") double z) {
+ public Vec3d(@NotNull net.minecraft.util.math.Vec3d vec3d) {
+ this(vec3d.x, vec3d.y, vec3d.z);
+ }
+
+ public static class Vec3dDeserializer implements JsonDeserializer<Vec3d> {
+ @Override
+ public Vec3d deserialize(JsonElement json,
+ Type typeOfT,
+ JsonDeserializationContext context) throws JsonParseException {
+ final JsonObject object = json.getAsJsonObject();
+ final double x = object.has("x") ?
+ object.get("x").getAsDouble() :
+ 0.0;
+ final double y = object.has("y") ?
+ object.get("y").getAsDouble() :
+ 0.0;
+ final double z = object.has("z") ?
+ object.get("z").getAsDouble() :
+ 0.0;
+ return new Vec3d(x, y, z);
+ }
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/java/moe/ymc/acron/common/WorldKey.java b/mod/src/main/java/moe/ymc/acron/common/WorldKey.java
new file mode 100644
index 0000000..fa10d54
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/common/WorldKey.java
@@ -0,0 +1,35 @@
+package moe.ymc.acron.common;
+
+import com.google.gson.annotations.SerializedName;
+import net.minecraft.util.Identifier;
+import net.minecraft.world.dimension.DimensionType;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+public enum WorldKey {
+ @SerializedName("overworld")
+ OVERWORLD,
+ @SerializedName("nether")
+ NETHER,
+ @SerializedName("end")
+ END;
+
+ private static final Logger LOGGER = LogManager.getLogger();
+
+ public static @Nullable WorldKey create(@NotNull Identifier identifier) {
+ if (identifier.equals(DimensionType.OVERWORLD_ID)) {
+ return OVERWORLD;
+ } else if (identifier.equals(DimensionType.THE_NETHER_ID)) {
+ return NETHER;
+ } else if (identifier.equals(DimensionType.THE_END_ID)) {
+ return END;
+ } else {
+ LOGGER.warn("Unknown world {}:{}. Returning NULL to the client.",
+ identifier.getNamespace(),
+ identifier.getPath());
+ return null;
+ }
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/java/moe/ymc/acron/config/Config.java b/mod/src/main/java/moe/ymc/acron/config/Config.java
new file mode 100644
index 0000000..3749c25
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/config/Config.java
@@ -0,0 +1,30 @@
+package moe.ymc.acron.config;
+
+import moe.ymc.acron.auth.Client;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.jetbrains.annotations.NotNull;
+
+import java.net.InetAddress;
+import java.util.Map;
+
+public record Config(@NotNull InetAddress address,
+ int port,
+ boolean useNativeTransport,
+ @NotNull Map<String, Client> clients) {
+ private static final Logger LOGGER = LogManager.getLogger();
+ private static Config globalConfig;
+
+ @NotNull
+ public static Config getGlobalConfig() {
+ return globalConfig;
+ }
+
+ public static void setGlobalConfig(@NotNull Config globalConfig) {
+ Config.globalConfig = globalConfig;
+ LOGGER.info("Config loaded with {} clients. Listening on {}:{}.",
+ globalConfig.clients.size(),
+ globalConfig.address.toString(),
+ globalConfig.port);
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/java/moe/ymc/acron/config/ConfigReloadCmd.java b/mod/src/main/java/moe/ymc/acron/config/ConfigReloadCmd.java
new file mode 100644
index 0000000..2774c4d
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/config/ConfigReloadCmd.java
@@ -0,0 +1,41 @@
+package moe.ymc.acron.config;
+
+import com.mojang.brigadier.Command;
+import com.mojang.brigadier.context.CommandContext;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import moe.ymc.acron.config.json.ConfigDeserializationException;
+import moe.ymc.acron.config.json.ConfigDeserializer;
+import net.fabricmc.loader.api.FabricLoader;
+import net.minecraft.server.command.ServerCommandSource;
+import net.minecraft.text.LiteralText;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import java.io.IOException;
+import java.nio.file.Path;
+
+public class ConfigReloadCmd implements Command<ServerCommandSource> {
+ private static final Logger LOGGER = LogManager.getLogger();
+
+ @Override
+ public int run(CommandContext<ServerCommandSource> context) throws CommandSyntaxException {
+ LOGGER.info("Reloading rules.");
+ try {
+ final Path config = FabricLoader
+ .getInstance().getConfigDir()
+ .resolve("acron.json");
+ if (!config.toFile().exists()) {
+ throw new IllegalStateException("Cannot find config/acron.json.");
+ }
+ final Config cfg = ConfigDeserializer.deserialize(config.toFile(), false);
+ Config.setGlobalConfig(cfg);
+ context.getSource().sendFeedback(new LiteralText("Rules reloaded."), true);
+ return 0;
+ } catch (IOException | ConfigDeserializationException e) {
+ LOGGER.error("Cannot reload config.", e);
+ context.getSource().sendError(new LiteralText("Cannot reload rules: " +
+ e.getMessage()));
+ return 1;
+ }
+ }
+}
diff --git a/mod/src/main/java/moe/ymc/acron/config/json/Client.java b/mod/src/main/java/moe/ymc/acron/config/json/Client.java
new file mode 100644
index 0000000..4d31308
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/config/json/Client.java
@@ -0,0 +1,45 @@
+package moe.ymc.acron.config.json;
+
+import com.google.gson.annotations.SerializedName;
+import moe.ymc.acron.auth.Action;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.List;
+
+class Client implements ConfigJsonObject<moe.ymc.acron.auth.Client> {
+ @SerializedName("id")
+ private final String id;
+
+ @SerializedName("token")
+ private final String token;
+
+ @SerializedName("policy_mode")
+ private final Action policyMode;
+
+ @SerializedName("rules")
+ private final List<Rule> rules;
+
+ private Client(String id,
+ String token,
+ Action policyMode,
+ List<Rule> rules) {
+ this.id = id;
+ this.token = token;
+ this.policyMode = policyMode;
+ this.rules = rules;
+ }
+
+ @Override
+ public @NotNull moe.ymc.acron.auth.Client create(boolean startup) throws ConfigDeserializationException {
+ if (id == null || id.trim().equals("") ||
+ token == null || token.trim().equals("")) {
+ throw new ConfigDeserializationException(".clients[].id or .clients[].token is not supplied.");
+ }
+ return new moe.ymc.acron.auth.Client(id,
+ token,
+ policyMode == null ? Action.DENY : policyMode,
+ rules == null ? new moe.ymc.acron.auth.Rule[]{} :
+ rules.stream().map(rule -> rule.create(startup)).toList()
+ .toArray(new moe.ymc.acron.auth.Rule[rules.size()]));
+ }
+}
diff --git a/mod/src/main/java/moe/ymc/acron/config/json/Config.java b/mod/src/main/java/moe/ymc/acron/config/json/Config.java
new file mode 100644
index 0000000..e8c5a83
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/config/json/Config.java
@@ -0,0 +1,103 @@
+package moe.ymc.acron.config.json;
+
+import com.google.gson.annotations.SerializedName;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.jetbrains.annotations.NotNull;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+class Config implements ConfigJsonObject<moe.ymc.acron.config.Config> {
+ private static final Logger LOGGER = LogManager.getLogger();
+
+ @SerializedName("listen")
+ private final String listen;
+
+ @SerializedName("port")
+ private final Integer port;
+
+ @SerializedName("native_transport")
+ private final boolean nativeTransport;
+
+ @SerializedName("clients")
+ private final List<Client> clients;
+
+ private Config(String listen,
+ Integer port,
+ boolean nativeTransport,
+ List<Client> clients) {
+ this.listen = listen;
+ this.port = port;
+ this.nativeTransport = nativeTransport;
+ this.clients = clients;
+ }
+
+ @Override
+ public @NotNull moe.ymc.acron.config.Config create(boolean startup) throws ConfigDeserializationException {
+ final InetAddress address;
+ final int p;
+ final boolean nt;
+ if (!startup) {
+ address = moe.ymc.acron.config.Config.getGlobalConfig().address();
+ p = moe.ymc.acron.config.Config.getGlobalConfig().port();
+ nt = moe.ymc.acron.config.Config.getGlobalConfig().useNativeTransport();
+ } else {
+ if (listen == null || listen.trim().equals("")) {
+ address = InetAddress.getLoopbackAddress();
+ } else {
+ try {
+ address = InetAddress.getByName(listen);
+ } catch (UnknownHostException e) {
+ throw new ConfigDeserializationException("Cannot parse address: " + e.getMessage(),
+ true);
+ }
+ }
+ if (port == null) {
+ p = 25575;
+ } else {
+ if (port < 0 || port > 65535) {
+ throw new ConfigDeserializationException("The port is out of range.", true);
+ }
+ p = port;
+ }
+ nt = nativeTransport;
+ }
+
+
+ Map<String, moe.ymc.acron.auth.Client> map;
+ try {
+ if (clients != null) {
+ map = clients.stream()
+ .collect(Collectors.<Client, String, moe.ymc.acron.auth.Client>
+ toMap(client -> client.create(startup).id(),
+ client -> client.create(startup)));
+ } else {
+ map = new HashMap<>(0);
+ }
+ } catch (IllegalStateException e) {
+ // Collision.
+ LOGGER.error("Duplicate clients with the same ID in the Acron configuration. All clients are ignored. " +
+ "Fix the configuration and reload.", e);
+ if (!startup) {
+ throw new ConfigDeserializationException("Duplicate clients with the same ID: " + e.getMessage());
+ }
+ map = new HashMap<>(0);
+ } catch (ConfigDeserializationException e) {
+ LOGGER.error("Cannot parse the Acron configuration. All clients are ignored. " +
+ "Fix the configuration and reload.", e);
+ if (!startup) {
+ throw e;
+ }
+ map = new HashMap<>(0);
+ }
+ return new moe.ymc.acron.config.Config(address,
+ p,
+ nt,
+ map);
+ }
+}
diff --git a/mod/src/main/java/moe/ymc/acron/config/json/ConfigDeserializationException.java b/mod/src/main/java/moe/ymc/acron/config/json/ConfigDeserializationException.java
new file mode 100644
index 0000000..baf5b35
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/config/json/ConfigDeserializationException.java
@@ -0,0 +1,18 @@
+package moe.ymc.acron.config.json;
+
+public class ConfigDeserializationException extends RuntimeException {
+ private final boolean fetal;
+
+ public ConfigDeserializationException(String message) {
+ this(message, false);
+ }
+
+ public ConfigDeserializationException(String message, boolean fetal) {
+ super(message);
+ this.fetal = fetal;
+ }
+
+ public boolean isFetal() {
+ return fetal;
+ }
+}
diff --git a/mod/src/main/java/moe/ymc/acron/config/json/ConfigDeserializer.java b/mod/src/main/java/moe/ymc/acron/config/json/ConfigDeserializer.java
new file mode 100644
index 0000000..e91b355
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/config/json/ConfigDeserializer.java
@@ -0,0 +1,27 @@
+package moe.ymc.acron.config.json;
+
+import com.google.gson.Gson;
+import moe.ymc.acron.config.Config;
+import org.jetbrains.annotations.NotNull;
+
+import java.io.File;
+import java.io.FileReader;
+import java.io.IOException;
+import java.io.Reader;
+
+public class ConfigDeserializer {
+ public static @NotNull Config deserialize(@NotNull File file, boolean startup)
+ throws ConfigDeserializationException, IOException {
+ final Reader reader = new FileReader(file);
+ final moe.ymc.acron.config.json.Config config;
+ try {
+ config = new Gson()
+ .fromJson(reader, moe.ymc.acron.config.json.Config.class);
+ } catch (Throwable e) {
+ throw new ConfigDeserializationException("Cannot parse JSON: " + e.getMessage(),
+ true);
+ }
+ reader.close();
+ return config.create(startup);
+ }
+}
diff --git a/mod/src/main/java/moe/ymc/acron/config/json/ConfigJsonObject.java b/mod/src/main/java/moe/ymc/acron/config/json/ConfigJsonObject.java
new file mode 100644
index 0000000..0efd9a9
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/config/json/ConfigJsonObject.java
@@ -0,0 +1,7 @@
+package moe.ymc.acron.config.json;
+
+import org.jetbrains.annotations.NotNull;
+
+public interface ConfigJsonObject<T> {
+ @NotNull T create(boolean startup) throws ConfigDeserializationException;
+}
diff --git a/mod/src/main/java/moe/ymc/acron/config/json/Rule.java b/mod/src/main/java/moe/ymc/acron/config/json/Rule.java
new file mode 100644
index 0000000..114e17d
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/config/json/Rule.java
@@ -0,0 +1,35 @@
+package moe.ymc.acron.config.json;
+
+import com.google.gson.annotations.SerializedName;
+import moe.ymc.acron.auth.Action;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.regex.Pattern;
+
+class Rule implements ConfigJsonObject<moe.ymc.acron.auth.Rule> {
+ @SerializedName("regex")
+ private final String regex;
+
+ @SerializedName("action")
+ private final Action action;
+
+ @SerializedName("display")
+ private final boolean display;
+
+ private Rule(String regex,
+ Action action,
+ boolean display) {
+ this.regex = regex;
+ this.action = action;
+ this.display = display;
+ }
+
+ public @NotNull moe.ymc.acron.auth.Rule create(boolean startup) throws ConfigDeserializationException {
+ if (regex == null || regex.trim().equals("") ||
+ action == null) throw new ConfigDeserializationException(".clients.[]rules.regex or .clients.[]rules.action is" +
+ "not specified.");
+ return new moe.ymc.acron.auth.Rule(Pattern.compile(regex),
+ action,
+ display);
+ }
+}
diff --git a/mod/src/main/java/moe/ymc/acron/jvav/Pair.java b/mod/src/main/java/moe/ymc/acron/jvav/Pair.java
new file mode 100644
index 0000000..29b83dc
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/jvav/Pair.java
@@ -0,0 +1,4 @@
+package moe.ymc.acron.jvav;
+
+public record Pair<L, R>(L l, R r) {
+} \ No newline at end of file
diff --git a/mod/src/main/java/moe/ymc/acron/mixin/CommandManagerMixin.java b/mod/src/main/java/moe/ymc/acron/mixin/CommandManagerMixin.java
new file mode 100644
index 0000000..9aaed2e
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/mixin/CommandManagerMixin.java
@@ -0,0 +1,38 @@
+package moe.ymc.acron.mixin;
+
+import com.mojang.brigadier.CommandDispatcher;
+import moe.ymc.acron.config.ConfigReloadCmd;
+import net.minecraft.server.command.CommandManager;
+import net.minecraft.server.command.ServerCommandSource;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.spongepowered.asm.mixin.Final;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+import static net.minecraft.server.command.CommandManager.literal;
+
+
+@Mixin(CommandManager.class)
+public abstract class CommandManagerMixin {
+ private static final Logger AC_LOGGER = LogManager.getLogger();
+
+ @Shadow
+ @Final
+ private CommandDispatcher<ServerCommandSource> dispatcher;
+
+ @Inject(method = "<init>", at = @At("RETURN"))
+ private void onRegister(CommandManager.RegistrationEnvironment arg, CallbackInfo ci) {
+ AC_LOGGER.debug("onRegister");
+ dispatcher.register(
+ literal("acron").requires(player -> player.hasPermissionLevel(4)).then(
+ literal("rule").then(
+ literal("update")
+ .executes(new ConfigReloadCmd()))
+ )
+ );
+ }
+}
diff --git a/mod/src/main/java/moe/ymc/acron/mixin/LivingEntityMixin.java b/mod/src/main/java/moe/ymc/acron/mixin/LivingEntityMixin.java
new file mode 100644
index 0000000..9e16569
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/mixin/LivingEntityMixin.java
@@ -0,0 +1,41 @@
+package moe.ymc.acron.mixin;
+
+import moe.ymc.acron.s2c.Entity;
+import moe.ymc.acron.s2c.EventQueue;
+import moe.ymc.acron.s2c.event.EventEntityDeath;
+import net.minecraft.entity.EntityType;
+import net.minecraft.entity.LivingEntity;
+import net.minecraft.entity.damage.DamageSource;
+import net.minecraft.entity.damage.DamageTracker;
+import net.minecraft.world.World;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+@Mixin(LivingEntity.class)
+public abstract class LivingEntityMixin extends net.minecraft.entity.Entity {
+ private static final Logger AC_LOGGER = LogManager.getLogger();
+
+ @Shadow public abstract DamageTracker getDamageTracker();
+
+ public LivingEntityMixin(EntityType<?> type, World world) {
+ super(type, world);
+ }
+
+ // The original onDeath() will call getDamageTracker().update(),
+ // which clears all recent damages, making the getDeathMessage()
+ // output always generic.
+ // Thus, we need to use @At("HEAD") to get the injection called
+ // before it does anything else.
+ @Inject(at = @At("HEAD"), method = "onDeath")
+ public void onDeath(DamageSource source, CallbackInfo ci) {
+ AC_LOGGER.debug("onDeath[{}]",
+ getUuid());
+ EventQueue.enqueue(new EventEntityDeath(new Entity(this),
+ getDamageTracker().getDeathMessage().getString()));
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/java/moe/ymc/acron/mixin/MinecraftDedicatedServerMixin.java b/mod/src/main/java/moe/ymc/acron/mixin/MinecraftDedicatedServerMixin.java
new file mode 100644
index 0000000..32d2fbf
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/mixin/MinecraftDedicatedServerMixin.java
@@ -0,0 +1,21 @@
+package moe.ymc.acron.mixin;
+
+import moe.ymc.acron.MinecraftServerHolder;
+import net.minecraft.server.dedicated.MinecraftDedicatedServer;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+@Mixin(MinecraftDedicatedServer.class)
+public class MinecraftDedicatedServerMixin {
+ private static final Logger AC_LOGGER = LogManager.getLogger();
+
+ @Inject(at = @At("RETURN"), method = "<init>")
+ private void init(CallbackInfo info) {
+ AC_LOGGER.debug("init");
+ MinecraftServerHolder.setServer((MinecraftDedicatedServer) (Object) this);
+ }
+}
diff --git a/mod/src/main/java/moe/ymc/acron/mixin/MinecraftServerMixin.java b/mod/src/main/java/moe/ymc/acron/mixin/MinecraftServerMixin.java
new file mode 100644
index 0000000..cb813b4
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/mixin/MinecraftServerMixin.java
@@ -0,0 +1,33 @@
+package moe.ymc.acron.mixin;
+
+import moe.ymc.acron.s2c.EventQueue;
+import moe.ymc.acron.s2c.event.EventLagging;
+import net.minecraft.server.MinecraftServer;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Redirect;
+
+@Mixin(MinecraftServer.class)
+public class MinecraftServerMixin {
+ private static final Logger AC_LOGGER = LogManager.getLogger();
+
+ @Redirect(method = "runServer()V",
+ remap = false,
+ at = @At(value = "INVOKE",
+ target = "Lorg/apache/logging/log4j/Logger;warn(Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Object;)V"))
+ private void startServer(Logger instance, String s, Object o1, Object o2) {
+ instance.warn(s, o1, o2);
+ if (s.equals("Can't keep up! " +
+ "Is the server overloaded? " +
+ "Running {}ms or {} ticks behind") &&
+ o1 instanceof Long &&
+ o2 instanceof Long) {
+ AC_LOGGER.debug("Lag: {}ms, {} ticks",
+ o1,
+ o2);
+ EventQueue.enqueue(new EventLagging((long) o1, (long) o2));
+ }
+ }
+}
diff --git a/mod/src/main/java/moe/ymc/acron/mixin/ServerLoginNetworkHandlerMixin.java b/mod/src/main/java/moe/ymc/acron/mixin/ServerLoginNetworkHandlerMixin.java
new file mode 100644
index 0000000..94a48b5
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/mixin/ServerLoginNetworkHandlerMixin.java
@@ -0,0 +1,47 @@
+package moe.ymc.acron.mixin;
+
+import com.mojang.authlib.GameProfile;
+import moe.ymc.acron.s2c.Entity;
+import moe.ymc.acron.s2c.EventQueue;
+import moe.ymc.acron.s2c.event.EventDisconnected;
+import moe.ymc.acron.s2c.event.EventPlayerJoined;
+import net.minecraft.network.ClientConnection;
+import net.minecraft.server.network.ServerLoginNetworkHandler;
+import net.minecraft.server.network.ServerPlayerEntity;
+import net.minecraft.text.Text;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.jetbrains.annotations.Nullable;
+import org.spongepowered.asm.mixin.Final;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+@Mixin(ServerLoginNetworkHandler.class)
+public class ServerLoginNetworkHandlerMixin {
+ private static final Logger AC_LOGGER = LogManager.getLogger();
+
+ @Shadow
+ @Nullable
+ GameProfile profile;
+
+ @Shadow
+ @Final
+ public ClientConnection connection;
+
+ @Inject(at = @At("RETURN"), method = "onDisconnected")
+ private void onDisconnected(Text reason, CallbackInfo ci) {
+ EventQueue.enqueue(new EventDisconnected(profile == null ? null :
+ new Entity(profile),
+ reason.getString()));
+ }
+
+ @Inject(at = @At("RETURN"), method = "addToServer")
+ private void addToServer(ServerPlayerEntity entity, CallbackInfo ci) {
+ AC_LOGGER.debug("addToServer: {}",
+ entity.getUuid());
+ EventQueue.enqueue(new EventPlayerJoined(new Entity(entity)));
+ }
+}
diff --git a/mod/src/main/java/moe/ymc/acron/mixin/ServerNetworkIoMixin.java b/mod/src/main/java/moe/ymc/acron/mixin/ServerNetworkIoMixin.java
new file mode 100644
index 0000000..f49914e
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/mixin/ServerNetworkIoMixin.java
@@ -0,0 +1,65 @@
+package moe.ymc.acron.mixin;
+
+import io.netty.bootstrap.ServerBootstrap;
+import io.netty.channel.ChannelFuture;
+import io.netty.channel.MultithreadEventLoopGroup;
+import io.netty.channel.ServerChannel;
+import io.netty.channel.epoll.Epoll;
+import io.netty.channel.epoll.EpollServerSocketChannel;
+import io.netty.channel.nio.NioEventLoopGroup;
+import io.netty.channel.socket.nio.NioServerSocketChannel;
+import moe.ymc.acron.config.Config;
+import moe.ymc.acron.net.AcronInitializer;
+import net.minecraft.server.ServerNetworkIo;
+import net.minecraft.util.Lazy;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.spongepowered.asm.mixin.Final;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+import java.util.List;
+
+import static net.minecraft.server.ServerNetworkIo.EPOLL_CHANNEL;
+
+@Mixin(ServerNetworkIo.class)
+public class ServerNetworkIoMixin {
+ private static final Logger AC_LOGGER = LogManager.getLogger();
+
+ @Shadow
+ @Final
+ private List<ChannelFuture> channels;
+
+ @Shadow
+ @Final
+ public static Lazy<NioEventLoopGroup> DEFAULT_CHANNEL;
+
+ @Inject(at = @At("RETURN"), method = "<init>")
+ private void init(CallbackInfo info) {
+ AC_LOGGER.debug("Adding Acron channel.");
+ Lazy<? extends MultithreadEventLoopGroup> group;
+ Class<? extends ServerChannel> channel;
+ if (Epoll.isAvailable() && Config.getGlobalConfig().useNativeTransport()) {
+ channel = EpollServerSocketChannel.class;
+ group = EPOLL_CHANNEL;
+ AC_LOGGER.info("Using native transport.");
+ } else {
+ channel = NioServerSocketChannel.class;
+ group = DEFAULT_CHANNEL;
+ AC_LOGGER.info("Not using native transport due to " +
+ "it is either disabled in acron.json or not available.");
+ }
+ channels.add(new ServerBootstrap()
+ .channel(channel)
+ .childHandler(new AcronInitializer())
+ .group(group.get())
+ .localAddress(Config.getGlobalConfig().address(),
+ Config.getGlobalConfig().port())
+ .bind()
+ .syncUninterruptibly());
+
+ }
+}
diff --git a/mod/src/main/java/moe/ymc/acron/mixin/ServerPlayNetworkHandlerMixin.java b/mod/src/main/java/moe/ymc/acron/mixin/ServerPlayNetworkHandlerMixin.java
new file mode 100644
index 0000000..58bef78
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/mixin/ServerPlayNetworkHandlerMixin.java
@@ -0,0 +1,43 @@
+package moe.ymc.acron.mixin;
+
+import moe.ymc.acron.s2c.Entity;
+import moe.ymc.acron.s2c.EventQueue;
+import moe.ymc.acron.s2c.event.EventDisconnected;
+import moe.ymc.acron.s2c.event.EventPlayerMessage;
+import net.minecraft.network.ClientConnection;
+import net.minecraft.server.filter.TextStream;
+import net.minecraft.server.network.ServerPlayNetworkHandler;
+import net.minecraft.server.network.ServerPlayerEntity;
+import net.minecraft.text.Text;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.spongepowered.asm.mixin.Final;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+@Mixin(ServerPlayNetworkHandler.class)
+public class ServerPlayNetworkHandlerMixin {
+ private static final Logger AC_LOGGER = LogManager.getLogger();
+
+ @Shadow
+ public ServerPlayerEntity player;
+
+ @Shadow
+ @Final
+ public ClientConnection connection;
+
+ @Inject(at = @At("RETURN"), method = "handleMessage")
+ private void handleMessage(TextStream.Message message, CallbackInfo ci) {
+ EventQueue.enqueue(new EventPlayerMessage(new Entity(player),
+ message.getRaw()));
+ }
+
+ @Inject(at = @At("RETURN"), method = "onDisconnected")
+ private void onDisconnected(Text reason, CallbackInfo ci) {
+ EventQueue.enqueue(new EventDisconnected(new Entity(player),
+ reason.getString()));
+ }
+}
diff --git a/mod/src/main/java/moe/ymc/acron/mixin/ServerPlayerEntityMixin.java b/mod/src/main/java/moe/ymc/acron/mixin/ServerPlayerEntityMixin.java
new file mode 100644
index 0000000..4c6758b
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/mixin/ServerPlayerEntityMixin.java
@@ -0,0 +1,32 @@
+package moe.ymc.acron.mixin;
+
+import moe.ymc.acron.s2c.EventQueue;
+import moe.ymc.acron.s2c.event.EventEntityDeath;
+import net.minecraft.entity.EntityType;
+import net.minecraft.entity.LivingEntity;
+import net.minecraft.entity.damage.DamageSource;
+import net.minecraft.server.network.ServerPlayerEntity;
+import net.minecraft.world.World;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+@Mixin(ServerPlayerEntity.class)
+public abstract class ServerPlayerEntityMixin extends LivingEntity {
+ private static final Logger AC_LOGGER = LogManager.getLogger();
+
+ public ServerPlayerEntityMixin(EntityType<? extends LivingEntity> entityType, World world) {
+ super(entityType, world);
+ }
+
+ @Inject(at = @At("HEAD"), method = "onDeath")
+ public void onDeath(DamageSource source, CallbackInfo ci) {
+ AC_LOGGER.debug("onDeath: {}",
+ getUuid());
+ EventQueue.enqueue(new EventEntityDeath(new moe.ymc.acron.s2c.Entity(this),
+ getDamageTracker().getDeathMessage().getString()));
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/java/moe/ymc/acron/net/AcronInitializer.java b/mod/src/main/java/moe/ymc/acron/net/AcronInitializer.java
new file mode 100644
index 0000000..c9953e3
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/net/AcronInitializer.java
@@ -0,0 +1,25 @@
+package moe.ymc.acron.net;
+
+import io.netty.channel.ChannelInitializer;
+import io.netty.channel.socket.SocketChannel;
+import io.netty.handler.codec.http.HttpObjectAggregator;
+import io.netty.handler.codec.http.HttpServerCodec;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+/**
+ * A channel initializer for all Acron handlers.
+ */
+public class AcronInitializer extends ChannelInitializer<SocketChannel> {
+ private static final Logger LOGGER = LogManager.getLogger();
+
+ @Override
+ protected void initChannel(SocketChannel ch) throws Exception {
+ LOGGER.debug("initChannel");
+ ch.pipeline()
+ .addLast(new HttpServerCodec())
+ .addLast(new HttpObjectAggregator(65536))
+ .addLast(new AuthHandler())
+ ;
+ }
+}
diff --git a/mod/src/main/java/moe/ymc/acron/net/Attributes.java b/mod/src/main/java/moe/ymc/acron/net/Attributes.java
new file mode 100644
index 0000000..ddb0f5c
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/net/Attributes.java
@@ -0,0 +1,13 @@
+package moe.ymc.acron.net;
+
+import io.netty.handler.codec.http.websocketx.WebSocketServerHandshaker;
+import io.netty.util.AttributeKey;
+
+final class Attributes {
+ public static final AttributeKey<ClientIdentification> ID =
+ AttributeKey.newInstance("CLENT_ID");
+ public static final AttributeKey<ClientConfiguration> CONFIGURATION =
+ AttributeKey.newInstance("CLIENT_CONFIG");
+ public static final AttributeKey<WebSocketServerHandshaker> HANDSHAKER =
+ AttributeKey.newInstance("HANDSHAKER");
+}
diff --git a/mod/src/main/java/moe/ymc/acron/net/AuthHandler.java b/mod/src/main/java/moe/ymc/acron/net/AuthHandler.java
new file mode 100644
index 0000000..3e42e14
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/net/AuthHandler.java
@@ -0,0 +1,98 @@
+package moe.ymc.acron.net;
+
+import io.netty.channel.ChannelFutureListener;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.SimpleChannelInboundHandler;
+import io.netty.handler.codec.http.*;
+import io.netty.handler.codec.http.websocketx.WebSocketServerHandshaker;
+import io.netty.handler.codec.http.websocketx.WebSocketServerHandshakerFactory;
+import moe.ymc.acron.auth.Client;
+import moe.ymc.acron.config.Config;
+import org.apache.commons.codec.digest.DigestUtils;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+/**
+ * Handle handshake request and authentication.
+ * We cannot use WebSocketServerProtocolHandler because it does not allow
+ * us doing anything before handshaking.
+ */
+public class AuthHandler extends SimpleChannelInboundHandler<HttpRequest> {
+ private static final Logger LOGGER = LogManager.getLogger();
+
+ @Override
+ protected void channelRead0(ChannelHandlerContext ctx, HttpRequest msg) throws Exception {
+ LOGGER.debug("channelRead0: {}", msg.uri());
+ if (msg.method() != HttpMethod.GET) {
+ ctx.channel().writeAndFlush(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1,
+ HttpResponseStatus.BAD_REQUEST))
+ .addListener(ChannelFutureListener.CLOSE);
+ return;
+ }
+ HttpHeaders headers = msg.headers();
+
+ if (!"Upgrade".equalsIgnoreCase(headers.get(HttpHeaderNames.CONNECTION)) ||
+ !"WebSocket".equalsIgnoreCase(headers.get(HttpHeaderNames.UPGRADE))) {
+ ctx.channel().writeAndFlush(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1,
+ HttpResponseStatus.BAD_REQUEST))
+ .addListener(ChannelFutureListener.CLOSE);
+ return;
+ }
+
+ final QueryStringDecoder decoder = new QueryStringDecoder(msg.uri());
+ if (!decoder.path().equals("/ws")) {
+ ctx.fireChannelRead(msg);
+ return;
+ }
+ if (decoder.parameters().isEmpty() ||
+ decoder.parameters().get("id") == null ||
+ decoder.parameters().get("id").size() != 1 ||
+ decoder.parameters().get("token") == null ||
+ decoder.parameters().get("token").size() != 1) {
+ ctx.writeAndFlush(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1,
+ HttpResponseStatus.BAD_REQUEST))
+ .addListener(ChannelFutureListener.CLOSE);
+ return;
+ }
+
+ final String id = decoder.parameters().get("id").get(0);
+ final String token = decoder.parameters().get("token").get(0);
+ final String versionRaw = (decoder.parameters().get("version") == null ||
+ decoder.parameters().get("version").isEmpty()) ? "0" :
+ decoder.parameters().get("version").get(0);
+ try {
+ if (Integer.parseInt(versionRaw) != 0) {
+ ctx.writeAndFlush(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1,
+ HttpResponseStatus.BAD_REQUEST))
+ .addListener(ChannelFutureListener.CLOSE);
+ return;
+ }
+ } catch (NumberFormatException ignored) {
+ ctx.writeAndFlush(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1,
+ HttpResponseStatus.BAD_REQUEST))
+ .addListener(ChannelFutureListener.CLOSE);
+ return;
+ }
+
+ final Client client = Config.getGlobalConfig().clients().get(id);
+ if (client == null ||
+ !client.token().equals(DigestUtils.sha256Hex(token))) {
+ ctx.writeAndFlush(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1,
+ HttpResponseStatus.UNAUTHORIZED))
+ .addListener(ChannelFutureListener.CLOSE);
+ return;
+ }
+ ctx.channel().attr(Attributes.ID).set(new ClientIdentification(0, client));
+ WebSocketServerHandshakerFactory wsFactory =
+ new WebSocketServerHandshakerFactory("/ws", null, true);
+ final WebSocketServerHandshaker handshaker = wsFactory.newHandshaker(msg);
+ if (handshaker == null) {
+ WebSocketServerHandshakerFactory.sendUnsupportedVersionResponse(ctx.channel());
+ return;
+ }
+ ctx.channel().attr(Attributes.HANDSHAKER).set(handshaker);
+ handshaker.handshake(ctx.channel(), msg);
+ ctx.pipeline().replace(this, "websocketHandler", new WSFrameHandler());
+ ctx.fireUserEventTriggered(new HandshakeComplete());
+ }
+}
diff --git a/mod/src/main/java/moe/ymc/acron/net/ClientConfiguration.java b/mod/src/main/java/moe/ymc/acron/net/ClientConfiguration.java
new file mode 100644
index 0000000..450ccd4
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/net/ClientConfiguration.java
@@ -0,0 +1,20 @@
+package moe.ymc.acron.net;
+
+import net.minecraft.server.world.ServerWorld;
+import net.minecraft.util.math.Vec2f;
+import net.minecraft.util.math.Vec3d;
+import org.jetbrains.annotations.NotNull;
+
+public record ClientConfiguration(@NotNull Vec3d pos,
+ @NotNull Vec2f rot,
+ @NotNull ServerWorld world,
+ @NotNull String name) {
+ public ClientConfiguration(@NotNull ServerWorld world,
+ @NotNull String name) {
+ // Rcon defaults. @see RconCommandOutput
+ this(Vec3d.of(world.getSpawnPos()),
+ Vec2f.ZERO,
+ world,
+ name);
+ }
+}
diff --git a/mod/src/main/java/moe/ymc/acron/net/ClientIdentification.java b/mod/src/main/java/moe/ymc/acron/net/ClientIdentification.java
new file mode 100644
index 0000000..1cb4375
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/net/ClientIdentification.java
@@ -0,0 +1,11 @@
+package moe.ymc.acron.net;
+
+import moe.ymc.acron.auth.Client;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Read only client configurations.
+ */
+public record ClientIdentification(int version,
+ @NotNull Client client) {
+}
diff --git a/mod/src/main/java/moe/ymc/acron/net/HandshakeComplete.java b/mod/src/main/java/moe/ymc/acron/net/HandshakeComplete.java
new file mode 100644
index 0000000..348b5e2
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/net/HandshakeComplete.java
@@ -0,0 +1,7 @@
+package moe.ymc.acron.net;
+
+/**
+ * User event used to tell WSFrameHandler that the handshake is complete.
+ */
+public class HandshakeComplete {
+}
diff --git a/mod/src/main/java/moe/ymc/acron/net/WSFrameHandler.java b/mod/src/main/java/moe/ymc/acron/net/WSFrameHandler.java
new file mode 100644
index 0000000..912e73a
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/net/WSFrameHandler.java
@@ -0,0 +1,174 @@
+package moe.ymc.acron.net;
+
+import com.google.gson.JsonParseException;
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.SimpleChannelInboundHandler;
+import io.netty.handler.codec.http.websocketx.*;
+import moe.ymc.acron.MinecraftServerHolder;
+import moe.ymc.acron.auth.Action;
+import moe.ymc.acron.auth.PolicyChecker;
+import moe.ymc.acron.c2s.ReqCmd;
+import moe.ymc.acron.c2s.ReqSetConfig;
+import moe.ymc.acron.c2s.Request;
+import moe.ymc.acron.cmd.CmdQueue;
+import moe.ymc.acron.jvav.Pair;
+import moe.ymc.acron.s2c.Event;
+import moe.ymc.acron.s2c.EventQueue;
+import moe.ymc.acron.s2c.response.EventError;
+import moe.ymc.acron.s2c.response.EventOk;
+import moe.ymc.acron.serialization.Serializer;
+import net.minecraft.server.world.ServerWorld;
+import net.minecraft.util.math.Vec2f;
+import net.minecraft.util.math.Vec3d;
+import net.minecraft.world.World;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * The handler for WebSocket requests.
+ */
+public class WSFrameHandler extends SimpleChannelInboundHandler<WebSocketFrame> {
+ private static final Logger LOGGER = LogManager.getLogger();
+
+ @Override
+ public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
+ super.handlerAdded(ctx);
+ }
+
+ @Override
+ protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame msg) throws Exception {
+ LOGGER.debug("channelRead0: {} {}",
+ this,
+ ctx.channel());
+ final WebSocketServerHandshaker handshaker =
+ ctx.channel().attr(Attributes.HANDSHAKER).get();
+ if (msg instanceof CloseWebSocketFrame) {
+ handshaker.close(ctx.channel(), (CloseWebSocketFrame) msg.retain());
+ return;
+ }
+ if (msg instanceof PingWebSocketFrame) {
+ ctx.write(new PongWebSocketFrame(msg.content().retain()));
+ return;
+ }
+ if (msg instanceof BinaryWebSocketFrame) {
+ throw new UnsupportedOperationException("Only text frames are accepted.");
+ }
+ final TextWebSocketFrame frame = (TextWebSocketFrame) msg;
+
+ final ClientIdentification identification = ctx.channel().attr(Attributes.ID).get();
+ final ClientConfiguration configuration = ctx.channel().attr(Attributes.CONFIGURATION).get();
+ int id;
+ final Request request;
+ try {
+ request = Serializer.deserialize(frame);
+ id = request.getId();
+ } catch (JsonParseException | IllegalArgumentException | IllegalStateException e) {
+ ctx.channel().writeAndFlush(
+ Serializer.serialize(new EventError(-2, EventError.Code.BAD_REQUEST.value, e.getMessage()))
+ );
+ return;
+ }
+ try {
+ ctx.channel().writeAndFlush(Serializer.serialize(handle(request,
+ identification,
+ configuration,
+ ctx.channel())));
+ } catch (Throwable e) {
+ LOGGER.info("An error occurred while processing the request. " +
+ "This may just be a malformed request. " +
+ "It is reported to the client.",
+ e);
+ ctx.channel().writeAndFlush(
+ Serializer.serialize(new EventError(id, EventError.Code.SERVER_ERROR.value, e.getMessage()))
+ );
+ }
+ }
+
+ @NotNull
+ private Event handle(@NotNull Request request,
+ @NotNull ClientIdentification identification,
+ @NotNull ClientConfiguration configuration,
+ @NotNull Channel channel) throws Throwable {
+ if (request instanceof final ReqCmd reqCmd) {
+ LOGGER.info("Client {} executed a command: `{}`.",
+ identification.client().id(),
+ reqCmd.cmd());
+ final Pair<Action, Boolean> res = PolicyChecker.check(identification.client(),
+ reqCmd.cmd());
+ if (res.l() == Action.DENY) {
+ return new EventError(reqCmd.id(),
+ EventError.Code.FORBIDDEN.value, "This client is not allowed to " +
+ "execute this command.");
+ }
+ // TODO: Ok event may be sent after executing the command.
+ CmdQueue.enqueue(reqCmd.id(),
+ res.r(),
+ channel,
+ reqCmd.config() == null ?
+ configuration :
+ convertConfiguration(reqCmd.config()),
+ reqCmd.cmd());
+ return new EventOk(request.getId());
+ } else if (request instanceof final ReqSetConfig reqSetConfig) {
+ channel.attr(Attributes.CONFIGURATION).set(convertConfiguration(reqSetConfig));
+ return new EventOk(request.getId());
+ }
+ // This should not occur.
+ throw new IllegalStateException("This should not occur.");
+ }
+
+ private ClientConfiguration convertConfiguration(@NotNull ReqSetConfig request) {
+ final ServerWorld world;
+ if (request.world() != null) {
+ switch (request.world()) {
+ case OVERWORLD -> world = MinecraftServerHolder.getServer().getWorld(World.OVERWORLD);
+ case NETHER -> world = MinecraftServerHolder.getServer().getWorld(World.NETHER);
+ case END -> world = MinecraftServerHolder.getServer().getWorld(World.END);
+ default -> throw new IllegalArgumentException();
+ }
+ } else {
+ world = MinecraftServerHolder.getServer().getOverworld();
+ }
+ if (world == null) {
+ throw new IllegalStateException(String.format("The requested world %s is not available at this time.",
+ request.world()));
+ }
+ return new ClientConfiguration(
+ request.pos() == null ?
+ Vec3d.of(world.getSpawnPos()) :
+ new Vec3d(request.pos().x(), request.pos().y(), request.pos().z()),
+ request.rot() == null ?
+ Vec2f.ZERO :
+ new Vec2f(request.rot().x(), request.rot().y()),
+ world,
+ request.name() == null ? this.toString() : request.name()
+ );
+ }
+
+ @Override
+ public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
+ LOGGER.debug("handshakeComplete: {} {}",
+ this,
+ ctx.channel());
+ if (evt instanceof HandshakeComplete) {
+ final ClientIdentification identification = ctx.channel().attr(Attributes.ID).get();
+ LOGGER.info("Client {} connected. It has {} rules with {} policy mode.",
+ identification.client().id(),
+ identification.client().rules().length,
+ identification.client().policyMode());
+ final ServerWorld defaultWorld = MinecraftServerHolder.getServer().getOverworld();
+ if (defaultWorld == null) {
+ throw new IllegalStateException("The default world is not available at this time.");
+ }
+ final ClientConfiguration configuration =
+ new ClientConfiguration(defaultWorld,
+ identification.client().id());
+ ctx.channel().attr(Attributes.CONFIGURATION).set(configuration);
+ EventQueue.registerMessageRecipient(ctx.channel());
+ } else {
+ ctx.fireUserEventTriggered(evt);
+ }
+ }
+}
diff --git a/mod/src/main/java/moe/ymc/acron/s2c/Entity.java b/mod/src/main/java/moe/ymc/acron/s2c/Entity.java
new file mode 100644
index 0000000..3e0add1
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/s2c/Entity.java
@@ -0,0 +1,26 @@
+package moe.ymc.acron.s2c;
+
+import com.google.gson.annotations.SerializedName;
+import com.mojang.authlib.GameProfile;
+import moe.ymc.acron.common.Vec3d;
+import moe.ymc.acron.common.WorldKey;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.UUID;
+
+public record Entity(@SerializedName("name") @NotNull String name,
+ @SerializedName("uuid") @NotNull UUID uuid,
+ @SerializedName("pos") @Nullable Vec3d pos,
+ @SerializedName("world") @Nullable WorldKey world) {
+ public Entity(@NotNull net.minecraft.entity.Entity entity) {
+ this(entity.getName().getString(),
+ entity.getUuid(),
+ new Vec3d(entity.getPos()),
+ WorldKey.create(entity.world.getRegistryKey().getValue()));
+ }
+
+ public Entity(@NotNull GameProfile profile) {
+ this(profile.getName(), profile.getId(), null, null);
+ }
+}
diff --git a/mod/src/main/java/moe/ymc/acron/s2c/Event.java b/mod/src/main/java/moe/ymc/acron/s2c/Event.java
new file mode 100644
index 0000000..1abc35c
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/s2c/Event.java
@@ -0,0 +1,4 @@
+package moe.ymc.acron.s2c;
+
+public interface Event {
+}
diff --git a/mod/src/main/java/moe/ymc/acron/s2c/EventQueue.java b/mod/src/main/java/moe/ymc/acron/s2c/EventQueue.java
new file mode 100644
index 0000000..8c470a1
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/s2c/EventQueue.java
@@ -0,0 +1,28 @@
+package moe.ymc.acron.s2c;
+
+import io.netty.channel.Channel;
+import io.netty.channel.group.ChannelGroup;
+import io.netty.channel.group.DefaultChannelGroup;
+import io.netty.util.concurrent.GlobalEventExecutor;
+import moe.ymc.acron.serialization.Serializer;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.jetbrains.annotations.NotNull;
+
+public class EventQueue {
+ private static final Logger LOGGER = LogManager.getLogger();
+
+ private static final ChannelGroup sMessageRecipients =
+ new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
+
+ public static void registerMessageRecipient(@NotNull Channel channel) {
+ sMessageRecipients.add(channel);
+ }
+
+ public static void enqueue(@NotNull Event message) {
+ LOGGER.debug("Enqueue: {} ({} channels)",
+ message,
+ sMessageRecipients.size());
+ sMessageRecipients.writeAndFlush(Serializer.serialize(message));
+ }
+}
diff --git a/mod/src/main/java/moe/ymc/acron/s2c/event/EventDisconnected.java b/mod/src/main/java/moe/ymc/acron/s2c/event/EventDisconnected.java
new file mode 100644
index 0000000..610fc58
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/s2c/event/EventDisconnected.java
@@ -0,0 +1,12 @@
+package moe.ymc.acron.s2c.event;
+
+import com.google.gson.annotations.SerializedName;
+import moe.ymc.acron.s2c.Entity;
+import moe.ymc.acron.s2c.Event;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+public record EventDisconnected(@SerializedName("player") @Nullable Entity player,
+ @SerializedName("reason") @NotNull String reason)
+ implements Event {
+}
diff --git a/mod/src/main/java/moe/ymc/acron/s2c/event/EventEntityDeath.java b/mod/src/main/java/moe/ymc/acron/s2c/event/EventEntityDeath.java
new file mode 100644
index 0000000..4735241
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/s2c/event/EventEntityDeath.java
@@ -0,0 +1,12 @@
+package moe.ymc.acron.s2c.event;
+
+import com.google.gson.annotations.SerializedName;
+import moe.ymc.acron.s2c.Entity;
+import moe.ymc.acron.s2c.Event;
+import org.jetbrains.annotations.NotNull;
+
+// TODO: More detailed death report.
+public record EventEntityDeath(@SerializedName("entity") @NotNull Entity entity,
+ @SerializedName("message") @NotNull String message)
+ implements Event {
+}
diff --git a/mod/src/main/java/moe/ymc/acron/s2c/event/EventLagging.java b/mod/src/main/java/moe/ymc/acron/s2c/event/EventLagging.java
new file mode 100644
index 0000000..30974df
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/s2c/event/EventLagging.java
@@ -0,0 +1,9 @@
+package moe.ymc.acron.s2c.event;
+
+import com.google.gson.annotations.SerializedName;
+import moe.ymc.acron.s2c.Event;
+
+public record EventLagging(@SerializedName("ms") long ms,
+ @SerializedName("ticks") long ticks)
+ implements Event {
+}
diff --git a/mod/src/main/java/moe/ymc/acron/s2c/event/EventPlayerJoined.java b/mod/src/main/java/moe/ymc/acron/s2c/event/EventPlayerJoined.java
new file mode 100644
index 0000000..408680b
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/s2c/event/EventPlayerJoined.java
@@ -0,0 +1,10 @@
+package moe.ymc.acron.s2c.event;
+
+import com.google.gson.annotations.SerializedName;
+import moe.ymc.acron.s2c.Entity;
+import moe.ymc.acron.s2c.Event;
+import org.jetbrains.annotations.NotNull;
+
+public record EventPlayerJoined(@SerializedName("player") @NotNull Entity player)
+ implements Event {
+}
diff --git a/mod/src/main/java/moe/ymc/acron/s2c/event/EventPlayerMessage.java b/mod/src/main/java/moe/ymc/acron/s2c/event/EventPlayerMessage.java
new file mode 100644
index 0000000..2769493
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/s2c/event/EventPlayerMessage.java
@@ -0,0 +1,12 @@
+package moe.ymc.acron.s2c.event;
+
+import com.google.gson.annotations.SerializedName;
+import moe.ymc.acron.s2c.Entity;
+import moe.ymc.acron.s2c.Event;
+import org.jetbrains.annotations.NotNull;
+
+public record EventPlayerMessage(@SerializedName("player") @NotNull Entity player,
+ @SerializedName("text") @NotNull String text)
+ implements Event {
+
+}
diff --git a/mod/src/main/java/moe/ymc/acron/s2c/response/EventCmdOut.java b/mod/src/main/java/moe/ymc/acron/s2c/response/EventCmdOut.java
new file mode 100644
index 0000000..a4cb798
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/s2c/response/EventCmdOut.java
@@ -0,0 +1,13 @@
+package moe.ymc.acron.s2c.response;
+
+import com.google.gson.annotations.SerializedName;
+import moe.ymc.acron.s2c.Event;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.UUID;
+
+public record EventCmdOut(@SerializedName("id") int id,
+ @SerializedName("sender") @NotNull UUID sender,
+ @SerializedName("out") @NotNull String out)
+ implements Event {
+}
diff --git a/mod/src/main/java/moe/ymc/acron/s2c/response/EventCmdRes.java b/mod/src/main/java/moe/ymc/acron/s2c/response/EventCmdRes.java
new file mode 100644
index 0000000..8c1b6a9
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/s2c/response/EventCmdRes.java
@@ -0,0 +1,10 @@
+package moe.ymc.acron.s2c.response;
+
+import com.google.gson.annotations.SerializedName;
+import moe.ymc.acron.s2c.Event;
+
+public record EventCmdRes(@SerializedName("id") int id,
+ @SerializedName("success") boolean success,
+ @SerializedName("result") int result)
+ implements Event {
+}
diff --git a/mod/src/main/java/moe/ymc/acron/s2c/response/EventError.java b/mod/src/main/java/moe/ymc/acron/s2c/response/EventError.java
new file mode 100644
index 0000000..370e8f3
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/s2c/response/EventError.java
@@ -0,0 +1,22 @@
+package moe.ymc.acron.s2c.response;
+
+import com.google.gson.annotations.SerializedName;
+import moe.ymc.acron.s2c.Event;
+import org.jetbrains.annotations.Nullable;
+
+public record EventError(@SerializedName("id") int id,
+ @SerializedName("code") int code,
+ @SerializedName("message") @Nullable String message)
+ implements Event {
+ public enum Code {
+ SERVER_ERROR(500),
+ BAD_REQUEST(400),
+ FORBIDDEN(403)
+ ;
+
+ public final int value;
+ Code(int value) {
+ this.value = value;
+ }
+ }
+}
diff --git a/mod/src/main/java/moe/ymc/acron/s2c/response/EventOk.java b/mod/src/main/java/moe/ymc/acron/s2c/response/EventOk.java
new file mode 100644
index 0000000..eb8c82d
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/s2c/response/EventOk.java
@@ -0,0 +1,8 @@
+package moe.ymc.acron.s2c.response;
+
+import com.google.gson.annotations.SerializedName;
+import moe.ymc.acron.s2c.Event;
+
+public record EventOk(@SerializedName("id") int id)
+ implements Event {
+}
diff --git a/mod/src/main/java/moe/ymc/acron/serialization/Serializer.java b/mod/src/main/java/moe/ymc/acron/serialization/Serializer.java
new file mode 100644
index 0000000..8091c25
--- /dev/null
+++ b/mod/src/main/java/moe/ymc/acron/serialization/Serializer.java
@@ -0,0 +1,60 @@
+package moe.ymc.acron.serialization;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonParseException;
+import com.google.gson.typeadapters.RuntimeTypeAdapterFactory;
+import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
+import moe.ymc.acron.c2s.ReqCmd;
+import moe.ymc.acron.c2s.ReqSetConfig;
+import moe.ymc.acron.c2s.Request;
+import moe.ymc.acron.common.Vec2f;
+import moe.ymc.acron.common.Vec3d;
+import moe.ymc.acron.s2c.Event;
+import moe.ymc.acron.s2c.event.*;
+import moe.ymc.acron.s2c.response.EventCmdOut;
+import moe.ymc.acron.s2c.response.EventCmdRes;
+import moe.ymc.acron.s2c.response.EventError;
+import moe.ymc.acron.s2c.response.EventOk;
+import org.jetbrains.annotations.NotNull;
+
+public class Serializer {
+ @NotNull
+ public static Request deserialize(@NotNull TextWebSocketFrame frame)
+ throws JsonParseException, IllegalArgumentException, IllegalStateException {
+ final String text = frame.text();
+ final RuntimeTypeAdapterFactory<Request> adapter =
+ RuntimeTypeAdapterFactory.of(Request.class, "type")
+ .registerSubtype(ReqSetConfig.class, "set_config")
+ .registerSubtype(ReqCmd.class, "cmd");
+ final Gson gson = new GsonBuilder()
+ .registerTypeAdapter(ReqSetConfig.class, new ReqSetConfig.ReqSetConfigDeserializer())
+ .registerTypeAdapter(Vec3d.class, new Vec3d.Vec3dDeserializer())
+ .registerTypeAdapter(Vec2f.class, new Vec2f.Vec2fDeserializer())
+ .registerTypeAdapter(ReqCmd.class, new ReqCmd.ReqCmdDeserializer())
+ .registerTypeAdapterFactory(adapter)
+ .create();
+ final Request request = gson.fromJson(text, Request.class);
+ request.validate();
+ return request;
+ }
+
+ @NotNull
+ public static TextWebSocketFrame serialize(@NotNull Event message) {
+ final RuntimeTypeAdapterFactory<Event> adapter =
+ RuntimeTypeAdapterFactory.of(Event.class, "type")
+ .registerSubtype(EventDisconnected.class, "disconnect")
+ .registerSubtype(EventPlayerMessage.class, "message")
+ .registerSubtype(EventPlayerJoined.class, "join")
+ .registerSubtype(EventEntityDeath.class, "death")
+ .registerSubtype(EventCmdOut.class, "cmd_out")
+ .registerSubtype(EventCmdRes.class, "cmd_result")
+ .registerSubtype(EventLagging.class, "lagging")
+ .registerSubtype(EventError.class, "error")
+ .registerSubtype(EventOk.class, "ok");
+ final Gson gson = new GsonBuilder()
+ .registerTypeAdapterFactory(adapter)
+ .create();
+ return new TextWebSocketFrame(gson.toJson(message, message.getClass()));
+ }
+}