259 lines
14 KiB
GDScript
259 lines
14 KiB
GDScript
# 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", "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")
|