Игрок: хитбокс 1.45 в шифте и прыжок с места 1.5

- RsxPlayerTuning: высота при подкрадывании 1.45 (ванилла 1.65), прыжок с
  места 1.5, в движении ванильные 1.25, высота глаз в шифте 1.37.
- RsxPlayer: прыжок по наличию горизонтального ввода, глаза при подкрадывании.
- Патч в EntityPlayer.updateSize: высота при подкрадывании из нашего класса.
- Импульс прыжка считается обратным счетом по физике ваниллы.

AABB: редактор хитбоксов и их отрисовка

- Протокол: Hitbox (имя, зона, min/max, поворот), пакеты Hitboxes,
  HitboxInstances и HitboxesReload.
- Сервер: HitboxRegistry читает JSON-файл (world/hitboxes.json, можно
  указать --hitboxes), перечитывает его по запросу клиента и шлет наборы
  игрокам. Сломанный файл не стирает рабочие коробки, а объясняет причину.
- Клиент: RsxHitboxDebug рисует коробки через RenderGlobal.drawBoundingBox
  с подписями DebugRenderer, красит зоны (голова красная, тело желтое, ноги
  синие). Команды: /rsx hitboxes on|off|reload|list|show NAME.
- Новый модуль rsx-plugin-api с интерфейсом Hitboxes: плагин может
  зарегистрировать свои коробки. У сервера появился скрипт сборки.

Тесты: протокол 34, сервер 80 (было 74). Форк собирается чисто, 3279 классов.
This commit is contained in:
sasheg dev
2026-10-21 22:31:16 +03:00
parent e7e332f4ca
commit 9980c1561d
18 changed files with 1528 additions and 14 deletions
+5 -4
View File
@@ -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'
+5 -3
View File
@@ -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) {
@@ -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.
*/
@@ -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<Integer, Player> players() {
return this.players;
@@ -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);
@@ -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:
*
* <pre>
* {
* "models": {
* "ore_stone": [
* { "name": "ore", "min": [-0.5, 0.0, -0.5], "max": [0.5, 0.8, 0.5] }
* ]
* }
* }
* </pre>
*
* 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<String, Packets.HitboxModel> models = new LinkedHashMap<String, Packets.HitboxModel>();
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<String, Packets.HitboxModel> 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<String, Packets.HitboxModel> 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<String, Packets.HitboxModel> readModels(Object root) throws IOException {
Map<String, Packets.HitboxModel> out = new LinkedHashMap<String, Packets.HitboxModel>();
Object models = Json.get(root, "models");
if (!(models instanceof Map)) {
throw new IOException("the file has no \"models\" table");
}
for (Map.Entry<String, Object> entry : ((Map<String, Object>) 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<Hitbox> boxes = new ArrayList<Hitbox>();
for (Object item : (List<Object>) 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<Object> list = (List<Object>) 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;
}
}
@@ -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<String, Object> readObject() throws IOException {
Map<String, Object> out = new LinkedHashMap<String, Object>();
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<Object> readArray() throws IOException {
List<Object> out = new ArrayList<Object>();
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<String, Object>) value).get(key);
}
/** The list at the key, or an empty list. */
@SuppressWarnings("unchecked")
public static List<Object> list(Object value, String key) {
Object got = get(value, key);
if (got instanceof List) {
return (List<Object>) got;
}
return new ArrayList<Object>();
}
/** 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;
}
}
@@ -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<String> failures = new java.util.ArrayList<String>();
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);
}
}