Шаг A: предметы и инвентарь

Фундамент предметов по данным плана (срез 11.2025) и серверный инвентарь.

В протоколе (общий словарь для клиента и сервера):

- ToolClass, ResourceClass - что чем ломается и что где лежит.
- ItemType - определение предмета: класс инструмента, урон по четырем
  частям тела, прочность, Gather Rates по каждому классу ресурса
  (сколько снимает удар, разброс, сколько уходит прочности инструмента),
  bench - требуемый уровень верстака, knownByDefault - открыт ли рецепт
  с начала игры, admin - только ли админы сервера.
- ItemRegistry - таблица предметов. Числа из плана, каждое в одном
  месте, чтобы игра и план не разъехались.
- Recipe - ингредиенты, количество, базовое время крафта.
- ItemStack - тип, количество, остаток прочности и свои данные на стеке
  (как теги у плагина в Paper): стек - это то, что путешествует.

Пакеты: Inventory (весь инвентарь при входе) и SlotChange (один слот).

На сервере: Inventory - 30 слотов, хотбар 6, сервер владеет инвентарем и
сообщает клиенту, а не спрашивает. Стартовый набор - камень в первом
слоте и в руке.

Тесты: предметы закреплены числами из плана (камень бьёт в голову на 20,
снимает 10 дерева и 5 руды; кирка не рубит дерево; меч не ломает вообще),
инвентарь переживает провод, сумка наполняется и опустошается, полная
сумма говорит об этом.

