# netbridge_test —— M2Client 游戏内 API + NetWorld 场景桥的 headless 自检。 # godot --headless --path project --script netbridge_test.gd # 无需服务器:用假 client 驱动 NetWorld;M2Client 部分只查绑定面 + 未入局时拒绝。 # 退出码 0 = 全过。 extends SceneTree const NetWorld = preload("res://net_world.gd") class FakeClient extends Node: signal entity_spawned(entity: Dictionary) signal entity_despawned(vid: int) signal entity_moved(vid: int) signal entity_main_set(vid: int) signal entity_info(vid: int, entity: Dictionary) signal chat(type: int, vid: int, text: String) signal fly_cue(type: int, start_vid: int, end_vid: int) signal fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, append: bool) signal fishing_event(subheader: int, info: int, dir: int) signal vitals_changed(vid: int) signal entity_dead(vid: int) signal damage(vid: int, amount: int, flag: int) signal pvp_changed(src_vid: int, dst_vid: int, mode: int) var ents := {} func get_entity(vid: int) -> Dictionary: return ents.get(vid, {}) func get_entities() -> Array: return ents.values() func get_pvp_relations() -> Array: return [] func spawn(vid, nm, pos, is_main := false): ents[vid] = {"vid": vid, "name": nm, "pos": pos, "is_main": is_main, "func": 0, "moving": false, "angle_deg": 0.0, "hp": 100, "max_hp": 100, "dead": false} if is_main: entity_main_set.emit(vid) entity_spawned.emit(ents[vid]) func set_info(vid, nm): ents[vid]["name"] = nm entity_info.emit(vid, ents[vid]) func move_to(vid, pos, f := 1): ents[vid].merge({"pos": pos, "func": f, "moving": true}, true) entity_moved.emit(vid) func hurt(vid, hp, amount, flag := 1): ents[vid]["hp"] = hp ents[vid]["dead"] = hp <= 0 damage.emit(vid, amount, flag) vitals_changed.emit(vid) if hp <= 0: entity_dead.emit(vid) func despawn(vid): ents.erase(vid); entity_despawned.emit(vid) func set_fly_target(shooter_vid, target_vid, target_cm, append := false): fly_targeting.emit(shooter_vid, target_vid, target_cm, append) var _fail := 0 func _ck(cond: bool, msg: String) -> void: if not cond: _fail += 1 push_error("FAIL: " + msg) printerr("FAIL: " + msg) func _init() -> void: _test_m2client_surface() await _test_netbridge() if _fail == 0: print("PASS: netbridge_test (M2Client API + NetWorld bridge)") quit(0) else: printerr("%d check(s) failed" % _fail) quit(1) func _test_m2client_surface() -> void: if not ClassDB.class_exists("M2Client"): _ck(false, "M2Client not registered (extension built?)") return var c: Object = ClassDB.instantiate("M2Client") get_root().add_child(c) for m in ["move", "character_position", "sync_positions", "request_warp", "fishing", "request_dungeon", "attack", "set_target", "say", "shoot", "add_fly_targeting", "change_name", "download_guild_symbol", "get_guild_symbol", "script_select_item", "quest_cancel", "party_use_skill", "get_skill_group", "get_entity", "get_entities", "get_main_vid", "get_points", "get_target", "select_character", "reconnect", "suspend", "resume", "move_item", "use_item", "drop_item", "pickup_item", "get_inventory", "get_equipment", "get_item", "get_ground_items", "get_pvp_relations", "get_lover", "get_land_areas", "get_observers", "whisper"]: _ck(c.has_method(m), "M2Client.%s bound" % m) for s in ["entity_spawned", "entity_despawned", "entity_moved", "entity_main_set", "chat", "suspended", "resumed", "vitals_changed", "entity_dead", "damage", "motion", "points_changed", "target_info", "inventory_changed", "ground_item_added", "ground_item_removed", "item_picked_up", "item_used", "whisper_received", "entity_info", "warp", "time_changed", "channel_changed", "npc_marks_changed", "land_areas_changed", "observer_event", "world_markers_changed", "mount_changed", "pvp_changed", "duel_started", "view_equipment", "lover_changed", "fly_targeting", "dig_motion", "fishing_event", "dungeon_event", "skill_group_changed", "guild_make_requested", "guild_symbol_ready"]: _ck(c.has_signal(s), "M2Client.%s signal" % s) # not in game -> intents refused, snapshot empty _ck(c.call("move", 1, 0, 90.0, 1000, 2000) == false, "move refused pre-game") _ck(c.call("character_position", 3) == false, "character_position refused pre-game") _ck(c.call("sync_positions", [{"vid": 1, "x": 10, "y": 20}]) == false, "sync_positions refused pre-game") _ck(c.call("request_warp") == false, "request_warp refused pre-game") _ck(c.call("fishing", 90.0) == false, "fishing refused pre-game") _ck(c.call("request_dungeon") == false, "request_dungeon refused pre-game") _ck(c.call("shoot", 1) == false, "shoot refused pre-game") _ck(c.call("script_select_item", 1) == false, "script_select_item refused pre-game") _ck(c.call("quest_cancel") == false, "quest_cancel refused pre-game") _ck(c.call("party_use_skill", 1, 1) == false, "party_use_skill refused pre-game") _ck(c.call("change_name", 0, "Renamed") == false, "change_name refused before connection") _ck(c.call("get_skill_group") == 0, "skill group defaults to 0 pre-game") _ck(c.call("attack", 0, 1) == false, "attack refused pre-game") _ck(c.call("get_entities").is_empty(), "no entities pre-game") _ck(c.call("get_main_vid") == 0, "no main vid pre-game") _ck(c.call("get_points").is_empty(), "no points pre-game") _ck(c.call("get_target").is_empty(), "no target pre-game") _ck(c.call("get_inventory").is_empty(), "no inventory pre-game") _ck(c.call("move_item", 1, 0, 2, 1, 1) == false, "move_item refused pre-game") c.queue_free() _test_proto() func _test_proto() -> void: _ck(ClassDB.class_exists("Metin2Proto"), "Metin2Proto registered") if not ClassDB.class_exists("Metin2Proto"): return var p: Object = ClassDB.instantiate("Metin2Proto") get_root().add_child(p) var root := AssetRoot.sub("locale/locale/en") if not FileAccess.file_exists(root + "/item_proto"): print(" (skip Metin2Proto load: no item_proto)") p.queue_free() return _ck(p.call("load_item_proto", root + "/item_proto"), "load_item_proto: %s" % p.call("get_last_error")) _ck(p.call("item_count") > 1000, "item_count > 1000 (%d)" % p.call("item_count")) var yang: Dictionary = p.call("item", 1) _ck(yang.get("name", "") == "Yang", "item(1).name == Yang") _ck(p.call("item", 999999999).is_empty(), "unknown vnum -> {}") p.queue_free() func _test_netbridge() -> void: var mount := Node3D.new() get_root().add_child(mount) var fc := FakeClient.new() get_root().add_child(fc) var nw: Node = NetWorld.new() get_root().add_child(nw) nw.setup(fc, mount) var added := [0] var removed := [0] var main_node := [null] nw.entity_added.connect(func(_n, _v): added[0] += 1) nw.entity_removed.connect(func(_v): removed[0] += 1) nw.main_entity_ready.connect(func(n, _v): main_node[0] = n) fc.spawn(1000, "Me", Vector3.ZERO, true) fc.spawn(2000, "", Vector3(10, 0, 0)) # 名字晚点走 entity_info await process_frame _ck(added[0] == 2, "2 entity nodes added") _ck(mount.get_child_count() == 2, "2 children under mount") _ck(main_node[0] != null and int(main_node[0].get_meta("vid")) == 1000, "main entity surfaced") fc.set_fly_target(2000, 1000, Vector2(120, 340)) await process_frame _ck(int(nw.node_for(2000).get_meta("fly_target_vid", 0)) == 1000, "fly_targeting -> shooter target metadata") var projectile_children := mount.get_child_count() fc.fly_cue.emit(1, 2000, 0) # coordinate/queued target fallback _ck(mount.get_child_count() == projectile_children + 1, "create_fly -> consumes queued target when end VID is absent") fc.fishing_event.emit(0, 2000, 18) await process_frame _ck(int(nw.node_for(2000).get_meta("fishing_subheader", -1)) == 0 and int(nw.node_for(2000).get_meta("fishing_dir", -1)) == 18, "fishing start -> entity fishing metadata") fc.fishing_event.emit(5, 30001, 0) # item vnum, not a fisher VID await process_frame _ck(not nw.node_for(2000).has_meta("fishing_item_vnum"), "fishing fish outcome is not treated as entity motion") # GC_CHAR_ADD_INFO late name -> 3D label refreshes (name_resolver optional) var mob0: Node3D = nw.node_for(2000) _ck(String((mob0.get_node("Label3D") as Label3D).text).begins_with("vid "), "placeholder label starts as 'vid N' (no name yet)") nw.name_resolver = func(d): return "Angry " + String(d.get("name", "")) fc.set_info(2000, "Mob") await process_frame _ck((mob0.get_node("Label3D") as Label3D).text == "Angry Mob", "entity_info -> label refreshed via name_resolver") fc.pvp_changed.emit(1000, 2000, 2) await process_frame var pvp_tag := mob0.get_node_or_null("PvpTag") as Label3D _ck(pvp_tag != null and pvp_tag.text == "PVP", "pvp fight -> world PVP tag") fc.pvp_changed.emit(1000, 2000, 0) await process_frame _ck(mob0.get_node_or_null("PvpTag") == null, "pvp none -> removes world tag") fc.move_to(2000, Vector3(10, 0, 40)) for i in 3: await process_frame var mob: Node3D = nw.node_for(2000) _ck(mob.position.distance_to(Vector3(10, 0, -40)) < 0.5, "far move -> snap") # to_world 翻 Z _ck(int(mob.get_meta("func")) == 1, "func -> FUNC_MOVE") fc.move_to(2000, Vector3(11, 0, 41)) await process_frame var d1 := mob.position.distance_to(Vector3(11, 0, -41)) for i in 20: await process_frame _ck(mob.position.distance_to(Vector3(11, 0, -41)) < d1, "near move -> lerp closes in") var got_chat := [""] nw.chat_line.connect(func(_t, _v, s): got_chat[0] = s) fc.chat.emit(1, 2000, "hi there") await process_frame _ck(got_chat[0] == "hi there", "chat line relayed") # combat: damage number + vitals + death var dmg := [0, false] var vit := [0, 0, false] nw.damage_number.connect(func(_v, amt, crit, _dodge): dmg[0] = amt; dmg[1] = crit) nw.vitals.connect(func(_v, hp, mhp, dead): vit[0] = hp; vit[1] = mhp; vit[2] = dead) fc.hurt(2000, 60, 40) await process_frame _ck(dmg[0] == 40, "damage_number relayed (40)") _ck(vit[0] == 60 and vit[1] == 100 and not vit[2], "vitals relayed (60/100)") var bar := mob.get_node_or_null("HpBar") _ck(bar != null and absf(bar.scale.x - 0.6) < 0.01, "HP bar scaled to 0.6") fc.hurt(2000, 0, 60, 1 << 5) # crit, lethal await process_frame _ck(dmg[1] == true, "crit flag relayed") _ck(vit[2] == true, "vitals dead=true") fc.despawn(2000) await process_frame await process_frame _ck(removed[0] == 1 and nw.node_for(2000) == null, "despawn frees node")