Implement playable Mac client and rendering validation

This commit is contained in:
shen
2026-09-11 14:58:22 +08:00
parent 1ab68bd06e
commit 374d4165d8
233 changed files with 6546 additions and 284 deletions
+373
View File
@@ -0,0 +1,373 @@
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)