# 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 motion(vid: int, victim_vid: int, motion: int) signal pvp_changed(src_vid: int, dst_vid: int, mode: int) signal duel_changed(duel: Dictionary) 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 get_duel() -> Dictionary: 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", "select_empire", "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_main_pid", "get_points", "get_target", "select_character", "enter_game", "reconnect", "suspend", "resume", "move_item", "use_item", "drop_item", "drop_item_count", "use_item_to_item", "give_item", "pickup_item", "get_inventory", "get_equipment", "get_belt_inventory", "get_view_equipment", "get_item", "get_ground_items", "get_pvp_relations", "get_duel", "get_lover", "get_land_areas", "get_observers", "is_observer_mode", "get_observer_count", "has_mobile_flag", "combo_skill_enabled", "get_stamina_state", "whisper", "party_invite", "party_answer", "party_leave", "party_set_distribute", "party_set_state", "get_party", "get_party_distribute_mode", "add_friend", "remove_friend", "friend_answer", "get_friends", "shop_buy", "shop_sell", "shop_close", "is_shop_open", "get_shop_items", "get_shop", "exchange_start", "exchange_add_item", "exchange_add_gold", "exchange_accept", "exchange_cancel", "get_exchange", "safebox_checkin", "safebox_checkout", "safebox_move", "is_safebox_open", "get_safebox_size", "get_safebox_gold", "get_safebox_items", "is_mall_open", "get_mall_size", "get_mall_items", "mall_checkout", "mall_password", "open_private_shop", "close_private_shop", "safebox_password", "get_cube", "cube_make", "cube_request_result_list", "cube_request_materials", "cube_open", "cube_close", "cube_list", "cube_add_item", "cube_delete_item", "get_guild", "get_guild_members", "get_guild_grades", "guild_add_member", "guild_remove_member", "guild_offer", "guild_charge_gsp", "guild_change_grade_name", "guild_change_grade_authority", "guild_change_member_grade", "guild_change_member_general", "guild_post_comment", "guild_delete_comment", "guild_refresh_comments", "guild_answer_invite", "guild_answer_make", "get_guild_comments", "get_guild_skill", "get_guild_wars", "get_guild_war", "get_guild_name", "use_guild_skill", "declare_guild_war", "download_guild_marks", "are_guild_marks_ready", "get_mark_server", "get_guild_mark", "get_guild_mark_image", "upload_guild_mark", "upload_guild_symbol", "refine", "ds_refine", "get_dragon_souls", "get_channel", "get_server_time", "get_npc_marks", "get_world_markers"]: _ck(c.has_method(m), "M2Client.%s bound" % m) for s in ["entity_spawned", "entity_despawned", "entity_moved", "entity_main_set", "empire_changed", "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", "observer_mode_changed", "observer_count_changed", "stone_detected", "stamina_changed", "mobile_flag_changed", "mobile_auth_required", "combo_changed", "world_markers_changed", "mount_changed", "pvp_changed", "duel_changed", "duel_started", "view_equipment", "lover_changed", "fly_targeting", "dig_motion", "fishing_event", "dungeon_event", "skill_group_changed", "guild_make_requested", "guild_symbol_ready", "party_changed", "party_invite_ask", "party_request_denied", "friends_changed", "friend_invite_ask", "shop_opened", "shop_closed", "shop_error", "exchange_changed", "safebox_changed", "safebox_password_required", "safebox_wrong_password", "mall_opened", "mall_changed", "mall_password_required", "private_shop_open_requested", "my_shop_price_list", "block_mode_changed", "gift_available", "cube_opened", "cube_closed", "cube_changed", "cube_result", "guild_changed", "guild_skill_changed", "guild_war_changed", "guild_comments_changed", "guild_invite_ask", "guild_war_event", "guild_war_point", "guild_marks_ready", "guild_mark_uploaded", "guild_mark_updated", "refine_ask", "refine_result", "ds_window_open", "ds_refine_result"]: _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") fc.motion.emit(2000, 1000, 325) # CRaceMotionData::NAME_DANCE_1 await process_frame _ck(int(nw.node_for(2000).get_meta("last_motion", 0)) == 325 and int(nw.node_for(2000).get_meta("motion_victim_vid", 0)) == 1000, "GC_MOTION -> entity motion metadata and bridge signal") # 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.ents[2000]["shop_sign"] = "Potion shop" fc.entity_info.emit(2000, fc.ents[2000]) await process_frame var shop_sign := mob0.get_node_or_null("ShopSign") as Label3D _ck(shop_sign != null and shop_sign.text == "Potion shop", "entity_info -> shop sign label") fc.ents[2000]["shop_sign"] = "" fc.entity_info.emit(2000, fc.ents[2000]) await process_frame _ck(mob0.get_node_or_null("ShopSign") == null, "empty shop sign removes label") 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.duel_changed.emit({"active": true, "cannot_attack": false, "opponents": [2000]}) await process_frame var duel_tag := mob0.get_node_or_null("PvpTag") as Label3D _ck(duel_tag != null and duel_tag.text == "决斗", "duel opponent -> world duel tag") fc.duel_changed.emit({"active": false, "cannot_attack": true, "opponents": []}) await process_frame _ck(mob0.get_node_or_null("PvpTag") == null, "duel cleared -> 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")