diff --git a/game-fork/patches/decompiler-fixes.txt b/game-fork/patches/decompiler-fixes.txt index dcb6f6c..11ce877 100644 --- a/game-fork/patches/decompiler-fixes.txt +++ b/game-fork/patches/decompiler-fixes.txt @@ -1263,3 +1263,17 @@ import org.lwjgl.opengl.ContextCapabilities; --- AFTER this.player.movementInput = new net.rustnotmc.client.RsxMovementInput(this.gameSettings); +# Our crouch is lower than the vanilla one, so the player fits through a hole +# of one and a half blocks. The number lives in RsxPlayerTuning. +=== FILE net/minecraft/entity/player/EntityPlayer.java +--- BEFORE + } else if (this.isSneaking()) { + f = 0.6F; + f1 = 1.65F; + } else { +--- AFTER + } else if (this.isSneaking()) { + f = 0.6F; + f1 = net.rustnotmc.client.RsxPlayerTuning.crouchHeight(); + } else { + diff --git a/game-fork/src-overlay/net/rustnotmc/client/RsxBootstrap.java b/game-fork/src-overlay/net/rustnotmc/client/RsxBootstrap.java index 3863b12..1bb38b3 100644 --- a/game-fork/src-overlay/net/rustnotmc/client/RsxBootstrap.java +++ b/game-fork/src-overlay/net/rustnotmc/client/RsxBootstrap.java @@ -114,6 +114,14 @@ public final class RsxBootstrap { * that has to see them and decide. */ public static boolean handleChatCommand(String line) { + if (line == null || !line.startsWith("/")) { + return false; + } + // our own commands come first: they are not mod commands, and a mod + // should not be able to take a name that the game itself uses + if (RsxHitboxDebug.command(line)) { + return true; + } if (client == null || !ClientHost.present()) { return false; } @@ -203,6 +211,7 @@ public final class RsxBootstrap { if (client != null) { client.advanceFrame(); } + RsxHitboxDebug.draw(minecraft); drawOverlay(minecraft); } diff --git a/game-fork/src-overlay/net/rustnotmc/client/RsxHitboxDebug.java b/game-fork/src-overlay/net/rustnotmc/client/RsxHitboxDebug.java new file mode 100644 index 0000000..414e7b8 --- /dev/null +++ b/game-fork/src-overlay/net/rustnotmc/client/RsxHitboxDebug.java @@ -0,0 +1,263 @@ +package net.rustnotmc.client; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.entity.EntityPlayerSP; +import net.minecraft.client.renderer.GlStateManager; +import net.minecraft.client.renderer.RenderGlobal; +import net.minecraft.client.renderer.debug.DebugRenderer; +import net.minecraft.entity.Entity; +import net.rustnotmc.protocol.Hitbox; +import net.rustnotmc.protocol.Packets; + +/** + * Draws the boxes of the models. + * + * The boxes are the editor: the server reads them from a file and sends them + * here, and they are drawn around the things they belong to. A model that + * does not fit is changed in the file, the file is read again, and the new + * numbers are on the screen. + * + * The command is: + * + *
+ *   /rsx hitboxes on        show the boxes
+ *   /rsx hitboxes off       hide them
+ *   /rsx hitboxes reload    ask the server to read its file again
+ *   /rsx hitboxes list      what the server knows
+ *   /rsx hitboxes show NAME put one model in front of the player
+ * 
+ */ +public final class RsxHitboxDebug { + + /** The model whose boxes describe a player. */ + private static final String PLAYER_MODEL = "player"; + + /** The height the player model is written for. */ + private static final float PLAYER_MODEL_HEIGHT = 1.8F; + + private static final Map MODELS = + new LinkedHashMap(); + private static final List INSTANCES = + new ArrayList(); + + private static boolean visible; + private static String preview; + + private RsxHitboxDebug() { + } + + /** Takes the boxes of the models from the server. */ + public static void accept(Packets.Hitboxes boxes) { + MODELS.clear(); + for (int i = 0; i < boxes.models.length; i++) { + MODELS.put(boxes.models[i].name, boxes.models[i]); + } + } + + /** Takes the places where the models stand. */ + public static void accept(Packets.HitboxInstances instances) { + INSTANCES.clear(); + for (int i = 0; i < instances.instances.length; i++) { + INSTANCES.add(instances.instances[i]); + } + } + + /** True when the boxes are being drawn. */ + public static boolean visible() { + return visible; + } + + /** How many models the server has sent. */ + public static int modelCount() { + return MODELS.size(); + } + + /** + * Carries out one of our commands. + * + * Returns true when the line was ours, so that the mods do not see it. + */ + public static boolean command(String line) { + if (line == null || !line.startsWith("/")) { + return false; + } + String[] parts = line.substring(1).trim().split("\\s+"); + if (parts.length < 2 || !"rsx".equalsIgnoreCase(parts[0]) + || !"hitboxes".equalsIgnoreCase(parts[1])) { + return false; + } + String what = parts.length > 2 ? parts[2].toLowerCase() : ""; + + if ("on".equals(what)) { + visible = true; + say("hitboxes on (" + MODELS.size() + " models)"); + return true; + } + if ("off".equals(what)) { + visible = false; + say("hitboxes off"); + return true; + } + if ("reload".equals(what)) { + net.rustnotmc.client.net.RsxNetworkManager.sendHitboxesReload(); + say("the server was asked to read its file again"); + return true; + } + if ("list".equals(what)) { + StringBuilder names = new StringBuilder(); + for (String name : MODELS.keySet()) { + if (names.length() > 0) { + names.append(", "); + } + names.append(name); + } + say("models: " + (names.length() == 0 ? "none" : names.toString())); + return true; + } + if ("show".equals(what)) { + if (parts.length < 4) { + say("usage: /rsx hitboxes show NAME"); + return true; + } + preview = parts[3]; + visible = true; + say("showing " + preview); + return true; + } + say("usage: /rsx hitboxes on|off|reload|list|show NAME"); + return true; + } + + private static void say(String message) { + net.rustnotmc.loader.Log.info("hitboxes: " + message); + Minecraft mc = Minecraft.getMinecraft(); + if (mc.ingameGUI != null) { + mc.ingameGUI.addChatMessage(net.minecraft.util.text.ChatType.CHAT, + new net.minecraft.util.text.TextComponentString(message)); + } + } + + /** + * Draws the boxes. + * + * Called from the frame hook, so this is after the world and after the + * interface: the lines are drawn over both, which is what a debug view + * should do. + */ + public static void draw(Minecraft mc) { + if (!visible || mc.player == null || mc.world == null) { + return; + } + Entity view = mc.getRenderViewEntity(); + if (view == null) { + return; + } + float partial = mc.getRenderPartialTicks(); + double camX = view.lastTickPosX + (view.posX - view.lastTickPosX) * partial; + double camY = view.lastTickPosY + (view.posY - view.lastTickPosY) * partial; + double camZ = view.lastTickPosZ + (view.posZ - view.lastTickPosZ) * partial; + + GlStateManager.enableBlend(); + GlStateManager.tryBlendFuncSeparate(GlStateManager.SourceFactor.SRC_ALPHA, + GlStateManager.DestFactor.ONE_MINUS_SRC_ALPHA, + GlStateManager.SourceFactor.ONE, GlStateManager.DestFactor.ZERO); + GlStateManager.glLineWidth(2.0F); + GlStateManager.disableTexture2D(); + GlStateManager.depthMask(false); + + for (int i = 0; i < INSTANCES.size(); i++) { + Packets.HitboxInstance instance = INSTANCES.get(i); + Packets.HitboxModel model = MODELS.get(instance.model); + if (model == null) { + continue; + } + drawModel(model, instance.x, instance.y, instance.z, instance.yaw, + camX, camY, camZ, 1.0F); + } + + if (preview != null) { + Packets.HitboxModel model = MODELS.get(preview); + if (model != null) { + EntityPlayerSP player = mc.player; + drawModel(model, player.posX + 1.0D, player.posY, player.posZ, + player.rotationYaw, camX, camY, camZ, 1.0F); + } + } + + // and the players, with the zones the damage is counted by + Packets.HitboxModel player = MODELS.get(PLAYER_MODEL); + if (player != null) { + List players = mc.world.getLoadedEntityList(); + for (int i = 0; i < players.size(); i++) { + Entity entity = players.get(i); + if (!(entity instanceof EntityPlayerSP)) { + continue; + } + float scale = entity.height / PLAYER_MODEL_HEIGHT; + drawModel(player, entity.posX, entity.posY, entity.posZ, + entity.rotationYaw, camX, camY, camZ, scale); + } + } + + GlStateManager.depthMask(true); + GlStateManager.enableTexture2D(); + GlStateManager.disableBlend(); + } + + /** + * Draws one model. + * + * The boxes of the model are turned by the yaw of the thing and moved to + * where it stands, and then drawn relative to the camera, because that is + * how the world is drawn too. + */ + private static void drawModel(Packets.HitboxModel model, double x, double y, double z, + float yaw, double camX, double camY, double camZ, float scale) { + double cos = Math.cos(Math.toRadians(yaw)); + double sin = Math.sin(Math.toRadians(yaw)); + for (int i = 0; i < model.boxes.length; i++) { + Hitbox box = model.boxes[i]; + float[] colour = colourOf(box.part); + double minX = turn(box.minX * scale, box.minZ * scale, cos, sin); + double minZ = turn(box.minZ * scale, -box.minX * scale, cos, sin); + double maxX = turn(box.maxX * scale, box.maxZ * scale, cos, sin); + double maxZ = turn(box.maxZ * scale, -box.maxX * scale, cos, sin); + double minY = box.minY * scale; + double maxY = box.maxY * scale; + + RenderGlobal.drawBoundingBox( + x + minX - camX, y + minY - camY, z + minZ - camZ, + x + maxX - camX, y + maxY - camY, z + maxZ - camZ, + colour[0], colour[1], colour[2], 0.9F); + + if (!box.name.isEmpty()) { + DebugRenderer.renderDebugText(box.name, + x + minX, y + maxY, z + minZ, 0.02F, 0xFFFFFFFF); + } + } + } + + /** Turns a point around the vertical axis. */ + private static double turn(double along, double across, double cos, double sin) { + return along * cos + across * sin; + } + + /** The colour of a box: the head is red, the body yellow, the legs blue. */ + private static float[] colourOf(String part) { + if (Hitbox.PART_HEAD.equals(part)) { + return new float[]{1.0F, 0.25F, 0.25F}; + } + if (Hitbox.PART_BODY.equals(part)) { + return new float[]{1.0F, 0.9F, 0.2F}; + } + if (Hitbox.PART_LEGS.equals(part)) { + return new float[]{0.3F, 0.55F, 1.0F}; + } + return new float[]{0.3F, 1.0F, 0.4F}; + } +} diff --git a/game-fork/src-overlay/net/rustnotmc/client/RsxPlayer.java b/game-fork/src-overlay/net/rustnotmc/client/RsxPlayer.java index 3e0902b..ff66e30 100644 --- a/game-fork/src-overlay/net/rustnotmc/client/RsxPlayer.java +++ b/game-fork/src-overlay/net/rustnotmc/client/RsxPlayer.java @@ -10,10 +10,11 @@ import net.minecraft.world.World; /** * Our player. * - * The vanilla player with two things changed: the crouch is a little faster - * and the jump is a little higher. Everything else is the vanilla behaviour, - * so the player still walks, swims, rides, fights and dies the way the game - * always did. + * The vanilla player with three things changed: the crouch is low enough to + * squeeze through a hole of one and a half blocks, the hop from a standstill + * is higher than the vanilla jump, and the eyes follow the lower crouch. + * Everything else is the vanilla behaviour, so the player still walks, swims, + * rides, fights and dies the way the game always did. * * The class exists so that the numbers of our movement live in our code and * not in the patches of the vanilla tree: the patches only have to say "the @@ -30,11 +31,38 @@ public class RsxPlayer extends EntityPlayerSP { /** * How high the player jumps. * + * Jumping on the spot is the Rust-like hop and clears more than the + * vanilla jump does. Jumping while moving keeps the vanilla height, so a + * running jump stays the same as it always was. + * * The override lives here and not in EntityLivingBase, because the mobs * have to keep jumping the way they always did. */ @Override protected float getJumpUpwardsMotion() { - return RsxPlayerTuning.jumpMotion(); + if (this.movementInput == null || !isMoving()) { + return RsxPlayerTuning.jumpMotion(RsxPlayerTuning.jumpHeightStanding()); + } + return RsxPlayerTuning.jumpMotion(RsxPlayerTuning.jumpHeightMoving()); + } + + /** True when the player is asking to move sideways or forward. */ + private boolean isMoving() { + return this.movementInput.moveStrafe != 0.0F || this.movementInput.moveForward != 0.0F; + } + + /** + * Where the eyes are. + * + * The vanilla crouch lowers the player to 1.65 and puts the eyes at 1.54. + * Ours is lower, so the eyes have to come down with it, or they would sit + * above the head. + */ + @Override + public float getEyeHeight() { + if (this.isSneaking() && !this.isPlayerSleeping() && !this.isElytraFlying()) { + return RsxPlayerTuning.crouchHeight() - RsxPlayerTuning.CROUCH_EYE_OFFSET; + } + return super.getEyeHeight(); } } diff --git a/game-fork/src-overlay/net/rustnotmc/client/RsxPlayerTuning.java b/game-fork/src-overlay/net/rustnotmc/client/RsxPlayerTuning.java index 3068e32..721a18d 100644 --- a/game-fork/src-overlay/net/rustnotmc/client/RsxPlayerTuning.java +++ b/game-fork/src-overlay/net/rustnotmc/client/RsxPlayerTuning.java @@ -30,6 +30,28 @@ public final class RsxPlayerTuning { /** How high the player jumps, in blocks. The vanilla jumps 1.25. */ public static final float JUMP_HEIGHT = 1.35F; + /** + * How high the player jumps when it is standing still, in blocks. + * + * Jumping on the spot is the Rust-like hop: it clears a block that the + * walking jump does not. Jumping while moving keeps the vanilla height. + */ + public static final float JUMP_HEIGHT_STANDING = 1.5F; + + /** How high the player jumps while moving, in blocks. The vanilla. */ + public static final float JUMP_HEIGHT_MOVING = 1.25F; + + /** + * How tall the player is while crouching, in blocks. + * + * The vanilla crouches to 1.65, which does not fit through a hole of one + * and a half blocks. Ours is low enough to squeeze through it. + */ + public static final float CROUCH_HEIGHT = 1.45F; + + /** How far below the top of the head the eyes sit while crouching. */ + public static final float CROUCH_EYE_OFFSET = 0.08F; + /** The crouch never goes below this, in blocks per second. */ public static final float SNEAK_SPEED_FLOOR = 1.5F; @@ -61,6 +83,21 @@ public final class RsxPlayerTuning { return setting("rsx.jumpHeight", JUMP_HEIGHT, JUMP_HEIGHT_FLOOR); } + /** The height of the hop from a standstill, in blocks. */ + public static float jumpHeightStanding() { + return setting("rsx.jumpHeightStanding", JUMP_HEIGHT_STANDING, JUMP_HEIGHT_FLOOR); + } + + /** The height of the jump while moving, in blocks. */ + public static float jumpHeightMoving() { + return setting("rsx.jumpHeightMoving", JUMP_HEIGHT_MOVING, JUMP_HEIGHT_FLOOR); + } + + /** How tall the player is while crouching, in blocks. */ + public static float crouchHeight() { + return setting("rsx.crouchHeight", CROUCH_HEIGHT, CROUCH_HEIGHT); + } + /** The factor the crouch slows the movement down by. */ public static float sneakFactor() { return sneakSpeed() / WALK_SPEED; diff --git a/game-fork/src-overlay/net/rustnotmc/client/net/RsxNetworkManager.java b/game-fork/src-overlay/net/rustnotmc/client/net/RsxNetworkManager.java index 6158337..9e2a21c 100644 --- a/game-fork/src-overlay/net/rustnotmc/client/net/RsxNetworkManager.java +++ b/game-fork/src-overlay/net/rustnotmc/client/net/RsxNetworkManager.java @@ -249,11 +249,35 @@ public final class RsxNetworkManager extends NetworkManager { RsxClientMode.set(gameMode.mode); return; } + case Packets.HITBOXES: { + Packets.Hitboxes boxes = Packets.Hitboxes.read(new Buf(frame.body())); + net.rustnotmc.client.RsxHitboxDebug.accept(boxes); + return; + } + case Packets.HITBOX_INSTANCES: { + Packets.HitboxInstances instances = + Packets.HitboxInstances.read(new Buf(frame.body())); + net.rustnotmc.client.RsxHitboxDebug.accept(instances); + return; + } default: onPlay(frame); } } + /** + * Asks the server to read its box file again. + * + * The file is the editor, so after a change the client wants the new + * numbers without the server having to be restarted. + */ + public static void sendHitboxesReload() { + RsxNetworkManager manager = active(); + if (manager != null) { + manager.send(Packets.HITBOXES_RELOAD, new Packets.HitboxesReload()); + } + } + private void onHelloAck(Frame frame) { Packets.HelloAck hello = Packets.HelloAck.read(new Buf(frame.body())); this.nonce = hello.nonce; diff --git a/rsx-plugin-api/scripts/build.ps1 b/rsx-plugin-api/scripts/build.ps1 new file mode 100644 index 0000000..fae5496 --- /dev/null +++ b/rsx-plugin-api/scripts/build.ps1 @@ -0,0 +1,73 @@ +# Builds rsx-plugin-api into dist\. +# +# The API is the way a plugin talks to the server. It is a separate module on +# purpose: the server and the plugin both compile against it, and neither +# needs the other. +# +# NOTE: ASCII on purpose. Windows PowerShell 5.1 reads a .ps1 with no byte order +# mark using the system ANSI code page, which mangles UTF-8 and breaks the +# parser. Keep script messages English. + +$ErrorActionPreference = 'Stop' + +$Repo = Split-Path -Parent $PSScriptRoot +if (-not $Repo) { $Repo = $PSCommandPath | Split-Path -Parent | Split-Path -Parent } +$Workspace = Split-Path -Parent $Repo +$Dist = Join-Path $Repo 'dist' +$Out = Join-Path $Repo 'build' + +function Say($text) { Write-Host " $text" -ForegroundColor Cyan } + +if (-not (Get-Command javac -ErrorAction SilentlyContinue)) { + Write-Host ' A JDK (javac) is required.' -ForegroundColor Red + exit 1 +} + +New-Item -ItemType Directory -Force -Path $Dist, $Out | Out-Null + +# The API speaks about items and boxes, so it is compiled against the +# protocol: the same jar the server and the client use. +$Protocol = Join-Path $Workspace 'rsx-protocol\build\classes' +if (-not (Test-Path $Protocol)) { + Write-Host " Missing dependency: $Protocol" -ForegroundColor Red + Write-Host ' Build rsx-protocol first.' -ForegroundColor Red + exit 1 +} + +$mainOut = Join-Path $Out 'classes' +Remove-Item $mainOut -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $mainOut | Out-Null + +$mainFiles = @(Get-ChildItem (Join-Path $Repo 'src\main\java') -Recurse -Filter '*.java' | + ForEach-Object { $_.FullName }) +if ($mainFiles.Count -eq 0) { + Write-Host ' No sources found.' -ForegroundColor Red + exit 1 +} + +& javac -encoding UTF-8 --release 17 -Xlint:-options -nowarn -cp $Protocol -d $mainOut $mainFiles +if ($LASTEXITCODE -ne 0) { + Write-Host ' The plugin API failed to compile.' -ForegroundColor Red + exit 1 +} +Say "plugin API compiled ($($mainFiles.Count) files)" + +$manifest = Join-Path $Out 'manifest.txt' +@" +Manifest-Version: 1.0 +Implementation-Title: RustNotMC plugin API +Implementation-Version: 0.1.0 +"@ | Set-Content -LiteralPath $manifest -Encoding ASCII + +$jar = Join-Path $Dist 'rsx-plugin-api.jar' +if (Test-Path -LiteralPath $jar) { Remove-Item -LiteralPath $jar -Force } +& jar cfm $jar $manifest -C $mainOut . +if ($LASTEXITCODE -ne 0) { + Write-Host ' Could not package the jar.' -ForegroundColor Red + exit 1 +} +Say "rsx-plugin-api.jar ($((Get-Item $jar).Length) bytes)" + +Write-Host '' +Write-Host ' Done:' -ForegroundColor Green +Write-Host " $jar" diff --git a/rsx-plugin-api/src/main/java/net/rustnotmc/api/server/Hitboxes.java b/rsx-plugin-api/src/main/java/net/rustnotmc/api/server/Hitboxes.java new file mode 100644 index 0000000..f6e5805 --- /dev/null +++ b/rsx-plugin-api/src/main/java/net/rustnotmc/api/server/Hitboxes.java @@ -0,0 +1,30 @@ +package net.rustnotmc.api.server; + +import net.rustnotmc.protocol.Hitbox; + +/** + * The boxes of the models, on the server. + * + * A plugin that brings its own object says where its boxes are, and the game + * uses them: for the collision, for the damage, and for the drawing that the + * editor does. + * + * The boxes of our own models are in a file that the server reads, so that + * they can be changed without touching any code. + */ +public interface Hitboxes { + + /** + * Says where the boxes of a model are. + * + * A model that is already known is replaced, so a plugin may take a + * model over. + */ + void register(String model, Hitbox[] boxes); + + /** The boxes of a model, or null when it has none. */ + Hitbox[] of(String model); + + /** True when the model has boxes. */ + boolean has(String model); +} diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Hitbox.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Hitbox.java new file mode 100644 index 0000000..4811794 --- /dev/null +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Hitbox.java @@ -0,0 +1,80 @@ +package net.rustnotmc.protocol; + +/** + * One box around a thing. + * + * A hitbox is not a block: it is a box in the space of a model, and it says + * what it is for. The name tells the two boxes of the same model apart, and + * the part tells which part of a player the box belongs to, because the damage + * of a hit depends on where it landed. + * + * The boxes live in a file, so that a model can be fitted by hand: the + * server reads the file, the client draws what it read, and changing a number + * and reloading shows the result right away. + */ +public final class Hitbox { + + /** The box is not on a player, so it takes no part in the damage. */ + public static final String PART_NONE = ""; + + /** The head of a player: a hit here hurts twice as much. */ + public static final String PART_HEAD = "head"; + + /** The torso of a player. */ + public static final String PART_BODY = "body"; + + /** The legs of a player. */ + public static final String PART_LEGS = "legs"; + + public final String name; + public final String part; + public final float minX; + public final float minY; + public final float minZ; + public final float maxX; + public final float maxY; + public final float maxZ; + + /** The turn of the box around the vertical axis, in degrees. */ + public final float yaw; + + public Hitbox(String name, String part, float minX, float minY, float minZ, + float maxX, float maxY, float maxZ, float yaw) { + this.name = name == null ? "" : name; + this.part = part == null ? PART_NONE : part; + this.minX = minX; + this.minY = minY; + this.minZ = minZ; + this.maxX = maxX; + this.maxY = maxY; + this.maxZ = maxZ; + this.yaw = yaw; + } + + /** True when the box belongs to a part of a player. */ + public boolean onPlayer() { + return !PART_NONE.equals(this.part); + } + + /** The width of the box. */ + public float width() { + return this.maxX - this.minX; + } + + /** The height of the box. */ + public float height() { + return this.maxY - this.minY; + } + + /** The depth of the box. */ + public float depth() { + return this.maxZ - this.minZ; + } + + @Override + public String toString() { + return "Hitbox[" + this.name + (this.part.isEmpty() ? "" : "/" + this.part) + + " " + this.minX + "," + this.minY + "," + this.minZ + + " .. " + this.maxX + "," + this.maxY + "," + this.maxZ + "]"; + } +} diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java index d49b420..4f31fc5 100644 --- a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java @@ -149,6 +149,15 @@ public final class Packets { /** The client learns a blueprint. */ public static final int LEARN_BLUEPRINT = 0x29; + /** The boxes of every model the server knows. */ + public static final int HITBOXES = 0x2A; + + /** The places where those models stand. */ + public static final int HITBOX_INSTANCES = 0x2B; + + /** The client asks the server to read its box file again. */ + public static final int HITBOXES_RELOAD = 0x2C; + /** * One slot of the inventory and what is in it now. * @@ -1372,6 +1381,156 @@ public final class Packets { } } + /** The boxes of one model. */ + public static final class HitboxModel { + + public final String name; + public final Hitbox[] boxes; + + public HitboxModel(String name, Hitbox[] boxes) { + this.name = name; + this.boxes = boxes; + } + } + + /** + * The boxes of every model the server knows. + * + * The file with the boxes is the editor: it is read by the server, sent + * to the client and drawn there, so that a change in the file can be + * seen without anything else being touched. + */ + public static final class Hitboxes implements Packet { + + public final HitboxModel[] models; + + public Hitboxes(HitboxModel[] models) { + this.models = models; + } + + public Buf write() { + Buf buf = new Buf(256 + this.models.length * 64); + buf.writeVarInt(this.models.length); + for (int i = 0; i < this.models.length; i++) { + HitboxModel model = this.models[i]; + buf.writeString(model.name); + buf.writeVarInt(model.boxes.length); + for (int j = 0; j < model.boxes.length; j++) { + Hitbox box = model.boxes[j]; + buf.writeString(box.name); + buf.writeString(box.part); + buf.writeFloat(box.minX); + buf.writeFloat(box.minY); + buf.writeFloat(box.minZ); + buf.writeFloat(box.maxX); + buf.writeFloat(box.maxY); + buf.writeFloat(box.maxZ); + buf.writeFloat(box.yaw); + } + } + return buf; + } + + public static Hitboxes read(Buf in) { + int count = in.readVarInt(); + if (count < 0 || count > 4096) { + throw new ProtocolException("that many box models is not real"); + } + HitboxModel[] models = new HitboxModel[count]; + for (int i = 0; i < count; i++) { + String name = in.readString(MAX_TEXT); + int boxes = in.readVarInt(); + if (boxes < 0 || boxes > 256) { + throw new ProtocolException("that many boxes is not real"); + } + Hitbox[] list = new Hitbox[boxes]; + for (int j = 0; j < boxes; j++) { + String boxName = in.readString(MAX_TEXT); + String part = in.readString(MAX_TEXT); + list[j] = new Hitbox(boxName, part, + in.readFloat(), in.readFloat(), in.readFloat(), + in.readFloat(), in.readFloat(), in.readFloat(), + in.readFloat()); + } + models[i] = new HitboxModel(name, list); + } + return new Hitboxes(models); + } + } + + /** One model standing somewhere in the world. */ + public static final class HitboxInstance { + + public final String model; + public final float x; + public final float y; + public final float z; + public final float yaw; + + public HitboxInstance(String model, float x, float y, float z, float yaw) { + this.model = model; + this.x = x; + this.y = y; + this.z = z; + this.yaw = yaw; + } + } + + /** Every place where a model with boxes stands. */ + public static final class HitboxInstances implements Packet { + + public final HitboxInstance[] instances; + + public HitboxInstances(HitboxInstance[] instances) { + this.instances = instances; + } + + public Buf write() { + Buf buf = new Buf(32 + this.instances.length * 32); + buf.writeVarInt(this.instances.length); + for (int i = 0; i < this.instances.length; i++) { + HitboxInstance instance = this.instances[i]; + buf.writeString(instance.model); + buf.writeFloat(instance.x); + buf.writeFloat(instance.y); + buf.writeFloat(instance.z); + buf.writeFloat(instance.yaw); + } + return buf; + } + + public static HitboxInstances read(Buf in) { + int count = in.readVarInt(); + if (count < 0 || count > 65536) { + throw new ProtocolException("that many box instances is not real"); + } + HitboxInstance[] instances = new HitboxInstance[count]; + for (int i = 0; i < count; i++) { + String model = in.readString(MAX_TEXT); + instances[i] = new HitboxInstance(model, in.readFloat(), in.readFloat(), + in.readFloat(), in.readFloat()); + } + return new HitboxInstances(instances); + } + } + + /** + * The client asks the server to read its box file again. + * + * The file is the editor, so after a change the client wants the new + * numbers without the server having to be restarted. + */ + public static final class HitboxesReload implements Packet { + + public Buf write() { + return new Buf(4); + } + + public static HitboxesReload read(Buf in) { + return new HitboxesReload(); + } + } + /** * Every packet id this build knows, and what it decodes into. * @@ -1412,6 +1571,9 @@ public final class Packets { map.put(Integer.valueOf(CONTAINER_OPEN), "ContainerOpen"); map.put(Integer.valueOf(CONTAINER_TAKE), "ContainerTake"); map.put(Integer.valueOf(LEARN_BLUEPRINT), "LearnBlueprint"); + map.put(Integer.valueOf(HITBOXES), "Hitboxes"); + map.put(Integer.valueOf(HITBOX_INSTANCES), "HitboxInstances"); + map.put(Integer.valueOf(HITBOXES_RELOAD), "HitboxesReload"); map.put(Integer.valueOf(DISCONNECT), "Disconnect"); return map; } diff --git a/rsx-server-core/scripts/build.ps1 b/rsx-server-core/scripts/build.ps1 index 4f4a377..2337c16 100644 --- a/rsx-server-core/scripts/build.ps1 +++ b/rsx-server-core/scripts/build.ps1 @@ -14,10 +14,11 @@ function Say($text) { Write-Host " $text" -ForegroundColor Cyan } $Protocol = Join-Path $Workspace 'rsx-protocol\build\classes' $Auth = Join-Path $Workspace 'rsx-auth\build\classes' -foreach ($dep in @($Protocol, $Auth)) { +$PluginApi = Join-Path $Workspace 'rsx-plugin-api\build\classes' +foreach ($dep in @($Protocol, $Auth, $PluginApi)) { if (-not (Test-Path $dep)) { Write-Host " Missing dependency: $dep" -ForegroundColor Red - Write-Host ' Build rsx-protocol and rsx-auth first.' -ForegroundColor Red + Write-Host ' Build rsx-protocol, rsx-auth and rsx-plugin-api first.' -ForegroundColor Red exit 1 } } @@ -32,7 +33,7 @@ $mainFiles = @(Get-ChildItem (Join-Path $Repo 'src\main\java') -Recurse -Filter ForEach-Object { $_.FullName }) if ($mainFiles.Count -eq 0) { Write-Host ' No sources found.' -ForegroundColor Red; exit 1 } -$cp = "$Protocol;$Auth" +$cp = "$Protocol;$Auth;$PluginApi" & javac -encoding UTF-8 --release 17 -Xlint:-options -nowarn -cp $cp -d $mainOut $mainFiles if ($LASTEXITCODE -ne 0) { Write-Host ' Server failed to compile.' -ForegroundColor Red; exit 1 } Say "server compiled ($($mainFiles.Count) files)" @@ -42,7 +43,7 @@ $manifest = Join-Path $Out 'manifest.txt' Manifest-Version: 1.0 Implementation-Title: RustNotMC server Implementation-Version: 0.1.0 -Class-Path: rsx-protocol.jar rsx-auth.jar +Class-Path: rsx-protocol.jar rsx-auth.jar rsx-plugin-api.jar "@ | Set-Content -LiteralPath $manifest -Encoding ASCII $jar = Join-Path $Dist 'rsx-server-core.jar' diff --git a/rsx-server-core/scripts/test.ps1 b/rsx-server-core/scripts/test.ps1 index fc05bc0..acdb7a9 100644 --- a/rsx-server-core/scripts/test.ps1 +++ b/rsx-server-core/scripts/test.ps1 @@ -16,10 +16,11 @@ function Say($text) { Write-Host " $text" -ForegroundColor Cyan } $Protocol = Join-Path $Workspace 'rsx-protocol\build\classes' $Auth = Join-Path $Workspace 'rsx-auth\build\classes' -foreach ($dep in @($Protocol, $Auth)) { +$PluginApi = Join-Path $Workspace 'rsx-plugin-api\build\classes' +foreach ($dep in @($Protocol, $Auth, $PluginApi)) { if (-not (Test-Path $dep)) { Write-Host " Missing dependency: $dep" -ForegroundColor Red - Write-Host ' Build rsx-protocol and rsx-auth first.' -ForegroundColor Red + Write-Host ' Build rsx-protocol, rsx-auth and rsx-plugin-api first.' -ForegroundColor Red exit 1 } } @@ -31,7 +32,7 @@ Remove-Item $mainOut -Recurse -Force -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $mainOut | Out-Null $mainFiles = @(Get-ChildItem (Join-Path $Repo 'src\main\java') -Recurse -Filter '*.java' | ForEach-Object { $_.FullName }) -$cp = "$Protocol;$Auth" +$cp = "$Protocol;$Auth;$PluginApi" & javac -encoding UTF-8 --release 17 -Xlint:-options -nowarn -cp $cp -d $mainOut $mainFiles if ($LASTEXITCODE -ne 0) { Write-Host ' Main failed.' -ForegroundColor Red; exit 1 } @@ -50,6 +51,7 @@ $suites = @( 'net.rustnotmc.server.world.NodeTest', 'net.rustnotmc.server.item.InventoryTest', 'net.rustnotmc.server.craft.CraftTest', + 'net.rustnotmc.server.hitbox.HitboxTest', 'net.rustnotmc.server.PlayTest' ) foreach ($suite in $suites) { diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/ClientSession.java b/rsx-server-core/src/main/java/net/rustnotmc/server/ClientSession.java index 1fdd424..31de3d2 100644 --- a/rsx-server-core/src/main/java/net/rustnotmc/server/ClientSession.java +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/ClientSession.java @@ -229,6 +229,9 @@ public final class ClientSession implements Runnable { case Packets.LEARN_BLUEPRINT: onLearnBlueprint(frame); return true; + case Packets.HITBOXES_RELOAD: + onHitboxesReload(frame); + return true; case Packets.PLAYER_MOVE: onPlayerMove(frame); return true; @@ -472,6 +475,8 @@ public final class ClientSession implements Runnable { new Packets.KnownRecipes(this.player.knowledge().knownRecipes())); send(Packets.TECH_TREE_STATE, new Packets.TechTreeState(this.player.knowledge().unlockedNodes())); + // the boxes of the models, so that the client can draw them + this.sendHitboxes(); this.player.streamChunks(this.server.world()); } @@ -632,8 +637,32 @@ public final class ClientSession implements Runnable { send(Packets.INVENTORY, this.player.inventory().toPacket()); } - /** - * The player learned a blueprint. + /** + * The client wants the box file read again. + * + * The file is the editor, so after a change the client asks for the new + * numbers. Everybody is told, not only the one who asked. + */ + private void onHitboxesReload(Frame frame) { + if (this.state != SessionState.PLAYING || this.player == null) { + return; + } + String error = this.server.reloadHitboxes(); + if (!error.isEmpty()) { + this.server.log("hitboxes: " + error); + } + } + + /** Sends the boxes of the models to the client. */ + void sendHitboxes() { + if (this.player == null) { + return; + } + send(Packets.HITBOXES, this.server.hitboxes().toPacket()); + } + + /** + * The client learned a blueprint. * * The blueprint is used up and the recipe opens for good. */ diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/Server.java b/rsx-server-core/src/main/java/net/rustnotmc/server/Server.java index aba7687..4af764c 100644 --- a/rsx-server-core/src/main/java/net/rustnotmc/server/Server.java +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/Server.java @@ -18,6 +18,7 @@ import net.rustnotmc.auth.Passwords; import net.rustnotmc.auth.TokenService; import net.rustnotmc.protocol.Packets; import net.rustnotmc.server.world.ChunkPos; +import net.rustnotmc.server.hitbox.HitboxRegistry; import net.rustnotmc.server.world.ChunkStorage; import net.rustnotmc.server.world.World; @@ -56,6 +57,7 @@ public final class Server { private final World world; private volatile LauncherAuth launcherAuth; private volatile boolean localAccounts = true; + private volatile HitboxRegistry hitboxes = new HitboxRegistry(null); private volatile ServerSocket listener; private volatile Thread acceptThread; @@ -85,6 +87,39 @@ public final class Server { return this.world; } + /** + * The boxes of the models. + * + * The file with the boxes is the editor: it is read by the server and + * sent to the client, which draws them, so that a model can be fitted by + * changing a number and looking at the result. + */ + public HitboxRegistry hitboxes() { + return this.hitboxes; + } + + public void hitboxes(HitboxRegistry hitboxes) { + this.hitboxes = hitboxes == null ? new HitboxRegistry(null) : hitboxes; + } + + /** + * Reads the box file again and tells everybody about it. + * + * Returns the reason when the file could not be read. + */ + public String reloadHitboxes() { + HitboxRegistry registry = this.hitboxes; + registry.reload(); + String error = registry.lastError(); + if (!error.isEmpty()) { + return error; + } + for (ClientSession session : this.sessions.values()) { + session.sendHitboxes(); + } + return ""; + } + /** The players in the world, by player id. */ public Map players() { return this.players; diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/ServerMain.java b/rsx-server-core/src/main/java/net/rustnotmc/server/ServerMain.java index 9c05060..97035ab 100644 --- a/rsx-server-core/src/main/java/net/rustnotmc/server/ServerMain.java +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/ServerMain.java @@ -45,6 +45,7 @@ public final class ServerMain { String gameMode = "survival"; String register = null; String password = null; + Path hitboxes = worldDir.resolve("hitboxes.json"); for (int i = 0; i < args.length - 1; i++) { String flag = args[i]; @@ -57,6 +58,8 @@ public final class ServerMain { accounts = Path.of(value); } else if ("--world".equals(flag)) { worldDir = Path.of(value); + } else if ("--hitboxes".equals(flag)) { + hitboxes = Path.of(value); } else if ("--seed".equals(flag)) { seed = Long.parseLong(value); } else if ("--launcher-api".equals(flag)) { @@ -78,6 +81,10 @@ public final class ServerMain { World world = new World(seed, new ChunkStorage(worldDir)); Server server = new Server(name, motd, capacity, port, world); + server.hitboxes(new net.rustnotmc.server.hitbox.HitboxRegistry(hitboxes)); + if (!server.hitboxes().lastError().isEmpty()) { + System.out.println("[rsx] hitboxes: " + server.hitboxes().lastError()); + } server.gameMode("creative".equalsIgnoreCase(gameMode) ? net.rustnotmc.protocol.Packets.GAMEMODE_CREATIVE : net.rustnotmc.protocol.Packets.GAMEMODE_SURVIVAL); diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/hitbox/HitboxRegistry.java b/rsx-server-core/src/main/java/net/rustnotmc/server/hitbox/HitboxRegistry.java new file mode 100644 index 0000000..0451d68 --- /dev/null +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/hitbox/HitboxRegistry.java @@ -0,0 +1,259 @@ +package net.rustnotmc.server.hitbox; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.rustnotmc.protocol.Hitbox; +import net.rustnotmc.protocol.Packets; +import net.rustnotmc.server.util.Json; + +/** + * The boxes of every model the server knows. + * + * The boxes are in a file, because they are the editor: a model is fitted by + * changing a number in the file, and the result can be seen right away in the + * game. The server reads the file, hands the numbers to the client, and the + * client draws them. + * + * The file looks like this: + * + *
+ * {
+ *   "models": {
+ *     "ore_stone": [
+ *       { "name": "ore", "min": [-0.5, 0.0, -0.5], "max": [0.5, 0.8, 0.5] }
+ *     ]
+ *   }
+ * }
+ * 
+ * + * A box may name a part of a player ("head", "body" or "legs"), and then it + * takes part in the damage: a hit on the head hurts twice as much as a hit on + * the body. + */ +public final class HitboxRegistry implements net.rustnotmc.api.server.Hitboxes { + + /** The file the boxes are read from, when there is one. */ + private final Path file; + + private final Map models = new LinkedHashMap(); + + private String lastError = ""; + + public HitboxRegistry(Path file) { + this.file = file; + if (file != null && !Files.isRegularFile(file)) { + writeDefault(); + } + reload(); + } + + /** + * Writes the file with the boxes we start with. + * + * A file that is not there is not an error: the editor starts with + * something to change. + */ + private void writeDefault() { + try { + Path parent = this.file.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.write(this.file, defaultFile().getBytes("UTF-8")); + } catch (IOException ignored) { + // Without a file there are simply no boxes, and the log says so. + } + } + + /** The file we start with: the zones of a player and a few models. */ + public static String defaultFile() { + return "{\n" + + " \"models\": {\n" + + " \"player\": [\n" + + " { \"name\": \"head\", \"part\": \"head\"," + + " \"min\": [-0.3, 1.4, -0.3], \"max\": [0.3, 1.8, 0.3] },\n" + + " { \"name\": \"body\", \"part\": \"body\"," + + " \"min\": [-0.3, 0.4, -0.3], \"max\": [0.3, 1.4, 0.3] },\n" + + " { \"name\": \"legs\", \"part\": \"legs\"," + + " \"min\": [-0.3, 0.0, -0.3], \"max\": [0.3, 0.4, 0.3] }\n" + + " ],\n" + + " \"ore_stone\": [\n" + + " { \"name\": \"ore\", \"min\": [-0.45, 0.0, -0.45]," + + " \"max\": [0.45, 0.6, 0.45] }\n" + + " ],\n" + + " \"ore_metal\": [\n" + + " { \"name\": \"ore\", \"min\": [-0.45, 0.0, -0.45]," + + " \"max\": [0.45, 0.6, 0.45] }\n" + + " ],\n" + + " \"ore_sulfur\": [\n" + + " { \"name\": \"ore\", \"min\": [-0.45, 0.0, -0.45]," + + " \"max\": [0.45, 0.6, 0.45] }\n" + + " ],\n" + + " \"barrel_blue\": [\n" + + " { \"name\": \"barrel\", \"min\": [-0.35, 0.0, -0.35]," + + " \"max\": [0.35, 0.95, 0.35] }\n" + + " ],\n" + + " \"barrel_red\": [\n" + + " { \"name\": \"barrel\", \"min\": [-0.35, 0.0, -0.35]," + + " \"max\": [0.35, 0.95, 0.35] }\n" + + " ],\n" + + " \"barrel_white\": [\n" + + " { \"name\": \"barrel\", \"min\": [-0.35, 0.0, -0.35]," + + " \"max\": [0.35, 0.95, 0.35] }\n" + + " ],\n" + + " \"crate\": [\n" + + " { \"name\": \"crate\", \"min\": [-0.45, 0.0, -0.45]," + + " \"max\": [0.45, 0.5, 0.45] }\n" + + " ],\n" + + " \"food_crate\": [\n" + + " { \"name\": \"crate\", \"min\": [-0.45, 0.0, -0.45]," + + " \"max\": [0.45, 0.5, 0.45] }\n" + + " ]\n" + + " }\n" + + "}\n"; + } + + /** The file the boxes come from, or null when there is none. */ + public Path file() { + return this.file; + } + + /** + * Reads the file again. + * + * A file that cannot be read does not empty the registry: the boxes that + * were good stay good, and the reason is kept for the log. + */ + public boolean reload() { + if (this.file == null) { + this.models.clear(); + this.lastError = ""; + return true; + } + try { + if (!Files.isRegularFile(this.file)) { + this.models.clear(); + this.lastError = "no file at " + this.file; + return false; + } + Object root = Json.readFile(this.file); + Map read = readModels(root); + this.models.clear(); + this.models.putAll(read); + this.lastError = ""; + return true; + } catch (IOException e) { + this.lastError = e.getMessage() == null ? e.toString() : e.getMessage(); + return false; + } + } + + /** Why the last read failed, or an empty string. */ + public String lastError() { + return this.lastError; + } + + /** How many models are known. */ + public int size() { + return this.models.size(); + } + + /** The boxes of one model, or null. */ + public Packets.HitboxModel model(String name) { + return this.models.get(name); + } + + /** Every model, in the order of the file. */ + public Map models() { + return Collections.unmodifiableMap(this.models); + } + + /** The boxes as the client is told about them. */ + public Packets.Hitboxes toPacket() { + return new Packets.Hitboxes(this.models.values().toArray( + new Packets.HitboxModel[0])); + } + + /** + * Registers a model from a plugin. + * + * The file is the editor for the models we fit ourselves; a plugin brings + * its own model and says so itself. + */ + @Override + public void register(String name, Hitbox[] boxes) { + this.models.put(name, new Packets.HitboxModel(name, boxes)); + } + + /** The boxes of a model, or null. */ + @Override + public Hitbox[] of(String name) { + Packets.HitboxModel model = this.models.get(name); + return model == null ? null : model.boxes; + } + + /** True when the model has boxes. */ + @Override + public boolean has(String name) { + return this.models.containsKey(name); + } + + @SuppressWarnings("unchecked") + private static Map readModels(Object root) throws IOException { + Map out = new LinkedHashMap(); + Object models = Json.get(root, "models"); + if (!(models instanceof Map)) { + throw new IOException("the file has no \"models\" table"); + } + for (Map.Entry entry : ((Map) models).entrySet()) { + String model = entry.getKey(); + Object value = entry.getValue(); + if (!(value instanceof List)) { + throw new IOException("the model " + model + " is not a list of boxes"); + } + List boxes = new ArrayList(); + for (Object item : (List) value) { + boxes.add(readBox(model, item)); + } + out.put(model, new Packets.HitboxModel(model, + boxes.toArray(new Hitbox[0]))); + } + return out; + } + + private static Hitbox readBox(String model, Object value) throws IOException { + if (!(value instanceof Map)) { + throw new IOException("a box of " + model + " is not an object"); + } + String name = Json.text(value, "name", ""); + String part = Json.text(value, "part", Hitbox.PART_NONE); + float[] min = readTriple(model, value, "min", 0.0F); + float[] max = readTriple(model, value, "max", 0.0F); + float yaw = (float) Json.number(value, "yaw", 0.0D); + return new Hitbox(name, part, min[0], min[1], min[2], max[0], max[1], max[2], yaw); + } + + private static float[] readTriple(String model, Object value, String key, float fallback) + throws IOException { + Object got = Json.get(value, key); + if (got == null) { + return new float[]{fallback, fallback, fallback}; + } + if (!(got instanceof List)) { + throw new IOException("the " + key + " of a box of " + model + " is not a list"); + } + List list = (List) got; + float[] out = new float[3]; + for (int i = 0; i < 3; i++) { + Object number = i < list.size() ? list.get(i) : null; + out[i] = number instanceof Number ? ((Number) number).floatValue() : fallback; + } + return out; + } +} diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/util/Json.java b/rsx-server-core/src/main/java/net/rustnotmc/server/util/Json.java new file mode 100644 index 0000000..e53ea75 --- /dev/null +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/util/Json.java @@ -0,0 +1,278 @@ +package net.rustnotmc.server.util; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * A small reader for JSON. + * + * The server has no libraries and does not want them, and the files it reads + * are ours: a table of boxes, a list of things. So this reads the whole of + * JSON and nothing else, and it says clearly what is wrong when a file is + * not what it should be. + * + * The result is plain Java: a map, a list, a string, a number, a boolean or + * null. + */ +public final class Json { + + private final String text; + private int pos; + + private Json(String text) { + this.text = text; + } + + /** Reads a file. */ + public static Object readFile(Path path) throws IOException { + String text = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + Json reader = new Json(text); + reader.skipSpace(); + Object value = reader.readValue(); + reader.skipSpace(); + if (reader.pos < reader.text.length()) { + throw new IOException("the file has something after the value at " + + reader.pos); + } + return value; + } + + /** Reads a string of JSON. */ + public static Object readString(String text) throws IOException { + Json reader = new Json(text); + reader.skipSpace(); + Object value = reader.readValue(); + reader.skipSpace(); + if (reader.pos < reader.text.length()) { + throw new IOException("there is something after the value at " + reader.pos); + } + return value; + } + + private Object readValue() throws IOException { + if (this.pos >= this.text.length()) { + throw new IOException("the value ends too early"); + } + char c = this.text.charAt(this.pos); + switch (c) { + case '{': + return readObject(); + case '[': + return readArray(); + case '"': + return readString(); + case 't': + expect("true"); + return Boolean.TRUE; + case 'f': + expect("false"); + return Boolean.FALSE; + case 'n': + expect("null"); + return null; + default: + return readNumber(); + } + } + + private Map readObject() throws IOException { + Map out = new LinkedHashMap(); + this.pos++; // { + skipSpace(); + if (peek() == '}') { + this.pos++; + return out; + } + while (true) { + skipSpace(); + if (peek() != '"') { + throw new IOException("a key is expected at " + this.pos); + } + String key = readString(); + skipSpace(); + if (peek() != ':') { + throw new IOException("a colon is expected at " + this.pos); + } + this.pos++; + skipSpace(); + out.put(key, readValue()); + skipSpace(); + char c = peek(); + if (c == ',') { + this.pos++; + continue; + } + if (c == '}') { + this.pos++; + return out; + } + throw new IOException("a comma or a brace is expected at " + this.pos); + } + } + + private List readArray() throws IOException { + List out = new ArrayList(); + this.pos++; // [ + skipSpace(); + if (peek() == ']') { + this.pos++; + return out; + } + while (true) { + skipSpace(); + out.add(readValue()); + skipSpace(); + char c = peek(); + if (c == ',') { + this.pos++; + continue; + } + if (c == ']') { + this.pos++; + return out; + } + throw new IOException("a comma or a bracket is expected at " + this.pos); + } + } + + private String readString() throws IOException { + this.pos++; // " + StringBuilder out = new StringBuilder(); + while (true) { + if (this.pos >= this.text.length()) { + throw new IOException("the string ends too early"); + } + char c = this.text.charAt(this.pos++); + if (c == '"') { + return out.toString(); + } + if (c != '\\') { + out.append(c); + continue; + } + if (this.pos >= this.text.length()) { + throw new IOException("the escape ends too early"); + } + char escape = this.text.charAt(this.pos++); + switch (escape) { + case '"': out.append('"'); break; + case '\\': out.append('\\'); break; + case '/': out.append('/'); break; + case 'b': out.append('\b'); break; + case 'f': out.append('\f'); break; + case 'n': out.append('\n'); break; + case 'r': out.append('\r'); break; + case 't': out.append('\t'); break; + case 'u': + if (this.pos + 4 > this.text.length()) { + throw new IOException("the escape ends too early"); + } + out.append((char) Integer.parseInt( + this.text.substring(this.pos, this.pos + 4), 16)); + this.pos += 4; + break; + default: + throw new IOException("unknown escape \\" + escape); + } + } + } + + private Object readNumber() throws IOException { + int start = this.pos; + if (peek() == '-' || peek() == '+') { + this.pos++; + } + while (this.pos < this.text.length()) { + char c = this.text.charAt(this.pos); + if ((c >= '0' && c <= '9') || c == '.' || c == 'e' || c == 'E' + || c == '+' || c == '-') { + this.pos++; + continue; + } + break; + } + String number = this.text.substring(start, this.pos); + if (number.isEmpty()) { + throw new IOException("a number is expected at " + start); + } + try { + if (number.indexOf('.') < 0 && number.indexOf('e') < 0 + && number.indexOf('E') < 0) { + return Long.valueOf(Long.parseLong(number)); + } + return Double.valueOf(Double.parseDouble(number)); + } catch (NumberFormatException e) { + throw new IOException("not a number: " + number); + } + } + + private void expect(String word) throws IOException { + if (!this.text.startsWith(word, this.pos)) { + throw new IOException("expected " + word + " at " + this.pos); + } + this.pos += word.length(); + } + + private char peek() throws IOException { + if (this.pos >= this.text.length()) { + throw new IOException("the value ends too early"); + } + return this.text.charAt(this.pos); + } + + private void skipSpace() { + while (this.pos < this.text.length()) { + char c = this.text.charAt(this.pos); + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + this.pos++; + continue; + } + break; + } + } + + // ------------------------------------------------------------- shortcuts + + /** The value at the key, or null. */ + @SuppressWarnings("unchecked") + public static Object get(Object value, String key) { + if (!(value instanceof Map)) { + return null; + } + return ((Map) value).get(key); + } + + /** The list at the key, or an empty list. */ + @SuppressWarnings("unchecked") + public static List list(Object value, String key) { + Object got = get(value, key); + if (got instanceof List) { + return (List) got; + } + return new ArrayList(); + } + + /** The number at the key, or the fallback. */ + public static double number(Object value, String key, double fallback) { + Object got = get(value, key); + if (got instanceof Number) { + return ((Number) got).doubleValue(); + } + return fallback; + } + + /** The text at the key, or the fallback. */ + public static String text(Object value, String key, String fallback) { + Object got = get(value, key); + if (got instanceof String) { + return (String) got; + } + return fallback; + } +} diff --git a/rsx-server-core/src/test/java/net/rustnotmc/server/hitbox/HitboxTest.java b/rsx-server-core/src/test/java/net/rustnotmc/server/hitbox/HitboxTest.java new file mode 100644 index 0000000..9560b88 --- /dev/null +++ b/rsx-server-core/src/test/java/net/rustnotmc/server/hitbox/HitboxTest.java @@ -0,0 +1,183 @@ +package net.rustnotmc.server.hitbox; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import net.rustnotmc.protocol.Hitbox; +import net.rustnotmc.protocol.Packets; +import net.rustnotmc.server.util.Json; + +/** + * Tests the boxes and the file they come from. + * + * The file is the editor, so what matters is that a number written in it + * arrives at the client unchanged, and that a file that makes no sense is + * refused with a reason instead of taking the server down. + */ +public final class HitboxTest { + + private static int checks; + private static int failed; + private static final java.util.List failures = new java.util.ArrayList(); + + public static void main(String[] args) throws Exception { + run("the json reader reads what it should", new Throwing() { + public void go() throws Exception { testJson(); } + }); + run("the json reader refuses rubbish", new Throwing() { + public void go() throws Exception { testBrokenJson(); } + }); + run("a file of boxes is read", new Throwing() { + public void go() throws Exception { testRead(); } + }); + run("a broken file keeps the old boxes", new Throwing() { + public void go() throws Exception { testBroken(); } + }); + run("a file that is not there is written", new Throwing() { + public void go() throws Exception { testDefault(); } + }); + run("the boxes survive the wire", new Throwing() { + public void go() throws Exception { testWire(); } + }); + + System.out.println(); + for (int i = 0; i < failures.size(); i++) { + System.out.println(" FAIL " + failures.get(i)); + } + System.out.println(); + System.out.println("checks: " + checks + ", failed: " + failed); + if (failed != 0) { + System.out.println("FAILED"); + System.exit(1); + } + System.out.println("OK"); + } + + private interface Throwing { + void go() throws Exception; + } + + private static void run(String name, Throwing body) { + checks++; + int before = failed; + try { + body.go(); + } catch (Throwable t) { + failed++; + failures.add(name + ": threw " + t); + t.printStackTrace(); + return; + } + if (failed == before) { + System.out.println(" ok " + name); + } else { + System.out.println(" FAIL " + name); + } + } + + private static void eq(Object what, Object expected, Object actual) { + if (expected == null ? actual != null : !expected.equals(actual)) { + failed++; + failures.add(what + ": expected " + expected + " but got " + actual); + } + } + + private static void yes(String what, boolean condition) { + if (!condition) { + failed++; + failures.add(what); + } + } + + /** Numbers that came from a file are floats, so they are compared loosely. */ + private static void near(Object what, double expected, double actual) { + if (Math.abs(expected - actual) > 0.0001D) { + failed++; + failures.add(what + ": expected " + expected + " but got " + actual); + } + } + + private static void testJson() throws Exception { + Object value = Json.readString("{\"a\": 1, \"b\": [1, 2, 3], \"c\": \"x\"," + + " \"d\": true, \"e\": null, \"f\": 2.5}"); + eq("a number", Double.valueOf(1), Json.number(value, "a", 0)); + eq("a list", Integer.valueOf(3), Integer.valueOf(Json.list(value, "b").size())); + eq("text", "x", Json.text(value, "c", "")); + eq("a boolean", Boolean.TRUE, Json.get(value, "d")); + eq("null", null, Json.get(value, "e")); + eq("a fraction", Double.valueOf(2.5), Json.number(value, "f", 0)); + } + + private static void testBrokenJson() throws Exception { + boolean refused = false; + try { + Json.readString("{\"a\": }"); + } catch (IOException expected) { + refused = true; + } + yes("a broken file is refused", refused); + } + + private static void testRead() throws Exception { + Path file = Files.createTempFile("hitboxes", ".json"); + Files.write(file, ("{\"models\": {\"ore\": [{\"name\": \"ore\", \"part\": \"\"," + + " \"min\": [-0.5, 0, -0.5], \"max\": [0.5, 0.6, 0.5], \"yaw\": 15}]}}") + .getBytes("UTF-8")); + HitboxRegistry registry = new HitboxRegistry(file); + eq("one model", Integer.valueOf(1), Integer.valueOf(registry.size())); + yes("the model is there", registry.has("ore")); + Hitbox[] boxes = registry.of("ore"); + eq("one box", Integer.valueOf(1), Integer.valueOf(boxes.length)); + eq("the name", "ore", boxes[0].name); + near("the min", -0.5, boxes[0].minX); + near("the max", 0.6, boxes[0].maxY); + near("the turn", 15, boxes[0].yaw); + java.nio.file.Files.deleteIfExists(file); + } + + private static void testBroken() throws Exception { + Path file = Files.createTempFile("hitboxes", ".json"); + Files.write(file, ("{\"models\": {\"ore\": [{\"name\": \"ore\"," + + " \"min\": [-0.5, 0, -0.5], \"max\": [0.5, 0.6, 0.5]}]}}") + .getBytes("UTF-8")); + HitboxRegistry registry = new HitboxRegistry(file); + eq("one model", Integer.valueOf(1), Integer.valueOf(registry.size())); + + Files.write(file, "this is not json at all".getBytes("UTF-8")); + yes("the read fails", !registry.reload()); + yes("and it says why", !registry.lastError().isEmpty()); + eq("but the old boxes stay", Integer.valueOf(1), Integer.valueOf(registry.size())); + java.nio.file.Files.deleteIfExists(file); + } + + private static void testDefault() throws Exception { + Path file = Files.createTempDirectory("hitboxes").resolve("hitboxes.json"); + HitboxRegistry registry = new HitboxRegistry(file); + yes("the file is written", Files.isRegularFile(file)); + yes("and it has models", registry.size() > 3); + yes("the player is one of them", registry.has("player")); + Hitbox[] zones = registry.of("player"); + eq("three zones", Integer.valueOf(3), Integer.valueOf(zones.length)); + eq("the head is a head", Hitbox.PART_HEAD, zones[0].part); + eq("the legs are legs", Hitbox.PART_LEGS, zones[2].part); + java.nio.file.Files.deleteIfExists(file); + } + + private static void testWire() throws Exception { + Path file = Files.createTempFile("hitboxes", ".json"); + Files.write(file, ("{\"models\": {\"ore\": [{\"name\": \"ore\", \"part\": \"head\"," + + " \"min\": [-0.5, 0, -0.5], \"max\": [0.5, 0.6, 0.5], \"yaw\": 15}]}}") + .getBytes("UTF-8")); + HitboxRegistry registry = new HitboxRegistry(file); + Packets.Hitboxes sent = registry.toPacket(); + Packets.Hitboxes back = Packets.Hitboxes.read( + new net.rustnotmc.protocol.Buf(sent.write().toByteArray())); + eq("one model", Integer.valueOf(1), Integer.valueOf(back.models.length)); + eq("the name", "ore", back.models[0].name); + eq("one box", Integer.valueOf(1), Integer.valueOf(back.models[0].boxes.length)); + eq("the part", Hitbox.PART_HEAD, back.models[0].boxes[0].part); + near("the min", -0.5, back.models[0].boxes[0].minX); + near("the turn", 15, back.models[0].boxes[0].yaw); + java.nio.file.Files.deleteIfExists(file); + } +}