Игрок: хитбокс 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:
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user