- 新增 script/playable_soak.sh:先做 soak 配置/资源校验,无 --allow-gameplay 只校验不启动客户端;N(>=10) 次独立正常退出运行 + 墙钟 soak,失败即停止后续 运行并记 BLOCKED;写本批次 release-manifest.json 后聚合 - run_client_gate.sh:确认的故障代理对所有 suite 启动(共享场景的退出运行也经代理); soak 超时下限只约束 soak 客户端(validate_soak 增加 soak_client 参数) - 新增本地 127.0.0.1 故障代理、RSS 采样/内存判定、窗口/指标/流程模块及其测试 - forest_mob_render_test 输出 PASS/FAIL 标记,供 rendering_batch_test.sh 识别 - 新增 docs/FIRST-MAC-PLAYABLE-STATUS.md:如实记录 PASS/BLOCKED、已知问题与环境需求 - .gitignore 排除本地场景配置、凭据文件与运行输出 离线回归:rendering_batch_test.sh failures=0,playable_gate_test.sh PASS, node 夹具测试 PASS。未联网运行,未重建候选包。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
182 lines
7.0 KiB
GDScript
182 lines
7.0 KiB
GDScript
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)
|