aboutsummaryrefslogtreecommitdiff
path: root/mod
diff options
context:
space:
mode:
authorTrumeet <yuuta@yuuta.moe>2022-07-26 19:06:11 -0700
committerTrumeet <yuuta@yuuta.moe>2022-07-26 19:06:11 -0700
commitd8fe269327a1a51f2588a3573a4764613da16388 (patch)
tree97bd20983700bb13eba7f7d524d3c6d529303b46 /mod
parent31a5d2990ba666ce5ef4a1f79f7999a9bdfaac2a (diff)
downloadacron-d8fe269327a1a51f2588a3573a4764613da16388.tar
acron-d8fe269327a1a51f2588a3573a4764613da16388.tar.gz
acron-d8fe269327a1a51f2588a3573a4764613da16388.tar.bz2
acron-d8fe269327a1a51f2588a3573a4764613da16388.zip
Move the mod to mod/
Diffstat (limited to 'mod')
-rw-r--r--mod/README.md403
-rw-r--r--mod/acron.json19
-rw-r--r--mod/build.gradle78
-rw-r--r--mod/gradle.properties11
-rw-r--r--mod/settings.gradle9
-rw-r--r--mod/src/main/java/com/google/gson/typeadapters/RuntimeTypeAdapterFactory.java272
-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
-rw-r--r--mod/src/main/resources/acron.mixins.json21
-rw-r--r--mod/src/main/resources/fabric.mod.json24
61 files changed, 2523 insertions, 0 deletions
diff --git a/mod/README.md b/mod/README.md
new file mode 100644
index 0000000..d84a718
--- /dev/null
+++ b/mod/README.md
@@ -0,0 +1,403 @@
+# Acron Server-Side Mod
+
+## Installation and Configuration
+
+See [README.md](../README.md).
+
+## Client API
+
+Acron uses polymorphic JSONs when communicating with clients. Therefore, each JSON
+has to contain a valid `type` parameter indicating its type:
+
+```json
+{
+ "type": "cmd",
+ "id": 1,
+ "cmd": "list"
+}
+```
+
+### Request ordering
+
+To work in a full-duplex environment, each command can specify a `id` parameter. Acron will
+return any results or errors with the same ID.
+
+Sample request:
+
+```json
+{
+ "type": "cmd",
+ "id": 1,
+ "cmd": "list"
+}
+```
+
+The parameter `id` can be any integer, but it is the client developer's responsibility to
+make it a unique value, so he or she can identify it.
+
+Parameter `id` defaults to -1.
+
+In response, any non-server-push responses (i. e. messages) will include the same `id` parameter:
+
+```json
+{
+ "type": "cmd_result",
+ "id": 1,
+ "result": 0,
+ "success": true
+}
+```
+
+If the server fails to parse the request and returns an error, it will report the default ID `-2`.
+
+### Error Handling
+
+Error handling: Besides from the handshake request, which will send errors using HTTP status
+codes, all faulty WebSocket requests will receive error in the following format:
+
+```json
+{
+ "type": "error",
+ "id": 1,
+ "code": 500,
+ "message": "Error message. Not machine-readable."
+}
+```
+
+Parameters:
+
+* `.code` (int, HTTP status codes, always present): The machine-readable error code (e. g. 400 for Bad Request).
+* `.message` (string, any, always present): The human-readable error message.
+
+Global error codes:
+
+* 400: The request is invalid.
+* 500: The server encountered an unknown error.
+
+**`.type` and `.id` are included in every request / response, except for further noticed. Thus,
+this document excludes them from the parameter lists.**
+
+### Handshaking
+
+Clients need to use the following connection string when connecting to the Acron server:
+
+```
+ws://host:port/ws?id=client_id&token=client_token&version=0
+```
+
+*A better approach for specifying the authentication parameters is using HTTP headers,
+but the JavaScript client does not allow so. To extend compatibility, Acron forces
+all users to use HTTP query parameters to supply information.*
+
+Parameters:
+
+* `id` (required): Client ID set by the administrator.
+* `token` (required): Client token set by the administrator.
+* `version` (default: 0): API version. Only 0 is accepted at this time.
+
+Responses:
+
+* HTTP 400 (Bad Request): If either `id` or `token` is missing, or `version` is not 0.
+* HTTP 401 (Unauthorized): If either `id` is not found or `token` does not match the record.
+* HTTP 101 (Switching Protocols): The handshake is complete, and the server is upgrading to
+WebSocket.
+
+### Setting Configuration
+
+This allows clients to set a per-connection default configuration to execute commands.
+
+Clients can override the configuration temporarily when executing commands.
+
+Request:
+```json
+{
+ "type": "set_config",
+ "id": 1,
+ "world": "overworld",
+ "pos": {
+ "x": 0.0,
+ "y": 0.0,
+ "z": 0.0
+ },
+ "rot": {
+ "x": 0.0,
+ "y": 0.0
+ },
+ "name": ""
+}
+```
+
+Parameters:
+
+* `.world` (enum, overworld / nether / end, overworld): The world to run commands in.
+* `.pos` (vec3d, *see below*, spawn point of `.world`): The position to run commands at.
+ * `.x` (double, any within border limit, 0.0): X
+ * `.y` (double, any within border limit, 0.0): Y
+ * `.z` (double, any within border limit, 0.0): Z
+* `.rot` (vec2f, *see below*, `0.0 0.0`): Rotation.
+ * `.x` (float, ?, 0.0): X
+ * `.z` (float, ?, 0.0): Z
+* `.name` (string, any, random): Name when running commands.
+
+When the client connects, Acron will set the configuration to default values.
+
+Successful response:
+
+```json
+{
+ "type": "ok"
+}
+```
+
+This shows that the configuration update is successful.
+
+### Executing Commands
+
+The main goal of Acron is to allow clients to run commands. A client can send
+any commands, and Acron will schedule them in the background.
+
+Request:
+
+```json
+{
+ "type": "cmd",
+ "id": 1,
+ "cmd": "list",
+ "config": {
+
+ }
+}
+```
+
+Parameters:
+
+* `.cmd` (string, any valid command, required): The command to execute. It may or may not begin with `/`.
+* `.config` (set_config, *see above*, current connection default configuration): Temporary configuration
+when running this command. It is the same `set_config` object in the above section, but `type` and `id`
+must not be supplied.
+
+Successful response:
+
+```json
+{
+ "type": "ok"
+}
+```
+
+This shows that the command is scheduled.
+
+If the connection breaks before it is done, it is still executed without any output to the connection.
+
+Possible failures:
+
+* 403: This client is not allowed to execute this command. (Configured by rules)
+
+**Command output:**
+
+When the command prints a line, Acron will send the following response:
+
+```json
+{
+ "type": "cmd_out",
+ "id": 1,
+ "sender": "UUID",
+ "out": "..."
+}
+```
+
+Parameters:
+
+* `.sender` (UUID, any UUID, always present): Sender UUID.
+* `.out` (string, any, always present): Output.
+
+**Command result:**
+
+When the command finishes without issues (?), Acron will send the following response:
+
+```json
+{
+ "type": "cmd_result",
+ "id": 1,
+ "result": 0,
+ "success": true
+}
+```
+
+All parameters always present.
+
+> **Note**
+>
+> The result completely depends on Minecraft server's response.
+> It may not be reliable, and the values of `.result` and `.success` are
+> undocumented.
+
+### Receiving Messages
+
+Another major part of Acron is to allow clients receive events from the server.
+
+Every event will have a pre-defined `type` with other custom parameters. Parameter `id` will not
+present in events.
+
+> **Contributor Guide**
+>
+> Internally, all message Acron sends to clients are called events, including
+> command results.
+
+#### Player joined
+
+Response:
+
+```json
+{
+ "type": "join",
+ "player": {
+ "name": "",
+ "uuid": "",
+ "pos": {
+ "x": 0.0,
+ "y": 0.0,
+ "z": 0.0
+ },
+ "world": "end"
+ }
+}
+```
+
+Parameters:
+
+* `.player` (entity, see below, always present): The player.
+ * `.name` (string, any valid Minecraft username, always present): Username.
+ * `.uuid` (uuid, UUID, always present): UUID.
+ * `.pos` (vec3d, see below, always present): The position he or she joins.
+ * `.x` (double, any within border limit, 0.0): X
+ * `.y` (double, any within border limit, 0.0): Y
+ * `.z` (double, any within border limit, 0.0): Z
+ * `.world` (enum, overworld / nether / end, not present if Acron cannot determine the world): The dimension
+he or she joins.
+
+#### Player Disconnected
+
+Response:
+
+```json
+{
+ "type": "disconnect",
+ "player": {
+ "name": "",
+ "uuid": "",
+ "pos": {
+ "x": 0.0,
+ "y": 0.0,
+ "z": 0.0
+ },
+ "world": "end"
+ },
+ "reason": ""
+}
+```
+
+Parameters:
+
+* `.player` (entity, see below, null only when the server cannot verify the user): The player.
+ * `.name` (string, any valid Minecraft username, always present): Username.
+ * `.uuid` (uuid, UUID, always present): UUID.
+ * `.pos` (vec3d, see below, always present): The position he or she leaves.
+ * `.x` (double, any within border limit, 0.0): X
+ * `.y` (double, any within border limit, 0.0): Y
+ * `.z` (double, any within border limit, 0.0): Z
+ * `.world` (enum, overworld / nether / end, not present if Acron cannot determine the world): The dimension
+ he or she leaves.
+* `.reason` (string, any valid disconnect reason, always present): Disconnect reason.
+
+#### Player Message
+
+Response:
+
+```json
+{
+ "type": "message",
+ "player": {
+ "name": "",
+ "uuid": "",
+ "pos": {
+ "x": 0.0,
+ "y": 0.0,
+ "z": 0.0
+ },
+ "world": "end"
+ },
+ "text": ""
+}
+```
+
+Parameters:
+
+* `.player` (entity, see below, always present): The player.
+ * `.name` (string, any valid Minecraft username, always present): Username.
+ * `.uuid` (uuid, UUID, always present): UUID.
+ * `.pos` (vec3d, see below, always present): The position he or she sends the message.
+ * `.x` (double, any within border limit, 0.0): X
+ * `.y` (double, any within border limit, 0.0): Y
+ * `.z` (double, any within border limit, 0.0): Z
+ * `.world` (enum, overworld / nether / end, not present if Acron cannot determine the world): The dimension
+he or she sends the message.
+* `.text` (string, any valid Minecraft message, always present): The message.
+
+#### Entity Death
+
+Response:
+
+```json
+{
+ "type": "death",
+ "entity": {
+ "name": "",
+ "uuid": "",
+ "pos": {
+ "x": 0.0,
+ "y": 0.0,
+ "z": 0.0
+ },
+ "world": "end"
+ },
+ "message": ""
+}
+```
+
+Parameters:
+
+* `.entity` (entity, see below, always present): The entity.
+ * `.name` (string, any, always present): Default name or custom name of the entity.
+ * `.uuid` (uuid, UUID, always present): UUID.
+ * `.pos` (vec3d, see below, always present): The position of the entity when died.
+ * `.x` (double, any within border limit, 0.0): X
+ * `.y` (double, any within border limit, 0.0): Y
+ * `.z` (double, any within border limit, 0.0): Z
+ * `.world` (enum, overworld / nether / end, not present if Acron cannot determine the world): The dimension
+of the entity when died.
+* `.message` (string, any valid death message, always present): The user-readable death message.
+
+> **Roadmap**
+>
+> Parsing the death message and sending a more machine-readable message is on the roadmap.
+
+#### Server Lagging
+
+Acron will send this event when the server prints
+`Can't keep up! Is the server overloaded? Running 4313ms or 86 ticks behind` to the standard output.
+
+Response:
+
+```json
+{
+ "type": "lagging",
+ "ms": 100,
+ "ticks": 1000
+}
+```
+
+Parameters:
+
+* `.ms` (long, >= 0, always present): Running {}ms behind.
+* `.ticks` (long, >= 0, always present): Running {} ticks behind.
+
diff --git a/mod/acron.json b/mod/acron.json
new file mode 100644
index 0000000..83edef5
--- /dev/null
+++ b/mod/acron.json
@@ -0,0 +1,19 @@
+{
+ "clients": [
+ {
+ "id": "1",
+ "token": "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3",
+ "rules": [
+ {
+ "regex": ".*",
+ "action": "allow",
+ "display": true
+ }
+ ]
+ },
+ {
+ "id": "2",
+ "token": "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"
+ }
+ ]
+} \ No newline at end of file
diff --git a/mod/build.gradle b/mod/build.gradle
new file mode 100644
index 0000000..4ea402e
--- /dev/null
+++ b/mod/build.gradle
@@ -0,0 +1,78 @@
+plugins {
+ id 'fabric-loom' version '0.12-SNAPSHOT'
+ id 'maven-publish'
+}
+
+version = project.mod_version
+group = project.maven_group
+
+repositories {
+ // Add repositories to retrieve artifacts from in here.
+ // You should only use this when depending on other mods because
+ // Loom adds the essential maven repositories to download Minecraft and libraries from automatically.
+ // See https://docs.gradle.org/current/userguide/declaring_repositories.html
+ // for more information about repositories.
+}
+
+dependencies {
+ // To change the versions see the gradle.properties file
+ minecraft "com.mojang:minecraft:${project.minecraft_version}"
+ mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2"
+ modImplementation "net.fabricmc:fabric-loader:${project.loader_version}"
+}
+
+processResources {
+ inputs.property "version", project.version
+ filteringCharset "UTF-8"
+
+ filesMatching("fabric.mod.json") {
+ expand "version": project.version
+ }
+}
+
+def targetJavaVersion = 16
+tasks.withType(JavaCompile).configureEach {
+ // ensure that the encoding is set to UTF-8, no matter what the system default is
+ // this fixes some edge cases with special characters not displaying correctly
+ // see http://yodaconditions.net/blog/fix-for-java-file-encoding-problems-with-gradle.html
+ // If Javadoc is generated, this must be specified in that task too.
+ it.options.encoding = "UTF-8"
+ if (targetJavaVersion >= 10 || JavaVersion.current().isJava10Compatible()) {
+ it.options.release = targetJavaVersion
+ }
+}
+
+java {
+ def javaVersion = JavaVersion.toVersion(targetJavaVersion)
+ if (JavaVersion.current() < javaVersion) {
+ toolchain.languageVersion = JavaLanguageVersion.of(targetJavaVersion)
+ }
+ archivesBaseName = project.archives_base_name
+ // Loom will automatically attach sourcesJar to a RemapSourcesJar task and to the "build" task
+ // if it is present.
+ // If you remove this line, sources will not be generated.
+ withSourcesJar()
+}
+
+jar {
+ from("LICENSE") {
+ rename { "${it}_${project.archivesBaseName}" }
+ }
+}
+
+// configure the maven publication
+publishing {
+ publications {
+ mavenJava(MavenPublication) {
+ from components.java
+ }
+ }
+
+ // See https://docs.gradle.org/current/userguide/publishing_maven.html for information on how to set up publishing.
+ repositories {
+ // Add repositories to publish to here.
+ // Notice: This block does NOT have the same function as the block in the top level.
+ // The repositories here will be used for publishing your artifact, not for
+ // retrieving dependencies.
+ }
+}
diff --git a/mod/gradle.properties b/mod/gradle.properties
new file mode 100644
index 0000000..85e12c2
--- /dev/null
+++ b/mod/gradle.properties
@@ -0,0 +1,11 @@
+# Done to increase the memory available to gradle.
+org.gradle.jvmargs=-Xmx1G
+# Fabric Properties
+# check these on https://modmuss50.me/fabric.html
+minecraft_version=1.17.1
+yarn_mappings=1.17.1+build.65
+loader_version=0.14.4
+# Mod Properties
+mod_version=1.0
+maven_group=moe.ymc
+archives_base_name=acron
diff --git a/mod/settings.gradle b/mod/settings.gradle
new file mode 100644
index 0000000..f91a4fe
--- /dev/null
+++ b/mod/settings.gradle
@@ -0,0 +1,9 @@
+pluginManagement {
+ repositories {
+ maven {
+ name = 'Fabric'
+ url = 'https://maven.fabricmc.net/'
+ }
+ gradlePluginPortal()
+ }
+}
diff --git a/mod/src/main/java/com/google/gson/typeadapters/RuntimeTypeAdapterFactory.java b/mod/src/main/java/com/google/gson/typeadapters/RuntimeTypeAdapterFactory.java
new file mode 100644
index 0000000..51f05ff
--- /dev/null
+++ b/mod/src/main/java/com/google/gson/typeadapters/RuntimeTypeAdapterFactory.java
@@ -0,0 +1,272 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.gson.typeadapters;
+
+import com.google.gson.*;
+import com.google.gson.reflect.TypeToken;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonWriter;
+
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Adapts values whose runtime type may differ from their declaration type. This
+ * is necessary when a field's type is not the same type that GSON should create
+ * when deserializing that field. For example, consider these types:
+ * <pre> {@code
+ * abstract class Shape {
+ * int x;
+ * int y;
+ * }
+ * class Circle extends Shape {
+ * int radius;
+ * }
+ * class Rectangle extends Shape {
+ * int width;
+ * int height;
+ * }
+ * class Diamond extends Shape {
+ * int width;
+ * int height;
+ * }
+ * class Drawing {
+ * Shape bottomShape;
+ * Shape topShape;
+ * }
+ * }</pre>
+ * <p>Without additional type information, the serialized JSON is ambiguous. Is
+ * the bottom shape in this drawing a rectangle or a diamond? <pre> {@code
+ * {
+ * "bottomShape": {
+ * "width": 10,
+ * "height": 5,
+ * "x": 0,
+ * "y": 0
+ * },
+ * "topShape": {
+ * "radius": 2,
+ * "x": 4,
+ * "y": 1
+ * }
+ * }}</pre>
+ * This class addresses this problem by adding type information to the
+ * serialized JSON and honoring that type information when the JSON is
+ * deserialized: <pre> {@code
+ * {
+ * "bottomShape": {
+ * "type": "Diamond",
+ * "width": 10,
+ * "height": 5,
+ * "x": 0,
+ * "y": 0
+ * },
+ * "topShape": {
+ * "type": "Circle",
+ * "radius": 2,
+ * "x": 4,
+ * "y": 1
+ * }
+ * }}</pre>
+ * Both the type field name ({@code "type"}) and the type labels ({@code
+ * "Rectangle"}) are configurable.
+ *
+ * <h3>Registering Types</h3>
+ * Create a {@code RuntimeTypeAdapterFactory} by passing the base type and type field
+ * name to the {@link #of} factory method. If you don't supply an explicit type
+ * field name, {@code "type"} will be used. <pre> {@code
+ * RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory
+ * = RuntimeTypeAdapterFactory.of(Shape.class, "type");
+ * }</pre>
+ * Next register all of your subtypes. Every subtype must be explicitly
+ * registered. This protects your application from injection attacks. If you
+ * don't supply an explicit type label, the type's simple name will be used.
+ * <pre> {@code
+ * shapeAdapterFactory.registerSubtype(Rectangle.class, "Rectangle");
+ * shapeAdapterFactory.registerSubtype(Circle.class, "Circle");
+ * shapeAdapterFactory.registerSubtype(Diamond.class, "Diamond");
+ * }</pre>
+ * Finally, register the type adapter factory in your application's GSON builder:
+ * <pre> {@code
+ * Gson gson = new GsonBuilder()
+ * .registerTypeAdapterFactory(shapeAdapterFactory)
+ * .create();
+ * }</pre>
+ * Like {@code GsonBuilder}, this API supports chaining: <pre> {@code
+ * RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory = RuntimeTypeAdapterFactory.of(Shape.class)
+ * .registerSubtype(Rectangle.class)
+ * .registerSubtype(Circle.class)
+ * .registerSubtype(Diamond.class);
+ * }</pre>
+ *
+ * <h3>Serialization and deserialization</h3>
+ * In order to serialize and deserialize a polymorphic object,
+ * you must specify the base type explicitly.
+ * <pre> {@code
+ * Diamond diamond = new Diamond();
+ * String json = gson.toJson(diamond, Shape.class);
+ * }</pre>
+ * And then:
+ * <pre> {@code
+ * Shape shape = gson.fromJson(json, Shape.class);
+ * }</pre>
+ */
+public final class RuntimeTypeAdapterFactory<T> implements TypeAdapterFactory {
+ private final Class<?> baseType;
+ private final String typeFieldName;
+ private final Map<String, Class<?>> labelToSubtype = new LinkedHashMap<>();
+ private final Map<Class<?>, String> subtypeToLabel = new LinkedHashMap<>();
+ private final boolean maintainType;
+
+ private RuntimeTypeAdapterFactory(Class<?> baseType, String typeFieldName, boolean maintainType) {
+ if (typeFieldName == null || baseType == null) {
+ throw new NullPointerException();
+ }
+ this.baseType = baseType;
+ this.typeFieldName = typeFieldName;
+ this.maintainType = maintainType;
+ }
+
+ /**
+ * Creates a new runtime type adapter using for {@code baseType} using {@code
+ * typeFieldName} as the type field name. Type field names are case sensitive.
+ * {@code maintainType} flag decide if the type will be stored in pojo or not.
+ */
+ public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType, String typeFieldName, boolean maintainType) {
+ return new RuntimeTypeAdapterFactory<>(baseType, typeFieldName, maintainType);
+ }
+
+ /**
+ * Creates a new runtime type adapter using for {@code baseType} using {@code
+ * typeFieldName} as the type field name. Type field names are case sensitive.
+ */
+ public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType, String typeFieldName) {
+ return new RuntimeTypeAdapterFactory<>(baseType, typeFieldName, false);
+ }
+
+ /**
+ * Creates a new runtime type adapter for {@code baseType} using {@code "type"} as
+ * the type field name.
+ */
+ public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType) {
+ return new RuntimeTypeAdapterFactory<>(baseType, "type", false);
+ }
+
+ /**
+ * Registers {@code type} identified by {@code label}. Labels are case
+ * sensitive.
+ *
+ * @throws IllegalArgumentException if either {@code type} or {@code label}
+ * have already been registered on this type adapter.
+ */
+ public RuntimeTypeAdapterFactory<T> registerSubtype(Class<? extends T> type, String label) {
+ if (type == null || label == null) {
+ throw new NullPointerException();
+ }
+ if (subtypeToLabel.containsKey(type) || labelToSubtype.containsKey(label)) {
+ throw new IllegalArgumentException("types and labels must be unique");
+ }
+ labelToSubtype.put(label, type);
+ subtypeToLabel.put(type, label);
+ return this;
+ }
+
+ /**
+ * Registers {@code type} identified by its {@link Class#getSimpleName simple
+ * name}. Labels are case sensitive.
+ *
+ * @throws IllegalArgumentException if either {@code type} or its simple name
+ * have already been registered on this type adapter.
+ */
+ public RuntimeTypeAdapterFactory<T> registerSubtype(Class<? extends T> type) {
+ return registerSubtype(type, type.getSimpleName());
+ }
+
+ @Override
+ public <R> TypeAdapter<R> create(Gson gson, TypeToken<R> type) {
+ // Workaround found at https://github.com/google/gson/issues/712#issuecomment-148955110
+ if (null == type || !baseType.isAssignableFrom(type.getRawType())) {
+ // if (type.getRawType() != baseType) {
+ return null;
+ }
+
+ final TypeAdapter<JsonElement> jsonElementAdapter = gson.getAdapter(JsonElement.class);
+ final Map<String, TypeAdapter<?>> labelToDelegate = new LinkedHashMap<>();
+ final Map<Class<?>, TypeAdapter<?>> subtypeToDelegate = new LinkedHashMap<>();
+ for (Map.Entry<String, Class<?>> entry : labelToSubtype.entrySet()) {
+ TypeAdapter<?> delegate = gson.getDelegateAdapter(this, TypeToken.get(entry.getValue()));
+ labelToDelegate.put(entry.getKey(), delegate);
+ subtypeToDelegate.put(entry.getValue(), delegate);
+ }
+
+ return new TypeAdapter<R>() {
+ @Override public R read(JsonReader in) throws IOException {
+ JsonElement jsonElement = jsonElementAdapter.read(in);
+ JsonElement labelJsonElement;
+ if (maintainType) {
+ labelJsonElement = jsonElement.getAsJsonObject().get(typeFieldName);
+ } else {
+ labelJsonElement = jsonElement.getAsJsonObject().remove(typeFieldName);
+ }
+
+ if (labelJsonElement == null) {
+ throw new JsonParseException("cannot deserialize " + baseType
+ + " because it does not define a field named " + typeFieldName);
+ }
+ String label = labelJsonElement.getAsString();
+ @SuppressWarnings("unchecked") // registration requires that subtype extends T
+ TypeAdapter<R> delegate = (TypeAdapter<R>) labelToDelegate.get(label);
+ if (delegate == null) {
+ throw new JsonParseException("cannot deserialize " + baseType + " subtype named "
+ + label + "; did you forget to register a subtype?");
+ }
+ return delegate.fromJsonTree(jsonElement);
+ }
+
+ @Override public void write(JsonWriter out, R value) throws IOException {
+ Class<?> srcType = value.getClass();
+ String label = subtypeToLabel.get(srcType);
+ @SuppressWarnings("unchecked") // registration requires that subtype extends T
+ TypeAdapter<R> delegate = (TypeAdapter<R>) subtypeToDelegate.get(srcType);
+ if (delegate == null) {
+ throw new JsonParseException("cannot serialize " + srcType.getName()
+ + "; did you forget to register a subtype?");
+ }
+ JsonObject jsonObject = delegate.toJsonTree(value).getAsJsonObject();
+
+ if (maintainType) {
+ jsonElementAdapter.write(out, jsonObject);
+ return;
+ }
+
+ JsonObject clone = new JsonObject();
+
+ if (jsonObject.has(typeFieldName)) {
+ throw new JsonParseException("cannot serialize " + srcType.getName()
+ + " because it already defines a field named " + typeFieldName);
+ }
+ clone.add(typeFieldName, new JsonPrimitive(label));
+
+ for (Map.Entry<String, JsonElement> e : jsonObject.entrySet()) {
+ clone.add(e.getKey(), e.getValue());
+ }
+ jsonElementAdapter.write(out, clone);
+ }
+ }.nullSafe();
+ }
+}
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()));
+ }
+}
diff --git a/mod/src/main/resources/acron.mixins.json b/mod/src/main/resources/acron.mixins.json
new file mode 100644
index 0000000..5d0911f
--- /dev/null
+++ b/mod/src/main/resources/acron.mixins.json
@@ -0,0 +1,21 @@
+{
+ "required": true,
+ "minVersion": "0.8",
+ "package": "moe.ymc.acron.mixin",
+ "compatibilityLevel": "JAVA_8",
+ "mixins": [
+ "CommandManagerMixin",
+ "LivingEntityMixin",
+ "MinecraftDedicatedServerMixin",
+ "MinecraftServerMixin",
+ "ServerLoginNetworkHandlerMixin",
+ "ServerNetworkIoMixin",
+ "ServerPlayerEntityMixin",
+ "ServerPlayNetworkHandlerMixin"
+ ],
+ "client": [
+ ],
+ "injectors": {
+ "defaultRequire": 1
+ }
+}
diff --git a/mod/src/main/resources/fabric.mod.json b/mod/src/main/resources/fabric.mod.json
new file mode 100644
index 0000000..999aeeb
--- /dev/null
+++ b/mod/src/main/resources/fabric.mod.json
@@ -0,0 +1,24 @@
+{
+ "schemaVersion": 1,
+ "id": "acron",
+ "version": "${version}",
+ "name": "Acron",
+ "description": "WebSocket based remote server management",
+ "authors": ["YuutaW"],
+ "contact": {},
+ "license": "GPL-2.0",
+ "icon": "assets/acron/icon.png",
+ "environment": "server",
+ "entrypoints": {
+ "main": [
+ "moe.ymc.acron.Acron"
+ ]
+ },
+ "mixins": [
+ "acron.mixins.json"
+ ],
+ "depends": {
+ "fabricloader": ">=0.14.4",
+ "minecraft": "1.17.1"
+ }
+}