374 lines
14 KiB
GDScript
374 lines
14 KiB
GDScript
extends Node
|
||
|
||
## 首个 Mac 内测的真实联网闭环。
|
||
##
|
||
## 这个脚本只负责调度生产入口并观察服务端结果:移动仍经 PlayerController,
|
||
## 目标仍经 NetPlay,拾取仍经 NetPlay.pick_ground_item,技能入口由后续 CBT
|
||
## 用例接入 Quickbar。没有 `MT_PLAYABLE_ALLOW_GAMEPLAY=1` 时只做登录/进场
|
||
## 观察并把后续必测项标记为 BLOCKED,不会误发游戏操作。
|
||
|
||
const Config = preload("res://testing/playable_config.gd")
|
||
const Report = preload("res://testing/playable_report.gd")
|
||
const Probe = preload("res://testing/playable_probe.gd")
|
||
|
||
const WAIT_WORLD_SECONDS := 60.0
|
||
const MOVE_SECONDS := 20.0
|
||
const TARGET_SECONDS := 30.0
|
||
const ATTACK_SECONDS := 90.0
|
||
const DROP_SECONDS := 30.0
|
||
const PICKUP_SECONDS := 15.0
|
||
|
||
var _flow: Node
|
||
var _client: Node
|
||
var _config: Dictionary
|
||
var _report: RefCounted
|
||
var _probe: Node
|
||
var _report_path := ""
|
||
var _events_path := ""
|
||
var _state := "boot"
|
||
var _state_since := 0.0
|
||
var _started := 0.0
|
||
var _finished := false
|
||
var _allow_gameplay := false
|
||
var _entered_count := 0
|
||
var _main_vid := 0
|
||
var _server_move_seen := false
|
||
var _waypoint_index := 0
|
||
var _expected_position := Vector3.ZERO
|
||
var _target_vid := 0
|
||
var _target_initial_hp := -1
|
||
var _target_damaged := false
|
||
var _target_dead := false
|
||
var _drop_vid := 0
|
||
var _drop_vnum := 0
|
||
var _pickup_before := 0
|
||
var _cases := {}
|
||
|
||
func setup(flow: Node, m2client: Node, config_path := "") -> void:
|
||
_flow = flow
|
||
_client = m2client
|
||
_started = Time.get_ticks_msec() / 1000.0
|
||
_state_since = _started
|
||
_allow_gameplay = OS.get_environment("MT_PLAYABLE_ALLOW_GAMEPLAY") == "1"
|
||
_report_path = OS.get_environment("MT_TEST_REPORT").strip_edges()
|
||
if _report_path.is_empty():
|
||
_report_path = "user://playable-client-report.json"
|
||
_events_path = OS.get_environment("MT_TEST_EVENTS").strip_edges()
|
||
if _events_path.is_empty():
|
||
_events_path = _report_path.get_base_dir().path_join("events.jsonl")
|
||
var path := config_path if not config_path.is_empty() else OS.get_environment("MT_PLAYABLE_CONFIG")
|
||
var loaded := Config.load_file(path)
|
||
_report = Report.new()
|
||
_report.begin(OS.get_environment("MT_PLAYABLE_RUN_ID"), "playable", loaded.config if loaded.ok else {})
|
||
if not loaded.ok:
|
||
_case("CONFIG-01", "FAIL", "invalid configuration: %s" % "; ".join(loaded.errors))
|
||
_finish_later()
|
||
return
|
||
_config = loaded.config
|
||
_case("CONFIG-01", "PASS", "validated")
|
||
if _client == null:
|
||
_case("NET-BOOT-01", "FAIL", "M2Client was not created")
|
||
_finish_later()
|
||
return
|
||
_probe = Probe.new()
|
||
_probe.name = "PlayableProbe"
|
||
add_child(_probe)
|
||
_probe.setup(_flow, _client)
|
||
_probe.observed.connect(_on_observed)
|
||
_bind("char_list", Callable(self, "_on_char_list"))
|
||
_bind("entered_game", Callable(self, "_on_entered_game"))
|
||
_bind("disconnected", Callable(self, "_on_disconnected"))
|
||
_bind("entity_moved", Callable(self, "_on_entity_moved"))
|
||
_bind("damage", Callable(self, "_on_damage"))
|
||
_bind("entity_dead", Callable(self, "_on_entity_dead"))
|
||
_bind("ground_item_added", Callable(self, "_on_ground_item_added"))
|
||
_bind("ground_item_removed", Callable(self, "_on_ground_item_removed"))
|
||
_bind("item_picked_up", Callable(self, "_on_item_picked_up"))
|
||
_bind("inventory_changed", Callable(self, "_on_inventory_changed"))
|
||
_set_state("WAIT_LOGIN")
|
||
_record("harness_started", {"allow_gameplay": _allow_gameplay})
|
||
|
||
func _process(_delta: float) -> void:
|
||
if _finished:
|
||
return
|
||
var now := Time.get_ticks_msec() / 1000.0
|
||
if now - _started > float(_config.get("timeout_seconds", 120)):
|
||
_fail_current("overall timeout in state %s" % _state)
|
||
_finish_later()
|
||
return
|
||
match _state:
|
||
"WAIT_LOGIN":
|
||
if _entered_count > 0:
|
||
_set_state("WAIT_WORLD")
|
||
elif now - _state_since > 30.0:
|
||
_fail_case("NET-LOGIN-01", "login/character selection timed out")
|
||
_finish_later()
|
||
"WAIT_WORLD":
|
||
var snap: Dictionary = _flow.get_playable_snapshot() if _flow and _flow.has_method("get_playable_snapshot") else {}
|
||
if bool(snap.get("scene_ready", false)):
|
||
_case("NET-LOGIN-01", "PASS", "character list received")
|
||
_case("NET-SELECT-01", "PASS", "entered_game received")
|
||
_case("NET-WORLD-01", "PASS", "game scene and map are ready")
|
||
_main_vid = int(_client.get_main_vid()) if _client.has_method("get_main_vid") else 0
|
||
if _main_vid <= 0:
|
||
_fail_case("NET-WORLD-02", "main VID is invalid")
|
||
_finish_later()
|
||
elif not _allow_gameplay:
|
||
_case("GAMEPLAY-ALLOW-01", "BLOCKED", "--allow-gameplay was not supplied")
|
||
_finish_later()
|
||
else:
|
||
_begin_move()
|
||
elif now - _state_since > WAIT_WORLD_SECONDS:
|
||
_fail_case("NET-WORLD-01", "scene did not become ready")
|
||
_finish_later()
|
||
"MOVE":
|
||
if _move_reached():
|
||
if _server_move_seen:
|
||
_case("NET-MOVE-%02d" % (_waypoint_index + 1), "PASS", "server entity_moved observed")
|
||
_waypoint_index += 1
|
||
if _waypoint_index >= _config.waypoints_cm.size():
|
||
_begin_target()
|
||
else:
|
||
_send_waypoint()
|
||
else:
|
||
_block_current("local arrival without server movement evidence")
|
||
_finish_later()
|
||
elif now - _state_since > MOVE_SECONDS:
|
||
_fail_current("waypoint not reached")
|
||
_finish_later()
|
||
"TARGET":
|
||
if _target_vid > 0:
|
||
_case("NET-TARGET-01", "PASS", "allowed live monster selected")
|
||
_begin_attack()
|
||
elif now - _state_since > TARGET_SECONDS:
|
||
_fail_case("NET-TARGET-01", "no allowed live monster found")
|
||
_finish_later()
|
||
"ATTACK":
|
||
if _target_dead or (_target_damaged and _target_hp_decreased()):
|
||
if _client.has_method("get_entity") and not _client.get_entity(_target_vid).is_empty():
|
||
_case("NET-ATTACK-01", "PASS", "server damage/death evidence observed")
|
||
else:
|
||
_block_current("target result arrived after entity removal")
|
||
_stop_attack()
|
||
_set_state("DROP")
|
||
elif now - _state_since > ATTACK_SECONDS:
|
||
_stop_attack()
|
||
_fail_case("NET-ATTACK-01", "no server damage or death result")
|
||
_finish_later()
|
||
"DROP":
|
||
if _drop_vid > 0:
|
||
_case("NET-DROP-01", "PASS", "allowed ground item added by server")
|
||
_begin_pickup()
|
||
elif now - _state_since > DROP_SECONDS:
|
||
_block_current("deterministic drop fixture was not observed")
|
||
_finish_later()
|
||
"PICKUP":
|
||
if _pickup_succeeded():
|
||
_case("NET-PICKUP-01", "PASS", "item_picked_up and inventory delta observed")
|
||
_case("NET-EXIT-01", "PASS", "client test state completed")
|
||
_finish_later()
|
||
elif now - _state_since > PICKUP_SECONDS:
|
||
_fail_case("NET-PICKUP-01", "pickup result did not include inventory delta")
|
||
_finish_later()
|
||
|
||
func _bind(signal_name: String, callback: Callable) -> void:
|
||
if _client.has_signal(signal_name) and not _client.is_connected(signal_name, callback):
|
||
_client.connect(signal_name, callback)
|
||
|
||
func _set_state(next: String) -> void:
|
||
_state = next
|
||
_state_since = Time.get_ticks_msec() / 1000.0
|
||
_record("state", {"name": next})
|
||
|
||
func _on_observed(kind: String, payload: Dictionary) -> void:
|
||
_record(kind, payload)
|
||
|
||
func _record(kind: String, payload := {}) -> void:
|
||
if _report == null:
|
||
return
|
||
var data: Dictionary = payload.duplicate(true)
|
||
data["monotonic_us"] = Time.get_ticks_usec()
|
||
data["run_id"] = OS.get_environment("MT_PLAYABLE_RUN_ID")
|
||
data["case_id"] = _state
|
||
_report.add_event(data, _events_path)
|
||
|
||
func _on_char_list(characters: Array) -> void:
|
||
_record("char_list", {"count": characters.size()})
|
||
var slot := int(_config.get("character_slot", -1))
|
||
var found := false
|
||
for character in characters:
|
||
if int(character.get("index", -1)) == slot:
|
||
found = true
|
||
break
|
||
if not found:
|
||
_fail_case("NET-LOGIN-01", "configured character slot is absent")
|
||
_finish_later()
|
||
|
||
func _on_entered_game() -> void:
|
||
_entered_count += 1
|
||
_record("entered_game", {"count": _entered_count})
|
||
|
||
func _on_disconnected(reason: String) -> void:
|
||
_record("disconnected", {"reason": reason})
|
||
if _state != "WAIT_LOGIN" and _state != "WAIT_WORLD":
|
||
_fail_current("unexpected disconnect: %s" % reason)
|
||
_finish_later()
|
||
|
||
func _on_entity_moved(vid: int) -> void:
|
||
if vid == _main_vid:
|
||
_server_move_seen = true
|
||
_record("main_entity_moved", {"vid": vid})
|
||
|
||
func _on_damage(vid: int, amount: int, flag: int) -> void:
|
||
if vid == _target_vid and amount > 0:
|
||
_target_damaged = true
|
||
_record("target_damage", {"vid": vid, "amount": amount, "flag": flag})
|
||
|
||
func _on_entity_dead(vid: int) -> void:
|
||
if vid == _target_vid:
|
||
_target_dead = true
|
||
_record("target_dead", {"vid": vid})
|
||
|
||
func _on_ground_item_added(item: Dictionary) -> void:
|
||
var vnum := int(item.get("vnum", 0))
|
||
if _state == "DROP" and vnum in _config.get("allowed_drop_vnums", []):
|
||
_drop_vid = int(item.get("vid", 0))
|
||
_drop_vnum = vnum
|
||
_record("allowed_drop", {"vid": _drop_vid, "vnum": vnum})
|
||
|
||
func _on_ground_item_removed(vid: int) -> void:
|
||
_record("ground_item_removed", {"vid": vid})
|
||
|
||
func _on_item_picked_up(vnum: int, count: int, source: String) -> void:
|
||
if _state == "PICKUP" and vnum == _drop_vnum:
|
||
_record("item_picked_up", {"vnum": vnum, "count": count, "source": source})
|
||
|
||
func _on_inventory_changed(window: int, cell: int) -> void:
|
||
_record("inventory_changed", {"window": window, "cell": cell})
|
||
|
||
func _begin_move() -> void:
|
||
_waypoint_index = 0
|
||
_send_waypoint()
|
||
|
||
func _send_waypoint() -> void:
|
||
var point: Array = _config.waypoints_cm[_waypoint_index]
|
||
var net := Vector3(float(point[0]) * 0.01, 0.0, -float(point[1]) * 0.01)
|
||
_expected_position = MapCoord.to_world(net)
|
||
var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {}
|
||
var controller: Node = context.get("pc", null)
|
||
_server_move_seen = false
|
||
if controller == null or not controller.has_method("walk_to"):
|
||
_fail_case("NET-MOVE-%02d" % (_waypoint_index + 1), "PlayerController.walk_to is unavailable")
|
||
_finish_later()
|
||
return
|
||
controller.walk_to(_expected_position)
|
||
_set_state("MOVE")
|
||
_record("move_requested", {"waypoint_index": _waypoint_index, "x_cm": point[0], "y_cm": point[1]})
|
||
|
||
func _move_reached() -> bool:
|
||
var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {}
|
||
var player: Node3D = context.get("player", null)
|
||
return is_instance_valid(player) and player.global_position.distance_to(_expected_position) <= 1.0
|
||
|
||
func _begin_target() -> void:
|
||
_target_vid = 0
|
||
var allowed: Array = _config.get("allowed_mob_vnums", [])
|
||
var best_distance := INF
|
||
var player_pos := Vector3.ZERO
|
||
var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {}
|
||
var player: Node3D = context.get("player", null)
|
||
if is_instance_valid(player):
|
||
player_pos = player.global_position
|
||
for entity in _client.get_entities():
|
||
var race := int(entity.get("race", 0))
|
||
var kind := int(entity.get("kind", entity.get("ch_type", -1)))
|
||
if race not in allowed or bool(entity.get("dead", false)) or kind != 2:
|
||
continue
|
||
var pos: Variant = entity.get("pos", null)
|
||
var distance := 0.0
|
||
if pos is Vector3:
|
||
distance = player_pos.distance_to(MapCoord.to_world(pos))
|
||
if distance < best_distance:
|
||
best_distance = distance
|
||
_target_vid = int(entity.get("vid", 0))
|
||
if _target_vid > 0:
|
||
var net_play: Node = context.get("net_play", null)
|
||
if net_play == null or not net_play.has_method("select_target") or not net_play.select_target(_target_vid):
|
||
_target_vid = 0
|
||
_set_state("TARGET")
|
||
|
||
func _begin_attack() -> void:
|
||
var entity: Dictionary = _client.get_entity(_target_vid)
|
||
_target_initial_hp = int(entity.get("hp", -1))
|
||
_target_damaged = false
|
||
_target_dead = false
|
||
var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {}
|
||
var net_play: Node = context.get("net_play", null)
|
||
if net_play == null or not net_play.has_method("set_attack_key"):
|
||
_fail_case("NET-ATTACK-01", "NetPlay.set_attack_key is unavailable")
|
||
_finish_later()
|
||
return
|
||
net_play.set_attack_key(true)
|
||
_set_state("ATTACK")
|
||
|
||
func _stop_attack() -> void:
|
||
var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {}
|
||
var net_play: Node = context.get("net_play", null)
|
||
if net_play and net_play.has_method("set_attack_key"):
|
||
net_play.set_attack_key(false)
|
||
|
||
func _target_hp_decreased() -> bool:
|
||
if not _client.has_method("get_entity"):
|
||
return false
|
||
var e: Dictionary = _client.get_entity(_target_vid)
|
||
return not e.is_empty() and _target_initial_hp > 0 and int(e.get("hp", _target_initial_hp)) < _target_initial_hp
|
||
|
||
func _begin_pickup() -> void:
|
||
_pickup_before = _inventory_count(_drop_vnum)
|
||
var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {}
|
||
var net_play: Node = context.get("net_play", null)
|
||
if net_play == null or not net_play.has_method("pick_ground_item"):
|
||
_fail_case("NET-PICKUP-01", "NetPlay.pick_ground_item is unavailable")
|
||
_finish_later()
|
||
return
|
||
net_play.pick_ground_item(_drop_vid)
|
||
_set_state("PICKUP")
|
||
|
||
func _inventory_count(vnum: int) -> int:
|
||
if not _client.has_method("get_inventory"):
|
||
return -1
|
||
var total := 0
|
||
for item in _client.get_inventory():
|
||
if int(item.get("vnum", 0)) == vnum:
|
||
total += int(item.get("count", 0))
|
||
return total
|
||
|
||
func _pickup_succeeded() -> bool:
|
||
return _drop_vnum > 0 and _inventory_count(_drop_vnum) > _pickup_before
|
||
|
||
func _case(case_id: String, status: String, reason: String) -> void:
|
||
if _cases.has(case_id):
|
||
return
|
||
_cases[case_id] = true
|
||
_report.add_case(case_id, status, reason)
|
||
|
||
func _fail_case(case_id: String, reason: String) -> void:
|
||
_case(case_id, "FAIL", reason)
|
||
|
||
func _block_current(reason: String) -> void:
|
||
_case("BLOCK-%s" % _state, "BLOCKED", reason)
|
||
|
||
func _fail_current(reason: String) -> void:
|
||
_fail_case("FAIL-%s" % _state, reason)
|
||
|
||
func _finish_later() -> void:
|
||
if _finished:
|
||
return
|
||
_finished = true
|
||
_stop_attack()
|
||
_report.write(_report_path)
|
||
call_deferred("_quit")
|
||
|
||
func _quit() -> void:
|
||
get_tree().quit(0)
|