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
This commit is contained in:
shenlei
2026-09-11 22:06:48 +09:00
co-authored by Claude Opus 5
parent 374d4165d8
commit f39a55fdd5
66 changed files with 9189 additions and 951 deletions
+139 -331
View File
@@ -1,373 +1,181 @@
extends Node
## 首个 Mac 内测的真实联网闭环。
## 首个 Mac 内测的真实联网闭环NET-01 / CBT-01
##
## 这个脚本只负责调度生产入口并观察服务端结果:移动仍经 PlayerController
## 目标仍经 NetPlay,拾取仍经 NetPlay.pick_ground_item,技能入口由后续 CBT
## 用例接入 Quickbar。没有 `MT_PLAYABLE_ALLOW_GAMEPLAY=1` 时只登录/进场
## 观察并把后续必测项标记为 BLOCKED,不会误发游戏操作。
## 本节点只做装配:加载配置、挂 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"]
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 _app_flow: Node
var _client: Object
var _report: RefCounted
var _probe: Node
var _machine: RefCounted
var _report_path := ""
var _events_path := ""
var _state := "boot"
var _state_since := 0.0
var _started := 0.0
var _output_dir := ""
var _watched_quickbar: Object
var _watched_fx: Object
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 := {}
var _metrics: RefCounted
var _tick_us := 0
var _tick_max_us := 0
var _ticks := 0
var _memory_start := {}
func setup(flow: Node, m2client: Node, config_path := "") -> void:
_flow = flow
func setup(app_flow: Node, m2client: Object, config: Dictionary = {}) -> void:
_app_flow = app_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)
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"), "playable", loaded.config if loaded.ok else {})
if not loaded.ok:
_case("CONFIG-01", "FAIL", "invalid configuration: %s" % "; ".join(loaded.errors))
_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
_config = loaded.config
_case("CONFIG-01", "PASS", "validated")
if _client == null:
_case("NET-BOOT-01", "FAIL", "M2Client was not created")
_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(_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})
_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 _finished:
if _machine == null or _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()
_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
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()
_machine.tick()
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 _mark_observed(kind: String, _data: Dictionary) -> void:
if kind in MARKED_KINDS:
_metrics.mark(kind)
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:
## 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 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)
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 _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 _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_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 _on_machine_finished(_status: String) -> void:
_finish_later()
func _finish_later() -> void:
if _finished:
return
_finished = true
_stop_attack()
_report.write(_report_path)
call_deferred("_quit")
call_deferred("_write_and_quit")
func _quit() -> void:
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)