From fbd01df88439958391b442525230173ee2264f32 Mon Sep 17 00:00:00 2001
From: sasheg dev <152582704+SashegDev@users.noreply.github.com>
Date: Wed, 21 Oct 2026 23:10:21 +0300
Subject: [PATCH] =?UTF-8?q?=D0=A7=D0=B0=D1=82=20=D0=B8=20=D1=81=D0=BF?=
=?UTF-8?q?=D0=B8=D1=81=D0=BE=D0=BA=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=BE?=
=?UTF-8?q?=D0=B2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Протокол: Chat (что сказал сервер), ChatSend (что сказал клиент) и PlayerList
(весь список игроков с их режимами).
- Сервер: сообщения уходят всем, а имя отправителя ставит сервер, поэтому подделать
его нельзя. Список игроков уходит всем целиком, когда он меняется. Команды
сервера: /help и /players.
- Клиент: перевод наших пакетов в игровые (SPacketChat и SPacketPlayerListItem),
уходящий чат перехватывается патчем, список игроков обновляется по разнице
(ушедших убираем, пришедших добавляем).
- Тесты: два игрока, один пишет - второй слышит с правильным именем; список
игроков приходит обоим и меняется, когда кто-то выходит.
Попутно: run-server.ps1 теперь знает про rsx-plugin-api, иначе сервер не
запускался (нет класса Hitboxes в classpath).
Тесты: протокол 34, сервер 87. Форк собирается чисто.
---
game-fork/patches/decompiler-fixes.txt | 12 ++
.../client/net/RsxNetworkManager.java | 72 +++++++++++
.../rustnotmc/client/net/RsxWorldBridge.java | 37 +++++-
.../java/net/rustnotmc/protocol/Packets.java | 106 ++++++++++++++++
.../java/net/rustnotmc/protocol/ItemTest.java | 17 +++
.../net/rustnotmc/protocol/ProtocolTest.java | 2 +-
rsx-server-core/scripts/run-server.ps1 | 7 +-
.../net/rustnotmc/server/ClientSession.java | 44 +++++++
.../java/net/rustnotmc/server/Server.java | 38 ++++++
.../java/net/rustnotmc/server/PlayTest.java | 115 ++++++++++++++++++
world/hitboxes.json | 33 +++++
11 files changed, 477 insertions(+), 6 deletions(-)
create mode 100644 world/hitboxes.json
diff --git a/game-fork/patches/decompiler-fixes.txt b/game-fork/patches/decompiler-fixes.txt
index 3425dc9..7b19c41 100644
--- a/game-fork/patches/decompiler-fixes.txt
+++ b/game-fork/patches/decompiler-fixes.txt
@@ -1290,3 +1290,15 @@ import org.lwjgl.opengl.ContextCapabilities;
this.connection.sendPacket(new CPacketUseEntity(entity));
net.rustnotmc.client.net.RsxNetworkManager.reportAttack(this.mc, entity);
+# The chat goes to our server, which is the one who decides who said what.
+=== FILE net/minecraft/client/entity/EntityPlayerSP.java
+--- BEFORE
+ public void sendChatMessage(String s) {
+ this.connection.sendPacket(new CPacketChatMessage(s));
+ }
+--- AFTER
+ public void sendChatMessage(String s) {
+ this.connection.sendPacket(new CPacketChatMessage(s));
+ net.rustnotmc.client.net.RsxNetworkManager.sendChat(s);
+ }
+
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 67601d2..5882e4f 100644
--- a/game-fork/src-overlay/net/rustnotmc/client/net/RsxNetworkManager.java
+++ b/game-fork/src-overlay/net/rustnotmc/client/net/RsxNetworkManager.java
@@ -74,6 +74,9 @@ public final class RsxNetworkManager extends NetworkManager {
private final String uuid;
private final BlockingQueue inbox = new LinkedBlockingQueue();
+
+ /** The names of the players the server told us about last time. */
+ private java.util.List knownPlayers = new java.util.ArrayList();
private final FrameDecoder decoder = new FrameDecoder();
private volatile Socket socket;
@@ -260,11 +263,67 @@ public final class RsxNetworkManager extends NetworkManager {
net.rustnotmc.client.RsxHitboxDebug.accept(instances);
return;
}
+ case Packets.CHAT: {
+ Packets.Chat chat = Packets.Chat.read(new Buf(frame.body()));
+ if (this.play != null) {
+ net.rustnotmc.client.net.RsxWorldBridge.chat(chat.from, chat.text)
+ .processPacket(this.play);
+ }
+ return;
+ }
+ case Packets.PLAYER_LIST: {
+ Packets.PlayerList list = Packets.PlayerList.read(new Buf(frame.body()));
+ this.updatePlayerList(list);
+ return;
+ }
default:
onPlay(frame);
}
}
+ /**
+ * Brings the list of players up to what the server says.
+ *
+ * The server sends the whole list every time it changes, so the client
+ * only has to take away the names that are gone and add the ones that
+ * are new.
+ */
+ private void updatePlayerList(Packets.PlayerList list) {
+ if (this.play == null) {
+ return;
+ }
+ try {
+ for (String gone : this.knownPlayers) {
+ if (!contains(list.names, gone)) {
+ net.rustnotmc.client.net.RsxWorldBridge.playerListItem(
+ net.minecraft.network.play.server.SPacketPlayerListItem.Action
+ .REMOVE_PLAYER,
+ gone, 0).processPacket(this.play);
+ }
+ }
+ for (int i = 0; i < list.names.length; i++) {
+ int mode = i < list.gameModes.length ? list.gameModes[i] : 0;
+ net.rustnotmc.client.net.RsxWorldBridge.playerListItem(
+ net.minecraft.network.play.server.SPacketPlayerListItem.Action
+ .ADD_PLAYER,
+ list.names[i], mode).processPacket(this.play);
+ }
+ this.knownPlayers = new java.util.ArrayList(
+ java.util.Arrays.asList(list.names));
+ } catch (java.io.IOException broken) {
+ // the list is a nicety: a broken one must not stop the game
+ }
+ }
+
+ private static boolean contains(String[] names, String name) {
+ for (int i = 0; i < names.length; i++) {
+ if (names[i].equals(name)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
/**
* Tells the server where a hit landed.
*
@@ -285,6 +344,19 @@ public final class RsxNetworkManager extends NetworkManager {
(float) hit.x, (float) hit.y, (float) hit.z, Packets.Attack.HAND_MAIN));
}
+ /**
+ * Says something to the server.
+ *
+ * The server is the one who decides who said what, so the line goes to
+ * the server and not into the vanilla's own channel.
+ */
+ public static void sendChat(String text) {
+ RsxNetworkManager manager = active();
+ if (manager != null) {
+ manager.send(Packets.CHAT_SEND, new Packets.ChatSend(text));
+ }
+ }
+
/**
* Asks the server to read its box file again.
*
diff --git a/game-fork/src-overlay/net/rustnotmc/client/net/RsxWorldBridge.java b/game-fork/src-overlay/net/rustnotmc/client/net/RsxWorldBridge.java
index 5b503af..7fb2f63 100644
--- a/game-fork/src-overlay/net/rustnotmc/client/net/RsxWorldBridge.java
+++ b/game-fork/src-overlay/net/rustnotmc/client/net/RsxWorldBridge.java
@@ -11,6 +11,7 @@ import net.minecraft.network.Packet;
import net.minecraft.network.PacketBuffer;
import net.minecraft.network.play.INetHandlerPlayClient;
import net.minecraft.network.play.server.SPacketBlockChange;
+import net.minecraft.network.play.server.SPacketChat;
import net.minecraft.network.play.server.SPacketChunkData;
import net.minecraft.network.play.server.SPacketDestroyEntities;
import net.minecraft.network.play.server.SPacketEntityTeleport;
@@ -225,13 +226,31 @@ public final class RsxWorldBridge {
* derives it for an offline account.
*/
public static SPacketPlayerListItem playerList(String name) throws java.io.IOException {
+ return playerListItem(SPacketPlayerListItem.Action.ADD_PLAYER, name, 0);
+ }
+
+ /**
+ * One entry of the list of players.
+ *
+ * The game wants a name, an identity and a game mode, and the server has
+ * only the name, so the identity is derived the same way the game derives
+ * it for an offline account.
+ */
+ public static SPacketPlayerListItem playerListItem(SPacketPlayerListItem.Action action,
+ String name, int gameMode)
+ throws java.io.IOException {
PacketBuffer buffer = new PacketBuffer(Unpooled.buffer());
- buffer.writeEnumValue(SPacketPlayerListItem.Action.ADD_PLAYER);
+ buffer.writeEnumValue(action);
buffer.writeVarInt(1);
buffer.writeUniqueId(uuidFor(name));
+ if (action == SPacketPlayerListItem.Action.REMOVE_PLAYER) {
+ SPacketPlayerListItem packet = new SPacketPlayerListItem();
+ packet.readPacketData(buffer);
+ return packet;
+ }
buffer.writeString(name);
buffer.writeVarInt(0); // no profile properties
- buffer.writeVarInt(GameType.SURVIVAL.getID());
+ buffer.writeVarInt(gameMode);
buffer.writeVarInt(0); // latency
buffer.writeBoolean(false); // no display name
SPacketPlayerListItem packet = new SPacketPlayerListItem();
@@ -239,6 +258,20 @@ public final class RsxWorldBridge {
return packet;
}
+ /**
+ * The game's packet for a line of chat.
+ *
+ * The name of the sender is put in by the server, so the client only has
+ * to write the line.
+ */
+ public static SPacketChat chat(String from, String text) {
+ String line = from == null || from.isEmpty()
+ ? text
+ : "<" + from + "> " + text;
+ return new SPacketChat(new net.minecraft.util.text.TextComponentString(line),
+ net.minecraft.util.text.ChatType.CHAT);
+ }
+
/** The identity an offline account gets. */
public static UUID uuidFor(String name) {
return UUID.nameUUIDFromBytes(
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 7788a8c..67db3bf 100644
--- a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java
+++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java
@@ -161,6 +161,15 @@ public final class Packets {
/** The client hits something. */
public static final int ATTACK = 0x2D;
+ /** What somebody said. */
+ public static final int CHAT = 0x2E;
+
+ /** The client says something. */
+ public static final int CHAT_SEND = 0x2F;
+
+ /** Who is in the world. */
+ public static final int PLAYER_LIST = 0x30;
+
/**
* One slot of the inventory and what is in it now.
*
@@ -1604,6 +1613,100 @@ public final class Packets {
}
}
+ /**
+ * What somebody said.
+ *
+ * The name of the sender is here, because the server is the one who
+ * decides who said what: a client cannot claim to be somebody else.
+ */
+ public static final class Chat implements Packet {
+
+ /** The name of the sender, or empty when the server speaks. */
+ public final String from;
+
+ public final String text;
+
+ public Chat(String from, String text) {
+ this.from = from == null ? "" : from;
+ this.text = text == null ? "" : text;
+ }
+
+ public Buf write() {
+ Buf buf = new Buf(64 + this.text.length());
+ buf.writeString(this.from);
+ buf.writeString(this.text);
+ return buf;
+ }
+
+ public static Chat read(Buf in) {
+ return new Chat(in.readString(MAX_TEXT), in.readString(MAX_TEXT));
+ }
+ }
+
+ /** The client says something. */
+ public static final class ChatSend implements Packet {
+
+ public final String text;
+
+ public ChatSend(String text) {
+ this.text = text == null ? "" : text;
+ }
+
+ public Buf write() {
+ Buf buf = new Buf(64 + this.text.length());
+ buf.writeString(this.text);
+ return buf;
+ }
+
+ public static ChatSend read(Buf in) {
+ return new ChatSend(in.readString(MAX_TEXT));
+ }
+ }
+
+ /**
+ * Who is in the world.
+ *
+ * The whole list, every time it changes: the list of a server with a
+ * handful of players is small, and a full list cannot get out of step
+ * with itself the way a difference can.
+ */
+ public static final class PlayerList implements Packet {
+
+ public final String[] names;
+
+ /** The game mode of each player, in the same order. */
+ public final int[] gameModes;
+
+ public PlayerList(String[] names, int[] gameModes) {
+ this.names = names;
+ this.gameModes = gameModes;
+ }
+
+ public Buf write() {
+ Buf buf = new Buf(32 + this.names.length * 24);
+ buf.writeVarInt(this.names.length);
+ for (int i = 0; i < this.names.length; i++) {
+ buf.writeString(this.names[i]);
+ buf.writeVarInt(i < this.gameModes.length ? this.gameModes[i] : 0);
+ }
+ return buf;
+ }
+
+ public static PlayerList read(Buf in) {
+ int count = in.readVarInt();
+ if (count < 0 || count > 4096) {
+ throw new ProtocolException("that many players is not real");
+ }
+ String[] names = new String[count];
+ int[] gameModes = new int[count];
+ for (int i = 0; i < count; i++) {
+ names[i] = in.readString(MAX_TEXT);
+ gameModes[i] = in.readVarInt();
+ }
+ return new PlayerList(names, gameModes);
+ }
+ }
+
/**
* Every packet id this build knows, and what it decodes into.
*
@@ -1648,6 +1751,9 @@ public final class Packets {
map.put(Integer.valueOf(HITBOX_INSTANCES), "HitboxInstances");
map.put(Integer.valueOf(HITBOXES_RELOAD), "HitboxesReload");
map.put(Integer.valueOf(ATTACK), "Attack");
+ map.put(Integer.valueOf(CHAT), "Chat");
+ map.put(Integer.valueOf(CHAT_SEND), "ChatSend");
+ map.put(Integer.valueOf(PLAYER_LIST), "PlayerList");
map.put(Integer.valueOf(DISCONNECT), "Disconnect");
return map;
}
diff --git a/rsx-protocol/src/test/java/net/rustnotmc/protocol/ItemTest.java b/rsx-protocol/src/test/java/net/rustnotmc/protocol/ItemTest.java
index d3f6315..127cc24 100644
--- a/rsx-protocol/src/test/java/net/rustnotmc/protocol/ItemTest.java
+++ b/rsx-protocol/src/test/java/net/rustnotmc/protocol/ItemTest.java
@@ -326,6 +326,23 @@ public final class ItemTest {
near("the place of the hit survives", 2.5, attackBack.y);
eq("the hand survives", Integer.valueOf(Packets.Attack.HAND_MAIN),
Integer.valueOf(attackBack.hand));
+
+ Packets.Chat chat = new Packets.Chat("Player", "hello there");
+ Packets.Chat chatBack = Packets.Chat.read(new Buf(chat.write().toByteArray()));
+ eq("the sender survives", "Player", chatBack.from);
+ eq("the line survives", "hello there", chatBack.text);
+
+ Packets.ChatSend send = new Packets.ChatSend("hi");
+ Packets.ChatSend sendBack = Packets.ChatSend.read(new Buf(send.write().toByteArray()));
+ eq("the sent line survives", "hi", sendBack.text);
+
+ Packets.PlayerList list = new Packets.PlayerList(
+ new String[]{"One", "Two"}, new int[]{0, 1});
+ Packets.PlayerList listBack =
+ Packets.PlayerList.read(new Buf(list.write().toByteArray()));
+ eq("two names", Integer.valueOf(2), Integer.valueOf(listBack.names.length));
+ eq("the first name", "One", listBack.names[0]);
+ eq("the second mode", Integer.valueOf(1), Integer.valueOf(listBack.gameModes[1]));
}
private static void near(Object what, double expected, double actual) {
diff --git a/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java b/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java
index fd21b02..a82c00a 100644
--- a/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java
+++ b/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java
@@ -552,7 +552,7 @@ public final class ProtocolTest {
Packets.CONTAINER_OPEN, Packets.CONTAINER_TAKE,
Packets.LEARN_BLUEPRINT,
Packets.HITBOXES, Packets.HITBOX_INSTANCES, Packets.HITBOXES_RELOAD,
- Packets.ATTACK,
+ Packets.ATTACK, Packets.CHAT, Packets.CHAT_SEND, Packets.PLAYER_LIST,
Packets.DISCONNECT,
};
for (int i = 0; i < ids.length; i++) {
diff --git a/rsx-server-core/scripts/run-server.ps1 b/rsx-server-core/scripts/run-server.ps1
index 4119bc7..3b40cc3 100644
--- a/rsx-server-core/scripts/run-server.ps1
+++ b/rsx-server-core/scripts/run-server.ps1
@@ -24,8 +24,9 @@ $Workspace = Split-Path -Parent $Repo
$Protocol = Join-Path $Workspace 'rsx-protocol\build\classes'
$Auth = Join-Path $Workspace 'rsx-auth\build\classes'
+$PluginApi = Join-Path $Workspace 'rsx-plugin-api\build\classes'
$Server = Join-Path $Repo 'build\classes'
-foreach ($dep in @($Protocol, $Auth, $Server)) {
+foreach ($dep in @($Protocol, $Auth, $PluginApi, $Server)) {
if (-not (Test-Path $dep)) {
Write-Host " Missing: $dep" -ForegroundColor Red
Write-Host ' Build rsx-protocol, rsx-auth and rsx-server-core first.' -ForegroundColor Red
@@ -37,7 +38,7 @@ if (-not $Accounts) { $Accounts = Join-Path $Repo 'accounts.txt' }
if (-not $World) { $World = Join-Path $Repo 'world' }
if ($Register) {
- & java -cp "$Protocol;$Auth;$Server" net.rustnotmc.server.ServerMain `
+ & java -cp "$Protocol;$Auth;$PluginApi;$Server" net.rustnotmc.server.ServerMain `
--accounts $Accounts --register $Register --password $Password
exit $LASTEXITCODE
}
@@ -56,7 +57,7 @@ Write-Host " port : $Port"
Write-Host " capacity : $Capacity"
Write-Host ''
-& java -cp "$Protocol;$Auth;$Server" net.rustnotmc.server.ServerMain `
+& java -cp "$Protocol;$Auth;$PluginApi;$Server" net.rustnotmc.server.ServerMain `
--port $Port --accounts $Accounts --capacity $Capacity --name $Name --motd $Motd `
--world $World --seed $Seed
exit $LASTEXITCODE
\ No newline at end of file
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 4dd2906..f9e7853 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
@@ -235,6 +235,9 @@ public final class ClientSession implements Runnable {
case Packets.ATTACK:
onAttack(frame);
return true;
+ case Packets.CHAT_SEND:
+ onChatSend(frame);
+ return true;
case Packets.PLAYER_MOVE:
onPlayerMove(frame);
return true;
@@ -640,6 +643,47 @@ public final class ClientSession implements Runnable {
send(Packets.INVENTORY, this.player.inventory().toPacket());
}
+ /**
+ * The client says something.
+ *
+ * The server puts the name of the sender in, so that nobody can claim to
+ * be somebody else, and then everybody hears it.
+ */
+ private void onChatSend(Frame frame) {
+ if (this.state != SessionState.PLAYING || this.player == null) {
+ return;
+ }
+ Packets.ChatSend chat = Packets.ChatSend.read(new Buf(frame.body()));
+ String text = chat.text.trim();
+ if (text.isEmpty()) {
+ return;
+ }
+ if (text.startsWith("/")) {
+ onServerCommand(text);
+ return;
+ }
+ this.server.broadcastChat(this.account, text);
+ }
+
+ /**
+ * A command of the server.
+ *
+ * There are only a few of them, and they answer everybody, because a
+ * command that only the one who typed it can see is of no use to anyone.
+ */
+ private void onServerCommand(String command) {
+ String name = command.split("\\s+")[0].toLowerCase();
+ if ("/help".equals(name)) {
+ this.server.broadcastChat("", "commands: /help, /players");
+ return;
+ }
+ if ("/players".equals(name)) {
+ this.server.broadcastChat("", "players: " + this.server.online());
+ return;
+ }
+ this.server.broadcastChat("", "there is no command " + name);
+ }
+
/**
* The client hits something.
*
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 693f309..993531e 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
@@ -437,6 +437,43 @@ public final class Server {
if (n > this.peakOnline.get()) {
this.peakOnline.set(n);
}
+ sendPlayerList();
+ }
+
+ /**
+ * Tells everybody who is in the world.
+ *
+ * The whole list, every time it changes: a list of a handful of players
+ * is small, and a full list cannot get out of step with itself.
+ */
+ private void sendPlayerList() {
+ List here = new ArrayList(this.players.values());
+ String[] names = new String[here.size()];
+ int[] modes = new int[here.size()];
+ for (int i = 0; i < here.size(); i++) {
+ names[i] = here.get(i).account();
+ modes[i] = here.get(i).gameMode();
+ }
+ Packets.PlayerList list = new Packets.PlayerList(names, modes);
+ for (Player player : here) {
+ player.session().send(Packets.PLAYER_LIST, list);
+ }
+ }
+
+ /**
+ * Says something to everybody who is in the world.
+ *
+ * The name of the sender is put in by the server, so that a client cannot
+ * claim to be somebody else.
+ */
+ public void broadcastChat(String from, String text) {
+ if (text == null || text.isEmpty()) {
+ return;
+ }
+ Packets.Chat chat = new Packets.Chat(from, text);
+ for (Player player : this.players.values()) {
+ player.session().send(Packets.CHAT, chat);
+ }
}
void closed(ClientSession session) {
@@ -452,6 +489,7 @@ public final class Server {
if (session.lastError() != null) {
log("closed " + session.address() + ": " + session.lastError());
}
+ sendPlayerList();
}
private static Packets.EntitySpawn spawnPacket(Player player) {
diff --git a/rsx-server-core/src/test/java/net/rustnotmc/server/PlayTest.java b/rsx-server-core/src/test/java/net/rustnotmc/server/PlayTest.java
index 3549936..4839cdc 100644
--- a/rsx-server-core/src/test/java/net/rustnotmc/server/PlayTest.java
+++ b/rsx-server-core/src/test/java/net/rustnotmc/server/PlayTest.java
@@ -72,6 +72,12 @@ public final class PlayTest {
run("the server's own accounts are shut when a launcher is set", new Throwing() {
public void go() throws Exception { testLocalAccountsShut(work); }
});
+ run("what one player says the other hears", new Throwing() {
+ public void go() throws Exception { testChat(work); }
+ });
+ run("the list of players is told to everybody", new Throwing() {
+ public void go() throws Exception { testPlayerList(work); }
+ });
System.out.println();
for (int i = 0; i < failures.size(); i++) {
@@ -374,6 +380,115 @@ public final class PlayTest {
}
}
+ /**
+ * What one player says, the other hears.
+ *
+ * The server puts the name of the sender in, so that nobody can claim to
+ * be somebody else.
+ */
+ private static void testChat(Path work) throws Exception {
+ Server server = boot(work, "First");
+ try {
+ server.accounts().create("Second", "password1".toCharArray(), 1L);
+ PlayClient first = new PlayClient("127.0.0.1", server.port(), 5000);
+ yes("the first player signed in", first.signIn("First", "password1") != null);
+ PlayClient second = new PlayClient("127.0.0.1", server.port(), 5000);
+ yes("the second player signed in", second.signIn("Second", "password1") != null);
+
+ second.send(Packets.CHAT_SEND, new Packets.ChatSend("hello there"));
+ Frame heard = first.await(Packets.CHAT, 5000L);
+ yes("the other player heard it", heard != null);
+ if (heard != null) {
+ Packets.Chat chat = Packets.Chat.read(new Buf(heard.body()));
+ eq("with the words", "hello there", chat.text);
+ eq("and the name of the sender", "Second", chat.from);
+ }
+
+ // the server is the one who names the sender
+ second.send(Packets.CHAT_SEND, new Packets.ChatSend("spoof"));
+ Frame again = first.await(Packets.CHAT, 5000L);
+ if (again != null) {
+ eq("the name cannot be made up", "Second",
+ Packets.Chat.read(new Buf(again.body())).from);
+ }
+
+ first.close();
+ second.close();
+ } finally {
+ server.stop();
+ }
+ }
+
+ /**
+ * The list of players reaches everybody.
+ *
+ * The whole list is sent every time it changes, so a client cannot fall
+ * out of step with it.
+ */
+ private static void testPlayerList(Path work) throws Exception {
+ Server server = boot(work, "First");
+ try {
+ server.accounts().create("Second", "password1".toCharArray(), 1L);
+ PlayClient first = new PlayClient("127.0.0.1", server.port(), 5000);
+ yes("the first player signed in", first.signIn("First", "password1") != null);
+ PlayClient second = new PlayClient("127.0.0.1", server.port(), 5000);
+ yes("the second player signed in", second.signIn("Second", "password1") != null);
+
+ Frame list = awaitPlayerList(first, 2, 5000L);
+ yes("the list arrives", list != null);
+ if (list != null) {
+ Packets.PlayerList players = Packets.PlayerList.read(new Buf(list.body()));
+ eq("with two names", Integer.valueOf(2),
+ Integer.valueOf(players.names.length));
+ boolean hasFirst = false;
+ boolean hasSecond = false;
+ for (int i = 0; i < players.names.length; i++) {
+ hasFirst |= "First".equals(players.names[i]);
+ hasSecond |= "Second".equals(players.names[i]);
+ }
+ yes("the first is in it", hasFirst);
+ yes("the second is in it", hasSecond);
+ }
+
+ second.close();
+ waitFor(() -> server.players().size() == 1, 3000L);
+ Frame after = awaitPlayerList(first, 1, 5000L);
+ yes("and the list changes when somebody leaves", after != null);
+ if (after != null) {
+ Packets.PlayerList players = Packets.PlayerList.read(new Buf(after.body()));
+ eq("with one name left", Integer.valueOf(1),
+ Integer.valueOf(players.names.length));
+ }
+ first.close();
+ } finally {
+ server.stop();
+ }
+ }
+
+ /**
+ * Waits for the list of players to have that many names.
+ *
+ * The server sends the whole list every time it changes, so the list that
+ * says two names comes after the one that says one.
+ */
+ private static Frame awaitPlayerList(PlayClient client, int names, long millis)
+ throws IOException {
+ long end = System.currentTimeMillis() + millis;
+ Frame last = null;
+ while (System.currentTimeMillis() < end) {
+ Frame frame = client.await(Packets.PLAYER_LIST, 500L);
+ if (frame == null) {
+ continue;
+ }
+ last = frame;
+ Packets.PlayerList list = Packets.PlayerList.read(new Buf(frame.body()));
+ if (list.names.length == names) {
+ return frame;
+ }
+ }
+ return last;
+ }
+
/**
* The world outlives the server.
*
diff --git a/world/hitboxes.json b/world/hitboxes.json
new file mode 100644
index 0000000..db2254f
--- /dev/null
+++ b/world/hitboxes.json
@@ -0,0 +1,33 @@
+{
+ "models": {
+ "player": [
+ { "name": "head", "part": "head", "min": [-0.3, 1.4, -0.3], "max": [0.3, 1.8, 0.3] },
+ { "name": "body", "part": "body", "min": [-0.3, 0.4, -0.3], "max": [0.3, 1.4, 0.3] },
+ { "name": "legs", "part": "legs", "min": [-0.3, 0.0, -0.3], "max": [0.3, 0.4, 0.3] }
+ ],
+ "ore_stone": [
+ { "name": "ore", "min": [-0.45, 0.0, -0.45], "max": [0.45, 0.6, 0.45] }
+ ],
+ "ore_metal": [
+ { "name": "ore", "min": [-0.45, 0.0, -0.45], "max": [0.45, 0.6, 0.45] }
+ ],
+ "ore_sulfur": [
+ { "name": "ore", "min": [-0.45, 0.0, -0.45], "max": [0.45, 0.6, 0.45] }
+ ],
+ "barrel_blue": [
+ { "name": "barrel", "min": [-0.35, 0.0, -0.35], "max": [0.35, 0.95, 0.35] }
+ ],
+ "barrel_red": [
+ { "name": "barrel", "min": [-0.35, 0.0, -0.35], "max": [0.35, 0.95, 0.35] }
+ ],
+ "barrel_white": [
+ { "name": "barrel", "min": [-0.35, 0.0, -0.35], "max": [0.35, 0.95, 0.35] }
+ ],
+ "crate": [
+ { "name": "crate", "min": [-0.45, 0.0, -0.45], "max": [0.45, 0.5, 0.45] }
+ ],
+ "food_crate": [
+ { "name": "crate", "min": [-0.45, 0.0, -0.45], "max": [0.45, 0.5, 0.45] }
+ ]
+ }
+}