286 lines
9.0 KiB
GDScript
286 lines
9.0 KiB
GDScript
# live_smoke_test —— 真实服务器端到端冒烟测试。
|
|
#
|
|
# 由 script/live_smoke_test.sh 以 MT_TEST_MODE=smoke 启动。测试流程:
|
|
# 自动登录 → 角色列表 → 进入游戏 → 世界/属性读取 → 移动/停止 → 聊天
|
|
# → 可选重连 → 输出 JSON 报告并以 PASS/FAIL 退出。
|
|
#
|
|
# 只使用可逆的常规操作,不执行删角、丢弃、强化、交易等破坏性动作。
|
|
extends Node
|
|
|
|
const FUNC_WAIT := 0
|
|
const FUNC_MOVE := 1
|
|
const CHAT_TALKING := 0
|
|
const SETTLE_SECONDS := 2.5
|
|
const STEP_TIMEOUT_SECONDS := 25.0
|
|
const TOTAL_TIMEOUT_SECONDS := 120.0
|
|
|
|
var _flow: Node
|
|
var _client: Node
|
|
var _state := "boot"
|
|
var _state_since_ms := 0
|
|
var _started_ms := 0
|
|
var _entered_count := 0
|
|
var _phase_history: Array[String] = []
|
|
var _checks: Array[Dictionary] = []
|
|
var _failures: Array[String] = []
|
|
var _events := {
|
|
"char_list": 0,
|
|
"entered_game": 0,
|
|
"entity_moved": 0,
|
|
"chat": 0,
|
|
"disconnected": 0,
|
|
}
|
|
var _origin := Vector3.ZERO
|
|
var _origin_valid := false
|
|
var _mutations := true
|
|
var _reconnect := true
|
|
var _finished := false
|
|
var _finish_queued := false
|
|
var _report_path := ""
|
|
|
|
func setup(flow: Node, m2client: Node) -> void:
|
|
_flow = flow
|
|
_client = m2client
|
|
_started_ms = Time.get_ticks_msec()
|
|
_state_since_ms = _started_ms
|
|
_mutations = OS.get_environment("MT_SMOKE_MUTATIONS") != "0"
|
|
_reconnect = OS.get_environment("MT_SMOKE_RECONNECT") != "0"
|
|
_report_path = OS.get_environment("MT_TEST_REPORT").strip_edges()
|
|
if _report_path.is_empty():
|
|
_report_path = "user://live_smoke_report.json"
|
|
|
|
if _client == null:
|
|
_fail("M2Client 未创建")
|
|
_finish_later()
|
|
return
|
|
if _client.has_signal("stage_changed"):
|
|
_client.stage_changed.connect(_on_stage_changed)
|
|
if _client.has_signal("phase_changed"):
|
|
_client.phase_changed.connect(_on_phase_changed)
|
|
if _client.has_signal("char_list"):
|
|
_client.char_list.connect(_on_char_list)
|
|
if _client.has_signal("entered_game"):
|
|
_client.entered_game.connect(_on_entered_game)
|
|
if _client.has_signal("login_failed"):
|
|
_client.login_failed.connect(_on_login_failed)
|
|
if _client.has_signal("disconnected"):
|
|
_client.disconnected.connect(_on_disconnected)
|
|
if _client.has_signal("entity_moved"):
|
|
_client.entity_moved.connect(_on_entity_moved)
|
|
if _client.has_signal("chat"):
|
|
_client.chat.connect(_on_chat)
|
|
|
|
_state = "waiting_login"
|
|
_log("START mutations=%s reconnect=%s report=%s" % [_mutations, _reconnect, _report_path])
|
|
_check(OS.get_environment("MT_PROTOCOL") == "classic", "classic 协议已启用")
|
|
_check(OS.get_environment("MT_ACCOUNT") != "", "账号环境变量已提供")
|
|
_check(OS.get_environment("MT_PASSWORD") != "", "密码环境变量已提供")
|
|
|
|
func _process(_delta: float) -> void:
|
|
if _finished:
|
|
return
|
|
var now := Time.get_ticks_msec()
|
|
var total_s := float(now - _started_ms) / 1000.0
|
|
if total_s > TOTAL_TIMEOUT_SECONDS:
|
|
_fail("总超时,当前状态=%s" % _state)
|
|
_finish_later()
|
|
return
|
|
|
|
match _state:
|
|
"settle_first":
|
|
if _seconds_in_state(now) >= SETTLE_SECONDS:
|
|
_run_world_checks()
|
|
"wait_move":
|
|
if _seconds_in_state(now) >= 1.5:
|
|
_send_stop()
|
|
"wait_chat":
|
|
if _seconds_in_state(now) >= 0.5:
|
|
_send_chat()
|
|
"wait_reconnect":
|
|
if _seconds_in_state(now) >= STEP_TIMEOUT_SECONDS:
|
|
_fail("重连后未重新进入游戏")
|
|
_finish_later()
|
|
"settle_second":
|
|
if _seconds_in_state(now) >= SETTLE_SECONDS:
|
|
_check(_client != null and _client.is_in_game(), "重连后客户端仍处于游戏状态")
|
|
var vid := int(_client.get_main_vid())
|
|
_check(vid != 0 and not _client.get_entity(vid).is_empty(), "重连后主角重新建立")
|
|
_check(not _client.get_points().is_empty() and _has_method_and_array("get_skills")
|
|
and _has_method_and_array("get_quickslots"), "重连后属性/技能/快捷栏可读取")
|
|
var scene: Node = _flow.get("_game")
|
|
_check(is_instance_valid(scene) and bool(scene.get("_model_built")), "重连后真实角色模型建立")
|
|
_finish_later()
|
|
"waiting_login", "waiting_first_game":
|
|
if _seconds_in_state(now) >= STEP_TIMEOUT_SECONDS:
|
|
_fail("阶段超时:%s" % _state)
|
|
_finish_later()
|
|
|
|
func _seconds_in_state(now: int) -> float:
|
|
return float(now - _state_since_ms) / 1000.0
|
|
|
|
func _set_state(next_state: String) -> void:
|
|
_state = next_state
|
|
_state_since_ms = Time.get_ticks_msec()
|
|
_log("STATE %s" % _state)
|
|
|
|
func _on_stage_changed(stage: String) -> void:
|
|
_log("stage=%s" % stage)
|
|
|
|
func _on_phase_changed(phase: String) -> void:
|
|
_phase_history.append(phase)
|
|
_log("phase=%s" % phase)
|
|
|
|
func _on_char_list(characters: Array) -> void:
|
|
_events["char_list"] += 1
|
|
_check(not characters.is_empty(), "角色列表非空")
|
|
if characters.is_empty():
|
|
_fail("服务器返回空角色列表")
|
|
_finish_later()
|
|
else:
|
|
_log("char_list count=%d" % characters.size())
|
|
|
|
func _on_entered_game() -> void:
|
|
_entered_count += 1
|
|
_events["entered_game"] = _entered_count
|
|
_check(true, "第 %d 次进入游戏" % _entered_count)
|
|
if _entered_count == 1:
|
|
_set_state("settle_first")
|
|
elif _state == "wait_reconnect":
|
|
_set_state("settle_second")
|
|
|
|
func _on_login_failed(reason: String) -> void:
|
|
_fail("登录失败:%s" % reason)
|
|
_finish_later()
|
|
|
|
func _on_disconnected(reason: String) -> void:
|
|
_events["disconnected"] += 1
|
|
_log("disconnected=%s" % reason)
|
|
if _state != "wait_reconnect" and _state != "settle_second":
|
|
_fail("测试期间意外断线:%s" % reason)
|
|
_finish_later()
|
|
|
|
func _on_entity_moved(_vid: int) -> void:
|
|
_events["entity_moved"] += 1
|
|
|
|
func _on_chat(_type: int, _vid: int, _text: String) -> void:
|
|
_events["chat"] += 1
|
|
|
|
func _run_world_checks() -> void:
|
|
var in_game: bool = _client != null and _client.has_method("is_in_game") and _client.is_in_game()
|
|
_check(in_game, "客户端处于 in_game")
|
|
var main_vid := int(_client.get_main_vid()) if _client.has_method("get_main_vid") else 0
|
|
_check(main_vid != 0, "主角色 VID 有效")
|
|
if main_vid == 0:
|
|
_fail("主角色不存在,无法继续常规测试")
|
|
_finish_later()
|
|
return
|
|
|
|
var main_entity: Dictionary = _client.get_entity(main_vid)
|
|
_check(not main_entity.is_empty(), "主角色实体可读取")
|
|
var entities: Array = _client.get_entities()
|
|
_check(not entities.is_empty(), "世界实体列表可读取")
|
|
var points: Dictionary = _client.get_points()
|
|
_check(not points.is_empty(), "属性数据可读取")
|
|
_check(_has_method_and_array("get_inventory"), "背包接口可读取")
|
|
_check(_has_method_and_array("get_skills"), "技能接口可读取")
|
|
_check(_has_method_and_array("get_quickslots"), "快捷栏接口可读取")
|
|
_check(_has_method_and_array("get_affects"), "状态接口可读取")
|
|
|
|
var p: Variant = main_entity.get("pos_cm", null)
|
|
if p is Vector3:
|
|
_origin = p
|
|
_origin_valid = true
|
|
_log("main_vid=%d pos_cm=%s entities=%d" % [main_vid, p, entities.size()])
|
|
else:
|
|
_fail("主角色缺少 pos_cm")
|
|
|
|
if not _mutations:
|
|
_start_reconnect()
|
|
return
|
|
_send_move()
|
|
|
|
func _has_method_and_array(method_name: String) -> bool:
|
|
if _client == null or not _client.has_method(method_name):
|
|
return false
|
|
var value: Variant = _client.call(method_name)
|
|
return value is Array
|
|
|
|
func _send_move() -> void:
|
|
if not _origin_valid:
|
|
_finish_later()
|
|
return
|
|
var ok := bool(_client.move(FUNC_MOVE, 0, 0.0, int(_origin.x) + 100, int(_origin.y)))
|
|
_check(ok, "发送 1 米移动包")
|
|
_set_state("wait_move")
|
|
|
|
func _send_stop() -> void:
|
|
var ok := bool(_client.move(FUNC_WAIT, 0, 0.0, int(_origin.x), int(_origin.y)))
|
|
_check(ok, "发送停止移动包")
|
|
_set_state("wait_chat")
|
|
|
|
func _send_chat() -> void:
|
|
var ok := bool(_client.say(CHAT_TALKING, "[smoke] client live smoke test"))
|
|
_check(ok, "发送普通聊天包")
|
|
_start_reconnect()
|
|
|
|
func _start_reconnect() -> void:
|
|
if not _reconnect:
|
|
_finish_later()
|
|
return
|
|
var reconnect_ok := bool(_client.reconnect())
|
|
_check(reconnect_ok, "调用客户端重连")
|
|
if reconnect_ok:
|
|
_set_state("wait_reconnect")
|
|
else:
|
|
_finish_later()
|
|
|
|
func _check(ok: bool, name: String) -> void:
|
|
_checks.append({"name": name, "ok": ok})
|
|
if ok:
|
|
print("LIVE_SMOKE PASS: " + name)
|
|
else:
|
|
_fail(name)
|
|
|
|
func _fail(message: String) -> void:
|
|
if not _failures.has(message):
|
|
_failures.append(message)
|
|
printerr("LIVE_SMOKE FAIL: " + message)
|
|
|
|
func _log(message: String) -> void:
|
|
print("LIVE_SMOKE: " + message)
|
|
|
|
func _finish_later() -> void:
|
|
if _finish_queued or _finished:
|
|
return
|
|
_finish_queued = true
|
|
call_deferred("_finish")
|
|
|
|
func _finish() -> void:
|
|
if _finished:
|
|
return
|
|
_finished = true
|
|
var passed := _failures.is_empty()
|
|
var report := {
|
|
"status": "PASS" if passed else "FAIL",
|
|
"started_at_ms": _started_ms,
|
|
"duration_seconds": float(Time.get_ticks_msec() - _started_ms) / 1000.0,
|
|
"entered_game_count": _entered_count,
|
|
"phase_history": _phase_history,
|
|
"events": _events,
|
|
"checks": _checks,
|
|
"failures": _failures,
|
|
"mutations": _mutations,
|
|
"reconnect": _reconnect,
|
|
}
|
|
var file := FileAccess.open(_report_path, FileAccess.WRITE)
|
|
if file:
|
|
file.store_string(JSON.stringify(report, " "))
|
|
file.close()
|
|
else:
|
|
printerr("LIVE_SMOKE: 无法写入报告:" + _report_path)
|
|
if passed:
|
|
print("LIVE_SMOKE RESULT: PASS")
|
|
else:
|
|
printerr("LIVE_SMOKE RESULT: FAIL (%d failures)" % _failures.size())
|
|
get_tree().quit(0 if passed else 1)
|