extends Node ## 首个 Mac 内测的真实联网闭环(NET-01 / CBT-01)。 ## ## 本节点只做装配:加载配置、挂 PlayableProbe、把观察转给 PlayableFlow 状态机、 ## 截图、写 client-report.json 后正常退出。所有游戏操作都在 PlayableFlow 里经 ## 生产入口发出;没有 MT_PLAYABLE_ALLOW_GAMEPLAY=1 时只验证登录/选角/进场。 ## ## env: ## MT_PLAYABLE_CONFIG 场景 JSON(不含凭据) ## MT_PLAYABLE_SUITE playable|full|soak(soak = STB-01 墙钟长跑,挂帧指标与窗口适配器) ## MT_PLAYABLE_RUN_ID 父脚本生成的 run_id ## MT_PLAYABLE_ALLOW_GAMEPLAY 1 = 允许移动/攻击/拾取/施法 ## MT_TEST_REPORT client-report.json 路径 ## MT_TEST_EVENTS events.jsonl 路径(默认与报告同目录) ## MT_TEST_OUTPUT 失败截图目录(默认与报告同目录) ## MTGODOT_GPUSKIN 仅作为帧指标分段标签 gpu_skin 记录 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 Flow = preload("res://testing/playable_flow.gd") const Metrics = preload("res://testing/playable_metrics.gd") const PlayableWindow = preload("res://testing/playable_window.gd") ## Kinds that do heavy work on the main thread; a long frame right after one is attributed to it. const MARKED_KINDS := ["entered_game", "disconnected", "entity_spawned", "ground_item_added"] var _app_flow: Node var _client: Object var _report: RefCounted var _probe: Node var _machine: RefCounted var _report_path := "" var _output_dir := "" var _watched_quickbar: Object var _watched_fx: Object var _finished := false var _metrics: RefCounted var _tick_us := 0 var _tick_max_us := 0 var _ticks := 0 var _memory_start := {} func setup(app_flow: Node, m2client: Object, config: Dictionary = {}) -> void: _app_flow = app_flow _client = m2client _report_path = OS.get_environment("MT_TEST_REPORT").strip_edges() if _report_path.is_empty(): _report_path = "user://playable-client-report.json" var 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") _output_dir = OS.get_environment("MT_TEST_OUTPUT").strip_edges() if _output_dir.is_empty(): _output_dir = _report_path.get_base_dir() var suite := OS.get_environment("MT_PLAYABLE_SUITE").strip_edges() if suite.is_empty(): suite = "playable" var errors: Array = [] if config.is_empty(): var loaded := Config.load_file(OS.get_environment("MT_PLAYABLE_CONFIG")) config = loaded.config errors = loaded.errors else: errors = Config.validate(config).errors if not (suite in Config.SUITES): errors.append("unknown suite") _report = Report.new() _report.begin(OS.get_environment("MT_PLAYABLE_RUN_ID"), suite, config if errors.is_empty() else {}, events_path) if not errors.is_empty(): _report.set_required_cases(["CONFIG-01"]) _report.add_case("CONFIG-01", "FAIL", "invalid configuration: %s" % "; ".join(errors)) _finish_later() return if _client == null: _report.set_required_cases(Config.required_cases(config, suite)) _report.add_case("CONFIG-01", "FAIL", "M2Client was not created") _finish_later() return _probe = Probe.new() _probe.name = "PlayableProbe" add_child(_probe) _probe.setup(_app_flow, _client) _machine = Flow.new() _machine.report = _report _machine.probe = _probe _machine.flow = _app_flow _machine.client = _client _machine.config = config _machine.suite = suite _machine.allow_gameplay = OS.get_environment("MT_PLAYABLE_ALLOW_GAMEPLAY") == "1" _machine.capture = Callable(self, "_capture") _machine.finished.connect(_on_machine_finished) _probe.observed.connect(_machine.on_observed) if suite == "soak": _metrics = Metrics.new() _metrics.configure({"window_seconds": 60}) _machine.metrics = _metrics _machine.window = PlayableWindow.new(get_viewport()) _memory_start = Metrics.memory_snapshot() _probe.observed.connect(_mark_observed) _machine.start() func _process(_delta: float) -> void: if _machine == null or _finished: return _watch_scene_signals() if _metrics != null: var tags: Dictionary = _machine.frame_tags() tags["gpu_skin"] = OS.get_environment("MTGODOT_GPUSKIN").strip_edges() _metrics.set_tags(tags) _metrics.frame() var started := Time.get_ticks_usec() _machine.tick() var spent := Time.get_ticks_usec() - started _tick_us += spent _tick_max_us = maxi(_tick_max_us, spent) _ticks += 1 return _machine.tick() func _mark_observed(kind: String, _data: Dictionary) -> void: if kind in MARKED_KINDS: _metrics.mark(kind) ## Quickbar / skill fx live in GameScene and are rebuilt with it; re-bind lazily. func _watch_scene_signals() -> void: if _app_flow == null or not _app_flow.has_method("get_playable_context"): return var context: Dictionary = _app_flow.get_playable_context() var quickbar: Object = context.get("quickbar", null) if quickbar != null and is_instance_valid(quickbar) and quickbar != _watched_quickbar: _watched_quickbar = quickbar _probe.watch_local(quickbar, "skill_cast_started", "cast_started") _probe.watch_local(quickbar, "skill_rejected", "skill_rejected") # EffectRegistry spawn / tree-exit boundaries (CBT-01 effect lifetime evidence). var registry: Object = context.get("fx", null) if registry != null and registry != _watched_fx: _watched_fx = registry _probe.watch_local(registry, "fx_spawned", "fx_spawned") _probe.watch_local(registry, "fx_finished", "fx_finished") func _capture(case_id: String) -> String: if DisplayServer.get_name() == "headless" or _output_dir.is_empty(): return "" var image := get_viewport().get_texture().get_image() if image == null or image.is_empty(): return "" var file_name := "%s.png" % case_id.to_lower() if image.save_png(_output_dir.path_join(file_name)) != OK: return "" return file_name func _on_machine_finished(_status: String) -> void: _finish_later() func _finish_later() -> void: if _finished: return _finished = true call_deferred("_write_and_quit") func _write_and_quit() -> void: if _machine != null: _machine.cancel_inputs() if _metrics != null: _report.set_section("frame_metrics", _metrics.summary()) _report.set_section("client_memory", {"start": _memory_start, "end": Metrics.memory_snapshot(), "note": "MEMORY_STATIC 是 Godot 分配器计数,不是进程 RSS;RSS 由父运行器按 PID 外部采样"}) _report.set_section("probe_overhead", {"sampler": Metrics.measure_overhead(), "flow_tick": {"ticks": _ticks, "total_us": _tick_us, "max_us": _tick_max_us, "mean_us": float(_tick_us) / maxf(1.0, _ticks)}}) if _probe != null: _probe.disconnect_all() if not _report.write(_report_path): printerr("PLAYABLE: cannot write client report") get_tree().quit(1) return print("PLAYABLE CLIENT: %s" % _report.finish().status) # Normal quit path: the parent runner owns the final exit gate. get_tree().quit(0)