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:
+139
-331
@@ -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|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"]
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user