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)