diff --git a/.gitignore b/.gitignore index 400b862..9b76e83 100755 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,13 @@ __pycache__/ /world/playerdata/ /pumpkin-ref/ + +# 本地编译产物 (由 build.bat/build.sh/CMake 生成) +native/terrain_gen +native/terrain_gen.exe +native/mob_ai +native/mob_ai.exe +native/pymc_native_server +native/pymc_native_server.exe +native/libpymc_native.so +native/pymc_native.dll diff --git a/README.md b/README.md index 4b060a1..2053451 100755 --- a/README.md +++ b/README.md @@ -22,7 +22,12 @@ PyMC 是一个用 Python 实现的 Minecraft Java 版 1.21.1 服务端原型, - [x] 玩家 JSON 存档:位置、生命值、饱食度、经验、游戏模式和个人出生点。 - [x] 基础聊天、方块挖掘/放置、掉落物、经验球和简单生物实体。 - [x] C++ 原生轻量生物 AI `native/mob_ai`,支持随机游走、看向玩家、敌对追击、近战冷却等行为。 -- [x] 基础 `gamerule`:昼夜流动、自然刷怪、自然回血、死亡后自动重生回出生点。 +- [x] 基础 `gamerule`:昼夜流动、天气循环 (doWeatherCycle)、自然刷怪、自然回血、死亡后自动重生回出生点。 +- [x] **天气客户端同步**:通过 Game Event 数据包同步下雨/雷暴的开始、结束与强度;入服时同步当前天气,`/weather` 持续时间参数生效并立即广播。 +- [x] **玩家互相可见**:Spawn Entity / Set Entity Data / Entity Teleport / Rotate Head 转发,玩家皮肤层元数据同步,移动更新按 `network-movement-rate-hz` 限频(原生 1.21.1 路径)。 +- [x] **战斗系统**:攻击生物(武器伤害表)、生物死亡掉落与经验球、骷髅等远程生物标记。 +- [x] **物品耐久与基础附魔**:工具/武器耐久消耗、耐久 (Unbreaking) 减免、锋利 (Sharpness) 增伤。 +- [x] **雷暴闪电**:雷暴期间在随机玩家附近落雷,对附近实体造成伤害。 - [x] 控制台和游戏内基础命令。 - [x] Web 管理台、权限组、OP、封禁和白名单。 - [x] 单元测试覆盖原生地形、原生 AI、种子解析和安全出生点。 @@ -38,8 +43,8 @@ PyMC 是一个用 Python 实现的 Minecraft Java 版 1.21.1 服务端原型, - [ ] **流体系统**:已有基础水/岩浆传播与交互,但尚未达到完整原版规则。 - [ ] **多版本协议兼容**:已有 47-770 的版本映射和处理器框架;只有核心路径经过有限验证,不能宣称所有版本完整兼容。 - [ ] **命令覆盖**:`CommandManager`、权限、别名和大量命令已注册,但部分命令或子命令仍只返回“暂未实现”。 -- [ ] **Watchdog**:已有 UDP 心跳、监控和重启框架,但缺少双进程端到端测试。 -- [ ] **网络优化**:已有批处理、限频和区块排序组件,但普通发送/移动路径尚未全面接入。 +- [ ] **Watchdog**:已有 UDP 心跳、监控、重启框架和真实 UDP 端到端测试,但尚未在长期运行环境中验证。 +- [ ] **网络优化**:批处理已接入实体同步广播路径、移动更新已限频,但普通发送路径尚未全面接入。 - [x] **CI/CD**:GitHub Actions 工作流,Linux/macOS/Windows 三平台构建、CMake 原生组件编译、pytest 测试和 Nuitka 打包。 ### 正在推进 / 待实现 @@ -49,9 +54,9 @@ PyMC 是一个用 Python 实现的 Minecraft Java 版 1.21.1 服务端原型, - [ ] 结构生成:村庄、废弃矿井、地牢、沉船、沙漠神殿、林地府邸、远古城市等。 - [ ] 完整洞穴系统:大型洞穴、繁茂洞穴、溶洞、地下水体、岩浆湖、深暗之域等。 - [ ] Nether、End 和多维度传送。 -- [ ] 完整光照、天气效果、昼夜同步和客户端可见的区块更新细节。 -- [ ] 更完整的物品栏交互:拖拽、Shift-点击、合并、物品耐久、附魔、药水效果。 -- [ ] 更完整的生物系统:寻路、繁殖、掉落、远程攻击、村民、Boss、刷怪规则和 mob cap。 +- [ ] 完整光照和客户端可见的区块更新细节。 +- [ ] 更完整的物品栏交互:创造模式物品栏、铁砧/附魔台完整界面、药水效果。 +- [ ] 更完整的生物系统:完整 A* 寻路、繁殖、村民、Boss、刷怪规则和 mob cap。 - [ ] 正版登录验证、加密链路和更完整的权限模型。 - [ ] 更完整的选择器、NBT 参数和 datapack/function 支持。 @@ -78,12 +83,16 @@ PyMC 是一个用 Python 实现的 Minecraft Java 版 1.21.1 服务端原型, - Linear V2 `.linear` 区域文件读写,并支持从 Anvil `.mca` 自动转换 - 玩家位置、生命值、饱食度、经验、游戏模式等 JSON 存档 - 基础聊天、方块挖掘/放置、掉落物、经验球和简单生物实体 +- 玩家互相可见:实体生成/元数据/移动转发(原生 1.21.1 路径) +- 战斗系统:攻击生物、武器伤害表、生物死亡掉落与经验、远程生物标记 +- 物品耐久与基础附魔效果:耐久消耗、Unbreaking 减免、Sharpness 增伤 - 原版 Goal 思路的轻量生物 AI:随机游走、看向玩家、敌对追击、近战冷却 - 命令框架:大量命令注册、权限检查和别名;部分子命令仍未完成 - Mod/插件:仅支持 PYMC 原生 Python API -- Watchdog:UDP 心跳与自动重启框架(尚缺端到端验证) -- 网络优化:批处理、移动限频和区块排序组件(尚未全面接入) -- 基础 `gamerule`:控制昼夜流动、自然刷怪、自然回血和死亡自动重生 +- Watchdog:UDP 心跳与自动重启框架(含真实 UDP 端到端测试) +- 网络优化:实体同步广播走批处理、移动更新限频、区块排序 +- 基础 `gamerule`:控制昼夜流动、天气循环、自然刷怪、自然回血和死亡自动重生 +- 天气客户端同步:Game Event 广播下雨/雷暴状态与强度,入服时同步当前天气;雷暴期间落雷并伤害附近实体 - 控制台和游戏内基础命令 - Web 管理台、权限组、OP、封禁和白名单 diff --git a/commands/vanilla/enchant.py b/commands/vanilla/enchant.py index 5eaef3a..6e42ef8 100755 --- a/commands/vanilla/enchant.py +++ b/commands/vanilla/enchant.py @@ -58,11 +58,12 @@ async def _execute(ctx: CommandContext) -> int: # Apply enchantment (record on inventory item if available) if hasattr(target, 'inventory_obj') and target.inventory_obj is not None: slot = target.selected_hotbar_slot - item = target.inventory_obj.get_item_in_slot(slot) - if item is not None: - if "enchantments" not in item: - item["enchantments"] = {} - item["enchantments"][enchant_name] = level + item = target.inventory_obj.get_slot(slot) + if item is not None and not item.is_empty: + from world.item_properties import add_enchantment + add_enchantment(item, enchant_name, level) + from world.inventory import send_inventory_sync + await send_inventory_sync(target) await ctx.reply(f"[PyMC] 已附魔 {target.username} 手持物品: {enchant_name} {level}") return SUCCESS else: diff --git a/commands/vanilla/weather.py b/commands/vanilla/weather.py index 2cf1c43..7bfac83 100755 --- a/commands/vanilla/weather.py +++ b/commands/vanilla/weather.py @@ -21,7 +21,6 @@ async def _execute(ctx: CommandContext) -> int: await ctx.reply(f"[PyMC] {e}") return FAILURE - ctx.server.weather = weather duration = 6000 if len(args) >= 2: try: @@ -29,6 +28,9 @@ async def _execute(ctx: CommandContext) -> int: except ValueError: pass + # 走统一入口: 更新 TimeManager 持续时间并立即广播到客户端 + await ctx.server.set_weather(weather, duration) + await ctx.reply(f"[PyMC] 天气已设置为 {weather}") return SUCCESS diff --git a/handlers/play/__init__.py b/handlers/play/__init__.py index 71abcd1..705c626 100755 --- a/handlers/play/__init__.py +++ b/handlers/play/__init__.py @@ -139,6 +139,12 @@ ENTITY_TYPE_IDS, ) +from handlers.play.combat import ( + _handle_interact, + damage_mob, + get_attack_damage, +) + from handlers.play.spawn import ( _resolve_spawn_location, _is_safe_player_location, @@ -154,6 +160,29 @@ SPAWN_CANOPY_BLOCKS, ) +from handlers.play.weather import ( + build_game_event_payload, + send_weather_state, + broadcast_weather_change, + rain_strength, + thunder_strength, + GAME_EVENT_END_RAINING, + GAME_EVENT_BEGIN_RAINING, + GAME_EVENT_RAIN_LEVEL, + GAME_EVENT_THUNDER_LEVEL, +) + +from handlers.play.players import ( + build_spawn_player_payload, + build_player_metadata_payload, + build_rotate_head_payload, + send_player_spawn, + sync_player_visibility, + relay_player_movement, + remove_player_entity, + PLAYER_ENTITY_TYPE, +) + logger = logging.getLogger("PyMC.游戏") @@ -254,8 +283,8 @@ async def handle_play(conn: Connection, packet_id: int, payload: bytes, _handle_close_container(conn, payload) elif _is_serverbound_packet(conn, packet_id, "interact", 0x14): - # Interact (Entity) - pass # TODO: entity interaction + # Interact (Entity): 攻击动作由战斗系统处理 + await _handle_interact(conn, payload, server) elif _is_serverbound_packet(conn, packet_id, "set_creative_mode_slot", 0x22): # Set Creative Mode Slot diff --git a/handlers/play/blocks.py b/handlers/play/blocks.py index 0c8a708..8e99f4d 100755 --- a/handlers/play/blocks.py +++ b/handlers/play/blocks.py @@ -307,6 +307,11 @@ async def _handle_block_dig(conn: Connection, payload: bytes, server): return await _broadcast_block_change(server, x, y, z, AIR) + # 生存/冒险模式下消耗工具耐久 + if conn.gamemode in ("survival", "adventure"): + from world.item_properties import damage_held_item + await damage_held_item(conn, server) + # Notify redstone engine of block change if server.redstone_engine: server.redstone_engine.on_block_change(x, y, z, current, AIR) diff --git a/handlers/play/combat.py b/handlers/play/combat.py new file mode 100644 index 0000000..6d65d0f --- /dev/null +++ b/handlers/play/combat.py @@ -0,0 +1,148 @@ +# ============================================================ +# PyMC - 战斗系统 +# 玩家攻击生物、生物死亡掉落与经验 +# ============================================================ + +""" +战斗处理。 + +包括: + - _handle_interact: 处理 Interact (serverbound 0x14) 攻击动作 + - damage_mob: 对生物造成伤害, 死亡时生成掉落物和经验球 + - WEAPON_DAMAGE: 手持武器伤害表 + +Interact 包格式 (1.21.1): + Entity ID (VarInt), Type (VarInt: 0=interact, 1=attack, 2=interact_at), + [type=2: target XYZ float x3], [type!=1: hand VarInt], sneaking (bool) +""" + +import logging +import math +import random +import time + +from protocol.data_types import read_varint +from network.connection import Connection + +logger = logging.getLogger("PyMC.战斗") + +INTERACT_TYPE_INTERACT = 0 +INTERACT_TYPE_ATTACK = 1 +INTERACT_TYPE_INTERACT_AT = 2 + +# 攻击判定距离 (平方), 比原版 3 格略宽松 +ATTACK_RANGE_SQUARED = 16.0 +# 攻击冷却 (秒), 防止客户端高频攻击包刷伤害 +ATTACK_COOLDOWN_SECONDS = 0.4 + +# 手持武器伤害 (空手 = 1.0) +WEAPON_DAMAGE = { + "minecraft:wooden_sword": 4.0, "minecraft:golden_sword": 4.0, + "minecraft:stone_sword": 5.0, "minecraft:iron_sword": 6.0, + "minecraft:diamond_sword": 7.0, "minecraft:netherite_sword": 8.0, + "minecraft:wooden_axe": 7.0, "minecraft:golden_axe": 7.0, + "minecraft:stone_axe": 9.0, "minecraft:iron_axe": 9.0, + "minecraft:diamond_axe": 9.0, "minecraft:netherite_axe": 10.0, + "minecraft:trident": 9.0, "minecraft:mace": 6.0, +} + +_rng = random.Random() + + +def parse_interact(payload: bytes) -> tuple[int, int] | None: + """解析 Interact 包, 返回 (entity_id, action_type)。""" + try: + entity_id, offset = read_varint(payload, 0) + action_type, _ = read_varint(payload, offset) + return entity_id, action_type + except (IndexError, ValueError): + return None + + +def get_attack_damage(conn: Connection) -> float: + """根据手持物品计算攻击伤害 (含锋利附魔加成)。""" + from world.item_properties import sharpness_damage_bonus + inventory = getattr(conn, "inventory_obj", None) + if inventory is not None: + held = inventory.get_held_item_from_slot(conn.selected_hotbar_slot) + if held is not None and not held.is_empty: + base = WEAPON_DAMAGE.get(held.item_id, 1.0) + return base + sharpness_damage_bonus(held) + return 1.0 + + +async def _handle_interact(conn: Connection, payload: bytes, server): + """处理 Interact 包中的攻击动作。""" + parsed = parse_interact(payload) + if parsed is None: + return + entity_id, action_type = parsed + if action_type != INTERACT_TYPE_ATTACK: + return + + # 攻击冷却 + now = time.monotonic() + last_attack = getattr(conn, "_last_attack_time", 0.0) + if now - last_attack < ATTACK_COOLDOWN_SECONDS: + return + conn._last_attack_time = now + + entity = server.entity_manager.get_entity(entity_id) + if entity is None or entity.kind != "mob": + return + if entity.distance_squared_to(conn.x, conn.y, conn.z) > ATTACK_RANGE_SQUARED: + return + + damage = get_attack_damage(conn) + killed = damage_mob(server, entity, damage, source=conn) + + # 生存/冒险模式下消耗武器耐久 + if conn.gamemode in ("survival", "adventure"): + from world.item_properties import damage_held_item + await damage_held_item(conn, server) + + if killed: + from handlers.play.chat import send_system_message + mob_name = entity.metadata.get("mob_type", "生物") + await send_system_message(conn, f"[PyMC] 你击杀了 {mob_name}") + + +def damage_mob(server, entity, amount: float, source=None) -> bool: + """ + 对生物造成伤害。死亡时生成掉落物和经验球并移除实体。 + + Returns: + True 如果生物被击杀。 + """ + if entity.kind != "mob": + return False + entity.health -= float(amount) + entity.metadata["health"] = entity.health + if entity.health > 0: + return False + + profile = getattr(entity, "profile", {}) + + # 掉落物 + for drop in profile.get("drops", []): + item_name, min_count, max_count = drop + count = _rng.randint(min_count, max_count) + if count <= 0: + continue + item = server.entity_manager.create_item( + entity.x, entity.y + 0.3, entity.z, item_name, count) + item.pickup_delay = 10 # 0.5s 拾取延迟, 避免死亡瞬间被吸走 + + # 经验球 + xp_range = profile.get("xp", (0, 0)) + xp = _rng.randint(xp_range[0], xp_range[1]) if xp_range else 0 + if xp > 0: + server.entity_manager.create_experience_orb( + entity.x, entity.y + 0.3, entity.z, xp) + + server.entity_manager.remove_entity(entity.entity_id) + logger.info( + f"生物 {entity.metadata.get('mob_type', '?')} (id={entity.entity_id}) " + f"被击杀, 掉落已生成" + ) + return True diff --git a/handlers/play/entities.py b/handlers/play/entities.py index 5d0065e..273d017 100755 --- a/handlers/play/entities.py +++ b/handlers/play/entities.py @@ -28,13 +28,16 @@ logger = logging.getLogger("PyMC.实体") -# --- 实体类型 ID 映射 --- +# --- 实体类型 ID 映射 (1.21.1 注册表) --- +# 来源: PrismarineJS/minecraft-data pc/1.20.5 entities.json +# (dataPaths 将 1.21/1.21.1 映射到该注册表) ENTITY_TYPE_IDS = { - "item": 71, - "cow": 30, - "pig": 100, - "sheep": 111, - "zombie": 150, + "item": 58, + "cow": 22, + "pig": 77, + "sheep": 87, + "zombie": 124, + "lightning_bolt": 64, } @@ -80,8 +83,8 @@ async def _send_generic_entity_spawn(conn: Connection, entity): await conn.send_packet(0x01, bytes(payload)) -async def _send_entity_teleport(conn: Connection, entity): - from protocol.packet_map import get_clientbound_packet +def build_entity_teleport_payload(entity) -> bytes: + """构建 Entity Teleport 数据包负载。""" payload = bytearray() payload.extend(write_varint(entity.entity_id)) payload.extend(write_double(entity.x)) @@ -90,9 +93,14 @@ async def _send_entity_teleport(conn: Connection, entity): payload.extend(write_angle(entity.yaw)) payload.extend(write_angle(entity.pitch)) payload.extend(write_boolean(entity.on_ground)) + return bytes(payload) + + +async def _send_entity_teleport(conn: Connection, entity): + from protocol.packet_map import get_clientbound_packet pid = get_clientbound_packet(conn.protocol_version, "entity_teleport") if pid is not None: - await conn.send_packet(pid, bytes(payload)) + await conn.send_packet(pid, build_entity_teleport_payload(entity)) def build_remove_entities(entity_ids: list[int]) -> bytes: diff --git a/handlers/play/join.py b/handlers/play/join.py index 4db10c8..b7450d9 100755 --- a/handlers/play/join.py +++ b/handlers/play/join.py @@ -204,6 +204,10 @@ def _generate_chunks(coords): await _send_set_experience(conn) await _send_time_update(conn, server) + # 同步当前天气状态 (晴天时为空操作) + from handlers.play.weather import send_weather_state + await send_weather_state(conn, server) + from world.inventory import send_inventory_sync await send_inventory_sync(conn) @@ -597,3 +601,7 @@ async def _broadcast_player_join(conn: Connection, server): pid = get_clientbound_packet(conn.protocol_version, "player_info") if pid is not None: await conn.send_packet(pid, other_info) + + # 双向生成玩家实体 (必须在 Player Info 之后, 客户端需要列表项渲染皮肤) + from handlers.play.players import sync_player_visibility + await sync_player_visibility(server, conn) diff --git a/handlers/play/movement.py b/handlers/play/movement.py index 46e05f6..3d623c6 100755 --- a/handlers/play/movement.py +++ b/handlers/play/movement.py @@ -86,6 +86,8 @@ async def _handle_player_position(conn: Connection, payload: bytes, server): conn.on_ground = on_ground from handlers.play.chunks import _schedule_chunk_stream_update _schedule_chunk_stream_update(conn, server) + from handlers.play.players import relay_player_movement + await relay_player_movement(server, conn) async def _handle_player_position_rotation(conn: Connection, payload: bytes, @@ -111,6 +113,8 @@ async def _handle_player_position_rotation(conn: Connection, payload: bytes, conn.on_ground = on_ground from handlers.play.chunks import _schedule_chunk_stream_update _schedule_chunk_stream_update(conn, server) + from handlers.play.players import relay_player_movement + await relay_player_movement(server, conn) async def _handle_player_rotation(conn: Connection, payload: bytes, server): @@ -127,6 +131,8 @@ async def _handle_player_rotation(conn: Connection, payload: bytes, server): conn.yaw = yaw conn.pitch = pitch conn.on_ground = on_ground + from handlers.play.players import relay_player_movement + await relay_player_movement(server, conn) async def _handle_player_on_ground(conn: Connection, payload: bytes, server): diff --git a/handlers/play/players.py b/handlers/play/players.py new file mode 100644 index 0000000..9c21913 --- /dev/null +++ b/handlers/play/players.py @@ -0,0 +1,176 @@ +# ============================================================ +# PyMC - 玩家实体可见性 +# 玩家实体的生成、移动转发和头部旋转同步 +# ============================================================ + +""" +玩家互相可见性同步。 + +包括: + - send_player_spawn: 向观察者生成玩家实体 (Spawn Entity + Set Entity Data) + - sync_player_visibility: 入服时双向生成所有在线玩家 + - relay_player_movement: 将玩家移动/视角转发给视距内的其他玩家 + (Entity Teleport + Rotate Head, 经网络优化器限频) + +协议说明 (1.21.1, 协议 767): + - Spawn Entity (0x01): 玩家实体类型 ID = 128 + (来源: PrismarineJS/minecraft-data pc/1.20.5 entities.json, + dataPaths 将 1.21/1.21.1 映射到该注册表) + - Set Entity Data (0x57): 玩家皮肤层 metadata (index 17, byte) + - Rotate Head (0x47): 头部朝向 + - Entity Teleport (0x70): 位置与身体朝向 (ID 走 packet_map) + +限制: 仅原生 1.21.1 路径 (version_handler 为 None 的客户端)。 +旧版本客户端的玩家实体生成需要各版本独立的 Spawn Player 包, +当前跳过,不影响其它功能。 +""" + +import logging + +from protocol.data_types import ( + write_varint, write_double, write_short, + write_uuid, write_angle, write_ubyte, +) +from protocol.packet_map import get_clientbound_packet +from network.connection import Connection + +logger = logging.getLogger("PyMC.玩家") + +# --- 1.21.1 (协议 767) 客户端包 ID --- +SPAWN_ENTITY_PID = 0x01 +SET_ENTITY_DATA_PID = 0x57 +ROTATE_HEAD_PID = 0x47 + +# minecraft:player 实体类型 ID (1.21.1 注册表) +PLAYER_ENTITY_TYPE = 128 + +# 玩家 metadata: 皮肤层 (index 17, Byte, 0x7F = 全部显示) +SKIN_PARTS_INDEX = 17 +SKIN_PARTS_ALL = 0x7F + + +def _supports_player_spawn(conn: Connection) -> bool: + """仅原生 1.21.1 路径支持玩家实体生成。""" + return conn.version_handler is None + + +def build_spawn_player_payload(player: Connection) -> bytes: + """构建 Spawn Entity (玩家) 数据包负载。""" + payload = bytearray() + payload.extend(write_varint(player.entity_id)) + payload.extend(write_uuid(player.uuid)) + payload.extend(write_varint(PLAYER_ENTITY_TYPE)) + payload.extend(write_double(player.x)) + payload.extend(write_double(player.y)) + payload.extend(write_double(player.z)) + payload.extend(write_angle(player.pitch)) + payload.extend(write_angle(player.yaw)) + payload.extend(write_angle(player.yaw)) # head yaw + payload.extend(write_varint(0)) # data + payload.extend(write_short(0)) # velocity X + payload.extend(write_short(0)) # velocity Y + payload.extend(write_short(0)) # velocity Z + return bytes(payload) + + +def build_player_metadata_payload(player: Connection) -> bytes: + """构建 Set Entity Data 负载: 显示全部皮肤层 (披风/帽子/袖子等)。""" + payload = bytearray() + payload.extend(write_varint(player.entity_id)) + payload.extend(write_ubyte(SKIN_PARTS_INDEX)) + payload.extend(write_varint(0)) # metadata type 0 = Byte + payload.extend(write_ubyte(SKIN_PARTS_ALL)) + payload.extend(write_ubyte(0xFF)) # terminator + return bytes(payload) + + +def build_rotate_head_payload(player: Connection) -> bytes: + """构建 Rotate Head 数据包负载。""" + payload = bytearray() + payload.extend(write_varint(player.entity_id)) + payload.extend(write_angle(player.yaw)) + return bytes(payload) + + +async def send_player_spawn(observer: Connection, player: Connection): + """ + 向观察者生成一个玩家实体。 + + 必须在 Player Info Update 之后发送 (客户端需要列表项渲染皮肤)。 + """ + if not _supports_player_spawn(observer): + return + if player.entity_id in observer.tracked_players: + return + await observer.send_packet(SPAWN_ENTITY_PID, build_spawn_player_payload(player)) + await observer.send_packet(SET_ENTITY_DATA_PID, build_player_metadata_payload(player)) + observer.tracked_players.add(player.entity_id) + + +async def sync_player_visibility(server, new_conn: Connection): + """ + 入服时双向同步玩家实体: + - 向新玩家生成所有已有玩家 + - 向所有已有玩家生成新玩家 + """ + for other in server.get_online_players(): + if other == new_conn: + continue + await send_player_spawn(new_conn, other) + await send_player_spawn(other, new_conn) + + +def _player_within_tracking_range(player: Connection, observer: Connection, + view_distance: int) -> bool: + max_distance = (view_distance * 16) ** 2 + dx = player.x - observer.x + dz = player.z - observer.z + return dx * dx + dz * dz <= max_distance + + +async def relay_player_movement(server, moved: Connection): + """ + 将玩家的位置和视角转发给视距内的其他玩家。 + + 经网络优化器限频 (network-movement-rate-hz, 默认 20Hz)。 + 位置使用 Entity Teleport (简单可靠), 头部朝向使用 Rotate Head。 + """ + if not moved.username: + return + + optimizer = getattr(server, "network_optimizer", None) + if optimizer is not None and not optimizer.should_send_movement(moved): + return + + rotate_payload = build_rotate_head_payload(moved) + + for observer in server.get_online_players(): + if observer == moved or not observer.alive: + continue + if not _supports_player_spawn(observer): + continue + if moved.entity_id not in observer.tracked_players: + continue + if not _player_within_tracking_range(moved, observer, server.view_distance): + continue + + teleport_pid = get_clientbound_packet( + observer.protocol_version, "entity_teleport") + + # Entity Teleport: 位置 + 身体朝向 + payload = bytearray() + payload.extend(write_varint(moved.entity_id)) + payload.extend(write_double(moved.x)) + payload.extend(write_double(moved.y)) + payload.extend(write_double(moved.z)) + payload.extend(write_angle(moved.yaw)) + payload.extend(write_angle(moved.pitch)) + payload.extend(b"\x01" if moved.on_ground else b"\x00") + if teleport_pid is not None: + await observer.send_packet(teleport_pid, bytes(payload)) + await observer.send_packet(ROTATE_HEAD_PID, rotate_payload) + + +async def remove_player_entity(observer: Connection, entity_id: int): + """从观察者处移除玩家实体并清理跟踪状态。""" + observer.tracked_players.discard(entity_id) diff --git a/handlers/play/weather.py b/handlers/play/weather.py new file mode 100644 index 0000000..4b3b9c3 --- /dev/null +++ b/handlers/play/weather.py @@ -0,0 +1,98 @@ +# ============================================================ +# PyMC - 天气客户端同步 +# 通过 Game Event 数据包向客户端同步天气变化 +# ============================================================ + +""" +天气状态同步。 + +Minecraft 客户端通过 Game Event 数据包感知天气: + - reason 1: End Raining (停止下雨) + - reason 2: Begin Raining (开始下雨) + - reason 7: Change Rain Strength (雨强度 0.0-1.0) + - reason 8: Change Thunder Strength (雷暴强度 0.0-1.0) + +入服时发送当前天气状态,天气变化时向全体在线玩家广播。 +""" + +import logging + +from protocol.data_types import write_ubyte, write_float +from protocol.packet_map import get_clientbound_packet +from network.connection import Connection + +logger = logging.getLogger("PyMC.天气") + +GAME_EVENT_END_RAINING = 1 +GAME_EVENT_BEGIN_RAINING = 2 +GAME_EVENT_RAIN_LEVEL = 7 +GAME_EVENT_THUNDER_LEVEL = 8 + +_RAINING_STATES = ("rain", "thunder") + + +def build_game_event_payload(event: int, value: float) -> bytes: + """构建 Game Event 数据包负载 (Unsigned Byte reason + Float value)。""" + payload = bytearray() + payload.extend(write_ubyte(event)) + payload.extend(write_float(value)) + return bytes(payload) + + +def rain_strength(weather: str) -> float: + """雨强度: 下雨或雷暴时为 1.0。""" + return 1.0 if weather in _RAINING_STATES else 0.0 + + +def thunder_strength(weather: str) -> float: + """雷暴强度: 仅雷暴时为 1.0。""" + return 1.0 if weather == "thunder" else 0.0 + + +async def _send_game_event(conn: Connection, event: int, value: float): + """按客户端协议版本发送 Game Event 数据包。""" + if conn.version_handler is not None: + await conn.version_handler.send_game_event(conn, event, value) + return + pid = get_clientbound_packet(conn.protocol_version, "game_event") + if pid is not None: + await conn.send_packet(pid, build_game_event_payload(event, value)) + + +async def send_weather_state(conn: Connection, server): + """ + 入服时向玩家同步当前天气。 + + 晴天无需发送任何数据包 (客户端默认晴天)。 + """ + weather = server.weather + if weather not in _RAINING_STATES: + return + await _send_game_event(conn, GAME_EVENT_BEGIN_RAINING, 0.0) + await _send_game_event(conn, GAME_EVENT_RAIN_LEVEL, rain_strength(weather)) + await _send_game_event(conn, GAME_EVENT_THUNDER_LEVEL, thunder_strength(weather)) + + +async def broadcast_weather_change(server, old_weather: str, new_weather: str): + """ + 向全体在线玩家广播天气变化。 + + - 晴 -> 雨/雷暴: Begin Raining + 强度 + - 雨/雷暴 -> 晴: End Raining + 强度归零 + - 雨 <-> 雷暴: 仅更新强度 (保持下雨状态) + """ + if old_weather == new_weather: + return + + was_raining = old_weather in _RAINING_STATES + is_raining = new_weather in _RAINING_STATES + + for conn in server.get_online_players(): + if is_raining and not was_raining: + await _send_game_event(conn, GAME_EVENT_BEGIN_RAINING, 0.0) + elif was_raining and not is_raining: + await _send_game_event(conn, GAME_EVENT_END_RAINING, 0.0) + await _send_game_event(conn, GAME_EVENT_RAIN_LEVEL, rain_strength(new_weather)) + await _send_game_event(conn, GAME_EVENT_THUNDER_LEVEL, thunder_strength(new_weather)) + + logger.info(f"天气已变更: {old_weather} -> {new_weather}") diff --git a/native/libpymc_native.so b/native/libpymc_native.so deleted file mode 100755 index 1877f4d..0000000 Binary files a/native/libpymc_native.so and /dev/null differ diff --git a/native/mob_ai b/native/mob_ai deleted file mode 100755 index 0f397ac..0000000 Binary files a/native/mob_ai and /dev/null differ diff --git a/native/mob_ai.exe b/native/mob_ai.exe deleted file mode 100755 index 119346c..0000000 Binary files a/native/mob_ai.exe and /dev/null differ diff --git a/native/pymc_native_server b/native/pymc_native_server deleted file mode 100755 index 77433b7..0000000 Binary files a/native/pymc_native_server and /dev/null differ diff --git a/native/terrain_gen b/native/terrain_gen deleted file mode 100755 index a910381..0000000 Binary files a/native/terrain_gen and /dev/null differ diff --git a/native/terrain_gen.exe b/native/terrain_gen.exe deleted file mode 100755 index bb11a44..0000000 Binary files a/native/terrain_gen.exe and /dev/null differ diff --git a/network/connection.py b/network/connection.py index 26a34ef..0063a0c 100755 --- a/network/connection.py +++ b/network/connection.py @@ -75,6 +75,7 @@ def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, self.personal_spawn: tuple[int, int, int] | None = None self.loaded_chunks: set[tuple[int, int]] = set() self.tracked_entities: set[int] = set() + self.tracked_players: set[int] = set() # 已生成玩家实体的 entity_id self.chunk_center: tuple[int, int] = (0, 0) self.chunk_stream_task: asyncio.Task | None = None diff --git a/network/managers/time.py b/network/managers/time.py index d9142d3..8bf09fc 100755 --- a/network/managers/time.py +++ b/network/managers/time.py @@ -37,6 +37,7 @@ class TimeManager: def __init__(self, initial_time: int = 1000, do_daylight_cycle: bool = True): self._time: int = initial_time % MAX_TIME self._do_daylight_cycle = do_daylight_cycle + self._do_weather_cycle: bool = True self._weather: str = "clear" self._weather_duration: int = 0 # ticks remaining self._thunder_duration: int = 0 @@ -71,6 +72,15 @@ def do_daylight_cycle(self) -> bool: def do_daylight_cycle(self, value: bool): self._do_daylight_cycle = value + @property + def do_weather_cycle(self) -> bool: + """是否启用天气循环 (gamerule doWeatherCycle)。""" + return self._do_weather_cycle + + @do_weather_cycle.setter + def do_weather_cycle(self, value: bool): + self._do_weather_cycle = value + def tick(self): """ 每游戏 tick 调用。 @@ -79,7 +89,9 @@ def tick(self): if self._do_daylight_cycle: self._time = (self._time + 1) % MAX_TIME - # 天气持续计时 + # 天气持续计时 (doWeatherCycle=false 时天气保持不变) + if not self._do_weather_cycle: + return if self._weather_duration > 0: self._weather_duration -= 1 if self._weather_duration == 0: @@ -145,6 +157,7 @@ def serialize(self) -> dict: "weather": self._weather, "weather_duration": self._weather_duration, "do_daylight_cycle": self._do_daylight_cycle, + "do_weather_cycle": self._do_weather_cycle, } @classmethod @@ -156,4 +169,5 @@ def deserialize(cls, data: dict) -> 'TimeManager': ) mgr._weather = data.get("weather", "clear") mgr._weather_duration = data.get("weather_duration", 0) + mgr._do_weather_cycle = data.get("do_weather_cycle", True) return mgr diff --git a/network/server.py b/network/server.py index 8890097..265475f 100755 --- a/network/server.py +++ b/network/server.py @@ -201,6 +201,34 @@ def broadcast_system_message(self, text: str, exclude: Connection = None): if conn != exclude and conn.version_handler is not None: asyncio.ensure_future(conn.version_handler.send_system_chat(conn, text)) + async def queue_or_send(self, conn: Connection, packet_id: int, payload: bytes): + """ + 优先经网络优化器批量发送 (合并为单次 TCP write), + 优化器未启用时直接发送。用于高频广播路径 (如实体位置同步)。 + """ + optimizer = self.network_optimizer + if optimizer is not None and optimizer.queue_packet(conn, packet_id, payload): + return + await conn.send_packet(packet_id, payload) + + async def set_weather(self, weather: str, duration: int = 0): + """ + 设置天气并立即同步到所有客户端。 + + Args: + weather: "clear" | "rain" | "thunder" + duration: 持续 tick 数 (0 = 无限, 直到天气循环或手动变更) + """ + old_weather = self.weather + self._time_manager.set_weather(weather, duration) + self.weather = self._time_manager.weather + if self.weather == old_weather: + return + from handlers.play.weather import broadcast_weather_change + await broadcast_weather_change(self, old_weather, self.weather) + from plugins.bridge import hook_weather_change + hook_weather_change(self, old_weather, self.weather) + def save_player_state(self, conn: Connection): """保存单个玩家存档。""" if not conn.username: @@ -754,6 +782,7 @@ async def _handle_player_leave(self, conn: Connection): for other_conn in self.get_online_players(): if other_conn == conn: continue + other_conn.tracked_players.discard(conn.entity_id) player_remove_pid = get_clientbound_packet(other_conn.protocol_version, "player_remove") if player_remove_pid is not None: await other_conn.send_packet(player_remove_pid, remove_info) @@ -778,14 +807,17 @@ async def _game_loop(self): # 使用 TimeManager 推进时间 self._time_manager.do_daylight_cycle = self.gamerules.get("doDaylightCycle", True) + self._time_manager.do_weather_cycle = self.gamerules.get("doWeatherCycle", True) old_weather = self.weather old_time = self.world_time self._time_manager.tick() self.world_time = self._time_manager.time self.weather = self._time_manager.weather - # Plugin hooks: weather/time change + # Plugin hooks + client sync: weather/time change if self.weather != old_weather: + from handlers.play.weather import broadcast_weather_change + await broadcast_weather_change(self, old_weather, self.weather) from plugins.bridge import hook_weather_change hook_weather_change(self, old_weather, self.weather) if self.world_time != old_time and tick_count % 200 == 0: @@ -839,6 +871,10 @@ async def _game_loop(self): if tick_count % 2 == 0: await self._tick_redstone() + # 雷暴落雷 (每 5 秒一次判定) + if tick_count % 100 == 0: + await self._tick_lightning() + # Fluid system tick if self.fluid_system is not None: await self._tick_fluids(tick_count) @@ -860,6 +896,43 @@ async def _game_loop(self): sleep_time = max(0, tick_interval - elapsed) await asyncio.sleep(sleep_time) + async def _tick_lightning(self): + """雷暴天气时在随机玩家附近落雷, 伤害落点附近的生物和玩家。""" + if self.weather != "thunder": + return + players = self.get_online_players() + if not players: + return + + import random + target_player = random.choice(players) + lx = target_player.x + random.uniform(-24, 24) + lz = target_player.z + random.uniform(-24, 24) + ly = target_player.y + + bolt = self.entity_manager.create_lightning(lx, ly, lz) + from handlers.play import broadcast_entity_spawn + await broadcast_entity_spawn(self, bolt) + logger.info( + f"闪电击中 ({lx:.1f}, {ly:.1f}, {lz:.1f}) " + f"附近玩家 {target_player.username}" + ) + + # 伤害落点 3 格内的玩家 + from handlers.play import _damage_player + for player in players: + if player.gamemode in {"creative", "spectator"}: + continue + if bolt.distance_squared_to(player.x, player.y, player.z) <= 9.0: + await _damage_player(player, 5.0, "闪电", self) + + # 伤害落点 3 格内的生物 + from handlers.play.combat import damage_mob + for entity in self.entity_manager.list_entities(): + if entity.kind == "mob" and entity.entity_id != bolt.entity_id: + if bolt.distance_squared_to(entity.x, entity.y, entity.z) <= 9.0: + damage_mob(self, entity, 5.0) + async def _tick_redstone(self): """Process a redstone tick and broadcast visual changes to players.""" if self.redstone_engine is None: @@ -1003,26 +1076,28 @@ async def _tick_entity_interactions(self): if entity.kind == "mob" and entity.metadata.get("category") == "hostile": if getattr(entity, "attack_cooldown", 0) > 0: continue + profile = getattr(entity, "profile", {}) + is_ranged = bool(profile.get("ranged", False)) for player in players: if player.gamemode in {"creative", "spectator"}: continue - attack_range = float(getattr(entity, "profile", {}).get("attack_range", 1.7)) + attack_range = float(profile.get("attack_range", 1.7)) if entity.distance_squared_to(player.x, player.y, player.z) > attack_range * attack_range: continue - entity.attack_cooldown = int(getattr(entity, "profile", {}).get("attack_interval", 20)) - damage = float(getattr(entity, "profile", {}).get("attack_damage", 2.0)) + entity.attack_cooldown = int(profile.get("attack_interval", 20)) + damage = float(profile.get("attack_damage", 2.0)) mob_name = entity.metadata.get("mob_type", "生物") - await _damage_player(player, damage, mob_name, self) + reason = f"{mob_name}的箭" if is_ranged else mob_name + await _damage_player(player, damage, reason, self) break async def _tick_entity_sync(self): """向客户端同步基础实体位置与移除。""" from handlers.play import ( - _send_entity_teleport, + _send_entity_remove, _send_experience_orb_spawn, _send_generic_entity_spawn, _entity_within_tracking_range, - build_remove_entities, broadcast_entity_remove, ) @@ -1034,7 +1109,7 @@ async def _tick_entity_sync(self): live_entities = { entity.entity_id: entity for entity in entities - if entity.kind in {"orb", "item", "mob"} + if entity.kind in {"orb", "item", "mob", "lightning_bolt"} } for conn in self.get_online_players(): @@ -1044,13 +1119,13 @@ async def _tick_entity_sync(self): ] if stale_ids: conn.tracked_entities.difference_update(stale_ids) - await conn.send_packet(0x42, build_remove_entities(stale_ids)) + await _send_entity_remove(conn, stale_ids) for entity in live_entities.values(): if not _entity_within_tracking_range(entity, conn, self.view_distance): if entity.entity_id in conn.tracked_entities: conn.tracked_entities.discard(entity.entity_id) - await conn.send_packet(0x42, build_remove_entities([entity.entity_id])) + await _send_entity_remove(conn, [entity.entity_id]) continue if entity.entity_id not in conn.tracked_entities: if entity.kind == "orb": @@ -1059,4 +1134,11 @@ async def _tick_entity_sync(self): await _send_generic_entity_spawn(conn, entity) conn.tracked_entities.add(entity.entity_id) continue - await _send_entity_teleport(conn, entity) + # 高频传送包走批量发送, 合并为单次 TCP write + from handlers.play.entities import build_entity_teleport_payload + from protocol.packet_map import get_clientbound_packet + teleport_pid = get_clientbound_packet( + conn.protocol_version, "entity_teleport") + if teleport_pid is not None: + await self.queue_or_send( + conn, teleport_pid, build_entity_teleport_payload(entity)) diff --git a/tests/test_combat.py b/tests/test_combat.py new file mode 100644 index 0000000..7df2140 --- /dev/null +++ b/tests/test_combat.py @@ -0,0 +1,118 @@ +import unittest +from types import SimpleNamespace + +from handlers.play.combat import ( + INTERACT_TYPE_ATTACK, + damage_mob, + get_attack_damage, + parse_interact, + WEAPON_DAMAGE, +) +from protocol.data_types import write_varint +from world.entities import MOB_PROFILES, MobEntity + + +def build_interact_packet(entity_id: int, action_type: int) -> bytes: + payload = bytearray() + payload.extend(write_varint(entity_id)) + payload.extend(write_varint(action_type)) + payload.extend(b"\x00") # sneaking = false + return bytes(payload) + + +class FakeEntityManager: + def __init__(self): + self.entities = {} + self.removed = [] + self.created_items = [] + self.created_orbs = [] + self._next_id = 1000 + + def add(self, entity): + self.entities[entity.entity_id] = entity + + def get_entity(self, entity_id): + return self.entities.get(entity_id) + + def remove_entity(self, entity_id): + self.removed.append(entity_id) + return self.entities.pop(entity_id, None) + + def create_item(self, x, y, z, item_name, count): + self.created_items.append((item_name, count)) + return SimpleNamespace(pickup_delay=0) + + def create_experience_orb(self, x, y, z, count): + self.created_orbs.append(count) + + +class ParseInteractTests(unittest.TestCase): + def test_parse_attack(self): + payload = build_interact_packet(42, INTERACT_TYPE_ATTACK) + self.assertEqual(parse_interact(payload), (42, INTERACT_TYPE_ATTACK)) + + def test_parse_garbage_returns_none(self): + self.assertIsNone(parse_interact(b"")) + self.assertIsNone(parse_interact(b"\xff")) + + +class WeaponDamageTests(unittest.TestCase): + def test_fist_damage(self): + conn = SimpleNamespace(inventory_obj=None, selected_hotbar_slot=0) + self.assertEqual(get_attack_damage(conn), 1.0) + + def test_sword_damage(self): + held = SimpleNamespace( + item_id="minecraft:diamond_sword", is_empty=False, nbt={}) + inventory = SimpleNamespace(get_held_item_from_slot=lambda slot: held) + conn = SimpleNamespace(inventory_obj=inventory, selected_hotbar_slot=0) + self.assertEqual( + get_attack_damage(conn), WEAPON_DAMAGE["minecraft:diamond_sword"]) + + +class DamageMobTests(unittest.TestCase): + def make_mob(self, mob_type="zombie"): + return MobEntity(entity_id=7, x=0.0, y=64.0, z=0.0, mob_type=mob_type) + + def test_non_lethal_hit(self): + manager = FakeEntityManager() + mob = self.make_mob() + manager.add(mob) + server = SimpleNamespace(entity_manager=manager) + killed = damage_mob(server, mob, 5.0) + self.assertFalse(killed) + self.assertEqual(mob.health, MOB_PROFILES["zombie"]["health"] - 5.0) + self.assertEqual(manager.removed, []) + + def test_kill_spawns_drops_and_xp(self): + manager = FakeEntityManager() + mob = self.make_mob("cow") + manager.add(mob) + server = SimpleNamespace(entity_manager=manager) + killed = damage_mob(server, mob, 999.0) + self.assertTrue(killed) + self.assertEqual(manager.removed, [7]) + item_names = [name for name, _ in manager.created_items] + self.assertIn("minecraft:beef", item_names) + self.assertTrue(all(c >= 1 for _, c in manager.created_items)) + self.assertEqual(len(manager.created_orbs), 1) + self.assertTrue(1 <= manager.created_orbs[0] <= 3) + + def test_zero_roll_drops_may_be_empty(self): + manager = FakeEntityManager() + mob = self.make_mob("zombie") # rotten_flesh 0-2 + manager.add(mob) + server = SimpleNamespace(entity_manager=manager) + damage_mob(server, mob, 999.0) + for name, count in manager.created_items: + self.assertEqual(name, "minecraft:rotten_flesh") + self.assertGreaterEqual(count, 1) + + def test_mob_profiles_have_drops_and_xp(self): + for mob_type, profile in MOB_PROFILES.items(): + self.assertIn("drops", profile, mob_type) + self.assertIn("xp", profile, mob_type) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_fluid_rules.py b/tests/test_fluid_rules.py new file mode 100644 index 0000000..93c990e --- /dev/null +++ b/tests/test_fluid_rules.py @@ -0,0 +1,123 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from world.blocks import AIR, WATER, LAVA, STONE +from world.fluids import ( + FluidSystem, + _get_fluid_level, + _get_water_state, + _is_solid, +) + + +class GridWorld: + """基于字典的简易方块世界。""" + + def __init__(self): + self.blocks = {} + self._fluid_updates = [] + + def get_block_at(self, x, y, z): + return self.blocks.get((x, y, z), AIR) + + def set_block(self, x, y, z, state): + self.blocks[(x, y, z)] = state + return {(x // 16, z // 16)} + + +def make_fluid_system(world): + server = SimpleNamespace( + get_block_at=world.get_block_at, + _fluid_updates=world._fluid_updates, + ) + fs = FluidSystem(server) + return fs + + +def run_flow(fs, world, x, y, z, fluid_type="water", ticks=1): + """放置流体并推进指定 tick 数。""" + fs.on_fluid_place(x, y, z, fluid_type) + with patch("world.editing.set_world_block", + lambda server, bx, by, bz, state: world.set_block(bx, by, bz, state)): + for _ in range(ticks): + fs.tick() + + +class FallingWaterTests(unittest.TestCase): + def test_falling_water_does_not_dry_up(self): + """下落水柱的中间段不应被误判为无源而干涸。""" + world = GridWorld() + world.set_block(0, 64, 0, WATER) # 源头 + world.set_block(0, 63, 0, _get_water_state(0, falling=True)) + world.set_block(0, 62, 0, _get_water_state(0, falling=True)) + world.set_block(0, 61, 0, STONE) # 地面 + fs = make_fluid_system(world) + + # 直接处理中间下落段: 上方是下落水 (level 8), 不应干涸 + with patch("world.editing.set_world_block", + lambda s, bx, by, bz, st: world.set_block(bx, by, bz, st)): + fs._process_flow(0, 63, 0, world.get_block_at(0, 63, 0), "water", []) + self.assertNotEqual(world.get_block_at(0, 63, 0), AIR) + + def test_falling_water_spreads_on_landing(self): + """下落到地面的水应能向四周扩散 (此前 level=8 永远不扩散)。""" + world = GridWorld() + world.set_block(0, 64, 0, WATER) + world.set_block(0, 63, 0, _get_water_state(0, falling=True)) + world.set_block(0, 62, 0, STONE) # 落点地面 + fs = make_fluid_system(world) + + with patch("world.editing.set_world_block", + lambda s, bx, by, bz, st: world.set_block(bx, by, bz, st)): + fs._process_flow(0, 63, 0, world.get_block_at(0, 63, 0), "water", []) + + # 下落水下方是固体, 应向水平方向扩散 level 1 + spread = any( + world.get_block_at(dx, 63, dz) != AIR + for dx, dz in [(1, 0), (-1, 0), (0, 1), (0, -1)] + ) + self.assertTrue(spread) + + +class InfiniteSourceTests(unittest.TestCase): + def test_two_sources_plus_solid_below_form_source(self): + world = GridWorld() + world.set_block(0, 63, 0, STONE) # 下方固体 + world.set_block(-1, 64, 0, WATER) # 西侧水源 + world.set_block(1, 64, 0, WATER) # 东侧水源 + fs = make_fluid_system(world) + self.assertTrue(fs._can_form_infinite_source(0, 64, 0)) + + def test_single_source_does_not_form(self): + world = GridWorld() + world.set_block(0, 63, 0, STONE) + world.set_block(-1, 64, 0, WATER) + fs = make_fluid_system(world) + self.assertFalse(fs._can_form_infinite_source(0, 64, 0)) + + def test_no_solid_below_does_not_form(self): + world = GridWorld() + world.set_block(-1, 64, 0, WATER) + world.set_block(1, 64, 0, WATER) + fs = make_fluid_system(world) + self.assertFalse(fs._can_form_infinite_source(0, 64, 0)) + + def test_flow_creates_source_block(self): + """两个水源之间的空位流入水时应直接变成水源。""" + world = GridWorld() + world.set_block(0, 63, 0, STONE) + world.set_block(-1, 64, 0, WATER) + world.set_block(1, 64, 0, WATER) + fs = make_fluid_system(world) + + with patch("world.editing.set_world_block", + lambda s, bx, by, bz, st: world.set_block(bx, by, bz, st)): + fs._process_flow(-1, 64, 0, WATER, "water", []) + + self.assertEqual(world.get_block_at(0, 64, 0), WATER) + self.assertEqual(_get_fluid_level(world.get_block_at(0, 64, 0)), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_item_properties.py b/tests/test_item_properties.py new file mode 100644 index 0000000..ec85db3 --- /dev/null +++ b/tests/test_item_properties.py @@ -0,0 +1,68 @@ +import unittest +from types import SimpleNamespace + +from world.inventory import ItemStack +from world.item_properties import ( + MAX_DURABILITY, + add_enchantment, + damage_item, + get_enchantment_level, + get_max_durability, + sharpness_damage_bonus, +) + + +class DurabilityTableTests(unittest.TestCase): + def test_tier_values(self): + self.assertEqual(get_max_durability("minecraft:wooden_pickaxe"), 59) + self.assertEqual(get_max_durability("minecraft:stone_sword"), 131) + self.assertEqual(get_max_durability("minecraft:iron_axe"), 250) + self.assertEqual(get_max_durability("minecraft:diamond_shovel"), 1561) + self.assertEqual(get_max_durability("minecraft:netherite_sword"), 2031) + + def test_non_tool_has_no_durability(self): + self.assertEqual(get_max_durability("minecraft:stone"), 0) + self.assertEqual(get_max_durability("minecraft:apple"), 0) + + +class EnchantmentStorageTests(unittest.TestCase): + def test_add_and_read(self): + item = ItemStack("minecraft:iron_sword", 1) + add_enchantment(item, "sharpness", 3) + self.assertEqual(get_enchantment_level(item, "sharpness"), 3) + self.assertEqual(get_enchantment_level(item, "unbreaking"), 0) + + def test_level_clamped(self): + item = ItemStack("minecraft:iron_sword", 1) + add_enchantment(item, "sharpness", 999) + self.assertEqual(get_enchantment_level(item, "sharpness"), 255) + + def test_sharpness_bonus(self): + item = ItemStack("minecraft:iron_sword", 1) + self.assertEqual(sharpness_damage_bonus(item), 0.0) + add_enchantment(item, "sharpness", 2) + self.assertEqual(sharpness_damage_bonus(item), 1.5) + + +class DamageItemTests(unittest.TestCase): + def test_durability_consumed_until_break(self): + item = ItemStack("minecraft:wooden_sword", 1) + for _ in range(58): + self.assertFalse(damage_item(item)) + self.assertTrue(damage_item(item)) # 第 59 次损毁 + + def test_non_tool_never_breaks(self): + item = ItemStack("minecraft:stone", 64) + self.assertFalse(damage_item(item, 100)) + + def test_unbreaking_extends_life(self): + # unbreaking 3: 每次仅 1/4 概率消耗, 59 次攻击几乎不可能耗尽 59 点耐久 + item = ItemStack("minecraft:wooden_sword", 1) + add_enchantment(item, "unbreaking", 3) + broke = any(damage_item(item) for _ in range(59)) + self.assertFalse(broke) + self.assertLess(item.damage, 59) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_lightning.py b/tests/test_lightning.py new file mode 100644 index 0000000..f98857c --- /dev/null +++ b/tests/test_lightning.py @@ -0,0 +1,83 @@ +import unittest +from types import SimpleNamespace + +from network.server import MinecraftServer +from world.entities import LightningBoltEntity, MobEntity + + +class LightningEntityTests(unittest.TestCase): + def test_expires_after_lifetime(self): + bolt = LightningBoltEntity(entity_id=1, x=0.0, y=64.0, z=0.0) + server = SimpleNamespace() + for _ in range(4): + bolt.tick(server) + self.assertFalse(bolt.alive) + + def test_kind_maps_to_verified_type_id(self): + from handlers.play.entities import ENTITY_TYPE_IDS + bolt = LightningBoltEntity(entity_id=1, x=0.0, y=64.0, z=0.0) + self.assertEqual(ENTITY_TYPE_IDS[bolt.kind], 64) + + +class FakeEntityManager: + def __init__(self): + self.entities = {} + self._next = 100 + + def create_lightning(self, x, y, z): + self._next += 1 + bolt = LightningBoltEntity(self._next, x, y, z) + self.entities[bolt.entity_id] = bolt + return bolt + + def list_entities(self): + return list(self.entities.values()) + + +class FakePlayer: + def __init__(self, x=0.0, y=64.0, z=0.0, gamemode="survival"): + self.x, self.y, self.z = x, y, z + self.gamemode = gamemode + self.username = "Steve" + self.health = 20.0 + self.damage_cooldown_ticks = 0 + self.last_damage_reason = "" + self.alive = True + + +class LightningStrikeTests(unittest.IsolatedAsyncioTestCase): + async def test_no_strike_when_not_thunder(self): + server = SimpleNamespace( + weather="rain", + entity_manager=FakeEntityManager(), + get_online_players=lambda: [FakePlayer()], + ) + await MinecraftServer._tick_lightning(server) + self.assertEqual(server.entity_manager.entities, {}) + + async def test_strike_spawns_bolt_and_damages_nearby(self): + from unittest.mock import patch, AsyncMock + player = FakePlayer() + manager = FakeEntityManager() + server = SimpleNamespace( + weather="thunder", + entity_manager=manager, + get_online_players=lambda: [player], + ) + with patch("handlers.play.broadcast_entity_spawn", new=AsyncMock()), \ + patch("handlers.play._damage_player", new=AsyncMock()) as dmg, \ + patch("handlers.play._send_update_health", new=AsyncMock()): + await MinecraftServer._tick_lightning(server) + + bolts = [e for e in manager.entities.values() + if isinstance(e, LightningBoltEntity)] + self.assertEqual(len(bolts), 1) + + # 落点在玩家 24 格范围内 + bolt = bolts[0] + self.assertLessEqual(abs(bolt.x - player.x), 24.0) + self.assertLessEqual(abs(bolt.z - player.z), 24.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_network_optimizer_wiring.py b/tests/test_network_optimizer_wiring.py new file mode 100644 index 0000000..cc3e7dd --- /dev/null +++ b/tests/test_network_optimizer_wiring.py @@ -0,0 +1,56 @@ +import unittest +from types import SimpleNamespace + +from network.server import MinecraftServer + + +class FakeConn: + def __init__(self): + self.username = "Steve" + self.address = "127.0.0.1:25565" + self.compression_threshold = -1 + self.alive = True + self.sent = [] + + async def send_packet(self, packet_id, payload=b""): + self.sent.append((packet_id, payload)) + + +class FakeOptimizer: + def __init__(self, accept=True): + self.accept = accept + self.queued = [] + + def queue_packet(self, conn, packet_id, payload): + if not self.accept: + return False + self.queued.append((conn, packet_id, payload)) + return True + + +class QueueOrSendTests(unittest.IsolatedAsyncioTestCase): + async def test_queues_when_optimizer_running(self): + conn = FakeConn() + optimizer = FakeOptimizer() + server = SimpleNamespace(network_optimizer=optimizer) + await MinecraftServer.queue_or_send(server, conn, 0x70, b"abc") + self.assertEqual(len(optimizer.queued), 1) + self.assertEqual(optimizer.queued[0][1], 0x70) + self.assertEqual(conn.sent, []) + + async def test_direct_send_when_optimizer_absent(self): + conn = FakeConn() + server = SimpleNamespace(network_optimizer=None) + await MinecraftServer.queue_or_send(server, conn, 0x70, b"abc") + self.assertEqual(conn.sent, [(0x70, b"abc")]) + + async def test_direct_send_when_queue_rejects(self): + conn = FakeConn() + optimizer = FakeOptimizer(accept=False) + server = SimpleNamespace(network_optimizer=optimizer) + await MinecraftServer.queue_or_send(server, conn, 0x70, b"abc") + self.assertEqual(conn.sent, [(0x70, b"abc")]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ore_distribution.py b/tests/test_ore_distribution.py new file mode 100644 index 0000000..7863b03 --- /dev/null +++ b/tests/test_ore_distribution.py @@ -0,0 +1,78 @@ +import random +import unittest + +from world.blocks import STONE, AIR, DIAMOND_ORE, LAPIS_ORE +from world.vanilla_terrain import ( + MIN_Y, + WORLD_HEIGHT, + OreVeinGenerator, +) + + +class TrapezoidSamplerTests(unittest.TestCase): + def test_within_bounds(self): + rng = random.Random(42) + for _ in range(5000): + y = OreVeinGenerator._sample_trapezoid(rng, -64, -32) + self.assertGreaterEqual(y, -64) + self.assertLessEqual(y, -32) + + def test_concentrated_in_middle(self): + """梯形分布中间 1/3 的采样密度应高于边缘。""" + rng = random.Random(42) + samples = [OreVeinGenerator._sample_trapezoid(rng, 0, 90) + for _ in range(20000)] + middle = sum(1 for s in samples if 30 <= s <= 60) + edge = sum(1 for s in samples if s < 15 or s > 75) + self.assertGreater(middle, edge) + + +class OrePlacementTests(unittest.TestCase): + def make_blocks(self, fill=STONE): + return [[[fill] * 16 for _ in range(16)] for _ in range(WORLD_HEIGHT)] + + def count_block(self, blocks, block_id): + return sum( + 1 + for y in range(WORLD_HEIGHT) + for z in range(16) + for x in range(16) + if blocks[y][z][x] == block_id + ) + + def test_deterministic_per_seed_and_chunk(self): + gen = OreVeinGenerator(999) + a = self.make_blocks() + b = self.make_blocks() + gen.place(a, 3, 7) + gen.place(b, 3, 7) + self.assertEqual(a, b) + + def test_buried_lapis_never_exposed_to_air(self): + """buried 批次 (air_discard=1.0) 贴着空气的矿脉必须全部丢弃。""" + # 世界大部分为空气, 仅 y=0 一层石头: 任何矿石都贴着空气 + blocks = [[[AIR] * 16 for _ in range(16)] for _ in range(WORLD_HEIGHT)] + yi = 0 - MIN_Y + for z in range(16): + for x in range(16): + blocks[yi][z][x] = STONE + gen = OreVeinGenerator(12345) + gen.place(blocks, 0, 0) + # buried lapis (air_discard=1.0) 不允许出现; + # 其它无丢弃概率的矿种可能出现, 这里只验证 lapis + self.assertEqual(self.count_block(blocks, LAPIS_ORE), 0) + + def test_diamond_respects_height_range(self): + blocks = self.make_blocks() + OreVeinGenerator(777).place(blocks, 1, 1) + for y in range(WORLD_HEIGHT): + wy = y + MIN_Y + for z in range(16): + for x in range(16): + if blocks[y][z][x] == DIAMOND_ORE: + self.assertGreaterEqual(wy, -64 - 1) # 允许 vein 扩散 1 格 + self.assertLessEqual(wy, 16 + 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_player_visibility.py b/tests/test_player_visibility.py new file mode 100644 index 0000000..03f4596 --- /dev/null +++ b/tests/test_player_visibility.py @@ -0,0 +1,159 @@ +import struct +import unittest +import uuid +from types import SimpleNamespace + +from handlers.play.entities import ENTITY_TYPE_IDS +from handlers.play.players import ( + PLAYER_ENTITY_TYPE, + ROTATE_HEAD_PID, + SET_ENTITY_DATA_PID, + SPAWN_ENTITY_PID, + build_player_metadata_payload, + build_rotate_head_payload, + build_spawn_player_payload, + relay_player_movement, + send_player_spawn, + sync_player_visibility, +) +from protocol.data_types import read_varint + + +class FakeConn: + def __init__(self, username, entity_id, x=0.0, z=0.0, version_handler=None): + self.username = username + self.entity_id = entity_id + self.uuid = uuid.uuid4() + self.x = x + self.y = 64.0 + self.z = z + self.yaw = 90.0 + self.pitch = 0.0 + self.on_ground = True + self.alive = True + self.version_handler = version_handler + self.protocol_version = 767 + self.tracked_players = set() + self.sent = [] # (packet_id, payload) + + async def send_packet(self, packet_id, payload=b""): + self.sent.append((packet_id, payload)) + + +def make_server(conns, view_distance=10, optimizer=None): + return SimpleNamespace( + view_distance=view_distance, + network_optimizer=optimizer, + get_online_players=lambda: list(conns), + ) + + +class EntityTypeIdTests(unittest.TestCase): + """1.21.1 注册表 ID (minecraft-data pc/1.20.5, 1.21/1.21.1 共用)。""" + + def test_verified_ids(self): + self.assertEqual(ENTITY_TYPE_IDS["item"], 58) + self.assertEqual(ENTITY_TYPE_IDS["cow"], 22) + self.assertEqual(ENTITY_TYPE_IDS["pig"], 77) + self.assertEqual(ENTITY_TYPE_IDS["sheep"], 87) + self.assertEqual(ENTITY_TYPE_IDS["zombie"], 124) + self.assertEqual(PLAYER_ENTITY_TYPE, 128) + + +class SpawnPayloadTests(unittest.TestCase): + def test_spawn_payload_layout(self): + player = FakeConn("Alex", 42, x=1.5, z=-2.5) + payload = build_spawn_player_payload(player) + eid, offset = read_varint(payload, 0) + self.assertEqual(eid, 42) + # UUID (16 bytes) 之后是类型 VarInt + type_id, _ = read_varint(payload, offset + 16) + self.assertEqual(type_id, PLAYER_ENTITY_TYPE) + + def test_metadata_payload_shows_all_skin_parts(self): + player = FakeConn("Alex", 42) + payload = build_player_metadata_payload(player) + eid, offset = read_varint(payload, 0) + self.assertEqual(eid, 42) + self.assertEqual(payload[offset], 17) # skin parts index + type_id, offset2 = read_varint(payload, offset + 1) + self.assertEqual(type_id, 0) # Byte + self.assertEqual(payload[offset2], 0x7F) # all layers + self.assertEqual(payload[offset2 + 1], 0xFF) + + def test_rotate_head_payload(self): + player = FakeConn("Alex", 42) + player.yaw = 180.0 + payload = build_rotate_head_payload(player) + eid, offset = read_varint(payload, 0) + self.assertEqual(eid, 42) + self.assertEqual(payload[offset], 128) # 180° -> 180/360*256 = 128 + + +class SpawnSyncTests(unittest.IsolatedAsyncioTestCase): + async def test_spawn_sends_spawn_and_metadata(self): + observer = FakeConn("Steve", 1) + player = FakeConn("Alex", 2) + await send_player_spawn(observer, player) + pids = [pid for pid, _ in observer.sent] + self.assertEqual(pids, [SPAWN_ENTITY_PID, SET_ENTITY_DATA_PID]) + self.assertIn(2, observer.tracked_players) + + async def test_spawn_is_idempotent(self): + observer = FakeConn("Steve", 1) + player = FakeConn("Alex", 2) + await send_player_spawn(observer, player) + await send_player_spawn(observer, player) + self.assertEqual(len(observer.sent), 2) + + async def test_spawn_skipped_for_versioned_clients(self): + observer = FakeConn("Steve", 1, version_handler=object()) + player = FakeConn("Alex", 2) + await send_player_spawn(observer, player) + self.assertEqual(observer.sent, []) + + async def test_visibility_sync_is_bidirectional(self): + a = FakeConn("Steve", 1) + b = FakeConn("Alex", 2) + server = make_server([a, b]) + await sync_player_visibility(server, b) + self.assertIn(2, a.tracked_players) + self.assertIn(1, b.tracked_players) + + +class MovementRelayTests(unittest.IsolatedAsyncioTestCase): + async def test_relay_sends_teleport_and_head_rotation(self): + a = FakeConn("Steve", 1) + b = FakeConn("Alex", 2) + a.tracked_players.add(2) + server = make_server([a, b]) + await relay_player_movement(server, b) + pids = [pid for pid, _ in a.sent] + self.assertIn(0x70, pids) # entity_teleport (767) + self.assertIn(ROTATE_HEAD_PID, pids) + + async def test_relay_skips_untracked_or_far_observers(self): + near = FakeConn("Near", 1) + far = FakeConn("Far", 3, x=10000.0) + untracked = FakeConn("Untracked", 4) + near.tracked_players.add(2) + far.tracked_players.add(2) + moved = FakeConn("Alex", 2) + server = make_server([near, far, untracked, moved], view_distance=10) + await relay_player_movement(server, moved) + self.assertTrue(near.sent) + self.assertEqual(far.sent, []) + self.assertEqual(untracked.sent, []) + + async def test_relay_rate_limited_by_optimizer(self): + a = FakeConn("Steve", 1) + b = FakeConn("Alex", 2) + a.tracked_players.add(2) + optimizer = SimpleNamespace(should_send_movement=lambda conn: False) + server = make_server([a, b], optimizer=optimizer) + await relay_player_movement(server, b) + self.assertEqual(a.sent, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_watchdog_e2e.py b/tests/test_watchdog_e2e.py new file mode 100644 index 0000000..45d02fa --- /dev/null +++ b/tests/test_watchdog_e2e.py @@ -0,0 +1,67 @@ +import asyncio +import json +import socket +import unittest +from types import SimpleNamespace + +from watchdog.process_manager import WatchdogManager + + +def make_watchdog(): + """真实 UDP 健康服务器, 端口由 OS 分配。""" + server = SimpleNamespace( + config={"watchdog-health-port": 0, "watchdog-max-missed-heartbeats": 5}, + get_online_players=lambda: [], + ) + return WatchdogManager(server) + + +class WatchdogEndToEndTests(unittest.IsolatedAsyncioTestCase): + async def test_ping_pong_over_real_udp(self): + """端到端: 真实 UDP socket 发送 PYMC_PING, 收到 PYMC_HEALTH。""" + wd = make_watchdog() + await wd._start_health_server() + self.assertIsNotNone(wd._health_server) + port = wd._health_server.get_extra_info("sockname")[1] + self.assertGreater(port, 0) + + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(3) + sock.sendto(b"PYMC_PING", ("127.0.0.1", port)) + data, _ = await asyncio.to_thread(sock.recvfrom, 4096) + sock.close() + + text = data.decode("utf-8") + self.assertTrue(text.startswith("PYMC_HEALTH|")) + payload = json.loads(text.split("|", 1)[1]) + self.assertIn("pid", payload) + self.assertIn("tps", payload) + self.assertIn("uptime_seconds", payload) + finally: + wd._health_server.close() + + async def test_heartbeat_updates_partner_state(self): + """端到端: 收到 PYMC_HB 心跳后更新伙伴进程状态。""" + wd = make_watchdog() + await wd._start_health_server() + port = wd._health_server.get_extra_info("sockname")[1] + + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.sendto(b"PYMC_HB|4242|19.5|3|512.0|1700000000", + ("127.0.0.1", port)) + sock.close() + # handle_heartbeat 通过 ensure_future 调度, 让事件循环执行 + await asyncio.sleep(0.2) + + self.assertEqual(wd.partner_pid, 4242) + self.assertAlmostEqual(wd._partner_tps, 19.5) + self.assertEqual(wd._partner_players, 3) + self.assertGreater(wd._partner_last_seen, 0) + finally: + wd._health_server.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_weather_sync.py b/tests/test_weather_sync.py new file mode 100644 index 0000000..a2b6623 --- /dev/null +++ b/tests/test_weather_sync.py @@ -0,0 +1,170 @@ +import struct +import unittest +from types import SimpleNamespace + +from handlers.play.weather import ( + GAME_EVENT_BEGIN_RAINING, + GAME_EVENT_END_RAINING, + GAME_EVENT_RAIN_LEVEL, + GAME_EVENT_THUNDER_LEVEL, + build_game_event_payload, + broadcast_weather_change, + rain_strength, + send_weather_state, + thunder_strength, +) +from network.managers.time import TimeManager +from network.server import MinecraftServer + + +class FakeConnection: + """记录收到的 Game Event (event, value) 序列。""" + + def __init__(self, protocol_version=767): + self.protocol_version = protocol_version + self.version_handler = None + self.events = [] + + async def send_packet(self, packet_id, payload=b""): + event = payload[0] + (value,) = struct.unpack(">f", payload[1:5]) + self.events.append((packet_id, event, value)) + + +def make_server(weather, conns): + return SimpleNamespace(weather=weather, get_online_players=lambda: conns) + + +class GameEventPayloadTests(unittest.TestCase): + def test_payload_layout(self): + payload = build_game_event_payload(GAME_EVENT_BEGIN_RAINING, 1.0) + self.assertEqual(payload[0], GAME_EVENT_BEGIN_RAINING) + (value,) = struct.unpack(">f", payload[1:5]) + self.assertAlmostEqual(value, 1.0) + self.assertEqual(len(payload), 5) + + def test_strength_helpers(self): + self.assertEqual(rain_strength("clear"), 0.0) + self.assertEqual(rain_strength("rain"), 1.0) + self.assertEqual(rain_strength("thunder"), 1.0) + self.assertEqual(thunder_strength("rain"), 0.0) + self.assertEqual(thunder_strength("thunder"), 1.0) + + +class WeatherBroadcastTests(unittest.IsolatedAsyncioTestCase): + async def test_clear_to_rain_begins_raining(self): + conn = FakeConnection() + server = make_server("rain", [conn]) + await broadcast_weather_change(server, "clear", "rain") + events = [e for _, e, _ in conn.events] + self.assertIn(GAME_EVENT_BEGIN_RAINING, events) + self.assertNotIn(GAME_EVENT_END_RAINING, events) + levels = dict((e, v) for _, e, v in conn.events) + self.assertEqual(levels[GAME_EVENT_RAIN_LEVEL], 1.0) + self.assertEqual(levels[GAME_EVENT_THUNDER_LEVEL], 0.0) + + async def test_rain_to_clear_ends_raining(self): + conn = FakeConnection() + server = make_server("clear", [conn]) + await broadcast_weather_change(server, "rain", "clear") + events = [e for _, e, _ in conn.events] + self.assertIn(GAME_EVENT_END_RAINING, events) + self.assertNotIn(GAME_EVENT_BEGIN_RAINING, events) + levels = dict((e, v) for _, e, v in conn.events) + self.assertEqual(levels[GAME_EVENT_RAIN_LEVEL], 0.0) + + async def test_rain_to_thunder_keeps_rain_state(self): + conn = FakeConnection() + server = make_server("thunder", [conn]) + await broadcast_weather_change(server, "rain", "thunder") + events = [e for _, e, _ in conn.events] + self.assertNotIn(GAME_EVENT_BEGIN_RAINING, events) + self.assertNotIn(GAME_EVENT_END_RAINING, events) + levels = dict((e, v) for _, e, v in conn.events) + self.assertEqual(levels[GAME_EVENT_THUNDER_LEVEL], 1.0) + + async def test_no_change_sends_nothing(self): + conn = FakeConnection() + server = make_server("rain", [conn]) + await broadcast_weather_change(server, "rain", "rain") + self.assertEqual(conn.events, []) + + async def test_uses_version_mapped_packet_id(self): + conn = FakeConnection(protocol_version=47) # 1.8.9: game_event = 0x1B + server = make_server("rain", [conn]) + await broadcast_weather_change(server, "clear", "rain") + self.assertTrue(all(pid == 0x1B for pid, _, _ in conn.events)) + + +class JoinWeatherStateTests(unittest.IsolatedAsyncioTestCase): + async def test_clear_weather_sends_nothing_on_join(self): + conn = FakeConnection() + await send_weather_state(conn, make_server("clear", [conn])) + self.assertEqual(conn.events, []) + + async def test_thunder_join_sends_state(self): + conn = FakeConnection() + await send_weather_state(conn, make_server("thunder", [conn])) + events = [e for _, e, _ in conn.events] + self.assertIn(GAME_EVENT_BEGIN_RAINING, events) + levels = dict((e, v) for _, e, v in conn.events) + self.assertEqual(levels[GAME_EVENT_RAIN_LEVEL], 1.0) + self.assertEqual(levels[GAME_EVENT_THUNDER_LEVEL], 1.0) + + +class WeatherCycleGameruleTests(unittest.TestCase): + def test_weather_duration_counts_down_by_default(self): + mgr = TimeManager() + mgr.set_weather("rain", duration=2) + mgr.tick() + self.assertEqual(mgr.weather, "rain") + mgr.tick() + self.assertEqual(mgr.weather, "clear") + + def test_do_weather_cycle_false_freezes_weather(self): + mgr = TimeManager() + mgr.do_weather_cycle = False + mgr.set_weather("rain", duration=1) + for _ in range(10): + mgr.tick() + self.assertEqual(mgr.weather, "rain") + + def test_serialize_roundtrip_keeps_weather_cycle_flag(self): + mgr = TimeManager() + mgr.do_weather_cycle = False + mgr.set_weather("thunder", duration=100) + restored = TimeManager.deserialize(mgr.serialize()) + self.assertFalse(restored.do_weather_cycle) + self.assertEqual(restored.weather, "thunder") + + +class SetWeatherTests(unittest.IsolatedAsyncioTestCase): + async def test_set_weather_updates_duration_and_broadcasts(self): + conn = FakeConnection() + server = SimpleNamespace( + weather="clear", + _time_manager=TimeManager(), + plugin_manager=None, + get_online_players=lambda: [conn], + ) + await MinecraftServer.set_weather(server, "rain", duration=1234) + + self.assertEqual(server.weather, "rain") + self.assertEqual(server._time_manager._weather_duration, 1234) + events = [e for _, e, _ in conn.events] + self.assertIn(GAME_EVENT_BEGIN_RAINING, events) + + async def test_set_weather_noop_when_unchanged(self): + conn = FakeConnection() + server = SimpleNamespace( + weather="clear", + _time_manager=TimeManager(), + plugin_manager=None, + get_online_players=lambda: [conn], + ) + await MinecraftServer.set_weather(server, "clear", duration=100) + self.assertEqual(conn.events, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/world/entities.py b/world/entities.py index 9e9d450..0a00302 100755 --- a/world/entities.py +++ b/world/entities.py @@ -17,6 +17,8 @@ "wander_interval": 120, "look_range": 8.0, "height": 0.9, + "drops": [("minecraft:porkchop", 1, 3)], + "xp": (1, 3), }, "cow": { "category": "passive", @@ -25,6 +27,8 @@ "wander_interval": 120, "look_range": 8.0, "height": 1.4, + "drops": [("minecraft:beef", 1, 3), ("minecraft:leather", 0, 2)], + "xp": (1, 3), }, "sheep": { "category": "passive", @@ -33,6 +37,8 @@ "wander_interval": 120, "look_range": 8.0, "height": 1.3, + "drops": [("minecraft:mutton", 1, 2), ("minecraft:white_wool", 1, 1)], + "xp": (1, 3), }, "zombie": { "category": "hostile", @@ -43,6 +49,8 @@ "attack_range": 1.7, "attack_interval": 20, "height": 1.95, + "drops": [("minecraft:rotten_flesh", 0, 2)], + "xp": (5, 5), }, "skeleton": { "category": "hostile", @@ -53,6 +61,9 @@ "attack_range": 15.0, "attack_interval": 30, "height": 1.99, + "ranged": True, + "drops": [("minecraft:bone", 0, 2), ("minecraft:arrow", 0, 2)], + "xp": (5, 5), }, "creeper": { "category": "hostile", @@ -63,6 +74,8 @@ "attack_range": 2.4, "attack_interval": 35, "height": 1.7, + "drops": [("minecraft:gunpowder", 0, 2)], + "xp": (5, 5), }, "spider": { "category": "hostile", @@ -73,6 +86,8 @@ "attack_range": 1.9, "attack_interval": 20, "height": 0.9, + "drops": [("minecraft:string", 0, 2), ("minecraft:spider_eye", 0, 1)], + "xp": (5, 5), }, } @@ -404,6 +419,23 @@ def _apply_physics(self, server): self.on_ground = False +@dataclass +class LightningBoltEntity(Entity): + """闪电实体: 雷暴天气时生成, 数 tick 后自动消失。""" + + lifetime_ticks: int = 4 + + def __init__(self, entity_id: int, x: float, y: float, z: float): + super().__init__(entity_id=entity_id, kind="lightning_bolt", x=x, y=y, z=z) + self.lifetime_ticks = 4 + + def tick(self, server): + super().tick(server) + self.lifetime_ticks -= 1 + if self.lifetime_ticks <= 0: + self.alive = False + + @dataclass class ExperienceOrbEntity(Entity): count: int = 1 @@ -469,6 +501,11 @@ def create_experience_orb(self, x: float, y: float, z: float, count: int = 1) -> self.add_entity(entity) return entity + def create_lightning(self, x: float, y: float, z: float) -> LightningBoltEntity: + entity = LightningBoltEntity(self.server.get_next_entity_id(), x, y, z) + self.add_entity(entity) + return entity + def add_entity(self, entity: Entity): self.entities[entity.entity_id] = entity self.spawned_at[entity.entity_id] = time.time() diff --git a/world/fluids.py b/world/fluids.py index fa34b3c..317660b 100755 --- a/world/fluids.py +++ b/world/fluids.py @@ -292,6 +292,7 @@ def _process_flow(self, x: int, y: int, z: int, current_state: int, level = _get_fluid_level(current_state) is_source = (level == 0) + is_falling = level >= 8 # 下落中的流体 (level 8-15) speed = WATER_FLOW_SPEED if fluid_type == "water" else (LAVA_FLOW_SPEED_NETHER if self.dimension == "the_nether" else LAVA_FLOW_SPEED) max_dist = WATER_MAX_DISTANCE if fluid_type == "water" else (LAVA_MAX_DISTANCE_NETHER if self.dimension == "the_nether" else LAVA_MAX_DISTANCE) @@ -333,8 +334,9 @@ def _process_flow(self, x: int, y: int, z: int, current_state: int, pass # 2. Horizontal flow (only if can't flow down OR is source) - if level < max_dist or is_source: - new_level = 1 if is_source else level + 1 + # 下落中的流体落地后按等级 1 向四周扩散 + if is_source or is_falling or level < max_dist: + new_level = 1 if (is_source or is_falling) else level + 1 for dx, dz in [(1, 0), (-1, 0), (0, 1), (0, -1)]: nx, nz = x + dx, z + dz @@ -375,7 +377,11 @@ def _process_flow(self, x: int, y: int, z: int, current_state: int, if n_type == fluid_type and n_level <= new_level: continue # Already have equal or better flow - if fluid_type == "water": + # 无限水源: 水平相邻 >=2 个水源且下方为固体/水源时, + # 流入的水直接成为水源 (仅水, 岩浆不适用) + if fluid_type == "water" and self._can_form_infinite_source(nx, y, nz): + new_state = WATER + elif fluid_type == "water": new_state = _get_water_state(new_level) else: new_state = _get_lava_state(new_level) @@ -396,12 +402,13 @@ def _process_flow(self, x: int, y: int, z: int, current_state: int, if neighbor is not None: if fluid_type == "water" and _is_water(neighbor): n_level = _get_fluid_level(neighbor) - if n_level < level: + # 正上方的下落水柱也是合法供给方 + if n_level < level or (dy == 1 and n_level >= 8): has_source_neighbor = True break elif fluid_type == "lava" and _is_lava(neighbor): n_level = _get_fluid_level(neighbor) - if n_level < level: + if n_level < level or (dy == 1 and n_level >= 8): has_source_neighbor = True break @@ -419,6 +426,29 @@ def _process_flow(self, x: int, y: int, z: int, current_state: int, n_speed = WATER_FLOW_SPEED if n_type == "water" else (LAVA_FLOW_SPEED_NETHER if self.dimension == "the_nether" else LAVA_FLOW_SPEED) remaining.append((nx, y, nz, self.tick_count + n_speed, n_type)) + def _can_form_infinite_source(self, x: int, y: int, z: int) -> bool: + """ + 判断流入 (x, y, z) 的水是否应形成无限水源。 + + 原版规则: 下方是固体方块或水源, 且水平相邻有 >= 2 个水源。 + """ + below = self.server.get_block_at(x, y - 1, z) + if below is None: + return False + below_is_base = _is_solid(below) or ( + _is_water(below) and _get_fluid_level(below) == 0 + ) + if not below_is_base: + return False + + source_count = 0 + for dx, dz in [(1, 0), (-1, 0), (0, 1), (0, -1)]: + neighbor = self.server.get_block_at(x + dx, y, z + dz) + if neighbor is not None and _is_water(neighbor): + if _get_fluid_level(neighbor) == 0: + source_count += 1 + return source_count >= 2 + def _notify_fluid_update(self, x: int, y: int, z: int, new_state: int): """Record a fluid update for broadcasting.""" self.server._fluid_updates = getattr(self.server, '_fluid_updates', []) diff --git a/world/item_properties.py b/world/item_properties.py new file mode 100644 index 0000000..07e0dee --- /dev/null +++ b/world/item_properties.py @@ -0,0 +1,131 @@ +# ============================================================ +# PyMC - 物品属性: 耐久与附魔 +# ============================================================ + +""" +工具/武器/装备的耐久度与附魔效果。 + +- MAX_DURABILITY: 各类可损耗物品的最大耐久 +- damage_held_item: 消耗手持物品耐久, 耐久耗尽时损毁 +- 附魔存储在 ItemStack.nbt["enchantments"] = {name: level} +- 已接入效果: + - sharpness (锋利): 攻击伤害 +0.5*level + 0.5 + - unbreaking (耐久): 1/(level+1) 概率才消耗耐久 +""" + +import logging +import random + +logger = logging.getLogger("PyMC.物品") + +_rng = random.Random() + +# --- 工具/武器最大耐久 (Java 版数值) --- +_TIER_DURABILITY = { + "wooden": 59, "golden": 32, "stone": 131, + "iron": 250, "diamond": 1561, "netherite": 2031, +} +_TOOL_KINDS = ("sword", "pickaxe", "axe", "shovel", "hoe") + +MAX_DURABILITY: dict[str, int] = { + f"minecraft:{tier}_{kind}": durability + for tier, durability in _TIER_DURABILITY.items() + for kind in _TOOL_KINDS +} +MAX_DURABILITY.update({ + "minecraft:bow": 384, "minecraft:crossbow": 465, + "minecraft:trident": 250, "minecraft:mace": 500, + "minecraft:shears": 238, "minecraft:shield": 336, + "minecraft:flint_and_steel": 64, "minecraft:fishing_rod": 64, + "minecraft:elytra": 432, + # 盔甲 (头盔/胸甲/护腿/靴子) + "minecraft:leather_helmet": 55, "minecraft:leather_chestplate": 80, + "minecraft:leather_leggings": 75, "minecraft:leather_boots": 65, + "minecraft:golden_helmet": 77, "minecraft:golden_chestplate": 112, + "minecraft:golden_leggings": 105, "minecraft:golden_boots": 91, + "minecraft:chainmail_helmet": 165, "minecraft:chainmail_chestplate": 240, + "minecraft:chainmail_leggings": 225, "minecraft:chainmail_boots": 195, + "minecraft:iron_helmet": 165, "minecraft:iron_chestplate": 240, + "minecraft:iron_leggings": 225, "minecraft:iron_boots": 195, + "minecraft:diamond_helmet": 363, "minecraft:diamond_chestplate": 528, + "minecraft:diamond_leggings": 495, "minecraft:diamond_boots": 429, + "minecraft:netherite_helmet": 407, "minecraft:netherite_chestplate": 592, + "minecraft:netherite_leggings": 555, "minecraft:netherite_boots": 481, + "minecraft:turtle_helmet": 275, +}) + + +def get_max_durability(item_id: str) -> int: + """物品最大耐久, 不可损耗物品返回 0。""" + return MAX_DURABILITY.get(item_id, 0) + + +def get_enchantment_level(item, name: str) -> int: + """读取物品附魔等级, 无该附魔返回 0。""" + if item is None: + return 0 + enchantments = item.nbt.get("enchantments", {}) + return int(enchantments.get(name, 0)) + + +def add_enchantment(item, name: str, level: int): + """给物品添加附魔 (存储在 NBT 中)。""" + if "enchantments" not in item.nbt: + item.nbt["enchantments"] = {} + item.nbt["enchantments"][name] = max(1, min(255, int(level))) + + +def sharpness_damage_bonus(item) -> float: + """锋利附魔的攻击伤害加成 (Java 1.9+: 0.5*level + 0.5)。""" + level = get_enchantment_level(item, "sharpness") + return 0.5 * level + 0.5 if level > 0 else 0.0 + + +def damage_item(item, amount: int = 1) -> bool: + """ + 消耗物品耐久 (应用耐久附魔)。 + + Returns: + True 如果物品损毁。 + """ + max_durability = get_max_durability(item.item_id) + if max_durability <= 0: + return False + + unbreaking = get_enchantment_level(item, "unbreaking") + for _ in range(max(1, amount)): + if unbreaking > 0 and _rng.random() > 1.0 / (unbreaking + 1): + continue # 耐久附魔: 本次不消耗 + item.damage += 1 + + if item.damage >= max_durability: + return True + return False + + +async def damage_held_item(conn, server, amount: int = 1) -> bool: + """ + 消耗玩家手持物品的耐久。损毁时清空槽位并同步物品栏。 + + Returns: + True 如果物品损毁。 + """ + inventory = getattr(conn, "inventory_obj", None) + if inventory is None: + return False + slot = conn.selected_hotbar_slot + item = inventory.get_slot(slot) + if item is None or item.is_empty: + return False + + if not damage_item(item, amount): + return False + + # 物品损毁 + inventory.set_slot(slot, None) + logger.info(f"{conn.username} 的 {item.item_id} 已损毁") + from handlers.play.chat import send_system_message + await send_system_message(conn, f"[PyMC] 你的 {item.item_id} 已损毁") + from world.inventory import send_inventory_sync + await send_inventory_sync(conn) + return True diff --git a/world/vanilla_terrain.py b/world/vanilla_terrain.py index 7d07851..56f018b 100755 --- a/world/vanilla_terrain.py +++ b/world/vanilla_terrain.py @@ -2193,60 +2193,99 @@ def carve(self, blocks: list, base_x: int, base_z: int, class OreVeinGenerator: """ - Vanilla-style ore vein generator with triangular distribution. - - Ore distributions match vanilla Minecraft 1.21.1: - - Coal: y=0 to y=320, peak at y=96 (main) + y=-64 to y=0, peak at y=-64 (underground) - - Iron: y=-64 to y=320, peak at y=16 (common) + y=80 to y=384, peak at y=232 (high) - - Gold: y=-64 to y=32, peak at y=-16 - - Diamond: y=-64 to y=16, peak at y=-64 - - Lapis: y=-64 to y=64, peak at y=0 - - Redstone: y=-64 to y=16 - - Copper: y=-16 to y=112, peak at y=48 - - Emerald: y=-16 to y=320, peak at y=256 (mountains only) + Vanilla-style ore vein generator. + + Ore batch parameters aligned with vanilla 1.21.1 worldgen configs + (ore_coal_upper/lower, ore_iron_middle/upper/small, ore_copper, + ore_gold, ore_redstone(+_lower), ore_lapis(+_buried), + ore_diamond_small/medium/large/buried): + - Coal: 30x triangle(136..320) + 20x triangle(0..192, peak 96) + - Iron: 10x triangle(-24..56, peak 16) + 10x triangle(80..384, peak 232) + + 10x uniform(-64..72) + - Copper: 16x triangle(-16..112, peak 48) + - Gold: 4x triangle(-64..32, peak -16) + - Redstone: 4x uniform(-64..15) + 8x trapezoid(-64..-32) + - Lapis: 2x triangle(-32..32, peak 0) + 4x uniform(-64..64, buried) + - Diamond: 7x triangle(-64..16) + 4x triangle(-64..16, medium) + + 2x uniform(-64..-4, buried) + 1/9 chunks large vein + - Emerald: mountain-gated in vanilla; biome gating not yet wired, + kept rare to avoid flooding non-mountain biomes + + Air exposure: veins whose seed block touches air are discarded with + the batch's probability (vanilla discard_chance_on_air_exposure). + + Known deviations from vanilla: + - emerald batch not biome-gated (kept rare instead) + - badlands extra gold / dripstone extra copper not implemented + - vein shape is a spherical random walk, not vanilla's ore blob """ def __init__(self, seed: int): self._seed = seed + @staticmethod + def _sample_trapezoid(rng, y_min: int, y_max: int) -> int: + """Vanilla trapezoid height provider: ramp up, flat middle, ramp down.""" + span = y_max - y_min + plateau = span // 3 + a = rng.random() * span + b = rng.random() * span + lo, hi = min(a, b), max(a, b) + if hi - lo >= plateau: + return int(y_min + (lo + hi) / 2) + mid = (lo + hi) / 2 + return int(y_min + max(lo, min(hi, mid))) + def place(self, blocks: list, base_x: int, base_z: int): - """Place ore veins in a chunk using vanilla triangular distribution.""" + """Place ore veins in a chunk using vanilla batch parameters.""" rng = _random.Random( self._seed ^ (base_x * 6364136223846793005 + base_z * 1442695040888963407) ) - # Ore configurations matching vanilla 1.21.1 - # (ore_id, deepslate_ore_id, attempts, vein_size, y_min, y_max, peak_y, distribution) + # (ore_id, deepslate_ore_id, attempts, vein_size, y_min, y_max, + # distribution, air_discard_chance) ore_configs = [ - # Coal - two distributions (main + underground) - (COAL_ORE, DEEPSLATE_COAL_ORE, 20, 10, 0, 320, 96, 'triangular'), - (COAL_ORE, DEEPSLATE_COAL_ORE, 10, 10, -64, 0, -64, 'triangular'), - # Iron - two distributions (common + high) - (IRON_ORE, DEEPSLATE_IRON_ORE, 20, 8, -64, 320, 16, 'triangular'), - (IRON_ORE, DEEPSLATE_IRON_ORE, 10, 4, 80, 384, 232, 'triangular'), + # Coal (size 17, air discard 0.5) + (COAL_ORE, DEEPSLATE_COAL_ORE, 30, 17, 136, 320, 'triangle', 0.5), + (COAL_ORE, DEEPSLATE_COAL_ORE, 20, 17, 0, 192, 'triangle', 0.5), + # Iron (air discard 0) + (IRON_ORE, DEEPSLATE_IRON_ORE, 10, 9, -24, 56, 'triangle', 0.0), + (IRON_ORE, DEEPSLATE_IRON_ORE, 10, 9, 80, 384, 'triangle', 0.0), + (IRON_ORE, DEEPSLATE_IRON_ORE, 10, 4, -64, 72, 'uniform', 0.0), # Copper - (COPPER_ORE, DEEPSLATE_COPPER_ORE, 16, 9, -16, 112, 48, 'triangular'), + (COPPER_ORE, DEEPSLATE_COPPER_ORE, 16, 10, -16, 112, 'triangle', 0.0), # Gold - (GOLD_ORE, DEEPSLATE_GOLD_ORE, 4, 7, -64, 32, -16, 'triangular'), + (GOLD_ORE, DEEPSLATE_GOLD_ORE, 4, 9, -64, 32, 'triangle', 0.0), # Redstone - (REDSTONE_ORE, DEEPSLATE_REDSTONE_ORE, 8, 6, -64, 16, -32, 'triangular'), - # Lapis - (LAPIS_ORE, DEEPSLATE_LAPIS_ORE, 2, 5, -64, 64, 0, 'triangular'), + (REDSTONE_ORE, DEEPSLATE_REDSTONE_ORE, 4, 8, -64, 15, 'uniform', 0.0), + (REDSTONE_ORE, DEEPSLATE_REDSTONE_ORE, 8, 8, -64, -32, 'trapezoid', 0.0), + # Lapis (buried batch never touches air) + (LAPIS_ORE, DEEPSLATE_LAPIS_ORE, 2, 7, -32, 32, 'triangle', 0.0), + (LAPIS_ORE, DEEPSLATE_LAPIS_ORE, 4, 7, -64, 64, 'uniform', 1.0), # Diamond - (DIAMOND_ORE, DEEPSLATE_DIAMOND_ORE, 2, 4, -64, 16, -64, 'triangular'), - # Emerald (mountains only - rare) - (EMERALD_ORE, DEEPSLATE_EMERALD_ORE, 1, 2, -16, 320, 256, 'triangular'), + (DIAMOND_ORE, DEEPSLATE_DIAMOND_ORE, 7, 4, -64, 16, 'triangle', 0.5), + (DIAMOND_ORE, DEEPSLATE_DIAMOND_ORE, 4, 8, -64, 16, 'triangle', 0.7), + (DIAMOND_ORE, DEEPSLATE_DIAMOND_ORE, 2, 8, -64, -4, 'uniform', 1.0), + # Emerald: vanilla gates to mountain biomes; kept rare until + # biome gating is wired into ore placement + (EMERALD_ORE, DEEPSLATE_EMERALD_ORE, 1, 3, -16, 320, 'triangle', 0.0), ] - for ore_id, deep_ore_id, attempts, vein_size, y_min, y_max, peak_y, dist in ore_configs: + # Diamond large vein: 1 attempt per 9 chunks (air discard 0.7) + if rng.randint(1, 9) == 1: + ore_configs.append( + (DIAMOND_ORE, DEEPSLATE_DIAMOND_ORE, 1, 12, -64, 16, 'triangle', 0.7)) + + for ore_id, deep_ore_id, attempts, vein_size, y_min, y_max, dist, air_discard in ore_configs: for _ in range(attempts): lx = rng.randint(0, 15) lz = rng.randint(0, 15) - if dist == 'triangular': - # Vanilla's triangular distribution: - # P(y) = triangular(y_min, y_max, peak_y) - wy = int(rng.triangular(y_min, y_max, peak_y)) + if dist == 'triangle': + # Vanilla triangle provider peaks at the midpoint + wy = int(rng.triangular(y_min, y_max, (y_min + y_max) / 2)) + elif dist == 'trapezoid': + wy = self._sample_trapezoid(rng, y_min, y_max) else: wy = rng.randint(y_min, y_max) @@ -2262,6 +2301,21 @@ def place(self, blocks: list, base_x: int, base_z: int): else: continue + # discard_chance_on_air_exposure: 矿脉贴着空气时按概率丢弃 + if air_discard > 0.0 and rng.random() < air_discard: + exposed = False + for dx, dy, dz in ((1, 0, 0), (-1, 0, 0), (0, 1, 0), + (0, -1, 0), (0, 0, 1), (0, 0, -1)): + nx2, ny2, nz2 = lx + dx, yi + dy, lz + dz + if not (0 <= nx2 < 16 and 0 <= nz2 < 16 + and 0 <= ny2 < WORLD_HEIGHT): + continue # 跨区块邻居未知, 不视为暴露 + if blocks[ny2][nz2][nx2] == AIR: + exposed = True + break + if exposed: + continue + # Place vein (spherical scatter, matching vanilla's ore vein shape) blocks[yi][lz][lx] = ore_block for _ in range(vein_size - 1):