Files
mtgodot-poc/project/netbridge_test.gd
T

259 lines
14 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# netbridge_test —— M2Client 游戏内 API + NetWorld 场景桥的 headless 自检。
# godot --headless --path project --script netbridge_test.gd
# 无需服务器:用假 client 驱动 NetWorldM2Client 部分只查绑定面 + 未入局时拒绝。
# 退出码 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", "send_emoticon",
"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", "emoticon_requested", "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")
# §3.2:位置插值移交 C++ EntityStore::tick()net_world._process 直接跟随 e.pos
# 不再叠自己的 follow_lerp。这里 FakeClient 的 pos 是瞬时值,节点应当直接对齐。
fc.move_to(2000, Vector3(11, 0, 41))
await process_frame
_ck(mob.position.distance_to(Vector3(11, 0, -41)) < 0.01, "move -> node follows e.pos directly")
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")