Files
mtgodot-poc/project/playable_live_test.gd
shenleiandClaude Opus 5 f39a55fdd5 feat(playable): 首个 Mac 联网内测版 STB-01 soak 工具与发布状态文档
- 新增 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
2026-09-11 22:06:48 +09:00

182 lines
7.0 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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|soaksoak = 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)