Замечание: прогрессия крафта в Rust упирается в лутбоксы - скрап падает
только из них, а чертёж требует найденного предмета. Это отдельный
вопрос (п. 5.2 плана), он не блокирует предметы и инвентарь.
This commit is contained in:
sasheg dev
2026-10-21 17:43:30 +03:00
parent 56f0341373
commit 8488a34c80
15 changed files with 1324 additions and 1 deletions
+1
View File
@@ -47,6 +47,7 @@ Say "compiled $($mainFiles.Count) main + $($testFiles.Count) test files"
$suites = @(
'net.rustnotmc.server.ServerTest',
'net.rustnotmc.server.world.WorldTest',
'net.rustnotmc.server.item.InventoryTest',
'net.rustnotmc.server.PlayTest'
)
foreach ($suite in $suites) {
@@ -441,9 +441,22 @@ public final class ClientSession implements Runnable {
this.player.viewDistance(),
ChunkFormat.HEIGHT,
this.player.gameMode()));
// what the player is carrying comes with the world: the server owns
// the bag, so the client is told what is in it
send(Packets.INVENTORY, this.player.inventory().toPacket());
this.player.streamChunks(this.server.world());
}
/** Tells the client that one slot of the bag changed. */
void sendSlot(int slot) {
if (this.player == null) {
return;
}
net.rustnotmc.protocol.ItemStack stack = this.player.inventory().get(slot);
send(Packets.INVENTORY_SLOT, new Packets.SlotChange(slot, stack.item,
stack.count, stack.condition));
}
/**
* The player's game mode changed while they were in the world.
*
@@ -6,6 +6,7 @@ import java.util.Iterator;
import java.util.Set;
import net.rustnotmc.protocol.Blocks;
import net.rustnotmc.protocol.Packets;
import net.rustnotmc.server.item.Inventory;
import net.rustnotmc.server.world.Chunk;
import net.rustnotmc.server.world.ChunkPos;
import net.rustnotmc.server.world.World;
@@ -49,8 +50,17 @@ public final class Player {
/** Survival or creative. The server decides, and the client is told. */
private volatile int gameMode = Packets.GAMEMODE_SURVIVAL;
/** What the player is carrying. The server owns it. */
private final Inventory inventory;
Player(ClientSession session, int playerId, int entityId, String account,
double[] spawn) {
this(session, playerId, entityId, account, spawn,
Inventory.startingKit());
}
Player(ClientSession session, int playerId, int entityId, String account,
double[] spawn, Inventory inventory) {
this.session = session;
this.playerId = playerId;
this.entityId = entityId;
@@ -58,7 +68,12 @@ public final class Player {
this.x = spawn[0];
this.y = spawn[1];
this.z = spawn[2];
this.viewDistance = DEFAULT_VIEW_DISTANCE;
this.inventory = inventory;
}
/** What the player is carrying. */
public Inventory inventory() {
return this.inventory;
}
// -------------------------------------------------------------- getters
@@ -0,0 +1,173 @@
package net.rustnotmc.server.item;
import java.util.ArrayList;
import java.util.List;
import net.rustnotmc.protocol.ItemRegistry;
import net.rustnotmc.protocol.ItemStack;
import net.rustnotmc.protocol.Packets;
/**
* What a player is carrying.
*
* The server is the only one who decides what is in the slots. The client is
* told, and when it is told it draws what it was given. A client that is free
* to decide what it holds is a client that can be made to hold anything.
*
* The layout is the one from the original game: the first slots are the hotbar
* and the rest is the bag behind it.
*/
public final class Inventory {
/** How many slots the hotbar has. */
public static final int HOTBAR = 6;
/** How many slots there are in all. */
public static final int SLOTS = 30;
private final ItemStack[] slots = new ItemStack[SLOTS];
private int held;
public Inventory() {
for (int i = 0; i < SLOTS; i++) {
this.slots[i] = ItemStack.EMPTY;
}
}
/**
* The starting kit.
*
* A rock and a torch, the way the original game hands them out. The rock
* is the first thing the player has and the only thing they can use at the
* start.
*/
public static Inventory startingKit() {
Inventory inventory = new Inventory();
inventory.set(0, ItemStack.of(ItemRegistry.ROCK, 1));
inventory.held = 0;
return inventory;
}
/** The stack in a slot, never null. */
public ItemStack get(int slot) {
if (slot < 0 || slot >= SLOTS) {
return ItemStack.EMPTY;
}
ItemStack stack = this.slots[slot];
return stack == null ? ItemStack.EMPTY : stack;
}
/** Puts a stack in a slot. */
public void set(int slot, ItemStack stack) {
if (slot < 0 || slot >= SLOTS) {
return;
}
this.slots[slot] = stack == null ? ItemStack.EMPTY : stack;
}
/** The slot the player is holding. */
public int held() {
return this.held;
}
/** Holds a different slot. */
public void held(int slot) {
if (slot >= 0 && slot < HOTBAR) {
this.held = slot;
}
}
/** The stack in the hand. */
public ItemStack inHand() {
return get(this.held);
}
/**
* Adds a stack to the bag, and to the hotbar first.
*
* Returns true when it fit. A bag that is full simply does not take it,
* which is the same thing every game does.
*/
public boolean add(ItemStack stack) {
if (stack == null || stack.isEmpty()) {
return true;
}
for (int i = 0; i < SLOTS; i++) {
ItemStack there = get(i);
if (there.item == stack.item && there.count < 64) {
int room = 64 - there.count;
int moved = Math.min(room, stack.count);
there.count += moved;
stack.count -= moved;
if (stack.count <= 0) {
return true;
}
}
}
for (int i = 0; i < SLOTS; i++) {
if (get(i).isEmpty()) {
set(i, stack);
return true;
}
}
return false;
}
/** How many of an item are in the bag. */
public int count(int item) {
int total = 0;
for (int i = 0; i < SLOTS; i++) {
ItemStack stack = get(i);
if (stack.item == item) {
total += stack.count;
}
}
return total;
}
/**
* Takes an item out of the bag.
*
* Returns how many were actually there, which is not always what was
* asked for.
*/
public int take(int item, int amount) {
if (amount <= 0) {
return 0;
}
int taken = 0;
for (int i = 0; i < SLOTS && taken < amount; i++) {
ItemStack stack = get(i);
if (stack.item != item || stack.count <= 0) {
continue;
}
int moved = Math.min(stack.count, amount - taken);
stack.count -= moved;
taken += moved;
if (stack.count <= 0) {
set(i, ItemStack.EMPTY);
}
}
return taken;
}
/** The bag as the client is told about it. */
public Packets.Inventory toPacket() {
Packets.Slot[] out = new Packets.Slot[SLOTS];
for (int i = 0; i < SLOTS; i++) {
out[i] = Packets.Slot.of(get(i));
}
return new Packets.Inventory(out, this.held);
}
/** The slots that are not empty, for a look at what a player carries. */
public List<ItemStack> contents() {
List<ItemStack> out = new ArrayList<ItemStack>();
for (int i = 0; i < SLOTS; i++) {
ItemStack stack = get(i);
if (!stack.isEmpty()) {
out.add(stack);
}
}
return out;
}
}
@@ -154,6 +154,13 @@ final class PlayClient {
: Packets.GameMode.read(new net.rustnotmc.protocol.Buf(frame.body()));
}
/** Waits for the server to say what is in the bag. */
Packets.Inventory awaitInventory(long millis) throws IOException {
Frame frame = await(Packets.INVENTORY, millis);
return frame == null ? null
: Packets.Inventory.read(new net.rustnotmc.protocol.Buf(frame.body()));
}
/** The launcher's session, offered instead of a password. */
void launcherAuth(String account, String uuid, String token) throws IOException {
send(Packets.LAUNCHER_AUTH, new Packets.LauncherAuth(account, uuid, token));
@@ -57,6 +57,9 @@ public final class PlayTest {
run("a survival player cannot break blocks", new Throwing() {
public void go() throws Exception { testSurvivalCannotBreak(work); }
});
run("the player starts with a rock in his hand", new Throwing() {
public void go() throws Exception { testStartingKit(work); }
});
run("the world survives a restart", new Throwing() {
public void go() throws Exception { testRestart(work); }
});
@@ -432,6 +435,52 @@ public final class PlayTest {
}
}
/**
* The player starts with a rock, and the client is told about it.
*
* The server owns the bag, so the client is not asked what is in it.
*/
private static void testStartingKit(Path work) throws Exception {
Server server = boot(work, "Kit");
try {
PlayClient client = new PlayClient("127.0.0.1", server.port(), 5000);
yes("signed in", client.signIn("Kit", "password1") != null);
client.send(Packets.JOIN_WORLD, Packets.JoinWorld.fresh());
client.await(Packets.WORLD_JOIN, 5000L);
Packets.Inventory inventory = client.awaitInventory(5000L);
yes("the client was told what is in the bag", inventory != null);
if (inventory == null) {
return;
}
eq("the bag has slots", Integer.valueOf(30),
Integer.valueOf(inventory.slots.length));
eq("the first slot holds the rock", Integer.valueOf(net.rustnotmc.protocol.ItemRegistry.ROCK),
Integer.valueOf(inventory.slots[0].item));
eq("one of it", Integer.valueOf(1),
Integer.valueOf(inventory.slots[0].count));
eq("and it is in his hand", Integer.valueOf(0),
Integer.valueOf(inventory.held));
Player player = null;
for (Player p : server.players().values()) {
player = p;
}
yes("the server knows the player", player != null);
if (player != null) {
eq("the server agrees about the rock",
Integer.valueOf(net.rustnotmc.protocol.ItemRegistry.ROCK),
Integer.valueOf(player.inventory().get(0).item));
eq("and about what is in the hand",
Integer.valueOf(net.rustnotmc.protocol.ItemRegistry.ROCK),
Integer.valueOf(player.inventory().inHand().item));
}
client.close();
} finally {
server.stop();
}
}
private static void testRestart(Path work) throws Exception {
Path dir = work.resolve("play-world");
deleteTree(dir);
@@ -0,0 +1,192 @@
package net.rustnotmc.server.item;
import java.util.List;
import net.rustnotmc.protocol.ItemRegistry;
import net.rustnotmc.protocol.ItemStack;
import net.rustnotmc.protocol.ItemType;
import net.rustnotmc.protocol.Packets;
import net.rustnotmc.protocol.ResourceClass;
/**
* Tests the bag.
*
* The bag belongs to the server and nobody else, so these are about the server
* being right about what a player has and what a player can do with it.
*/
public final class InventoryTest {
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) {
run("the starting kit is a rock in the hand", new Throwing() {
public void go() { testStartingKit(); }
});
run("a bag holds what is put in it", new Throwing() {
public void go() { testAdd(); }
});
run("a bag gives things back", new Throwing() {
public void go() { testTake(); }
});
run("a full bag says so", new Throwing() {
public void go() { testFull(); }
});
run("the client is told what is in the bag", new Throwing() {
public void go() { testPacket(); }
});
run("a tool knows how much life it has left", new Throwing() {
public void go() { testCondition(); }
});
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);
}
}
private static void yes(String what, boolean condition) {
if (!condition) {
failed++;
failures.add(what);
}
}
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 testStartingKit() {
Inventory inventory = Inventory.startingKit();
eq("the first slot holds the rock", Integer.valueOf(ItemRegistry.ROCK),
Integer.valueOf(inventory.get(0).item));
eq("one of it", Integer.valueOf(1), Integer.valueOf(inventory.get(0).count));
eq("and it is in the hand", Integer.valueOf(0),
Integer.valueOf(inventory.held()));
eq("the hand holds the rock", Integer.valueOf(ItemRegistry.ROCK),
Integer.valueOf(inventory.inHand().item));
eq("nothing else", Integer.valueOf(1),
Integer.valueOf(inventory.contents().size()));
}
private static void testAdd() {
Inventory inventory = new Inventory();
yes("the rock goes in", inventory.add(ItemStack.of(ItemRegistry.ROCK, 1)));
eq("and it is there", Integer.valueOf(1),
Integer.valueOf(inventory.count(ItemRegistry.ROCK)));
// the same kind of thing piles up
yes("more wood goes in", inventory.add(ItemStack.of(ItemRegistry.WOOD, 10)));
yes("and more", inventory.add(ItemStack.of(ItemRegistry.WOOD, 5)));
eq("wood piles up", Integer.valueOf(15),
Integer.valueOf(inventory.count(ItemRegistry.WOOD)));
}
private static void testTake() {
Inventory inventory = Inventory.startingKit();
inventory.add(ItemStack.of(ItemRegistry.WOOD, 10));
eq("four wood comes out", Integer.valueOf(4),
Integer.valueOf(inventory.take(ItemRegistry.WOOD, 4)));
eq("six are left", Integer.valueOf(6),
Integer.valueOf(inventory.count(ItemRegistry.WOOD)));
eq("more than there is gives what there is", Integer.valueOf(6),
Integer.valueOf(inventory.take(ItemRegistry.WOOD, 100)));
eq("and nothing is left", Integer.valueOf(0),
Integer.valueOf(inventory.count(ItemRegistry.WOOD)));
eq("taking nothing takes nothing", Integer.valueOf(0),
Integer.valueOf(inventory.take(ItemRegistry.WOOD, 0)));
}
private static void testFull() {
Inventory inventory = new Inventory();
// a slot holds a stack of up to sixty four, so the bag is full when
// every slot is
for (int i = 0; i < Inventory.SLOTS; i++) {
yes("slot " + i + " takes a stack",
inventory.add(ItemStack.of(ItemRegistry.WOOD, 64)));
}
yes("a full bag says so", !inventory.add(ItemStack.of(ItemRegistry.WOOD, 1)));
eq("and it did not take the last one", Integer.valueOf(Inventory.SLOTS * 64),
Integer.valueOf(inventory.count(ItemRegistry.WOOD)));
}
private static void testPacket() {
Inventory inventory = Inventory.startingKit();
inventory.add(ItemStack.of(ItemRegistry.SCRAP, 3));
inventory.held(1);
Packets.Inventory packet = inventory.toPacket();
eq("every slot is there", Integer.valueOf(Inventory.SLOTS),
Integer.valueOf(packet.slots.length));
eq("the rock is in the first slot", Integer.valueOf(ItemRegistry.ROCK),
Integer.valueOf(packet.slots[0].item));
eq("the scrap is in the second", Integer.valueOf(ItemRegistry.SCRAP),
Integer.valueOf(packet.slots[1].item));
eq("three of it", Integer.valueOf(3),
Integer.valueOf(packet.slots[1].count));
eq("and the hand is the second slot", Integer.valueOf(1),
Integer.valueOf(packet.held));
eq("the empty slots stay empty", Integer.valueOf(ItemRegistry.NOTHING),
Integer.valueOf(packet.slots[5].item));
}
private static void testCondition() {
// a tool has a life, a pile of wood does not
ItemStack rock = ItemStack.of(ItemRegistry.ROCK, 1);
eq("the rock starts whole", Integer.valueOf(100), Integer.valueOf(rock.condition));
ItemStack wood = ItemStack.of(ItemRegistry.WOOD, 1);
eq("wood does not wear out", Integer.valueOf(-1),
Integer.valueOf(wood.condition));
// a swing costs the tool something
ItemType rockType = ItemRegistry.require(ItemRegistry.ROCK);
eq("a swing on a tree costs the rock", 0.3F,
rockType.gather(ResourceClass.TREE).conditionLoss, 0.0001F);
eq("a swing on an ore costs the same", 0.3F,
rockType.gather(ResourceClass.METAL).conditionLoss, 0.0001F);
}
private static void eq(String what, Object expected, Object actual, float tolerance) {
if (expected instanceof Float && actual instanceof Float) {
float difference = Math.abs(((Float) expected).floatValue()
- ((Float) actual).floatValue());
if (difference <= tolerance) {
return;
}
}
eq(what, expected, actual);
}
}