mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Drop guest cursor and crafting inputs on disconnect
Co-Authored-By: Codex <[email protected]>
This commit is contained in:
1 parent
696d021699
commit
0e043eafae
6 files changed
+596
-21
No files matched your search
@@ -2083,7 +2083,7 @@ impl Game<'_> {
|
||||
let conn = guest.conn;
|
||||
let at = host.hub.guests.len();
|
||||
host.hub.guests.push(*guest);
|
||||
self.guest_close_all(&mut host, conn);
|
||||
self.guest_disconnected(&mut host, conn);
|
||||
*guest = host.hub.guests.remove(at);
|
||||
}
|
||||
if let Err(e) = self.enter_level(Some(&mut host), back) {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! block log, the level events and the entity tracker.
|
||||
use super::{
|
||||
Dim, Game, GuestUse, Hit, InteractTarget, Mining, Screen, lan::LanHost,
|
||||
respawn_anchor::RespawnConfig,
|
||||
lan_crafting::MenuRemoval, respawn_anchor::RespawnConfig,
|
||||
};
|
||||
use horizon_client::{
|
||||
command::vanilla,
|
||||
@@ -1487,9 +1487,25 @@ impl Game<'_> {
|
||||
/// whose server is closing. The guest must be in `host.hub.guests` and the
|
||||
/// level they are in must be the one in place.
|
||||
pub(super) fn guest_close_all(&mut self, host: &mut LanHost, conn: u32) {
|
||||
self.guest_remove_menus(host, conn, MenuRemoval::Live);
|
||||
}
|
||||
|
||||
/// `AbstractContainerMenu.removed` after the player has disconnected.
|
||||
/// Only HostEvent::Left selects this policy; ordinary closes and shutdown
|
||||
/// keep their existing return-to-inventory behavior.
|
||||
pub(super) fn guest_disconnected(&mut self, host: &mut LanHost, conn: u32) {
|
||||
self.guest_remove_menus(host, conn, MenuRemoval::Disconnected);
|
||||
}
|
||||
|
||||
/// `AbstractContainerMenu.removed`: close the guest's active menu using
|
||||
/// its live or disconnected disposition for cursor and crafting inputs.
|
||||
fn guest_remove_menus(&mut self, host: &mut LanHost, conn: u32, removal: MenuRemoval) {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
if removal == MenuRemoval::Disconnected {
|
||||
self.guest_drop_carried(host, gi);
|
||||
}
|
||||
if let Some((window, slots)) = host.hub.guests[gi]
|
||||
.menu
|
||||
.as_ref()
|
||||
@@ -1504,10 +1520,17 @@ impl Game<'_> {
|
||||
host.hub.container_closed(conn, window);
|
||||
}
|
||||
self.lan_merchant_closed(host, conn, None);
|
||||
self.lan_crafting_closed(host, conn, None);
|
||||
self.lan_crafting_removed(host, conn, None, removal);
|
||||
if removal == MenuRemoval::Disconnected {
|
||||
// The inventory grid is separate from an open table. Empty both
|
||||
// before lan_crafting_left saves the guest; never drop result slots.
|
||||
self.lan_crafting_removed(host, conn, Some(0), removal);
|
||||
}
|
||||
self.lan_station_closed(host, conn, None);
|
||||
self.lan_mount_closed(host, conn, None);
|
||||
self.guest_carried_back(host, gi);
|
||||
if removal == MenuRemoval::Live {
|
||||
self.guest_carried_back(host, gi);
|
||||
}
|
||||
// `PlayerContainerEvent.Close`, after the menu's `removed`.
|
||||
self.player_menu_closing(host, conn);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,14 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
|
||||
/// `AbstractContainerMenu.dropOrPlaceInInventory`: actual network disconnects
|
||||
/// drop inputs; a live close still returns them to the inventory.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum MenuRemoval {
|
||||
Live,
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
/// The guest's menus the game runs: kept between clicks, so a drag (quick
|
||||
/// craft) spans them as in vanilla.
|
||||
#[derive(Default)]
|
||||
@@ -675,6 +683,18 @@ impl Game<'_> {
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
window: Option<i32>,
|
||||
) -> bool {
|
||||
self.lan_crafting_removed(host, conn, window, MenuRemoval::Live)
|
||||
}
|
||||
|
||||
/// `AbstractContainerMenu.clearContainer`: only input slots are removed;
|
||||
/// `dropOrPlaceInInventory` chooses drops for a disconnected player.
|
||||
pub(super) fn lan_crafting_removed(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
window: Option<i32>,
|
||||
removal: MenuRemoval,
|
||||
) -> bool {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return false;
|
||||
@@ -695,7 +715,10 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
if !grid.is_empty() {
|
||||
self.lan_place_back(host, gi, grid);
|
||||
match removal {
|
||||
MenuRemoval::Live => self.lan_place_back(host, gi, grid),
|
||||
MenuRemoval::Disconnected => self.drop_guest_items(host, gi, grid),
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -117,6 +117,7 @@ public final class LocatorBarClient {
|
||||
private static volatile boolean state;
|
||||
private static volatile boolean events;
|
||||
private static volatile boolean spawns;
|
||||
private static volatile boolean menus;
|
||||
private static final Map<UUID, String> NAMES = new LinkedHashMap<>();
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
@@ -127,6 +128,7 @@ public final class LocatorBarClient {
|
||||
int port = Integer.parseInt(args[1]);
|
||||
Path output = Path.of(args[2]);
|
||||
Map<String, Player> players = new LinkedHashMap<>();
|
||||
List<Player> sessions = new ArrayList<>();
|
||||
BufferedReader script = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
|
||||
String line;
|
||||
while ((line = script.readLine()) != null) {
|
||||
@@ -137,10 +139,12 @@ public final class LocatorBarClient {
|
||||
case "join" -> {
|
||||
Player p = new Player(w[1]);
|
||||
players.put(w[1], p);
|
||||
sessions.add(p);
|
||||
NAMES.put(UUIDUtil.createOfflinePlayerUUID(w[1]), w[1]);
|
||||
p.join(host, port);
|
||||
}
|
||||
case "cmd" -> players.get(w[1]).command(w[2]);
|
||||
case "send-cmd" -> players.get(w[1]).send(new ServerboundChatCommandPacket(w[2]));
|
||||
case "shift" -> players.get(w[1]).send(new ServerboundPlayerInputPacket(
|
||||
new Input(false, false, false, false, false, w[2].equals("on"), false)));
|
||||
case "fly" -> {
|
||||
@@ -163,6 +167,23 @@ public final class LocatorBarClient {
|
||||
case "pickup" -> players.get(w[1]).send(new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
|
||||
0, 0, Short.parseShort(w[2]), (byte) 0, net.minecraft.world.inventory.ContainerInput.PICKUP,
|
||||
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY));
|
||||
// A stale state requests the server's complete authoritative post-click snapshot.
|
||||
case "click" -> {
|
||||
String[] a = w[2].split(" ");
|
||||
Player p = players.get(w[1]);
|
||||
int window = Integer.parseInt(a[0]);
|
||||
p.send(new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
|
||||
window, -1, Short.parseShort(a[1]), (byte) 0,
|
||||
net.minecraft.world.inventory.ContainerInput.PICKUP,
|
||||
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY));
|
||||
}
|
||||
case "close-menu" -> players.get(w[1]).send(
|
||||
new net.minecraft.network.protocol.game.ServerboundContainerClosePacket(Integer.parseInt(w[2])));
|
||||
case "menus" -> menus = w[1].equals("on");
|
||||
case "dump" -> {
|
||||
writeSessions(output, sessions);
|
||||
stdout.println("dumped " + w[1]);
|
||||
}
|
||||
case "sync" -> {
|
||||
try {
|
||||
players.get(w[1]).sync();
|
||||
@@ -184,14 +205,8 @@ public final class LocatorBarClient {
|
||||
case "events" -> events = w[1].equals("on");
|
||||
case "spawns" -> spawns = w[1].equals("on");
|
||||
case "end" -> {
|
||||
try (PrintWriter out = new PrintWriter(Files.newBufferedWriter(output, StandardCharsets.UTF_8))) {
|
||||
for (Player p : players.values()) {
|
||||
synchronized (p.lines) {
|
||||
for (String l : p.lines) out.println(p.name + " " + l);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (Player p : players.values()) p.close();
|
||||
writeSessions(output, sessions);
|
||||
for (Player p : sessions) p.close();
|
||||
System.exit(0);
|
||||
}
|
||||
default -> throw new IllegalArgumentException("unknown script line " + line);
|
||||
@@ -205,6 +220,28 @@ public final class LocatorBarClient {
|
||||
System.exit(1);
|
||||
}
|
||||
|
||||
private static void writeSessions(Path output, List<Player> sessions) throws Exception {
|
||||
try (PrintWriter out = new PrintWriter(Files.newBufferedWriter(output, StandardCharsets.UTF_8))) {
|
||||
for (Player p : sessions) {
|
||||
synchronized (p.lines) {
|
||||
for (String line : p.lines) out.println(p.name + " " + line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static com.google.gson.JsonElement menuStack(net.minecraft.world.item.ItemStack stack) throws Exception {
|
||||
if (stack.isEmpty()) return com.google.gson.JsonNull.INSTANCE;
|
||||
var tag = net.minecraft.world.item.ItemStack.CODEC.encodeStart(
|
||||
ParityData.access.createSerializationContext(net.minecraft.nbt.NbtOps.INSTANCE), stack).getOrThrow();
|
||||
var bytes = new java.io.ByteArrayOutputStream();
|
||||
net.minecraft.nbt.NbtIo.write((net.minecraft.nbt.CompoundTag) tag, new java.io.DataOutputStream(bytes));
|
||||
var json = new com.google.gson.JsonObject();
|
||||
json.addProperty("snbt", tag.toString());
|
||||
json.addProperty("nbt", java.util.HexFormat.of().formatHex(bytes.toByteArray()));
|
||||
return json;
|
||||
}
|
||||
|
||||
/** The waypoint packet's fields, read back from its bytes (the operation and type are private enums). */
|
||||
static String waypoint(byte[] payload) {
|
||||
FriendlyByteBuf b = new FriendlyByteBuf(Unpooled.wrappedBuffer(payload));
|
||||
@@ -237,6 +274,7 @@ public final class LocatorBarClient {
|
||||
private static final class Player {
|
||||
final String name;
|
||||
final List<String> lines = new ArrayList<>();
|
||||
final Map<Integer, Integer> menuStates = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
private Socket socket;
|
||||
private DataInputStream in;
|
||||
private OutputStream out;
|
||||
@@ -266,9 +304,10 @@ public final class LocatorBarClient {
|
||||
void command(String command) throws Exception {
|
||||
synchronized (replyLock) {
|
||||
int before = replies;
|
||||
int beforeSync = syncs;
|
||||
send(new ServerboundChatCommandPacket(command));
|
||||
long until = System.nanoTime() + 30_000_000_000L;
|
||||
while (replies == before) {
|
||||
while (replies == before && syncs == beforeSync) {
|
||||
long left = (until - System.nanoTime()) / 1_000_000L;
|
||||
if (left <= 0) {
|
||||
record("error no reply to /" + command);
|
||||
@@ -453,6 +492,49 @@ public final class LocatorBarClient {
|
||||
reader.start();
|
||||
}
|
||||
|
||||
private void recordMenu(Packet<?> p, byte[] payload) throws Exception {
|
||||
var j = new com.google.gson.JsonObject();
|
||||
j.addProperty("packet", p.type().id().toString());
|
||||
j.addProperty("wire", java.util.HexFormat.of().formatHex(payload));
|
||||
if (p instanceof net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket c) {
|
||||
j.addProperty("kind", "content");
|
||||
j.addProperty("window", c.containerId());
|
||||
j.addProperty("state", c.stateId());
|
||||
menuStates.put(c.containerId(), c.stateId());
|
||||
var items = new com.google.gson.JsonArray();
|
||||
for (var stack : c.items()) items.add(menuStack(stack));
|
||||
j.add("items", items);
|
||||
j.add("carried", menuStack(c.carriedItem()));
|
||||
} else if (p instanceof net.minecraft.network.protocol.game.ClientboundContainerSetSlotPacket c) {
|
||||
j.addProperty("kind", "slot");
|
||||
j.addProperty("window", c.getContainerId());
|
||||
j.addProperty("state", c.getStateId());
|
||||
menuStates.put(c.getContainerId(), c.getStateId());
|
||||
j.addProperty("slot", c.getSlot());
|
||||
j.add("item", menuStack(c.getItem()));
|
||||
} else if (p instanceof net.minecraft.network.protocol.game.ClientboundSetCursorItemPacket c) {
|
||||
j.addProperty("kind", "cursor");
|
||||
j.add("item", menuStack(c.contents()));
|
||||
} else if (p instanceof net.minecraft.network.protocol.game.ClientboundSetPlayerInventoryPacket c) {
|
||||
j.addProperty("kind", "inventory");
|
||||
j.addProperty("slot", c.slot());
|
||||
j.add("item", menuStack(c.contents()));
|
||||
} else if (p instanceof net.minecraft.network.protocol.game.ClientboundOpenScreenPacket c) {
|
||||
j.addProperty("kind", "open");
|
||||
j.addProperty("window", c.getContainerId());
|
||||
j.addProperty("type", net.minecraft.core.registries.BuiltInRegistries.MENU.getKey(c.getType()).toString());
|
||||
} else if (p instanceof net.minecraft.network.protocol.game.ClientboundLoginPacket c) {
|
||||
j.addProperty("kind", "login");
|
||||
j.addProperty("mode", c.commonPlayerSpawnInfo().gameType().getId());
|
||||
} else if (p instanceof ClientboundSystemChatPacket c) {
|
||||
j.addProperty("kind", "chat");
|
||||
j.add("text", net.minecraft.network.chat.ComponentSerialization.CODEC.encodeStart(
|
||||
ParityData.access.createSerializationContext(com.mojang.serialization.JsonOps.INSTANCE), c.content()).getOrThrow());
|
||||
j.addProperty("overlay", c.overlay());
|
||||
} else return;
|
||||
record("menu-wire " + j);
|
||||
}
|
||||
|
||||
private void play() {
|
||||
try {
|
||||
while (true) {
|
||||
@@ -467,6 +549,7 @@ public final class LocatorBarClient {
|
||||
&& e.getPos().equals(new net.minecraft.core.BlockPos(0, 80, 0)))
|
||||
record("piston-event " + e.getB0() + " " + e.getB1());
|
||||
if (state) recordState(p);
|
||||
if (menus) recordMenu(p, r.payload);
|
||||
if (hooks && p instanceof net.minecraft.network.protocol.game.ClientboundAddEntityPacket a) {
|
||||
entityTypes.put(a.getId(), net.minecraft.world.entity.EntityType.getKey(a.getType()).toString());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real guest menu clicks, live close, disconnect drops, saves and same-UUID reconnect.
|
||||
|
||||
The raw transcript retains complete decoded packets and their bytes. Canonical menu
|
||||
checkpoints replay authoritative content/slot/cursor packets, never a client prediction.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import anvil_dump as nbt
|
||||
import test_locator_bar_parity as net
|
||||
|
||||
OBSERVER, GUEST = 'H319Observer', 'H319Guest'
|
||||
|
||||
|
||||
def offline(name):
|
||||
raw = bytearray(hashlib.md5(f'OfflinePlayer:{name}'.encode()).digest())
|
||||
raw[6], raw[8] = (raw[6] & 15) | 48, (raw[8] & 63) | 128
|
||||
return str(uuid.UUID(bytes=bytes(raw)))
|
||||
|
||||
|
||||
def uuid_words(name):
|
||||
import struct
|
||||
return list(struct.unpack('>4i', uuid.UUID(offline(name)).bytes))
|
||||
|
||||
|
||||
def stack(value):
|
||||
return None if value is None else nbt.read_named(bytes.fromhex(value['nbt']))
|
||||
|
||||
|
||||
def menus(path, label):
|
||||
windows, carried, opened = {}, None, None
|
||||
for line in path.read_text().splitlines():
|
||||
prefix = GUEST + ' step='
|
||||
if not line.startswith(prefix) or ' menu-wire ' not in line:
|
||||
continue
|
||||
step, raw = line[len(prefix):].split(' menu-wire ', 1)
|
||||
p = json.loads(raw)
|
||||
kind = p['kind']
|
||||
if kind == 'login':
|
||||
windows, carried, opened = {}, None, None
|
||||
elif kind == 'open':
|
||||
opened = p['window']
|
||||
elif kind == 'content':
|
||||
windows[p['window']] = [stack(s) for s in p['items']]
|
||||
carried = stack(p['carried'])
|
||||
elif kind == 'slot':
|
||||
if p['window'] in windows:
|
||||
windows[p['window']][p['slot']] = stack(p['item'])
|
||||
elif kind == 'cursor':
|
||||
carried = stack(p['item'])
|
||||
elif kind == 'inventory':
|
||||
i = p['slot']
|
||||
if 0 in windows:
|
||||
slot = 36 + i if i < 9 else i if i < 36 else 44 - i if i < 40 else 45
|
||||
windows[0][slot] = stack(p['item'])
|
||||
if opened in windows and i < 36:
|
||||
windows[opened][37 + i if i < 9 else 10 + i - 9] = stack(p['item'])
|
||||
return {'windows': windows, 'carried': carried, 'opened': opened}
|
||||
|
||||
|
||||
def wait_new(server, start, pattern):
|
||||
deadline = time.monotonic() + 30
|
||||
while time.monotonic() < deadline:
|
||||
if any(re.search(pattern, line) for line in server.lines[start:]):
|
||||
return
|
||||
if server.process.poll() is not None:
|
||||
break
|
||||
time.sleep(.02)
|
||||
raise AssertionError(f'no new {pattern}: {server.lines[start:][-10:]}')
|
||||
|
||||
|
||||
def generate(command, build):
|
||||
directory = build / 'generate'
|
||||
net.server_dir(directory)
|
||||
server = net.Server(command, directory, 'generate')
|
||||
try:
|
||||
server.wait_for(r'Done \(')
|
||||
for cmd, ack in [('tick freeze', 'frozen'), ('gamerule spawn_mobs false', 'spawn_mobs'),
|
||||
('gamerule advance_time false', 'advance_time'), ('gamerule respawn_radius 0', 'respawn_radius'),
|
||||
('forceload add 0 0 31 31', 'force loaded'), ('fill 0 99 0 31 99 31 stone', '1024'),
|
||||
('fill 0 100 0 31 104 31 air', 'blocks'), ('setworldspawn 4 100 4', 'world spawn'),
|
||||
('save-all flush', 'Saved the game')]:
|
||||
start = len(server.lines)
|
||||
server.console(cmd)
|
||||
wait_new(server, start, ack)
|
||||
finally:
|
||||
server.stop()
|
||||
return directory / 'world'
|
||||
|
||||
|
||||
def run(label, command, client_command, build, world):
|
||||
directory = build / label
|
||||
port = net.server_dir(directory, world)
|
||||
server = net.Server(command, directory, label)
|
||||
out = build / f'{label}-wire.txt'
|
||||
checkpoints = {}
|
||||
log = (build / f'{label}-client.log').open('w')
|
||||
client = None
|
||||
try:
|
||||
server.wait_for(r'Done \(')
|
||||
client = net.Client(client_command, port, out, log)
|
||||
client.tell('menus on')
|
||||
client.tell('state on')
|
||||
client.tell('chat on')
|
||||
|
||||
def sync():
|
||||
client.sync([OBSERVER, GUEST])
|
||||
|
||||
def cmd(text, who=GUEST):
|
||||
action = 'cmd' if 'run time query gametime' in text else 'send-cmd'
|
||||
client.tell(f'{action} {who} {text}')
|
||||
client.sync([who])
|
||||
|
||||
def phase(name):
|
||||
client.tell(f'step {name}')
|
||||
|
||||
def checkpoint(name):
|
||||
sync()
|
||||
client.tell(f'dump {name}')
|
||||
while (line := client.process.stdout.readline()) != f'dumped {name}\n':
|
||||
assert line, 'client exited before menu dump'
|
||||
log.write(line)
|
||||
checkpoints[name] = menus(out, name)
|
||||
(build / f'{label}-menus.json').write_text(json.dumps(checkpoints, indent=2))
|
||||
return checkpoints[name]
|
||||
|
||||
def await_menu(name, expected):
|
||||
deadline = time.monotonic() + 3
|
||||
while True:
|
||||
actual = checkpoint(name)
|
||||
if expected(actual):
|
||||
return actual
|
||||
assert time.monotonic() < deadline, f'authoritative menu prerequisite {name}: {actual}'
|
||||
time.sleep(.02)
|
||||
|
||||
def await_return(name, item):
|
||||
def returned(_):
|
||||
for line in out.read_text().splitlines():
|
||||
if not line.startswith(f'{GUEST} step={name} menu-wire '):
|
||||
continue
|
||||
packet = json.loads(line.split(' menu-wire ', 1)[1])
|
||||
player_slot = packet.get('kind') == 'inventory' and packet.get('slot') == 0
|
||||
menu_slot = packet.get('kind') == 'slot' and packet.get('window') == 0 and packet.get('slot') == 36
|
||||
if (player_slot or menu_slot) and stack(packet['item']) == item:
|
||||
return True
|
||||
return False
|
||||
return await_menu(name, returned)
|
||||
|
||||
def snapshot(name, player=True):
|
||||
cmd(f'data modify storage h319:proof {name}.clock set value 0L', OBSERVER)
|
||||
cmd(f'execute store result storage h319:proof {name}.clock long 1 run time query gametime', OBSERVER)
|
||||
if player:
|
||||
cmd(f'data modify storage h319:proof {name}.player set from entity {GUEST}', OBSERVER)
|
||||
cmd(f'data modify storage h319:proof {name}.drops set value []', OBSERVER)
|
||||
cmd(f'execute as @e[type=minecraft:item] run data modify storage h319:proof {name}.drops append from entity @s', OBSERVER)
|
||||
|
||||
def disconnect(name):
|
||||
phase(name)
|
||||
start = len(server.lines)
|
||||
client.tell(f'quit {GUEST}')
|
||||
wait_new(server, start, GUEST + r'.*(left the game|lost connection)')
|
||||
client.sync([OBSERVER])
|
||||
cmd(f'execute store success storage h319:proof {name}.absent byte 1 run execute unless entity {offline(GUEST)}', OBSERVER)
|
||||
cmd(f'data modify storage h319:proof {name}.observer set from entity {OBSERVER}', OBSERVER)
|
||||
snapshot(name, False)
|
||||
start = len(server.lines)
|
||||
server.console('save-all flush')
|
||||
wait_new(server, start, 'Saved the game')
|
||||
files = list((directory / 'world').rglob(offline(GUEST) + '.dat'))
|
||||
assert len(files) == 1, files
|
||||
shutil.copy2(files[0], build / f'{label}-{name}-player.dat')
|
||||
|
||||
def join(name):
|
||||
phase(name)
|
||||
client.tell(f'join {GUEST}')
|
||||
sync()
|
||||
checkpoint(name)
|
||||
snapshot(name)
|
||||
|
||||
client.tell(f'join {OBSERVER}')
|
||||
client.sync([OBSERVER])
|
||||
cmd('tick freeze', OBSERVER)
|
||||
cmd('gamerule spawn_mobs false', OBSERVER)
|
||||
cmd('gamerule advance_time false', OBSERVER)
|
||||
cmd('tp @s 20.5 100 20.5', OBSERVER)
|
||||
join('first-join')
|
||||
cmd('gamemode survival')
|
||||
cmd('tp @s 4.5 100 4.5 0 0')
|
||||
cmd('clear @s')
|
||||
cmd('kill @e[type=minecraft:item]', OBSERVER)
|
||||
for i, (x, z) in enumerate([(0, 0), (0, 16), (16, 0), (16, 16)]):
|
||||
cmd(f'execute store success storage h319:proof guards.loaded{i} byte 1 run execute if loaded {x} 100 {z}', OBSERVER)
|
||||
for key, condition in [('floor', 'if block 4 99 4 minecraft:stone'), ('air', 'if block 4 100 4 minecraft:air')]:
|
||||
cmd(f'execute store success storage h319:proof guards.{key} byte 1 run execute {condition}', OBSERVER)
|
||||
cmd(f'execute store success storage h319:proof guards.guest_type byte 1 run execute if entity @a[nbt={{UUID:[I;{",".join(map(str, uuid_words(GUEST))) }]}}]', OBSERVER)
|
||||
snapshot('inventory_ready')
|
||||
cmd('item replace entity @s hotbar.0 with minecraft:dirt 10')
|
||||
cmd('item replace entity @s hotbar.1 with minecraft:stone 5')
|
||||
phase('inventory-inputs')
|
||||
checkpoint('inventory-inputs')
|
||||
for slot in (36, 1, 37):
|
||||
phase(f'inventory-click-{slot}')
|
||||
client.tell(f'click {GUEST} 0 {slot}')
|
||||
expected = None if slot == 1 else {'count': 10 if slot == 36 else 5, 'id': 'minecraft:dirt' if slot == 36 else 'minecraft:stone'}
|
||||
await_menu(f'inventory-click-{slot}', lambda m: m['carried'] == expected)
|
||||
admitted = checkpoint('inventory-admitted')
|
||||
assert admitted['windows'][0][1] == {'id': 'minecraft:dirt', 'count': 10}
|
||||
assert admitted['carried'] == {'id': 'minecraft:stone', 'count': 5}
|
||||
snapshot('inventory_admitted')
|
||||
disconnect('inventory_disconnect')
|
||||
join('inventory-reconnect')
|
||||
cmd('kill @e[type=minecraft:item]', OBSERVER)
|
||||
cmd('clear @s')
|
||||
cmd('item replace entity @s hotbar.0 with minecraft:oak_log 3')
|
||||
cmd('setblock 6 100 4 minecraft:crafting_table', OBSERVER)
|
||||
cmd('execute store success storage h319:proof guards.table byte 1 run execute if block 6 100 4 minecraft:crafting_table', OBSERVER)
|
||||
phase('table-open')
|
||||
client.tell(f'use-on {GUEST} 6 100 4')
|
||||
table = await_menu('table-open', lambda m: m['opened'] in m['windows'])
|
||||
window = table['opened']
|
||||
assert window is not None, 'no actual crafting-table window'
|
||||
for slot in (37, 5):
|
||||
phase(f'table-click-{slot}')
|
||||
client.tell(f'click {GUEST} {window} {slot}')
|
||||
expected = {'id': 'minecraft:oak_log', 'count': 3} if slot == 37 else None
|
||||
await_menu(f'table-click-{slot}', lambda m: m['carried'] == expected)
|
||||
admitted = checkpoint('table-admitted')
|
||||
assert admitted['windows'][window][5] == {'id': 'minecraft:oak_log', 'count': 3}
|
||||
assert admitted['windows'][window][0] == {'id': 'minecraft:oak_planks', 'count': 4}
|
||||
snapshot('table_admitted')
|
||||
disconnect('table_disconnect')
|
||||
join('table-reconnect')
|
||||
cmd('kill @e[type=minecraft:item]', OBSERVER)
|
||||
cmd('clear @s')
|
||||
cmd('item replace entity @s hotbar.0 with minecraft:stone[minecraft:custom_data={h319:7}] 5')
|
||||
phase('live-cursor-pickup')
|
||||
client.tell(f'click {GUEST} 0 36')
|
||||
await_menu('live-cursor-admitted', lambda m: m['carried'] is not None)
|
||||
phase('live-cursor-close')
|
||||
client.tell(f'close-menu {GUEST} 0')
|
||||
await_menu('live-cursor-returned', lambda m: m['carried'] is None)
|
||||
snapshot('live_cursor_returned')
|
||||
cmd('clear @s')
|
||||
cmd('item replace entity @s hotbar.0 with minecraft:oak_log 3')
|
||||
phase('live-table-open')
|
||||
client.tell(f'use-on {GUEST} 6 100 4')
|
||||
window = await_menu('live-table-open', lambda m: m['opened'] in m['windows'])['opened']
|
||||
for slot in (37, 5):
|
||||
client.tell(f'click {GUEST} {window} {slot}')
|
||||
expected = {'id': 'minecraft:oak_log', 'count': 3} if slot == 37 else None
|
||||
await_menu(f'live-table-click-{slot}', lambda m: m['carried'] == expected)
|
||||
checkpoint('live-table-admitted')
|
||||
phase('live-table-close')
|
||||
client.tell(f'close-menu {GUEST} {window}')
|
||||
await_return('live-table-close', {'id': 'minecraft:oak_log', 'count': 3})
|
||||
checkpoint('live-table-returned')
|
||||
snapshot('live_table_returned')
|
||||
client.end()
|
||||
finally:
|
||||
if client:
|
||||
client.kill()
|
||||
server.stop()
|
||||
log.close()
|
||||
storage = list((directory / 'world').rglob('*command_storage*.dat'))
|
||||
assert storage, 'no saved command-storage proof'
|
||||
raw = {str(p.relative_to(directory)): nbt.read_dat(p) for p in storage}
|
||||
(build / f'{label}-storage.json').write_text(json.dumps(raw, indent=2))
|
||||
return checkpoints, raw
|
||||
|
||||
|
||||
def facts(build, label):
|
||||
"""Declared exact fields plus typed finite/local facts for random drop entities.
|
||||
|
||||
Complete packets, entity compounds and saves remain unmodified diagnostics.
|
||||
State IDs are protocol revision counters; drop UUIDs/motion are random.
|
||||
These are not compared as deterministic values or replaced in raw captures.
|
||||
"""
|
||||
raw = json.loads((build / f'{label}-storage.json').read_text())
|
||||
proof = next(iter(raw.values()))['data']['contents']['proof']
|
||||
checkpoints = json.loads((build / f'{label}-menus.json').read_text())
|
||||
result = {}
|
||||
for name, value in checkpoints.items():
|
||||
active = value['opened'] if ('table' in name and name not in ('table-reconnect', 'live-table-returned', 'live-table-close')) else 0
|
||||
result[f'menu/{name}/window'] = active
|
||||
result[f'menu/{name}/slots'] = value['windows'][str(active)]
|
||||
result[f'menu/{name}/cursor'] = value['carried']
|
||||
start = proof['inventory_ready']['clock']
|
||||
for name, value in sorted(proof.items()):
|
||||
if name == 'guards':
|
||||
for key, actual in value.items():
|
||||
result[f'guard/{key}'] = actual
|
||||
continue
|
||||
result[f'{name}/clock-type'] = value['clock'][0]
|
||||
result[f'{name}/clock-delta'] = value['clock'][1] - start[1]
|
||||
if 'player' in value:
|
||||
for key in ('UUID', 'Health', 'Pos', 'Rotation', 'playerGameType', 'Inventory'):
|
||||
result[f'{name}/player/{key}'] = value['player'].get(key)
|
||||
if 'absent' in value:
|
||||
result[f'{name}/guest-absent'] = value['absent']
|
||||
for key in ('UUID', 'Health', 'Pos', 'playerGameType'):
|
||||
result[f'{name}/observer/{key}'] = value['observer'].get(key)
|
||||
drops = value['drops']
|
||||
result[f'{name}/drop-stacks'] = sorted((d['Item'] for d in drops), key=lambda s: s['id'])
|
||||
result[f'{name}/drop-count'] = len(drops)
|
||||
ids = [d.get('UUID') for d in drops]
|
||||
result[f'{name}/drop-identity'] = all(
|
||||
isinstance(v, list) and len(v) == 2 and v[0] == 'I' and len(v[1]) == 4
|
||||
and any(v[1]) and all(type(i) is int for i in v[1]) for v in ids
|
||||
) and len({json.dumps(v) for v in ids}) == len(ids)
|
||||
result[f'{name}/drop-typed-state'] = all(
|
||||
d.get('Health') == ['s', 5] and d.get('Age') == ['s', 0]
|
||||
and d.get('PickupDelay') == ['s', 40]
|
||||
and d.get('Pos') == [['d', 4.5], ['d', 101.31999999284744], ['d', 4.5]] for d in drops)
|
||||
import math
|
||||
result[f'{name}/drop-motion-local-finite'] = all(
|
||||
len(d.get('Motion', [])) == 3 and all(
|
||||
isinstance(v, list) and v[0] == 'd' and math.isfinite(v[1]) and abs(v[1]) <= 1
|
||||
for v in d['Motion']) for d in drops)
|
||||
for name in ('inventory_disconnect', 'table_disconnect'):
|
||||
saved = nbt.read_dat(build / f'{label}-{name}-player.dat')
|
||||
for key in ('UUID', 'Health', 'Pos', 'playerGameType', 'Inventory'):
|
||||
result[f'save/{name}/{key}'] = saved.get(key)
|
||||
# The complete relevant component JSON is retained alongside the raw packet bytes.
|
||||
wires = [json.loads(line.split(' menu-wire ', 1)[1]) for line in (build / f'{label}-wire.txt').read_text().splitlines()
|
||||
if line.startswith(GUEST + ' ') and ' menu-wire ' in line]
|
||||
result['network/login-modes'] = [p['mode'] for p in wires if p['kind'] == 'login']
|
||||
result['network/open-menu-types'] = [p['type'] for p in wires if p['kind'] == 'open']
|
||||
return json.loads(json.dumps(result))
|
||||
|
||||
|
||||
def validate_java(result):
|
||||
"""Independent fixture expectations, checked before using Java as the paired reference."""
|
||||
def item(name, count, **extra):
|
||||
return {'id': 'minecraft:' + name, 'count': count, **extra}
|
||||
def slots(entries):
|
||||
return [entries.get(i) for i in range(46)]
|
||||
custom = item('stone', 5, components={'minecraft:custom_data': {'h319': 7}})
|
||||
expected = {
|
||||
'first-join': ({}, None),
|
||||
'inventory-inputs': ({36: item('dirt', 10), 37: item('stone', 5)}, None),
|
||||
'inventory-click-36': ({37: item('stone', 5)}, item('dirt', 10)),
|
||||
'inventory-click-1': ({1: item('dirt', 10), 37: item('stone', 5)}, None),
|
||||
'inventory-click-37': ({1: item('dirt', 10)}, item('stone', 5)),
|
||||
'inventory-admitted': ({1: item('dirt', 10)}, item('stone', 5)),
|
||||
'inventory-reconnect': ({}, None),
|
||||
'table-open': ({37: item('oak_log', 3)}, None),
|
||||
'table-click-37': ({}, item('oak_log', 3)),
|
||||
'table-click-5': ({0: item('oak_planks', 4), 5: item('oak_log', 3)}, None),
|
||||
'table-admitted': ({0: item('oak_planks', 4), 5: item('oak_log', 3)}, None),
|
||||
'table-reconnect': ({}, None),
|
||||
'live-cursor-admitted': ({}, custom),
|
||||
'live-cursor-returned': ({36: custom}, None),
|
||||
'live-table-open': ({37: item('oak_log', 3)}, None),
|
||||
'live-table-click-37': ({}, item('oak_log', 3)),
|
||||
'live-table-click-5': ({0: item('oak_planks', 4), 5: item('oak_log', 3)}, None),
|
||||
'live-table-admitted': ({0: item('oak_planks', 4), 5: item('oak_log', 3)}, None),
|
||||
'live-table-close': ({36: item('oak_log', 3)}, None),
|
||||
'live-table-returned': ({36: item('oak_log', 3)}, None),
|
||||
}
|
||||
# Closed table packets need not refresh the abandoned preview/grid; its active
|
||||
# inventory is checked by full saved command state below, not stale client slots.
|
||||
for name, (entries, cursor) in expected.items():
|
||||
assert result[f'menu/{name}/slots'] == slots(entries), name
|
||||
assert result[f'menu/{name}/cursor'] == cursor, name
|
||||
for key, actual in result.items():
|
||||
if key.endswith('/clock-delta'):
|
||||
assert actual == 0, (key, actual)
|
||||
if key.startswith('guard/') or key.endswith('/guest-absent'):
|
||||
assert actual == ['b', 1], (key, actual)
|
||||
if '/drop-' in key and key.endswith(('identity', 'typed-state', 'local-finite')):
|
||||
assert actual is True, (key, actual)
|
||||
for phase in ('inventory_disconnect', 'inventory-reconnect'):
|
||||
assert result[f'{phase}/drop-stacks'] == [item('dirt', 10), item('stone', 5)]
|
||||
for phase in ('table_disconnect', 'table-reconnect'):
|
||||
assert result[f'{phase}/drop-stacks'] == [item('oak_log', 3)]
|
||||
assert result['live_cursor_returned/player/Inventory'] == [dict(custom, Slot=['b', 0])]
|
||||
assert result['live_table_returned/player/Inventory'] == [dict(item('oak_log', 3), Slot=['b', 0])]
|
||||
for name in ('inventory_disconnect', 'table_disconnect'):
|
||||
assert result[f'save/{name}/Inventory'] == []
|
||||
for key, actual in result.items():
|
||||
if key.endswith('/UUID'):
|
||||
who = OBSERVER if '/observer/' in key else GUEST
|
||||
assert actual == ['I', uuid_words(who)], (key, actual)
|
||||
elif key.endswith('/Health'):
|
||||
assert actual == ['f', 20.0], (key, actual)
|
||||
elif key.endswith('/Pos'):
|
||||
expected_pos = [20.5, 100., 20.5] if '/observer/' in key else [4.5, 100., 4.5]
|
||||
assert actual == [['d', value] for value in expected_pos], (key, actual)
|
||||
elif key.endswith('/playerGameType'):
|
||||
expected_mode = 1 if '/observer/' in key or key.startswith('first-join/') else 0
|
||||
assert actual == expected_mode, (key, actual)
|
||||
elif key.endswith('/Rotation'):
|
||||
assert actual == [['f', 0.], ['f', 0.]], (key, actual)
|
||||
assert result['network/login-modes'] == [1, 0, 0]
|
||||
assert result['network/open-menu-types'] == ['minecraft:crafting', 'minecraft:crafting']
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--side', choices=['java', 'native', 'both'], default='both')
|
||||
parser.add_argument('--label')
|
||||
parser.add_argument('--binary', type=Path)
|
||||
parser.add_argument('--build-name', default='disconnect-crafting-parity')
|
||||
parser.add_argument('--reference', type=Path, help='An unchanged fresh Java facts file for an old/fixed pair')
|
||||
args = parser.parse_args()
|
||||
net.PLAYERS = [OBSERVER, GUEST]
|
||||
net.PROPERTIES['force-gamemode'] = 'false'
|
||||
_, build, java, native, client = net.sides(args.build_name, args.binary.resolve() if args.binary else None)
|
||||
world = build / 'generate/world'
|
||||
if not world.exists():
|
||||
world = generate(java, build)
|
||||
reference = json.loads(args.reference.read_text()) if args.reference else None
|
||||
for side in (['java', 'native'] if args.side == 'both' else [args.side]):
|
||||
label = args.label or side
|
||||
if args.side == 'both':
|
||||
label = f'{label}-{side}' if args.label else side
|
||||
run(label, java if side == 'java' else native, client, build, world)
|
||||
actual = facts(build, label)
|
||||
(build / f'{label}-facts.json').write_text(json.dumps(actual, indent=2, sort_keys=True))
|
||||
if side == 'java':
|
||||
validate_java(actual)
|
||||
reference = actual
|
||||
if reference is not None:
|
||||
differences = [{'label': key, 'expected': reference[key], 'actual': actual.get(key)}
|
||||
for key in sorted(reference) if reference[key] != actual.get(key)]
|
||||
assert set(actual) == set(reference), 'the declared fact labels changed'
|
||||
report = {'checks': len(reference), 'passed': len(reference) - len(differences),
|
||||
'differences': differences, 'scope': __doc__}
|
||||
(build / f'{label}-report.json').write_text(json.dumps(report, indent=2))
|
||||
print(f'{label}: {report["passed"]}/{report["checks"]} exact facts')
|
||||
if differences:
|
||||
raise SystemExit(1)
|
||||
else:
|
||||
print(f'{label}: {len(actual)} declared facts captured')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -243,7 +243,7 @@ def run_side(label, server_command, client_command, build, world):
|
||||
return out.read_text(encoding='utf-8').splitlines()
|
||||
|
||||
|
||||
def sides(name):
|
||||
def sides(name, binary=None):
|
||||
"""The vanilla and Horizon dedicated servers and the LocatorBarClient that joins them, built for harness
|
||||
`name`: (lock, build directory, vanilla server, Horizon server, client) commands."""
|
||||
lock, jars, libraries = base.verify_artifacts()
|
||||
@@ -258,12 +258,13 @@ def sides(name):
|
||||
subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none', '-nowarn', '-sourcepath',
|
||||
lab.ROOT / 'mods/horizon/src/test/java', '-cp', dependencies, '-d', classes, oracle],
|
||||
cwd=lab.ROOT, check=True, capture_output=True)
|
||||
subprocess.run(['cargo', 'build', '--profile', 'fast', '-p', 'horizon-client', '--bin', 'horizon-client'], cwd=lab.ROOT,
|
||||
check=True, capture_output=True)
|
||||
import json
|
||||
metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--no-deps', '--format-version', '1'],
|
||||
cwd=lab.ROOT, text=True))
|
||||
binary = Path(metadata['target_directory']) / 'fast' / ('horizon-client.exe' if os.name == 'nt' else 'horizon-client')
|
||||
if binary is None:
|
||||
subprocess.run(['cargo', 'build', '--profile', 'fast', '-p', 'horizon-client', '--bin', 'horizon-client'], cwd=lab.ROOT,
|
||||
check=True, capture_output=True)
|
||||
import json
|
||||
metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--no-deps', '--format-version', '1'],
|
||||
cwd=lab.ROOT, text=True))
|
||||
binary = Path(metadata['target_directory']) / 'fast' / ('horizon-client.exe' if os.name == 'nt' else 'horizon-client')
|
||||
vanilla_server = ['java', '-Xmx2g', '-Duser.timezone=UTC', '-cp', dependencies, 'net.minecraft.server.Main', 'nogui']
|
||||
horizon_server = [binary, '--resources', lab.LOCAL / 'horizon-client/26.3', '--dedicated', '--nogui']
|
||||
client = ['java', '-Xmx1g', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.LocatorBarClient']
|
||||
|
||||
Reference in new issue
Block a user