From 5c35b5eba6300d3e0193b34cad39122b8fc1c11c Mon Sep 17 00:00:00 2001 From: Arun Date: Fri, 14 Aug 2026 08:44:21 +0000 Subject: [PATCH 01/10] fix: complete audited protocol and admin features --- README.md | 52 +++++++------ admin/web.py | 21 ++++- config.py | 8 +- handlers/play/__init__.py | 130 +++++++++++++++++++++++-------- network/server.py | 3 +- protocol/packet_map.py | 10 +++ server.properties | 5 +- tests/test_inventory_clicks.py | 83 ++++++++++++++++++++ tests/test_play_dispatch.py | 30 +++++++ tests/test_web_admin_security.py | 26 +++++++ 10 files changed, 306 insertions(+), 62 deletions(-) create mode 100644 tests/test_inventory_clicks.py create mode 100644 tests/test_play_dispatch.py create mode 100644 tests/test_web_admin_security.py diff --git a/README.md b/README.md index 2c667ec..a8de2d4 100755 --- a/README.md +++ b/README.md @@ -26,16 +26,20 @@ PyMC 是一个用 Python 实现的 Minecraft Java 版 1.21.1 服务端原型, - [x] 控制台和游戏内基础命令。 - [x] Web 管理台、权限组、OP、封禁和白名单。 - [x] 单元测试覆盖原生地形、原生 AI、种子解析和安全出生点。 -- [x] **红石系统**:线、火把、中继器、比较器、活塞、按钮、拉杆、压力板等全组件模拟,每 2 游戏刻 (0.1s) 执行一次红石刻。 -- [x] **1:1 原版地形生成器**:密度函数管线、气候采样、洞穴雕刻 (Cheese/Spaghetti/Noodle + Aquifer)、生物群系表面规则、三角矿石分布,力求输入相同种子后生成同位置同地形。 -- [x] **物品栏系统**:`ItemStack` 表示、`PlayerInventory` 管理、协议序列化、生存模式消耗、创造模式物品拾取。 -- [x] **方块行为系统**:箱子/工作台/熔炉/铁砧/附魔台交互、门/活板门/栅栏门开关、床/告示牌/TNT/音符盒/耕地等行为、方块硬度与工具要求。 -- [x] **流体系统**:水和岩浆流动模拟、水平扩散和垂直下落、水-岩浆交互 (石头/圆石/黑曜石)、维度感知流速。 -- [x] **多版本协议兼容**:1.8.9 - 1.21.4 (协议版本 47-770),版本化数据包 ID 映射、配置阶段仅限 1.20.2+、版本化登录/加入游戏格式。 -- [x] **命令框架**:`CommandManager` 统一注册、解析、权限检查和调度;50+ 原版命令已注册;Tab 补全支持;别名系统。 -- [x] **插件兼容层**:`PluginManager` 支持 Paper/Bukkit `.jar` 插件 (C++ JVM 桥接) 和 PYMC 原生 Python 插件;`PythonEventBus` 事件系统;Bukkit 兼容事件名。 -- [x] **Watchdog 双进程保护**:UDP 心跳互检、自动重启伙伴进程、健康检查 API、协调关闭。 -- [x] **网络优化**:`PlayerNetworkOptimizer` 数据包批量发送、移动更新频率限制、区块发送距离排序。 +- [x] **PYMC 原生扩展 API**:支持 Python Mod/Plugin 的发现、依赖排序、生命周期、事件和命令注册。Java Fabric/Forge Mod 与 Bukkit/Paper `.jar` 插件不受支持,详见 `MOD_COMPATIBILITY.md`。 +- [x] **Web 管理台安全默认值**:默认只监听 `127.0.0.1`;由于当前没有内置认证,远程监听必须显式设置 `web-admin-allow-remote=true` 并部署外部访问控制。 + +### 部分实现(不可视为完成) + +- [ ] **红石系统**:已有 tick 引擎和多种组件,但 TNT、音符盒、发射器等行为仍有未实现路径。 +- [ ] **原版地形近似实现**:已有密度、气候、洞穴、surface rule 和矿石管线,但并非同种子逐区块 1:1 复刻。 +- [ ] **物品栏系统**:已有 `ItemStack`、玩家物品栏、协议序列化、创造模式修改和基础左右键点击;拖拽、Shift-点击、容器持久化等仍待完成。 +- [ ] **方块行为系统**:已有多种交互框架和基础行为,但部分容器与特殊方块仍是简化实现。 +- [ ] **流体系统**:已有基础水/岩浆传播与交互,但尚未达到完整原版规则。 +- [ ] **多版本协议兼容**:已有 47-770 的版本映射和处理器框架;只有核心路径经过有限验证,不能宣称所有版本完整兼容。 +- [ ] **命令覆盖**:`CommandManager`、权限、别名和大量命令已注册,但部分命令或子命令仍只返回“暂未实现”。 +- [ ] **Watchdog**:已有 UDP 心跳、监控和重启框架,但缺少双进程端到端测试。 +- [ ] **网络优化**:已有批处理、限频和区块排序组件,但普通发送/移动路径尚未全面接入。 - [x] **CI/CD**:GitHub Actions 工作流,Linux/Windows 双平台构建、CMake 原生组件编译、Nuitka 打包。 ### 正在推进 / 待实现 @@ -56,7 +60,7 @@ PyMC 是一个用 Python 实现的 Minecraft Java 版 1.21.1 服务端原型, - 这是服务端原型,不是完整原版服务端替代品。 - 当前地形生成不会下载或运行 Mojang 原版服务端;地形和 AI 走本项目 C++/Python clean-room 实现。 - 目标是逐步逼近原版,但现在还不能保证任意原版种子生成完全相同地形。 -- Mod/插件兼容层目前为框架实现,JVM 桥接需要编译 C++ 原生库才能完整支持 .jar mod/插件。 +- 不支持 Java Fabric/Forge/NeoForge/Quilt Mod 或 Bukkit/Paper `.jar` 插件;仅支持 PYMC 原生 Python 扩展。 ## 当前能力 @@ -66,20 +70,19 @@ PyMC 是一个用 Python 实现的 Minecraft Java 版 1.21.1 服务端原型, - 数据包压缩、KeepAlive、玩家移动同步 - 类原版 384 高度主世界区块 - 优先使用 `native/terrain_gen` C++ 原生地形生成器,回退到 1:1 Vanilla 或 Python 生成器 -- 红石系统:全组件模拟,每 2 游戏刻 (0.1s) 红石刻 +- 红石系统:基础组件模拟,每 2 游戏刻 (0.1s) 红石刻;部分组件行为仍待完成 - 流体系统:水/岩浆流动,水-岩浆交互 -- 物品栏系统:ItemStack、PlayerInventory、协议序列化 -- 方块行为:箱子、工作台、熔炉、门、床、告示牌、TNT 等交互 +- 物品栏系统:ItemStack、PlayerInventory、协议序列化和基础点击交互 +- 方块行为:基础容器和多种交互框架,部分行为为简化实现 - 安全出生点解析,避免首次进入或重生时卡在地下、水里或危险方块上 - Linear V2 `.linear` 区域文件读写,并支持从 Anvil `.mca` 自动转换 - 玩家位置、生命值、饱食度、经验、游戏模式等 JSON 存档 - 基础聊天、方块挖掘/放置、掉落物、经验球和简单生物实体 - 原版 Goal 思路的轻量生物 AI:随机游走、看向玩家、敌对追击、近战冷却 -- 命令框架:50+ 原版命令注册,Tab 补全,权限检查 -- Mod 兼容:Fabric/Forge/NeoForge/Quilt mod 扫描和识别 -- 插件兼容:Paper/Bukkit .jar 插件 (C++ 桥接) + Python 原生插件 -- Watchdog 双进程保护:UDP 心跳、自动重启 -- 网络优化:数据包批量发送、移动频率限制 +- 命令框架:大量命令注册、权限检查和别名;部分子命令仍未完成 +- Mod/插件:仅支持 PYMC 原生 Python API +- Watchdog:UDP 心跳与自动重启框架(尚缺端到端验证) +- 网络优化:批处理、移动限频和区块排序组件(尚未全面接入) - 基础 `gamerule`:控制昼夜流动、自然刷怪和自然回血 - 控制台和游戏内基础命令 - Web 管理台、权限组、OP、封禁和白名单 @@ -105,8 +108,8 @@ PyMC 是一个用 Python 实现的 Minecraft Java 版 1.21.1 服务端原型, - `watchdog/network_optimizer.py`:网络优化器 - `watchdog/health_check.py`:健康检查 - `watchdog/restart_handler.py`:自动重启处理 -- `mods/`:Mod 兼容层 (Fabric/Forge/NeoForge/Quilt) -- `plugins/`:插件兼容层 (Paper/Bukkit + Python) +- `mods/`:PYMC 原生 Python Mod API +- `plugins/`:PYMC 原生 Python Plugin API(事件名受 Bukkit 启发,不运行 Java 插件) - `native/`:C++ 原生地形生成器、红石引擎、光照引擎、物理引擎 - `pumpkin-ref/`:本地参考源码,不属于 PyMC 运行时 @@ -122,7 +125,7 @@ python main.py 默认监听: - Minecraft 服务端: `0.0.0.0:25565` -- Web 管理台: `0.0.0.0:25568` +- Web 管理台: `127.0.0.1:25568`(无内置认证,默认禁止远程监听) - Watchdog 健康检查: `0.0.0.0:25569` (启用时) 使用 Minecraft Java 1.21.1 客户端连接 `localhost:25565`。 @@ -139,6 +142,8 @@ python main.py - `level-seed`:世界种子 - `gamemode`:默认游戏模式 - `web-admin-enabled`:是否启用 Web 管理台 +- `web-admin-host`:管理台监听地址,默认 `127.0.0.1` +- `web-admin-allow-remote`:允许无内置认证的远程监听;仅应在受认证反向代理等外部访问控制保护时启用 - `permissions-file`:权限文件路径 - `join-immediate-radius`:玩家入服时优先同步的近距离区块半径 - `min-protocol-version` / `max-protocol-version`:允许的协议版本范围 (默认 47-770) @@ -192,7 +197,8 @@ Linux/macOS 可参考 `build.sh` 和 `CMakeLists.txt`。 支持平台: - Linux (ubuntu-latest):CMake 原生编译 + pytest + Nuitka 打包 -- Windows (windows-latest):Visual Studio 原生编译 + Nuitka 打包 +- macOS (macos-latest):CMake 原生编译 + Nuitka 打包 +- Windows (windows-latest):MinGW/CMake 原生编译 + Nuitka 打包 ## 运行数据 diff --git a/admin/web.py b/admin/web.py index f532656..c8e8592 100755 --- a/admin/web.py +++ b/admin/web.py @@ -3,6 +3,7 @@ # ============================================================ import asyncio +import ipaddress import json import logging import threading @@ -661,16 +662,24 @@ class WebAdminServer: """轻量 Web 管理端。""" - def __init__(self, server, host: str, port: int): + def __init__(self, server, host: str, port: int, + allow_remote: bool = False): self.server = server self.host = host self.port = port + self.allow_remote = allow_remote self._httpd: ThreadingHTTPServer | None = None self._thread: threading.Thread | None = None def start(self): if self._httpd is not None: return + if not self.allow_remote and not self._is_loopback_host(self.host): + raise ValueError( + "Web admin has no authentication and may only bind to loopback. " + "Set web-admin-allow-remote=true only when protected by an " + "authenticated reverse proxy or equivalent access control." + ) handler_cls = self._make_handler() self._httpd = ThreadingHTTPServer((self.host, self.port), handler_cls) self._thread = threading.Thread( @@ -681,6 +690,16 @@ def start(self): self._thread.start() logger.info(f"Web 管理台已启动: http://{self.host}:{self.port}") + @staticmethod + def _is_loopback_host(host: str) -> bool: + normalized = host.strip().lower() + if normalized == "localhost": + return True + try: + return ipaddress.ip_address(normalized).is_loopback + except ValueError: + return False + def stop(self): if self._httpd is None: return diff --git a/config.py b/config.py index 0579f7a..b6cd019 100755 --- a/config.py +++ b/config.py @@ -28,8 +28,9 @@ "spawn-protection": 0, "enable-command-block": True, "web-admin-enabled": True, - "web-admin-host": "0.0.0.0", + "web-admin-host": "127.0.0.1", "web-admin-port": 25568, + "web-admin-allow-remote": False, "permissions-file": "permissions.json", "chunk-generation-multithreading": False, "chunk-generation-workers": 0, @@ -59,8 +60,8 @@ "fluid-flow-enabled": True, # Enable water/lava flow simulation # Mod and plugin directories - "mods-directory": "mods", # Directory to scan for Fabric/Forge mods - "plugins-directory": "plugins", # Directory to scan for Paper/Bukkit plugins + "mods-directory": "mods", # Directory for PYMC native Python mods + "plugins-directory": "plugins", # Directory for PYMC native Python plugins } # 类型映射 (用于自动转换配置值) @@ -77,6 +78,7 @@ "enable-command-block": lambda v: v.lower() in ("true", "1", "yes"), "web-admin-enabled": lambda v: v.lower() in ("true", "1", "yes"), "web-admin-port": int, + "web-admin-allow-remote": lambda v: v.lower() in ("true", "1", "yes"), "chunk-generation-multithreading": lambda v: v.lower() in ("true", "1", "yes"), "chunk-generation-workers": int, "join-immediate-radius": int, diff --git a/handlers/play/__init__.py b/handlers/play/__init__.py index dd997b6..727f00b 100755 --- a/handlers/play/__init__.py +++ b/handlers/play/__init__.py @@ -157,85 +157,94 @@ logger = logging.getLogger("PyMC.游戏") +def _is_serverbound_packet(conn: Connection, packet_id: int, + packet_name: str, + native_packet_id: int | None = None) -> bool: + """Match a packet without leaking native IDs into older protocols.""" + from protocol.packet_map import get_serverbound_packet + from protocol.versions import NATIVE_PROTOCOL_VERSION + + mapped_id = get_serverbound_packet(conn.protocol_version, packet_name) + if mapped_id is not None: + return packet_id == mapped_id + return (conn.protocol_version == NATIVE_PROTOCOL_VERSION + and native_packet_id is not None + and packet_id == native_packet_id) + + async def handle_play(conn: Connection, packet_id: int, payload: bytes, server): """分发 Play 阶段的客户端数据包。""" - # Use version-specific packet dispatching - from protocol.packet_map import get_serverbound_packet - - # Map known packet names to handlers - # For older versions, packet IDs differ, so we check by name - - if packet_id == 0x00 or get_serverbound_packet(conn.protocol_version, "confirm_teleportation") == packet_id: + if _is_serverbound_packet(conn, packet_id, "confirm_teleportation", 0x00): # Confirm Teleportation _handle_confirm_teleportation(conn, payload) - elif packet_id == 0x05 or get_serverbound_packet(conn.protocol_version, "chat_command") == packet_id: + elif _is_serverbound_packet(conn, packet_id, "chat_command", 0x05): # Chat Command await _handle_chat_command(conn, payload, server) - elif packet_id == 0x06 or get_serverbound_packet(conn.protocol_version, "signed_chat_command") == packet_id: + elif _is_serverbound_packet(conn, packet_id, "signed_chat_command", 0x06): # Signed Chat Command await _handle_chat_command(conn, payload, server) - elif packet_id == 0x07 or get_serverbound_packet(conn.protocol_version, "chat_message") == packet_id: + elif _is_serverbound_packet(conn, packet_id, "chat_message", 0x07): # Chat Message (聊天消息) await _handle_chat_message(conn, payload, server) - elif packet_id == 0x09 or get_serverbound_packet(conn.protocol_version, "chunk_batch_received") == packet_id: + elif _is_serverbound_packet(conn, packet_id, "chunk_batch_received", 0x09): # Chunk Batch Received (客户端确认区块批次) pass # 不需要特殊处理 - elif packet_id == 0x1A or get_serverbound_packet(conn.protocol_version, "keep_alive") == packet_id: + elif _is_serverbound_packet(conn, packet_id, "keep_alive", 0x1A): # Keep Alive _handle_keepalive(conn, payload) - elif packet_id == 0x1C or get_serverbound_packet(conn.protocol_version, "player_position") == packet_id: + elif _is_serverbound_packet(conn, packet_id, "player_position", 0x1C): # Player Position await _handle_player_position(conn, payload, server) - elif packet_id == 0x1D or get_serverbound_packet(conn.protocol_version, "player_position_rotation") == packet_id: + elif _is_serverbound_packet(conn, packet_id, "player_position_rotation", 0x1D): # Player Position and Rotation await _handle_player_position_rotation(conn, payload, server) - elif packet_id == 0x1E or get_serverbound_packet(conn.protocol_version, "player_rotation") == packet_id: + elif _is_serverbound_packet(conn, packet_id, "player_rotation", 0x1E): # Player Rotation await _handle_player_rotation(conn, payload, server) - elif packet_id == 0x1F or get_serverbound_packet(conn.protocol_version, "player_on_ground") == packet_id: + elif _is_serverbound_packet(conn, packet_id, "player_on_ground", 0x1F): # Player On Ground await _handle_player_on_ground(conn, payload, server) - elif packet_id == 0x26 or get_serverbound_packet(conn.protocol_version, "block_dig") == packet_id: + elif _is_serverbound_packet(conn, packet_id, "block_dig", 0x26): # Block Dig await _handle_block_dig(conn, payload, server) - elif packet_id == 0x31 or get_serverbound_packet(conn.protocol_version, "held_item_slot") == packet_id: + elif _is_serverbound_packet(conn, packet_id, "held_item_slot", 0x31): # Held Item Slot _handle_held_item_slot(conn, payload) - elif packet_id == 0x3A or get_serverbound_packet(conn.protocol_version, "block_place") == packet_id: + elif _is_serverbound_packet(conn, packet_id, "block_place", 0x3A): # Use Item On / Block Place await _handle_block_place(conn, payload, server) - elif packet_id == 0x0C or get_serverbound_packet(conn.protocol_version, "click_container") == packet_id: + elif _is_serverbound_packet(conn, packet_id, "click_container", 0x0C): # Click Container (window click) await _handle_click_container(conn, payload, server) - elif packet_id == 0x0D or get_serverbound_packet(conn.protocol_version, "close_container") == packet_id: + elif _is_serverbound_packet(conn, packet_id, "close_container", 0x0D): # Close Container _handle_close_container(conn, payload) - elif packet_id == 0x14 or get_serverbound_packet(conn.protocol_version, "interact") == packet_id: + elif _is_serverbound_packet(conn, packet_id, "interact", 0x14): # Interact (Entity) pass # TODO: entity interaction - elif packet_id == 0x22 or get_serverbound_packet(conn.protocol_version, "set_creative_mode_slot") == packet_id: + elif _is_serverbound_packet(conn, packet_id, "set_creative_mode_slot", 0x22): # Set Creative Mode Slot await _handle_creative_inventory_action(conn, payload, server) - elif packet_id == 0x29 or get_serverbound_packet(conn.protocol_version, "use_item") == packet_id: + elif _is_serverbound_packet(conn, packet_id, "use_item", 0x29): # Use Item (right-click air) pass # TODO: item use in air @@ -246,8 +255,8 @@ async def handle_play(conn: Connection, packet_id: int, payload: bytes, async def _handle_click_container(conn: Connection, payload: bytes, server): """Handle Click Container packet (0x0C).""" - from protocol.data_types import read_varint, read_short, read_byte, read_boolean - from world.inventory import ItemStack, decode_slot_entry, send_slot_update + from protocol.data_types import read_varint, read_short, read_byte + from world.inventory import decode_slot_entry, send_inventory_sync offset = 0 window_id, offset = read_varint(payload, offset) @@ -256,19 +265,76 @@ async def _handle_click_container(conn: Connection, payload: bytes, server): button, offset = read_byte(payload, offset) mode, offset = read_varint(payload, offset) - # Validate state ID + # Only the player inventory is currently backed by server-side storage. + if window_id != 0: + return + + # Reject stale client actions and restore the authoritative state. if state_id != conn.inventory_state_id: - # Client is out of sync; they will retry + await send_inventory_sync(conn) return inv = getattr(conn, 'inventory_obj', None) if inv is None: return - # Read the clicked item (slot data) - carried = decode_slot_entry(payload, offset) - # Update the inventory state + # Consume, but do not trust, the client-predicted slot changes. + changed_count, offset = read_varint(payload, offset) + if changed_count < 0 or changed_count > 128: + await send_inventory_sync(conn) + return + for _ in range(changed_count): + _, offset = read_short(payload, offset) + _, offset = decode_slot_entry(payload, offset) + _, offset = decode_slot_entry(payload, offset) + + if mode == 0 and 0 <= slot_idx < inv.TOTAL_SLOTS and button in (0, 1): + slot_item = inv.get_slot(slot_idx) + cursor = inv.carried_item + if button == 0: # left click: pick up, place, merge, or swap + if cursor is None or cursor.is_empty: + inv.carried_item = slot_item + inv.set_slot(slot_idx, None) + elif slot_item is None or slot_item.is_empty: + inv.set_slot(slot_idx, cursor) + inv.carried_item = None + elif slot_item.can_stack_with(cursor) and slot_item.count < slot_item.max_stack_size: + moved = min(cursor.count, slot_item.max_stack_size - slot_item.count) + slot_item.count += moved + cursor.count -= moved + if cursor.count <= 0: + inv.carried_item = None + inv.state_id += 1 + else: + inv.set_slot(slot_idx, cursor) + inv.carried_item = slot_item + else: # right click: pick up half or place one + if cursor is None or cursor.is_empty: + if slot_item is not None and not slot_item.is_empty: + take = (slot_item.count + 1) // 2 + inv.carried_item = slot_item.copy() + inv.carried_item.count = take + slot_item.count -= take + if slot_item.count <= 0: + inv.set_slot(slot_idx, None) + else: + inv.state_id += 1 + elif slot_item is None or slot_item.is_empty: + placed = cursor.copy() + placed.count = 1 + inv.set_slot(slot_idx, placed) + cursor.count -= 1 + if cursor.count <= 0: + inv.carried_item = None + elif slot_item.can_stack_with(cursor) and slot_item.count < slot_item.max_stack_size: + slot_item.count += 1 + cursor.count -= 1 + if cursor.count <= 0: + inv.carried_item = None + inv.state_id += 1 + conn.inventory_state_id += 1 + await send_inventory_sync(conn) def _handle_close_container(conn: Connection, payload: bytes): @@ -298,7 +364,7 @@ async def _handle_creative_inventory_action(conn: Connection, payload: bytes, se if slot_idx < 0 or slot_idx >= 46: return # Invalid slot - if clicked_item is None or clicked_item.is_empty(): + if clicked_item is None or clicked_item.is_empty: inv.set_slot(slot_idx, None) else: inv.set_slot(slot_idx, clicked_item) diff --git a/network/server.py b/network/server.py index 944b442..fc8d0c0 100755 --- a/network/server.py +++ b/network/server.py @@ -306,8 +306,9 @@ async def start(self): try: self.web_admin = WebAdminServer( self, - self.config.get("web-admin-host", "0.0.0.0"), + self.config.get("web-admin-host", "127.0.0.1"), self.config.get("web-admin-port", 25568), + self.config.get("web-admin-allow-remote", False), ) self.web_admin.start() except Exception as e: diff --git a/protocol/packet_map.py b/protocol/packet_map.py index 9e88f7f..d6f0e41 100755 --- a/protocol/packet_map.py +++ b/protocol/packet_map.py @@ -700,6 +700,11 @@ "held_item_slot": 0x31, "block_place": 0x3A, "chunk_batch_received": 0x09, + "click_container": 0x0C, + "close_container": 0x0D, + "interact": 0x14, + "set_creative_mode_slot": 0x22, + "use_item": 0x29, }, 770: { "confirm_teleportation": 0x00, @@ -715,6 +720,11 @@ "held_item_slot": 0x32, "block_place": 0x3B, "chunk_batch_received": 0x09, + "click_container": 0x10, + "close_container": 0x11, + "interact": 0x18, + "set_creative_mode_slot": 0x36, + "use_item": 0x3D, }, } diff --git a/server.properties b/server.properties index 39183a4..1d42964 100755 --- a/server.properties +++ b/server.properties @@ -21,8 +21,9 @@ level-spawn-z=0 spawn-protection=0 enable-command-block=true web-admin-enabled=true -web-admin-host=0.0.0.0 +web-admin-host=127.0.0.1 web-admin-port=25568 +web-admin-allow-remote=false permissions-file=permissions.json chunk-generation-multithreading=false chunk-generation-workers=0 @@ -51,6 +52,6 @@ redstone-enabled=true # Fluids fluid-flow-enabled=true -# Mod and plugin directories +# PYMC native Python mod and plugin directories mods-directory=mods plugins-directory=plugins diff --git a/tests/test_inventory_clicks.py b/tests/test_inventory_clicks.py new file mode 100644 index 0000000..977cdae --- /dev/null +++ b/tests/test_inventory_clicks.py @@ -0,0 +1,83 @@ +import unittest +from types import SimpleNamespace + +from handlers.play import _handle_click_container +from protocol.data_types import write_varint, write_short, write_byte +from world.inventory import ItemStack, PlayerInventory, encode_slot_entry + + +class FakeConnection(SimpleNamespace): + async def send_packet(self, packet_id, payload=b""): + self.sent_packets.append((packet_id, payload)) + + +def click_payload(slot, button=0, state_id=0, mode=0): + return b"".join(( + write_varint(0), + write_varint(state_id), + write_short(slot), + write_byte(button), + write_varint(mode), + write_varint(0), + encode_slot_entry(None), + )) + + +class InventoryPrimitiveTests(unittest.TestCase): + def test_item_stack_empty_is_a_property(self): + self.assertTrue(ItemStack().is_empty) + self.assertFalse(ItemStack("minecraft:stone", 1).is_empty) + + def test_slots_normalize_empty_stacks(self): + inventory = PlayerInventory() + inventory.set_slot(0, ItemStack()) + self.assertIsNone(inventory.get_slot(0)) + + def test_stack_compatibility_includes_nbt_and_damage(self): + stack = ItemStack("minecraft:stone", 1, damage=2, nbt={"key": "value"}) + self.assertTrue(stack.can_stack_with(stack.copy())) + self.assertFalse(stack.can_stack_with(ItemStack("minecraft:stone", 1, damage=3))) + + +class InventoryClickTests(unittest.IsolatedAsyncioTestCase): + def make_connection(self): + return FakeConnection( + protocol_version=767, + inventory_obj=PlayerInventory(), + inventory_state_id=0, + sent_packets=[], + ) + + async def test_left_click_picks_up_stack_and_syncs(self): + conn = self.make_connection() + conn.inventory_obj.set_slot(0, ItemStack("minecraft:stone", 12)) + + await _handle_click_container(conn, click_payload(0), None) + + self.assertIsNone(conn.inventory_obj.get_slot(0)) + self.assertEqual(conn.inventory_obj.carried_item, ItemStack("minecraft:stone", 12)) + self.assertEqual(conn.inventory_state_id, 1) + self.assertEqual(conn.sent_packets[-1][0], 0x11) + + async def test_right_click_picks_up_rounded_half(self): + conn = self.make_connection() + conn.inventory_obj.set_slot(0, ItemStack("minecraft:stone", 5)) + + await _handle_click_container(conn, click_payload(0, button=1), None) + + self.assertEqual(conn.inventory_obj.get_slot(0).count, 2) + self.assertEqual(conn.inventory_obj.carried_item.count, 3) + + async def test_stale_click_does_not_mutate_inventory(self): + conn = self.make_connection() + conn.inventory_obj.set_slot(0, ItemStack("minecraft:stone", 5)) + + await _handle_click_container(conn, click_payload(0, state_id=9), None) + + self.assertEqual(conn.inventory_obj.get_slot(0).count, 5) + self.assertIsNone(conn.inventory_obj.carried_item) + self.assertEqual(len(conn.sent_packets), 1) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_play_dispatch.py b/tests/test_play_dispatch.py new file mode 100644 index 0000000..28c5225 --- /dev/null +++ b/tests/test_play_dispatch.py @@ -0,0 +1,30 @@ +import unittest +from types import SimpleNamespace + +from handlers.play import _is_serverbound_packet + + +class PlayDispatchTests(unittest.TestCase): + def test_native_packet_ids_are_used_for_native_protocol(self): + conn = SimpleNamespace(protocol_version=767) + self.assertTrue(_is_serverbound_packet(conn, 0x1A, "keep_alive", 0x1A)) + self.assertTrue(_is_serverbound_packet(conn, 0x0C, "click_container", 0x0C)) + + def test_native_packet_ids_do_not_leak_into_old_protocols(self): + conn = SimpleNamespace(protocol_version=47) + self.assertFalse(_is_serverbound_packet(conn, 0x1A, "keep_alive", 0x1A)) + self.assertTrue(_is_serverbound_packet(conn, 0x00, "keep_alive", 0x1A)) + + def test_unknown_packet_name_does_not_match_old_protocol(self): + conn = SimpleNamespace(protocol_version=340) + self.assertFalse(_is_serverbound_packet(conn, 0x0C, "click_container", 0x0C)) + + def test_1_21_4_uses_its_versioned_interaction_ids(self): + conn = SimpleNamespace(protocol_version=770) + self.assertTrue(_is_serverbound_packet(conn, 0x10, "click_container", 0x0C)) + self.assertTrue(_is_serverbound_packet(conn, 0x3D, "use_item", 0x29)) + self.assertFalse(_is_serverbound_packet(conn, 0x0C, "click_container", 0x0C)) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_web_admin_security.py b/tests/test_web_admin_security.py new file mode 100644 index 0000000..a424d24 --- /dev/null +++ b/tests/test_web_admin_security.py @@ -0,0 +1,26 @@ +import unittest +from types import SimpleNamespace + +from admin.web import WebAdminServer + + +class WebAdminSecurityTests(unittest.TestCase): + def test_loopback_hosts_are_allowed_by_default(self): + self.assertTrue(WebAdminServer._is_loopback_host("127.0.0.1")) + self.assertTrue(WebAdminServer._is_loopback_host("::1")) + self.assertTrue(WebAdminServer._is_loopback_host("localhost")) + + def test_wildcard_and_remote_hosts_are_rejected(self): + self.assertFalse(WebAdminServer._is_loopback_host("0.0.0.0")) + self.assertFalse(WebAdminServer._is_loopback_host("::")) + self.assertFalse(WebAdminServer._is_loopback_host("192.168.1.20")) + self.assertFalse(WebAdminServer._is_loopback_host("admin.example.com")) + + def test_public_bind_requires_explicit_opt_in(self): + admin = WebAdminServer(SimpleNamespace(), "0.0.0.0", 0) + with self.assertRaisesRegex(ValueError, "web-admin-allow-remote"): + admin.start() + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file From 96c6655301304736445a74936ffb750567bca842 Mon Sep 17 00:00:00 2001 From: Arun Date: Fri, 14 Aug 2026 09:05:44 +0000 Subject: [PATCH 02/10] fix: prevent inventory loss and restore runtime lifecycles --- handlers/handshake.py | 15 ++---- handlers/login.py | 6 ++- handlers/play/blocks.py | 12 ++--- handlers/play/join.py | 14 ++++++ handlers/player.py | 12 ++--- handlers/versioned/__init__.py | 2 +- handlers/versioned/base.py | 6 +++ network/connection.py | 9 +++- network/server.py | 43 +++++++++++++---- protocol/versions.py | 14 +++--- tests/test_inventory_clicks.py | 86 ++++++++++++++++++++++++++++++++++ tests/test_login_identity.py | 31 ++++++++++++ tests/test_play_dispatch.py | 14 ++++++ world/inventory.py | 4 ++ 14 files changed, 224 insertions(+), 44 deletions(-) create mode 100644 tests/test_login_identity.py diff --git a/handlers/handshake.py b/handlers/handshake.py index d51e2eb..59d47af 100755 --- a/handlers/handshake.py +++ b/handlers/handshake.py @@ -44,19 +44,10 @@ async def handle_handshake(conn: Connection, packet_id: int, payload: bytes): f"地址={server_address}:{server_port}, " f"下一状态={next_state}") - # Check if this protocol version is supported + # Preserve the requested protocol. Unsupported versions may query status, + # but login rejects them instead of silently speaking a different protocol. if not is_supported(protocol_version): - # Try to find the closest supported version - from protocol.versions import get_closest_supported_version, SUPPORTED_VERSIONS - closest = get_closest_supported_version(protocol_version) - if closest is not None: - logger.info(f"协议版本 {protocol_version} 不直接支持," - f"将使用最接近的支持版本 {closest} ({get_version_name(closest)}) 的格式") - conn.protocol_version = closest - conn.mc_version = get_version_name(closest) - else: - logger.warning(f"协议版本 {protocol_version} 不受支持且无兼容版本") - # We'll still try to handle it with the native version + logger.warning(f"协议版本 {protocol_version} 不受支持") # Set up the version handler _setup_version_handler(conn) diff --git a/handlers/login.py b/handlers/login.py index 58b08ff..bb4e703 100755 --- a/handlers/login.py +++ b/handlers/login.py @@ -10,7 +10,7 @@ read_string, read_varint, write_string, write_varint, write_uuid, write_boolean, read_uuid ) -from protocol.versions import has_configuration_phase +from protocol.versions import has_configuration_phase, is_supported from network.connection import Connection, ConnectionState logger = logging.getLogger("PyMC.登录") @@ -60,7 +60,9 @@ async def _handle_login_start(conn: Connection, payload: bytes, server): # Check if this protocol version is allowed by server config min_version = int(server.config.get("min-protocol-version", 47)) max_version = int(server.config.get("max-protocol-version", 770)) - if conn.protocol_version < min_version or conn.protocol_version > max_version: + if (not is_supported(conn.protocol_version) + or conn.protocol_version < min_version + or conn.protocol_version > max_version): logger.info(f"拒绝玩家 {username}: 协议版本 {conn.protocol_version} " f"不在允许范围 [{min_version}, {max_version}]") await _send_disconnect_login(conn, f"Your protocol version ({conn.protocol_version}) is not supported.") diff --git a/handlers/play/blocks.py b/handlers/play/blocks.py index a60d7ec..680f9d7 100755 --- a/handlers/play/blocks.py +++ b/handlers/play/blocks.py @@ -199,7 +199,7 @@ def _get_held_item_for_placement(conn: Connection) -> tuple[int, str | None]: inv = getattr(conn, 'inventory_obj', None) if inv is not None: held = inv.get_held_item_from_slot(conn.selected_hotbar_slot) - if held is not None and not held.is_empty(): + if held is not None and not held.is_empty: # Check if this item corresponds to a placeable block block_state = BLOCK_NAME_TO_DEFAULT_STATE.get(held.item_id) if block_state is not None: @@ -232,7 +232,7 @@ def _consume_placement_item(conn: Connection, item_name: str | None): inv = getattr(conn, 'inventory_obj', None) if inv is not None: held = inv.get_held_item_from_slot(conn.selected_hotbar_slot) - if held is not None and not held.is_empty() and held.item_id == item_name: + if held is not None and not held.is_empty and held.item_id == item_name: held.count -= 1 if held.count <= 0: inv.set_slot(conn.selected_hotbar_slot, None) @@ -290,7 +290,7 @@ async def _handle_block_dig(conn: Connection, payload: bytes, server): # Drop container contents before removing if conn.gamemode in ("survival", "adventure"): for item in container.items: - if item is not None and not item.is_empty(): + if item is not None and not item.is_empty: entity = server.entity_manager.create_item( x + 0.5, y + 0.5, z + 0.5, item_name=item.item_id, count=item.count @@ -332,7 +332,7 @@ async def _handle_block_dig(conn: Connection, payload: bytes, server): if drops: for drop in drops: - if drop.is_empty(): + if drop.is_empty: continue entity = server.entity_manager.create_item( x + 0.5, y + 0.5, z + 0.5, @@ -356,7 +356,7 @@ async def _handle_block_dig(conn: Connection, payload: bytes, server): await broadcast_entity_spawn(server, entity) # Damage tool on use - if tool_item is not None and not tool_item.is_empty(): + if tool_item is not None and not tool_item.is_empty: tool_type = tool_item.get_tool_type() if tool_type is not None: tool_item.damage += 1 @@ -375,7 +375,7 @@ def _drop_held_item(conn: Connection, server): return held = inv.get_held_item_from_slot(conn.selected_hotbar_slot) - if held is None or held.is_empty(): + if held is None or held.is_empty: return if conn.gamemode in ("creative", "spectator"): diff --git a/handlers/play/join.py b/handlers/play/join.py index 662ae94..24b101d 100755 --- a/handlers/play/join.py +++ b/handlers/play/join.py @@ -138,6 +138,17 @@ def _generate_chunks(coords): # --- 5. 恢复玩家存档位置,必要时回退到安全出生点 --- player_state = server.world_storage.load_player_data(str(conn.uuid)) + from world.inventory import PlayerInventory, initialize_player_inventory + inventory_data = player_state.get("inventory") if player_state else None + if isinstance(inventory_data, list): + # Compatibility with compact saves written by earlier releases. + conn.inventory_obj = PlayerInventory() + conn.inventory_obj.deserialize(inventory_data) + elif isinstance(inventory_data, dict): + conn.inventory_obj = PlayerInventory.deserialize_full(inventory_data) + else: + initialize_player_inventory(conn) + conn.inventory_state_id = 0 target_x, target_y, target_z = _resolve_initial_player_location( server, player_state ) @@ -193,6 +204,9 @@ def _generate_chunks(coords): await _send_set_experience(conn) await _send_time_update(conn, server) + from world.inventory import send_inventory_sync + await send_inventory_sync(conn) + # --- 7. 通知其他玩家 --- await _broadcast_player_join(conn, server) await _send_visible_entities_to_player(conn, server) diff --git a/handlers/player.py b/handlers/player.py index 2ebcc0b..acf29eb 100755 --- a/handlers/player.py +++ b/handlers/player.py @@ -81,7 +81,7 @@ def slots(self) -> dict: result = {} for i in range(NewPlayerInventory.TOTAL_SLOTS): item = self._inv.get_slot(i) - if item is not None and not item.is_empty(): + if item is not None and not item.is_empty: result[i] = (item.item_id, item.count) return result @@ -108,7 +108,7 @@ def ender_chest(self) -> dict: result = {} for i in range(27): item = self._inv.ender_chest[i] - if item is not None and not item.is_empty(): + if item is not None and not item.is_empty: result[i] = (item.item_id, item.count) return result @@ -131,7 +131,7 @@ def selected_slot(self, value: int): @property def carried_item(self): """Legacy carried item accessor.""" - if self._inv.carried_item is not None and not self._inv.carried_item.is_empty(): + if self._inv.carried_item is not None and not self._inv.carried_item.is_empty: return (self._inv.carried_item.item_id, self._inv.carried_item.count) return None @@ -153,7 +153,7 @@ def state_id(self, value: int): def get_slot(self, slot: int) -> Optional[tuple[str, int]]: """获取指定槽位的物品 (legacy tuple format).""" item = self._inv.get_slot(slot) - if item is not None and not item.is_empty(): + if item is not None and not item.is_empty: return (item.item_id, item.count) return None @@ -173,7 +173,7 @@ def set_item_in_slot(self, slot: int, data: dict): def get_item_in_slot(self, slot: int) -> dict | None: """Get item in slot as dict (for /item command).""" item = self._inv.get_slot(slot) - if item is not None and not item.is_empty(): + if item is not None and not item.is_empty: return {"item": item.item_id, "count": item.count} return None @@ -208,7 +208,7 @@ def get_armor(self) -> dict[int, tuple[str, int]]: armor = self._inv.get_armor() result = {} for i, item in enumerate(armor): - if item is not None and not item.is_empty(): + if item is not None and not item.is_empty: slot = 36 + i # Armor slots start at 36 result[slot] = (item.item_id, item.count) return result diff --git a/handlers/versioned/__init__.py b/handlers/versioned/__init__.py index 813877d..8fbe552 100755 --- a/handlers/versioned/__init__.py +++ b/handlers/versioned/__init__.py @@ -51,4 +51,4 @@ def get_version_handler(protocol_version: int): from protocol.versions import get_handler_version handler_key = get_handler_version(protocol_version) handler_class = HANDLER_MAP.get(handler_key, VersionHandlerV1_21) - return handler_class() + return handler_class(protocol_version) diff --git a/handlers/versioned/base.py b/handlers/versioned/base.py index 77c6e1d..76688c9 100755 --- a/handlers/versioned/base.py +++ b/handlers/versioned/base.py @@ -32,6 +32,12 @@ class VersionHandler: HAS_FLATTENING = True HAS_DIMENSION_REGISTRY = True + def __init__(self, protocol_version: int | None = None): + # Grouped handlers share payload logic, but packet IDs must use the + # exact protocol negotiated with this connection. + if protocol_version is not None: + self.PROTOCOL_VERSION = protocol_version + def get_packet_map(self) -> dict: """Return clientbound packet ID mappings for this version.""" from protocol.packet_map import get_clientbound_map diff --git a/network/connection.py b/network/connection.py index cedfb52..9b9a079 100755 --- a/network/connection.py +++ b/network/connection.py @@ -4,6 +4,7 @@ # ============================================================ import asyncio +import hashlib import logging import uuid from enum import IntEnum @@ -123,8 +124,12 @@ async def disconnect(self, reason: str = ""): def generate_offline_uuid(self) -> uuid.UUID: """根据用户名生成离线模式 UUID。""" # 与 Java 版一致: UUID.nameUUIDFromBytes("OfflinePlayer:" + name) - return uuid.uuid3(uuid.UUID("00000000-0000-0000-0000-000000000000"), - f"OfflinePlayer:{self.username}") + digest = bytearray( + hashlib.md5(f"OfflinePlayer:{self.username}".encode("utf-8")).digest() + ) + digest[6] = (digest[6] & 0x0F) | 0x30 + digest[8] = (digest[8] & 0x3F) | 0x80 + return uuid.UUID(bytes=bytes(digest)) def __repr__(self): return f"" diff --git a/network/server.py b/network/server.py index fc8d0c0..f86f970 100755 --- a/network/server.py +++ b/network/server.py @@ -231,7 +231,7 @@ def save_player_state(self, conn: Connection): # 保存物品栏数据 if hasattr(conn, 'inventory_obj') and conn.inventory_obj is not None: - player_data["inventory"] = conn.inventory_obj.serialize() + player_data["inventory"] = conn.inventory_obj.serialize_full() self.world_storage.save_player_data(str(conn.uuid), player_data) @@ -242,6 +242,11 @@ def save_all_player_states(self): async def start(self): """启动服务器。""" + if self.online_mode: + raise RuntimeError( + "online-mode=true is not supported yet: encryption and Mojang " + "session authentication are not implemented" + ) self.running = True self.start_time = time.time() self.loop = asyncio.get_running_loop() @@ -285,12 +290,10 @@ async def start(self): # Mod 管理器集成 (如果 main.py 没有提前初始化) if self.mod_manager is None: - from mods import ModManager - self.mod_manager = ModManager(self) + from mods.bridge import init_mod_system mods_dir = self.config.get("mods-directory", "mods") - discovered = self.mod_manager.discover_mods(mods_dir) - self.mod_manager.load_all() - logger.info(f"Mod 管理器已初始化: 发现 {len(discovered)} 个 Mod") + init_mod_system(self, mods_dir) + logger.info(f"Mod 管理器已初始化: {self.mod_manager.mod_count} 个 Mod 已启用") # 插件管理器集成 (如果 main.py 没有提前初始化) if self.plugin_manager is None: @@ -668,6 +671,8 @@ async def stop(self): # 卸载所有 Mod if self.mod_manager is not None: + from mods.bridge import shutdown_mod_system + shutdown_mod_system(self) self.mod_manager = None # 停止网络优化器 @@ -949,13 +954,33 @@ async def _tick_entity_interactions(self): continue count = int(entity.metadata.get("count", 1)) - self.entity_manager.remove_entity(entity.entity_id) - await _send_collect_entity(player, entity.entity_id, player.entity_id, count) if entity.kind == "orb": + self.entity_manager.remove_entity(entity.entity_id) + await _send_collect_entity(player, entity.entity_id, player.entity_id, count) await _add_player_experience(player, count) else: item_name = entity.metadata.get("item_name", "minecraft:stone") - await send_system_message(player, f"[PyMC] 拾取 {item_name} x{count}") + inventory = getattr(player, "inventory_obj", None) + if inventory is None: + continue + from world.inventory import ItemStack, send_inventory_sync + leftover = inventory.add_item(ItemStack(item_name, count)) + accepted = count - leftover + if accepted <= 0: + continue + player.inventory_state_id += 1 + await _send_collect_entity( + player, entity.entity_id, player.entity_id, accepted + ) + if leftover == 0: + self.entity_manager.remove_entity(entity.entity_id) + else: + entity.count = leftover + entity.metadata["count"] = leftover + await send_inventory_sync(player) + await send_system_message( + player, f"[PyMC] 拾取 {item_name} x{accepted}" + ) break if entity.kind == "mob" and entity.metadata.get("category") == "hostile": diff --git a/protocol/versions.py b/protocol/versions.py index d71c266..b2bfa35 100755 --- a/protocol/versions.py +++ b/protocol/versions.py @@ -16,13 +16,13 @@ 498: {"name": "1.14.4", "major": "1.14"}, 578: {"name": "1.15.2", "major": "1.15"}, 736: {"name": "1.16.1", "major": "1.16"}, - 754: {"name": "1.16.2", "major": "1.16"}, - 757: {"name": "1.17.1", "major": "1.17"}, + 754: {"name": "1.16.4/1.16.5", "major": "1.16"}, + 757: {"name": "1.18/1.18.1", "major": "1.18"}, 758: {"name": "1.18.2", "major": "1.18"}, - 761: {"name": "1.19.2", "major": "1.19"}, - 764: {"name": "1.19.3", "major": "1.19"}, - 765: {"name": "1.19.4", "major": "1.19"}, - 766: {"name": "1.20.1", "major": "1.20"}, + 761: {"name": "1.19.3", "major": "1.19"}, + 764: {"name": "1.20.2", "major": "1.20"}, + 765: {"name": "1.20.3/1.20.4", "major": "1.20"}, + 766: {"name": "1.20.5/1.20.6", "major": "1.20"}, 767: {"name": "1.21.1", "major": "1.21"}, 770: {"name": "1.21.4", "major": "1.21"}, } @@ -122,6 +122,8 @@ def get_handler_version(protocol_version: int) -> str: return "v1_21" elif protocol_version >= 764: return "v1_20" + elif protocol_version >= 761: + return "v1_19" elif protocol_version >= 757: return "v1_17" elif protocol_version >= 736: diff --git a/tests/test_inventory_clicks.py b/tests/test_inventory_clicks.py index 977cdae..b16c876 100644 --- a/tests/test_inventory_clicks.py +++ b/tests/test_inventory_clicks.py @@ -2,7 +2,9 @@ from types import SimpleNamespace from handlers.play import _handle_click_container +from network.server import MinecraftServer from protocol.data_types import write_varint, write_short, write_byte +from world.entities import EntityManager from world.inventory import ItemStack, PlayerInventory, encode_slot_entry @@ -38,6 +40,24 @@ def test_stack_compatibility_includes_nbt_and_damage(self): self.assertTrue(stack.can_stack_with(stack.copy())) self.assertFalse(stack.can_stack_with(ItemStack("minecraft:stone", 1, damage=3))) + def test_full_persistence_round_trip_preserves_all_item_data(self): + inventory = PlayerInventory() + inventory.set_slot( + 4, ItemStack("minecraft:diamond_pickaxe", 1, damage=17, + nbt={"custom_name": "Miner"}) + ) + inventory.ender_chest[2] = ItemStack( + "minecraft:diamond", 7, nbt={"source": "test"} + ) + inventory.set_held_slot(4) + + restored = PlayerInventory.deserialize_full(inventory.serialize_full()) + + self.assertEqual(restored.get_slot(4), inventory.get_slot(4)) + self.assertEqual(restored.get_slot(4).nbt, {"custom_name": "Miner"}) + self.assertEqual(restored.ender_chest[2].nbt, {"source": "test"}) + self.assertEqual(restored.held_slot, 4) + class InventoryClickTests(unittest.IsolatedAsyncioTestCase): def make_connection(self): @@ -79,5 +99,71 @@ async def test_stale_click_does_not_mutate_inventory(self): self.assertEqual(len(conn.sent_packets), 1) +class ItemPickupTests(unittest.IsolatedAsyncioTestCase): + def make_server(self, inventory): + player = FakeConnection( + username="Tester", + x=0.0, y=64.0, z=0.0, + entity_id=1, + protocol_version=767, + version_handler=None, + inventory_obj=inventory, + inventory_state_id=0, + sent_packets=[], + ) + server = SimpleNamespace() + server.get_online_players = lambda: [player] + next_entity_id = iter(range(2, 1000)) + server.get_next_entity_id = lambda: next(next_entity_id) + server.entity_manager = EntityManager(server) + return server, player + + async def test_item_pickup_adds_stack_before_removing_entity(self): + server, player = self.make_server(PlayerInventory()) + entity = server.entity_manager.create_item( + 0.0, 64.0, 0.0, item_name="minecraft:diamond", count=3 + ) + entity.pickup_delay = 0 + + await MinecraftServer._tick_entity_interactions(server) + + self.assertEqual(player.inventory_obj.count_item("minecraft:diamond"), 3) + self.assertIsNone(server.entity_manager.get_entity(entity.entity_id)) + self.assertGreaterEqual(len(player.sent_packets), 2) + + async def test_partial_pickup_preserves_remainder_entity(self): + inventory = PlayerInventory() + for slot in list(range(9, 36)) + list(range(0, 9)) + [40]: + inventory.set_slot(slot, ItemStack("minecraft:dirt", 64)) + inventory.set_slot(0, ItemStack("minecraft:diamond", 63)) + server, player = self.make_server(inventory) + entity = server.entity_manager.create_item( + 0.0, 64.0, 0.0, item_name="minecraft:diamond", count=3 + ) + entity.pickup_delay = 0 + + await MinecraftServer._tick_entity_interactions(server) + + self.assertEqual(player.inventory_obj.count_item("minecraft:diamond"), 64) + self.assertEqual(entity.metadata["count"], 2) + self.assertIs(server.entity_manager.get_entity(entity.entity_id), entity) + + async def test_full_inventory_does_not_delete_item(self): + inventory = PlayerInventory() + for slot in list(range(9, 36)) + list(range(0, 9)) + [40]: + inventory.set_slot(slot, ItemStack("minecraft:dirt", 64)) + server, player = self.make_server(inventory) + entity = server.entity_manager.create_item( + 0.0, 64.0, 0.0, item_name="minecraft:diamond", count=3 + ) + entity.pickup_delay = 0 + + await MinecraftServer._tick_entity_interactions(server) + + self.assertEqual(player.inventory_obj.count_item("minecraft:diamond"), 0) + self.assertIs(server.entity_manager.get_entity(entity.entity_id), entity) + self.assertEqual(player.sent_packets, []) + + if __name__ == "__main__": unittest.main() \ No newline at end of file diff --git a/tests/test_login_identity.py b/tests/test_login_identity.py new file mode 100644 index 0000000..94e347c --- /dev/null +++ b/tests/test_login_identity.py @@ -0,0 +1,31 @@ +import unittest +import uuid +from types import SimpleNamespace + +from network.connection import Connection +from network.server import MinecraftServer + + +class LoginIdentityTests(unittest.TestCase): + def test_offline_uuid_matches_java_name_uuid_vectors(self): + vectors = { + "Notch": "b50ad385-829d-3141-a216-7e7d7539ba7f", + "Steve": "5627dd98-e6be-3c21-b8a8-e92344183641", + "notch": "42653081-a90e-3475-b3d6-3550cdb43f8e", + } + for username, expected in vectors.items(): + conn = SimpleNamespace(username=username) + actual = Connection.generate_offline_uuid(conn) + self.assertEqual(actual, uuid.UUID(expected)) + + +class OnlineModeTests(unittest.IsolatedAsyncioTestCase): + async def test_online_mode_fails_before_server_startup(self): + server = SimpleNamespace(online_mode=True, running=False) + with self.assertRaisesRegex(RuntimeError, "session authentication"): + await MinecraftServer.start(server) + self.assertFalse(server.running) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_play_dispatch.py b/tests/test_play_dispatch.py index 28c5225..c474b8c 100644 --- a/tests/test_play_dispatch.py +++ b/tests/test_play_dispatch.py @@ -2,6 +2,8 @@ from types import SimpleNamespace from handlers.play import _is_serverbound_packet +from handlers.versioned import get_version_handler +from protocol.packet_map import get_clientbound_packet class PlayDispatchTests(unittest.TestCase): @@ -25,6 +27,18 @@ def test_1_21_4_uses_its_versioned_interaction_ids(self): self.assertTrue(_is_serverbound_packet(conn, 0x3D, "use_item", 0x29)) self.assertFalse(_is_serverbound_packet(conn, 0x0C, "click_container", 0x0C)) + def test_grouped_handler_preserves_exact_negotiated_protocol(self): + handler = get_version_handler(770) + self.assertEqual(handler.PROTOCOL_VERSION, 770) + self.assertEqual( + handler.get_packet_id("keep_alive"), + get_clientbound_packet(770, "keep_alive"), + ) + + def test_1_19_protocol_routes_to_1_19_handler(self): + handler = get_version_handler(761) + self.assertEqual(handler.__class__.__name__, "VersionHandlerV1_19") + if __name__ == "__main__": unittest.main() \ No newline at end of file diff --git a/world/inventory.py b/world/inventory.py index 189bfa1..7ab8818 100755 --- a/world/inventory.py +++ b/world/inventory.py @@ -788,6 +788,7 @@ def serialize_full(self) -> dict: "item": slot.item_id, "count": slot.count, "damage": slot.damage, + "nbt": slot.nbt, } ender_data = {} @@ -797,6 +798,7 @@ def serialize_full(self) -> dict: "item": slot.item_id, "count": slot.count, "damage": slot.damage, + "nbt": slot.nbt, } return { @@ -825,6 +827,7 @@ def deserialize_full(cls, data: dict) -> 'PlayerInventory': item_id=v.get("item", "minecraft:air"), count=v.get("count", 1), damage=v.get("damage", 0), + nbt=v.get("nbt", {}), ) for k, v in data.get("ender_chest", {}).items(): slot_idx = int(k) @@ -833,6 +836,7 @@ def deserialize_full(cls, data: dict) -> 'PlayerInventory': item_id=v.get("item", "minecraft:air"), count=v.get("count", 1), damage=v.get("damage", 0), + nbt=v.get("nbt", {}), ) inv._selected_slot = data.get("selected_slot", 0) inv.held_slot = data.get("held_slot", 0) From d2eba06b97181a8f21c71493b6c452114adc7427 Mon Sep 17 00:00:00 2001 From: Arun Date: Fri, 14 Aug 2026 09:07:41 +0000 Subject: [PATCH 03/10] fix: wire fluid, furnace, mod tick, and gamerule behavior --- commands/vanilla/gamerule.py | 23 +++++++++++++++++++---- network/server.py | 21 ++++++++++++++++++--- tests/test_gameplay_systems.py | 34 ++++++++++++++++++++++++++++++++++ world/fluids.py | 6 +++++- 4 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 tests/test_gameplay_systems.py diff --git a/commands/vanilla/gamerule.py b/commands/vanilla/gamerule.py index 7450176..3e73bf9 100755 --- a/commands/vanilla/gamerule.py +++ b/commands/vanilla/gamerule.py @@ -32,11 +32,26 @@ async def _execute(ctx: CommandContext) -> int: return SUCCESS raw_value = args[1].lower() - if raw_value not in {"true", "false"}: - await ctx.reply("[PyMC] 当前 gamerule 仅支持 true/false 值") + current = rules[rule_name] + if isinstance(current, bool): + if raw_value not in {"true", "false"}: + await ctx.reply(f"[PyMC] {rule_name} 需要 true/false 值") + return FAILURE + value = raw_value == "true" + elif isinstance(current, int): + try: + value = int(raw_value) + except ValueError: + await ctx.reply(f"[PyMC] {rule_name} 需要整数值") + return FAILURE + if value < 0: + await ctx.reply(f"[PyMC] {rule_name} 不能为负数") + return FAILURE + else: + await ctx.reply(f"[PyMC] 不支持的游戏规则类型: {rule_name}") return FAILURE - rules[rule_name] = raw_value == "true" - await ctx.reply(f"[PyMC] 游戏规则 {rule_name} 已设置为 {raw_value}") + rules[rule_name] = value + await ctx.reply(f"[PyMC] 游戏规则 {rule_name} 已设置为 {str(value).lower()}") return SUCCESS cmd = Command( diff --git a/network/server.py b/network/server.py index f86f970..65f1613 100755 --- a/network/server.py +++ b/network/server.py @@ -830,6 +830,13 @@ async def _game_loop(self): # Plugin/mod tick hook from plugins.bridge import hook_server_tick hook_server_tick(self) + if self.mod_manager is not None: + from mods.bridge import hook_tick + hook_tick(self) + + # Process active furnace-like containers. + from world.block_behavior import container_manager + container_manager.tick_furnaces(self) # 发送 KeepAlive 心跳 if tick_count % keepalive_interval == 0: @@ -900,9 +907,17 @@ async def _tick_fluids(self, tick_count: int): return self.fluid_system.tick() - - # Fluid system handles its own block updates internally - # via _broadcast_block_change in its _notify_fluid_update method + updates = getattr(self, "_fluid_updates", []) + self._fluid_updates = [] + if not updates: + return + from handlers.play import _broadcast_block_change + # Keep only the final state when a position changes repeatedly in one tick. + final_updates = {} + for x, y, z, new_state in updates: + final_updates[(x, y, z)] = new_state + for (x, y, z), new_state in final_updates.items(): + await _broadcast_block_change(self, x, y, z, new_state) async def _send_keepalive(self): """向所有在线玩家发送 KeepAlive 数据包。""" diff --git a/tests/test_gameplay_systems.py b/tests/test_gameplay_systems.py new file mode 100644 index 0000000..797c725 --- /dev/null +++ b/tests/test_gameplay_systems.py @@ -0,0 +1,34 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from network.server import MinecraftServer +from world.fluids import FluidSystem, _get_fluid_level, _get_water_state + + +class FluidTests(unittest.TestCase): + def test_falling_water_is_not_encoded_as_source(self): + falling = _get_water_state(0, falling=True) + self.assertNotEqual(falling, _get_water_state(0)) + self.assertEqual(_get_fluid_level(falling), 8) + + +class FluidBroadcastTests(unittest.IsolatedAsyncioTestCase): + async def test_tick_broadcasts_final_state_once_per_position(self): + fluid_system = SimpleNamespace(tick=lambda: None) + server = SimpleNamespace( + fluid_system=fluid_system, + _fluid_updates=[(1, 2, 3, 4), (1, 2, 3, 5), (6, 7, 8, 9)], + ) + broadcaster = AsyncMock() + with patch("handlers.play._broadcast_block_change", broadcaster): + await MinecraftServer._tick_fluids(server, 1) + + self.assertEqual(server._fluid_updates, []) + self.assertEqual(broadcaster.await_count, 2) + broadcaster.assert_any_await(server, 1, 2, 3, 5) + broadcaster.assert_any_await(server, 6, 7, 8, 9) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/world/fluids.py b/world/fluids.py index a490ce9..fa34b3c 100755 --- a/world/fluids.py +++ b/world/fluids.py @@ -144,6 +144,8 @@ def _is_air_or_fluid_passable(state_id: int | None, fluid_type: str = "water") - def _get_water_state(level: int, falling: bool = False) -> int: """Get the water block state ID for a given level.""" + if falling: + level = 8 if level == 0: return WATER # Source # Look up flowing water state @@ -158,6 +160,8 @@ def _get_water_state(level: int, falling: bool = False) -> int: def _get_lava_state(level: int, falling: bool = False) -> int: """Get the lava block state ID for a given level.""" + if falling: + level = 8 if level == 0: return LAVA # Source props = {"level": str(level)} @@ -315,7 +319,7 @@ def _process_flow(self, x: int, y: int, z: int, current_state: int, set_world_block(self.server, x, y - 1, z, STONE) self._notify_fluid_update(x, y - 1, z, STONE) elif _is_air_or_fluid_passable(below, fluid_type) and not _is_fluid(below): - # Flow down - create source at bottom + # Downward flow is a falling state, never a new source. if fluid_type == "water": new_state = _get_water_state(0, falling=True) else: From 03af092459ccdbfb8b14849d5ec5658ec4f7afcb Mon Sep 17 00:00:00 2001 From: Arun Date: Fri, 14 Aug 2026 09:10:13 +0000 Subject: [PATCH 04/10] fix: validate keepalive and expose watchdog health --- .github/workflows/build.yml | 4 ++++ handlers/play/movement.py | 23 ++++++++++++++++++---- network/connection.py | 3 +++ network/server.py | 6 ++++++ tests/test_keepalive.py | 37 +++++++++++++++++++++++++++++++++++ tests/test_watchdog_health.py | 34 ++++++++++++++++++++++++++++++++ watchdog/process_manager.py | 9 +++++++-- 7 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 tests/test_keepalive.py create mode 100644 tests/test_watchdog_health.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dd6f152..5f827d6 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -59,6 +59,8 @@ jobs: mkdir -p build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_FLAGS="-Wno-unused-parameter -Wno-unused-variable" make -j$(sysctl -n hw.ncpu) + - name: Run tests + run: python3 -m pytest tests/ -v - name: Build with Nuitka run: | pip install nuitka @@ -98,6 +100,8 @@ jobs: mkdir -p build && cd build cmake .. -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_FLAGS="-Wno-unused-parameter -Wno-unused-variable" mingw32-make -j$(nproc) + - name: Run tests + run: python -m pytest tests/ -v - name: Build with Nuitka run: | pip install nuitka diff --git a/handlers/play/movement.py b/handlers/play/movement.py index 99e3ecc..46e05f6 100755 --- a/handlers/play/movement.py +++ b/handlers/play/movement.py @@ -18,6 +18,7 @@ import struct import logging +import time from protocol.data_types import ( read_varint, read_double, read_float, @@ -34,10 +35,24 @@ def _handle_confirm_teleportation(conn: Connection, payload: bytes): def _handle_keepalive(conn: Connection, payload: bytes): - """处理 Keep Alive (0x18) 响应。""" - if len(payload) >= 8: - keepalive_id = struct.unpack('>q', payload[:8])[0] - logger.debug(f"{conn.username} KeepAlive 响应: {keepalive_id}") + """Validate a Keep Alive response and record round-trip latency.""" + if len(payload) < 8: + return False + keepalive_id = struct.unpack('>q', payload[:8])[0] + if not conn.keepalive_pending or keepalive_id != conn.keepalive_id: + logger.warning( + f"{conn.username} returned unexpected KeepAlive ID {keepalive_id}" + ) + return False + conn.keepalive_pending = False + conn.keepalive_rtt_ms = max( + 0.0, (time.monotonic() - conn.keepalive_sent_at) * 1000.0 + ) + logger.debug( + f"{conn.username} KeepAlive response: {keepalive_id} " + f"({conn.keepalive_rtt_ms:.1f}ms)" + ) + return True def _read_movement_on_ground(payload: bytes, offset: int) -> tuple[bool, int]: diff --git a/network/connection.py b/network/connection.py index 9b9a079..26a34ef 100755 --- a/network/connection.py +++ b/network/connection.py @@ -89,6 +89,9 @@ def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, # 连接状态 self.alive = True self.keepalive_id: int = 0 + self.keepalive_pending: bool = False + self.keepalive_sent_at: float = 0.0 + self.keepalive_rtt_ms: float | None = None self.teleport_id: int = 0 logger.info(f"新连接来自 {self.address}") diff --git a/network/server.py b/network/server.py index 65f1613..eb08a38 100755 --- a/network/server.py +++ b/network/server.py @@ -928,7 +928,13 @@ async def _send_keepalive(self): payload = struct.pack('>q', keepalive_id) for conn in self.get_online_players(): + if (conn.keepalive_pending + and time.monotonic() - conn.keepalive_sent_at > 30.0): + await conn.disconnect("KeepAlive timeout") + continue conn.keepalive_id = keepalive_id + conn.keepalive_pending = True + conn.keepalive_sent_at = time.monotonic() # Use version-specific KeepAlive packet ID pid = get_clientbound_packet(conn.protocol_version, "keep_alive") if pid is not None: diff --git a/tests/test_keepalive.py b/tests/test_keepalive.py new file mode 100644 index 0000000..ed97b42 --- /dev/null +++ b/tests/test_keepalive.py @@ -0,0 +1,37 @@ +import struct +import time +import unittest +from types import SimpleNamespace + +from handlers.play.movement import _handle_keepalive + + +class KeepAliveTests(unittest.TestCase): + def make_connection(self): + return SimpleNamespace( + username="Tester", + keepalive_id=123, + keepalive_pending=True, + keepalive_sent_at=time.monotonic() - 0.01, + keepalive_rtt_ms=None, + ) + + def test_matching_response_clears_pending_and_records_rtt(self): + conn = self.make_connection() + self.assertTrue(_handle_keepalive(conn, struct.pack(">q", 123))) + self.assertFalse(conn.keepalive_pending) + self.assertGreaterEqual(conn.keepalive_rtt_ms, 0.0) + + def test_mismatched_response_is_rejected(self): + conn = self.make_connection() + self.assertFalse(_handle_keepalive(conn, struct.pack(">q", 456))) + self.assertTrue(conn.keepalive_pending) + + def test_truncated_response_is_rejected(self): + conn = self.make_connection() + self.assertFalse(_handle_keepalive(conn, b"\x00")) + self.assertTrue(conn.keepalive_pending) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_watchdog_health.py b/tests/test_watchdog_health.py new file mode 100644 index 0000000..9967f69 --- /dev/null +++ b/tests/test_watchdog_health.py @@ -0,0 +1,34 @@ +import json +import unittest +from types import SimpleNamespace + +from watchdog.process_manager import WatchdogManager + + +class FakeTransport: + def __init__(self): + self.sent = [] + + def sendto(self, data, addr): + self.sent.append((data, addr)) + + +class WatchdogHealthTests(unittest.TestCase): + def test_ping_returns_health_payload(self): + watchdog = SimpleNamespace() + watchdog._health_server = FakeTransport() + watchdog.get_health_status = lambda: {"pid": 42, "tps": 20.0} + + WatchdogManager._handle_incoming_message( + watchdog, "PYMC_PING", ("127.0.0.1", 9999) + ) + + data, addr = watchdog._health_server.sent[0] + prefix, payload = data.decode().split("|", 1) + self.assertEqual(prefix, "PYMC_HEALTH") + self.assertEqual(json.loads(payload)["pid"], 42) + self.assertEqual(addr, ("127.0.0.1", 9999)) + + +if __name__ == "__main__": + unittest.main() diff --git a/watchdog/process_manager.py b/watchdog/process_manager.py index 3e8b25c..2bbb6bf 100755 --- a/watchdog/process_manager.py +++ b/watchdog/process_manager.py @@ -278,7 +278,7 @@ def error_received(self, exc): try: transport, protocol = await loop.create_datagram_endpoint( lambda: HealthProtocol(self), - local_addr=('0.0.0.0', self.health_port), + local_addr=('127.0.0.1', self.health_port), ) self._health_server = transport logger.info(f"Health check UDP server started on port {self.health_port}") @@ -291,7 +291,7 @@ def error_received(self, exc): self.health_port = alt_port transport, protocol = await loop.create_datagram_endpoint( lambda: HealthProtocol(self), - local_addr=('0.0.0.0', self.health_port), + local_addr=('127.0.0.1', self.health_port), ) self._health_server = transport logger.info(f"Health check UDP server started on alternate port {self.health_port}") @@ -307,6 +307,11 @@ def _handle_incoming_message(self, message: str, addr: tuple): elif message.startswith("PYMC_HEALTH|"): # Health check response pass + elif message == "PYMC_PING": + import json + response = f"PYMC_HEALTH|{json.dumps(self.get_health_status())}" + if self._health_server is not None: + self._health_server.sendto(response.encode("utf-8"), addr) elif message.startswith("PYMC_RESTART|"): # Restart command self._handle_restart_command(message) From a1c68cfc8a6f560c603a2b1f06a2653e1a36e566 Mon Sep 17 00:00:00 2001 From: Arun Date: Fri, 14 Aug 2026 09:11:36 +0000 Subject: [PATCH 05/10] fix: route gameplay events through native mods --- handlers/play/blocks.py | 4 ++-- handlers/play/chat.py | 2 +- handlers/play/join.py | 2 +- network/server.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/handlers/play/blocks.py b/handlers/play/blocks.py index 680f9d7..0c8a708 100755 --- a/handlers/play/blocks.py +++ b/handlers/play/blocks.py @@ -273,7 +273,7 @@ async def _handle_block_dig(conn: Connection, payload: bytes, server): return # Plugin hook: allow plugins to cancel block break - from plugins.bridge import hook_block_break + from mods.bridge import hook_block_break if not hook_block_break(server, conn, x, y, z, current): return # Cancelled by a plugin @@ -517,7 +517,7 @@ async def _handle_block_place(conn: Connection, payload: bytes, server): # Place the block # Plugin hook: allow plugins to cancel block placement - from plugins.bridge import hook_block_place + from mods.bridge import hook_block_place if not hook_block_place(server, conn, place_x, place_y, place_z, block_state): return # Cancelled by a plugin diff --git a/handlers/play/chat.py b/handlers/play/chat.py index d7fa7d5..8928bbb 100755 --- a/handlers/play/chat.py +++ b/handlers/play/chat.py @@ -97,7 +97,7 @@ async def _handle_chat_message(conn: Connection, payload: bytes, server): message, offset = read_string(payload, offset) # Plugin hook: allow plugins to cancel or modify chat - from plugins.bridge import hook_player_chat + from mods.bridge import hook_player_chat if not hook_player_chat(server, conn, message): return # Cancelled by a plugin diff --git a/handlers/play/join.py b/handlers/play/join.py index 24b101d..4db10c8 100755 --- a/handlers/play/join.py +++ b/handlers/play/join.py @@ -212,7 +212,7 @@ def _generate_chunks(coords): await _send_visible_entities_to_player(conn, server) # Plugin hook: fire PlayerJoinEvent - from plugins.bridge import hook_player_join + from mods.bridge import hook_player_join hook_player_join(server, conn) load_elapsed = time.time() - load_start diff --git a/network/server.py b/network/server.py index eb08a38..015141a 100755 --- a/network/server.py +++ b/network/server.py @@ -759,7 +759,7 @@ async def _handle_client(self, reader: asyncio.StreamReader, if conn.username and conn.state == ConnectionState.PLAY: self.save_player_state(conn) # Plugin hook: fire PlayerQuitEvent - from plugins.bridge import hook_player_quit + from mods.bridge import hook_player_quit hook_player_quit(self, conn) logger.info(f"玩家 {conn.username} 离开了游戏") await self._handle_player_leave(conn) From b14933191147c1f581bb1bc55ca70db1bebaed0c Mon Sep 17 00:00:00 2001 From: Arun Date: Fri, 14 Aug 2026 09:12:49 +0000 Subject: [PATCH 06/10] fix: encode login success by protocol family --- handlers/login.py | 22 +++++++------ tests/test_login_packets.py | 63 +++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 9 deletions(-) create mode 100644 tests/test_login_packets.py diff --git a/handlers/login.py b/handlers/login.py index bb4e703..c9a7585 100755 --- a/handlers/login.py +++ b/handlers/login.py @@ -125,20 +125,24 @@ async def _send_login_success(conn: Connection): """ 发送 Login Success 数据包 (0x02)。 - 格式 varies by version: - - 1.8-1.15: UUID + String(username) - - 1.16-1.19.1: UUID + String(username) + VarInt(properties) - - 1.19.3+: UUID + String(username) + VarInt(properties) + Boolean(strict) + Format varies by protocol family: + - 1.8-1.15: String(UUID) + String(username) + - 1.16-1.19.1: UUID + String(username) + - 1.19.3-1.20.6: UUID + String(username) + properties + - 1.21.1: same plus strict error handling + - 1.21.4: strict error handling was removed """ payload = bytearray() - payload.extend(write_uuid(conn.uuid)) + if conn.protocol_version <= 578: + payload.extend(write_string(str(conn.uuid))) + else: + payload.extend(write_uuid(conn.uuid)) payload.extend(write_string(conn.username)) - # Properties (empty) - payload.extend(write_varint(0)) # 属性数量 = 0 - - # Strict error handling (1.19.3+ / protocol 761+) if conn.protocol_version >= 761: + payload.extend(write_varint(0)) # Empty profile properties + + if conn.protocol_version == 767: payload.extend(write_boolean(True)) await conn.send_packet(0x02, bytes(payload)) diff --git a/tests/test_login_packets.py b/tests/test_login_packets.py new file mode 100644 index 0000000..9767459 --- /dev/null +++ b/tests/test_login_packets.py @@ -0,0 +1,63 @@ +import unittest +import uuid +from types import SimpleNamespace + +from handlers.login import _send_login_success +from protocol.data_types import read_string, read_varint + + +class FakeConnection(SimpleNamespace): + async def send_packet(self, packet_id, payload=b""): + self.sent = (packet_id, payload) + + +class LoginSuccessPacketTests(unittest.IsolatedAsyncioTestCase): + async def encode(self, protocol_version): + conn = FakeConnection( + protocol_version=protocol_version, + uuid=uuid.UUID("b50ad385-829d-3141-a216-7e7d7539ba7f"), + username="Notch", + sent=None, + ) + await _send_login_success(conn) + self.assertEqual(conn.sent[0], 0x02) + return conn.sent[1] + + async def test_1_8_uses_string_uuid_without_properties(self): + payload = await self.encode(47) + uuid_text, offset = read_string(payload, 0) + username, offset = read_string(payload, offset) + self.assertEqual(uuid_text, "b50ad385-829d-3141-a216-7e7d7539ba7f") + self.assertEqual(username, "Notch") + self.assertEqual(offset, len(payload)) + + async def test_1_16_uses_binary_uuid_without_properties(self): + payload = await self.encode(736) + self.assertEqual(payload[:16], uuid.UUID("b50ad385-829d-3141-a216-7e7d7539ba7f").bytes) + username, offset = read_string(payload, 16) + self.assertEqual(username, "Notch") + self.assertEqual(offset, len(payload)) + + async def test_1_19_3_includes_empty_properties(self): + payload = await self.encode(761) + username, offset = read_string(payload, 16) + properties, offset = read_varint(payload, offset) + self.assertEqual(username, "Notch") + self.assertEqual(properties, 0) + self.assertEqual(offset, len(payload)) + + async def test_1_21_1_includes_strict_flag(self): + payload = await self.encode(767) + _, offset = read_string(payload, 16) + _, offset = read_varint(payload, offset) + self.assertEqual(payload[offset:], b"\x01") + + async def test_1_21_4_omits_strict_flag(self): + payload = await self.encode(770) + _, offset = read_string(payload, 16) + _, offset = read_varint(payload, offset) + self.assertEqual(offset, len(payload)) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file From 8a57ce1c34cbf691f22e89164305b7e5ec8251ed Mon Sep 17 00:00:00 2001 From: Arun Date: Fri, 14 Aug 2026 09:14:10 +0000 Subject: [PATCH 07/10] fix: package native artifacts and correct runtime docs --- .github/workflows/build.yml | 6 ++++++ README.md | 8 ++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5f827d6..dea082f 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,6 +26,7 @@ jobs: mkdir -p build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_FLAGS="-Wno-unused-parameter -Wno-unused-variable" make -j$(nproc) + cmake --install . --prefix stage - name: Run tests run: python3 -m pytest tests/ -v - name: Build with Nuitka @@ -33,6 +34,7 @@ jobs: pip install nuitka python3 -m nuitka --standalone --onefile --assume-yes-for-downloads \ --include-data-file=world/blocks.json=world/blocks.json \ + --include-data-dir=build/stage/native=native \ main.py -o pymc-server-linux-x86_64 - name: Upload Linux artifact uses: actions/upload-artifact@v4 @@ -59,6 +61,7 @@ jobs: mkdir -p build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_FLAGS="-Wno-unused-parameter -Wno-unused-variable" make -j$(sysctl -n hw.ncpu) + cmake --install . --prefix stage - name: Run tests run: python3 -m pytest tests/ -v - name: Build with Nuitka @@ -66,6 +69,7 @@ jobs: pip install nuitka python3 -m nuitka --standalone --onefile --assume-yes-for-downloads \ --include-data-file=world/blocks.json=world/blocks.json \ + --include-data-dir=build/stage/native=native \ main.py -o pymc-server-macos-arm64 - name: Upload macOS artifact uses: actions/upload-artifact@v4 @@ -100,6 +104,7 @@ jobs: mkdir -p build && cd build cmake .. -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_FLAGS="-Wno-unused-parameter -Wno-unused-variable" mingw32-make -j$(nproc) + cmake --install . --prefix stage - name: Run tests run: python -m pytest tests/ -v - name: Build with Nuitka @@ -107,6 +112,7 @@ jobs: pip install nuitka python -m nuitka --standalone --onefile --mingw64 --assume-yes-for-downloads ` --include-data-file=world/blocks.json=world/blocks.json ` + --include-data-dir=build/stage/native=native ` main.py -o pymc-server-windows-x86_64.exe - name: Upload Windows artifact uses: actions/upload-artifact@v4 diff --git a/README.md b/README.md index a8de2d4..46988e7 100755 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ PyMC 是一个用 Python 实现的 Minecraft Java 版 1.21.1 服务端原型, - 多版本协议兼容 (1.8.9 - 1.21.4, 协议版本 47-770) - 数据包压缩、KeepAlive、玩家移动同步 - 类原版 384 高度主世界区块 -- 优先使用 `native/terrain_gen` C++ 原生地形生成器,回退到 1:1 Vanilla 或 Python 生成器 +- 优先使用 `native/terrain_gen` C++ 原生地形生成器,回退到原版风格近似或基础 Python 生成器 - 红石系统:基础组件模拟,每 2 游戏刻 (0.1s) 红石刻;部分组件行为仍待完成 - 流体系统:水/岩浆流动,水-岩浆交互 - 物品栏系统:ItemStack、PlayerInventory、协议序列化和基础点击交互 @@ -97,7 +97,7 @@ PyMC 是一个用 Python 实现的 Minecraft Java 版 1.21.1 服务端原型, - `protocol/`:VarInt、NBT、Packet 编解码 - `world/`:方块、区块编码、地形生成、存档、实体和世界编辑 - `world/redstone.py`:红石引擎 - - `world/vanilla_terrain.py`:1:1 原版地形生成器 + - `world/vanilla_terrain.py`:原版风格近似地形生成器 - `world/inventory.py`:物品栏系统 - `world/block_behavior.py`:方块行为系统 - `world/fluids.py`:流体系统 @@ -126,7 +126,7 @@ python main.py - Minecraft 服务端: `0.0.0.0:25565` - Web 管理台: `127.0.0.1:25568`(无内置认证,默认禁止远程监听) -- Watchdog 健康检查: `0.0.0.0:25569` (启用时) +- Watchdog UDP 健康检查: `0.0.0.0:25569` (启用时) 使用 Minecraft Java 1.21.1 客户端连接 `localhost:25565`。 @@ -147,7 +147,7 @@ python main.py - `permissions-file`:权限文件路径 - `join-immediate-radius`:玩家入服时优先同步的近距离区块半径 - `min-protocol-version` / `max-protocol-version`:允许的协议版本范围 (默认 47-770) -- `vanilla-terrain`:使用 1:1 原版地形生成器 (默认 `true`) +- `vanilla-terrain`:使用原版风格近似地形生成器 (默认 `true`) - `redstone-enabled`:启用红石模拟 (默认 `true`) - `fluid-flow-enabled`:启用流体流动 (默认 `true`) - `mods-directory`:Mod 扫描目录 (默认 `mods`) From e2c8fcdbc6319a3799c2e11ff9e1a4997360a910 Mon Sep 17 00:00:00 2001 From: Arun Date: Fri, 14 Aug 2026 09:15:12 +0000 Subject: [PATCH 08/10] docs: correct watchdog bind address --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 46988e7..5557556 100755 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ python main.py - Minecraft 服务端: `0.0.0.0:25565` - Web 管理台: `127.0.0.1:25568`(无内置认证,默认禁止远程监听) -- Watchdog UDP 健康检查: `0.0.0.0:25569` (启用时) +- Watchdog UDP 健康检查: `127.0.0.1:25569` (启用时) 使用 Minecraft Java 1.21.1 客户端连接 `localhost:25565`。 From 6a5576dcf34ed108968225af197ca5f3921a6e20 Mon Sep 17 00:00:00 2001 From: Arun Date: Fri, 14 Aug 2026 09:17:11 +0000 Subject: [PATCH 09/10] fix: enforce configured protocol allowlist --- handlers/login.py | 12 +++++++++--- protocol/versions.py | 14 +++++++++++--- tests/test_protocol_allowlist.py | 22 ++++++++++++++++++++++ 3 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 tests/test_protocol_allowlist.py diff --git a/handlers/login.py b/handlers/login.py index c9a7585..cbc0f76 100755 --- a/handlers/login.py +++ b/handlers/login.py @@ -10,7 +10,9 @@ read_string, read_varint, write_string, write_varint, write_uuid, write_boolean, read_uuid ) -from protocol.versions import has_configuration_phase, is_supported +from protocol.versions import ( + filter_supported_versions, has_configuration_phase, is_supported, +) from network.connection import Connection, ConnectionState logger = logging.getLogger("PyMC.登录") @@ -60,9 +62,13 @@ async def _handle_login_start(conn: Connection, payload: bytes, server): # Check if this protocol version is allowed by server config min_version = int(server.config.get("min-protocol-version", 47)) max_version = int(server.config.get("max-protocol-version", 770)) + allowed_versions = filter_supported_versions( + server.config.get("support-protocol-versions", "all"), + min_version, + max_version, + ) if (not is_supported(conn.protocol_version) - or conn.protocol_version < min_version - or conn.protocol_version > max_version): + or conn.protocol_version not in allowed_versions): logger.info(f"拒绝玩家 {username}: 协议版本 {conn.protocol_version} " f"不在允许范围 [{min_version}, {max_version}]") await _send_disconnect_login(conn, f"Your protocol version ({conn.protocol_version}) is not supported.") diff --git a/protocol/versions.py b/protocol/versions.py index b2bfa35..23df70e 100755 --- a/protocol/versions.py +++ b/protocol/versions.py @@ -149,9 +149,17 @@ def filter_supported_versions(version_list: list[int] | str, min_version: Minimum allowed protocol version (inclusive) max_version: Maximum allowed protocol version (inclusive) """ - if version_list == "all": - return [pv for pv in SUPPORTED_VERSIONS - if min_version <= pv <= max_version] + if isinstance(version_list, str): + if version_list.strip().lower() == "all": + return [pv for pv in SUPPORTED_VERSIONS + if min_version <= pv <= max_version] + parsed = [] + for value in version_list.split(","): + try: + parsed.append(int(value.strip())) + except ValueError: + continue + version_list = parsed result = [] for pv in version_list: diff --git a/tests/test_protocol_allowlist.py b/tests/test_protocol_allowlist.py new file mode 100644 index 0000000..51c3380 --- /dev/null +++ b/tests/test_protocol_allowlist.py @@ -0,0 +1,22 @@ +import unittest + +from protocol.versions import filter_supported_versions + + +class ProtocolAllowlistTests(unittest.TestCase): + def test_all_respects_minimum_and_maximum(self): + self.assertEqual(filter_supported_versions("all", 767, 770), [767, 770]) + + def test_comma_separated_allowlist_is_parsed_and_filtered(self): + self.assertEqual( + filter_supported_versions("47, 340, 767, 999, invalid", 47, 770), + [47, 340, 767], + ) + + def test_empty_or_invalid_allowlist_enables_nothing(self): + self.assertEqual(filter_supported_versions("", 47, 770), []) + self.assertEqual(filter_supported_versions("invalid", 47, 770), []) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file From d070fef77ffc715dcb5b047524cf5bd5fef931de Mon Sep 17 00:00:00 2001 From: Arun Date: Fri, 14 Aug 2026 09:18:06 +0000 Subject: [PATCH 10/10] fix: map player inventory protocol slots correctly --- handlers/play/__init__.py | 39 +++++++++++++++++++++++++--------- tests/test_inventory_clicks.py | 20 +++++++++++++---- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/handlers/play/__init__.py b/handlers/play/__init__.py index 727f00b..16f6524 100755 --- a/handlers/play/__init__.py +++ b/handlers/play/__init__.py @@ -172,6 +172,23 @@ def _is_serverbound_packet(conn: Connection, packet_id: int, and packet_id == native_packet_id) +def _player_window_slot_to_inventory(slot: int) -> int | None: + """Translate protocol window-0 slots to PlayerInventory indices.""" + if slot == 0: # crafting output + return 45 + if 1 <= slot <= 4: # 2x2 crafting input + return 40 + slot + if 5 <= slot <= 8: # helmet, chest, legs, boots + return 44 - slot + if 9 <= slot <= 35: # main inventory + return slot + if 36 <= slot <= 44: # hotbar + return slot - 36 + if slot == 45: # offhand + return 40 + return None + + async def handle_play(conn: Connection, packet_id: int, payload: bytes, server): """分发 Play 阶段的客户端数据包。""" @@ -288,15 +305,16 @@ async def _handle_click_container(conn: Connection, payload: bytes, server): _, offset = decode_slot_entry(payload, offset) _, offset = decode_slot_entry(payload, offset) - if mode == 0 and 0 <= slot_idx < inv.TOTAL_SLOTS and button in (0, 1): - slot_item = inv.get_slot(slot_idx) + inventory_slot = _player_window_slot_to_inventory(slot_idx) + if mode == 0 and inventory_slot is not None and button in (0, 1): + slot_item = inv.get_slot(inventory_slot) cursor = inv.carried_item if button == 0: # left click: pick up, place, merge, or swap if cursor is None or cursor.is_empty: inv.carried_item = slot_item - inv.set_slot(slot_idx, None) + inv.set_slot(inventory_slot, None) elif slot_item is None or slot_item.is_empty: - inv.set_slot(slot_idx, cursor) + inv.set_slot(inventory_slot, cursor) inv.carried_item = None elif slot_item.can_stack_with(cursor) and slot_item.count < slot_item.max_stack_size: moved = min(cursor.count, slot_item.max_stack_size - slot_item.count) @@ -306,7 +324,7 @@ async def _handle_click_container(conn: Connection, payload: bytes, server): inv.carried_item = None inv.state_id += 1 else: - inv.set_slot(slot_idx, cursor) + inv.set_slot(inventory_slot, cursor) inv.carried_item = slot_item else: # right click: pick up half or place one if cursor is None or cursor.is_empty: @@ -316,13 +334,13 @@ async def _handle_click_container(conn: Connection, payload: bytes, server): inv.carried_item.count = take slot_item.count -= take if slot_item.count <= 0: - inv.set_slot(slot_idx, None) + inv.set_slot(inventory_slot, None) else: inv.state_id += 1 elif slot_item is None or slot_item.is_empty: placed = cursor.copy() placed.count = 1 - inv.set_slot(slot_idx, placed) + inv.set_slot(inventory_slot, placed) cursor.count -= 1 if cursor.count <= 0: inv.carried_item = None @@ -361,12 +379,13 @@ async def _handle_creative_inventory_action(conn: Connection, payload: bytes, se if inv is None: return - if slot_idx < 0 or slot_idx >= 46: + inventory_slot = _player_window_slot_to_inventory(slot_idx) + if inventory_slot is None: return # Invalid slot if clicked_item is None or clicked_item.is_empty: - inv.set_slot(slot_idx, None) + inv.set_slot(inventory_slot, None) else: - inv.set_slot(slot_idx, clicked_item) + inv.set_slot(inventory_slot, clicked_item) conn.inventory_state_id += 1 diff --git a/tests/test_inventory_clicks.py b/tests/test_inventory_clicks.py index b16c876..7fef01b 100644 --- a/tests/test_inventory_clicks.py +++ b/tests/test_inventory_clicks.py @@ -1,7 +1,9 @@ import unittest from types import SimpleNamespace -from handlers.play import _handle_click_container +from handlers.play import ( + _handle_click_container, _player_window_slot_to_inventory, +) from network.server import MinecraftServer from protocol.data_types import write_varint, write_short, write_byte from world.entities import EntityManager @@ -58,6 +60,16 @@ def test_full_persistence_round_trip_preserves_all_item_data(self): self.assertEqual(restored.ender_chest[2].nbt, {"source": "test"}) self.assertEqual(restored.held_slot, 4) + def test_player_window_slot_mapping_matches_java_layout(self): + self.assertEqual(_player_window_slot_to_inventory(0), 45) + self.assertEqual(_player_window_slot_to_inventory(5), 39) + self.assertEqual(_player_window_slot_to_inventory(8), 36) + self.assertEqual(_player_window_slot_to_inventory(9), 9) + self.assertEqual(_player_window_slot_to_inventory(36), 0) + self.assertEqual(_player_window_slot_to_inventory(44), 8) + self.assertEqual(_player_window_slot_to_inventory(45), 40) + self.assertIsNone(_player_window_slot_to_inventory(-999)) + class InventoryClickTests(unittest.IsolatedAsyncioTestCase): def make_connection(self): @@ -72,7 +84,7 @@ async def test_left_click_picks_up_stack_and_syncs(self): conn = self.make_connection() conn.inventory_obj.set_slot(0, ItemStack("minecraft:stone", 12)) - await _handle_click_container(conn, click_payload(0), None) + await _handle_click_container(conn, click_payload(36), None) self.assertIsNone(conn.inventory_obj.get_slot(0)) self.assertEqual(conn.inventory_obj.carried_item, ItemStack("minecraft:stone", 12)) @@ -83,7 +95,7 @@ async def test_right_click_picks_up_rounded_half(self): conn = self.make_connection() conn.inventory_obj.set_slot(0, ItemStack("minecraft:stone", 5)) - await _handle_click_container(conn, click_payload(0, button=1), None) + await _handle_click_container(conn, click_payload(36, button=1), None) self.assertEqual(conn.inventory_obj.get_slot(0).count, 2) self.assertEqual(conn.inventory_obj.carried_item.count, 3) @@ -92,7 +104,7 @@ async def test_stale_click_does_not_mutate_inventory(self): conn = self.make_connection() conn.inventory_obj.set_slot(0, ItemStack("minecraft:stone", 5)) - await _handle_click_container(conn, click_payload(0, state_id=9), None) + await _handle_click_container(conn, click_payload(36, state_id=9), None) self.assertEqual(conn.inventory_obj.get_slot(0).count, 5) self.assertIsNone(conn.inventory_obj.carried_item)