Files
mtgodot-poc/project/testing/playable_flow.gd
T
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

1358 lines
56 KiB
GDScript

class_name PlayableFlow
extends RefCounted
## NET-01 / CBT-01 联网闭环状态机(纯逻辑,可由假客户端驱动)。
##
## 只经生产入口操作:AppFlow 自动登录/选角、PlayerController.walk_to、
## NetPlay.select_target / set_attack_key / pick_ground_item、Quickbar.assign/activate。
## 成功证据只来自 PlayableProbe 的 source=server 观察或 M2Client 状态查询;
## 本地位置、施法开始、特效节点只作补充,不单独构成服务端结果。
##
## 所有时间来自注入的 clock_us,harness 可以不等待真实时间验证超时分支。
##
## STB-01 soak:同一套 MOVE…PICKUP 按轮重复,按墙钟期限(clock_us 经过的时长)结束,
## 不按轮数或帧数。每轮后依次:真实传输故障(需确认的本地代理)、合法传送门往返
## (需确认的路线)、三种逻辑分辨率切换(需窗口适配器)、≥30s 休息(内存低水位窗口)。
## 主动 reconnect() 与真实故障分开计数;未确认的条件保持 BLOCKED,不互相替代。
signal finished(status: String)
const Config = preload("res://testing/playable_config.gd")
const Metrics = preload("res://testing/playable_metrics.gd")
const LOGIN_TIMEOUT_US := 30_000_000
const SELECT_TIMEOUT_US := 45_000_000
const WORLD_TIMEOUT_US := 60_000_000
const MOVE_TIMEOUT_US := 20_000_000
const CONFIRM_TIMEOUT_US := 60_000_000
const TARGET_TIMEOUT_US := 30_000_000
const ATTACK_TIMEOUT_US := 90_000_000
const DROP_TIMEOUT_US := 30_000_000
const PICKUP_TIMEOUT_US := 15_000_000
const PICKUP_SETTLE_US := 2_000_000
const SKILL_CAST_TIMEOUT_US := 10_000_000
const SKILL_RETRY_US := 500_000
## Soak: the scene must stay ready this long after a re-login before rebind checks run,
## so a node still fading out from the old connection is not mistaken for a live duplicate.
const REBIND_SETTLE_US := 1_000_000
const FAULT_DISCONNECT_TIMEOUT_US := 30_000_000
## Recovery budget after the transport fault ends: ReconnectUI retry delay + login + map load.
const FAULT_RECOVERY_GRACE_US := 120_000_000
const WARP_TIMEOUT_US := 120_000_000
const RESIZE_SETTLE_US := 500_000
const RESIZE_TIMEOUT_US := 5_000_000
const RESIZE_TOLERANCE_PX := 2
const FRAME_HISTORY_CAP := 64
const RECONNECT_STAGES := ["MOVE_CONFIRM", "WARP", "FAULT"]
const SOAK_PLAY_STAGES := ["MOVE", "TARGET", "ATTACK", "DROP", "PICKUP", "RESIZE", "REST"]
const SOAK_STEPS := ["FAULT", "WARP", "RESIZE", "REST"]
const ARRIVE_M := 1.5
const CORRECTION_CM := 500.0
const CONFIRM_CM := 300.0
const DROP_RADIUS_CM := 600.0
const MAX_KILLS := 3
const MONSTER_CH_TYPE := 2
const SKILL_SLOT := 7
const DEFAULT_SKILL_REPEATS := 10
## One-shot effects must leave the tree within their MSE-defined lifetime + this grace.
const FX_GRACE_MS := 1000
## PlayerSkill codes that mean the fixture (not the client) is missing something.
const FIXTURE_SKILL_CODES := ["NOT_YET_LEARN", "NO_SKILL_DATA", "PASSIVE", "NOT_MATCHABLE_WEAPON", "EMPTY_ARROW", "HAVE_TO_RIDE", "NO_RIDING_SKILL"]
var report: RefCounted
var probe: Object
var flow: Object
var client: Object
var config: Dictionary = {}
var suite := "playable"
var allow_gameplay := false
var clock_us: Callable = func() -> int: return Time.get_ticks_usec()
## func(name: String) -> String; returns an evidence path relative to the report, or "".
var capture: Callable
## Soak only. PlayableMetrics fed once per frame by the live runner (null offline -> STB-FRAMES-01 BLOCKED).
var metrics: RefCounted
## Soak only. Window adapter (testing/playable_window.gd): availability / request_logical_size /
## logical_size / physical_size / viewport_size / screen_scale. null -> STB-RESIZE-01 BLOCKED.
var window: Object
## Unix wall-clock milliseconds for rest windows matched against the external RSS sampler.
var wall_ms: Callable = func() -> int: return int(Time.get_unix_time_from_system() * 1000.0)
var stage := "BOOT"
var done := false
var status := ""
var _stage_since := 0
var _main_vid := 0
var _bound_epoch := 0
var _entered_seen := false
var _waypoint := 0
var _expected_world := Vector3.ZERO
var _reconnect_epoch := 0
var _reconnect_entered := 0
var _rebind_since := -1
var _confirm_target_cm := Vector2.ZERO
var _confirm_blocked := false
var _kills := 0
var _abandoned := {}
var _target := {}
var _attack_down := false
var _damaged := false
var _dead := false
var _kill_pos_cm := Vector2.ZERO
var _pre_attack_iids := {}
var _drop := {}
var _pickup_before := 0
var _pickup_removed_at := -1
var _skill_index := 0
var _skill_successes := 0
var _skill_cast := {}
var _skill_next_try := 0
var _skill_draining := false
var _open_fx := {} # fx_id -> {effect, lifetime_ms, spawned_us, case_id}
## §7.1 item 6: the first cast of each skill case is captured at its own event
## times (before / trigger / peak / end), not on a fixed one-second delay.
var _shots: Array = [] # evidence files attached to the skill case result
var _shot_phases := {} # phase -> true once attempted for this case
var _shot_fx := {} # fx_id -> peak due (us) for first-cast timed effects
var _shot_end_pending := false
var _start_us := 0
# --- soak ---
var _round := 0
var _rounds_done := 0
var _soak_start_us := -1
var _soak_wall_start_ms := 0
var _soak_blocked := ""
var _active_reconnects := 0
var _faults := {"close": 0, "unreachable": 0}
var _fault := {}
var _warps := 0
var _warp_leg := 0
var _warp_expected := ""
var _resize_index := -1
var _resize_since := 0
var _resize_before := Vector2i.ZERO
var _resize_counts := {}
var _resize_blocked := ""
var _severe_seen := {}
var _frame_history := {} # interaction (stage) -> last FRAME_HISTORY_CAP bools
var _repeated_severe := {} # interaction -> true once 2 of 3 consecutive repetitions had a >100ms frame
func start() -> void:
_start_us = _now()
report.set_required_cases(Config.required_cases(config, suite))
var server: Dictionary = config.get("server", {})
var address: Dictionary = {}
if flow != null and flow.has_method("select_test_server"):
address = flow.select_test_server(int(server.get("server_index", -1)), int(server.get("channel", 0)))
var match_ok := not address.is_empty()
for key in ["auth_host", "game_host"]:
match_ok = match_ok and String(address.get(key, "")) == String(server.get(key, ""))
for key in ["auth_port", "game_port"]:
match_ok = match_ok and int(address.get(key, -1)) == int(server.get(key, -2))
_event("address_selected", {"server_index": int(server.get("server_index", -1)),
"channel": int(server.get("channel", 0)), "address_match": match_ok})
if not match_ok:
_finish_case("CONFIG-01", "FAIL", "client serverinfo address differs from the prechecked config address")
_finish()
return
report.add_case("CONFIG-01", "PASS", "config validated and client address matches precheck")
_enter("LOGIN")
## Probe observation entry. Every observation is persisted as a contract event
## before it is interpreted.
func on_observed(kind: String, data: Dictionary) -> void:
var epoch := int(data.get("connection_epoch", 0))
var stale := _bound_epoch > 0 and epoch != _bound_epoch and kind != "entered_game" and kind != "disconnected"
var payload: Dictionary = data.duplicate()
payload.erase("connection_epoch")
payload.erase("actor_vid")
payload.erase("target_vid")
payload.erase("received_us")
if stale:
payload["stale_epoch"] = true
report.record(kind, stage, _case_for_stage(), epoch, int(data.get("actor_vid", 0)),
int(data.get("target_vid", 0)), payload)
if done:
return
match kind:
"login_failed":
if stage == "LOGIN" or stage == "SELECT":
_finish_case("NET-LOGIN-01", "FAIL", "login rejected by server")
_finish()
"char_list":
if stage == "LOGIN":
var slot := int(config.get("character_slot", -1))
if slot in data.get("slots", []):
report.add_case("NET-LOGIN-01", "PASS", "server char_list contains the configured slot")
_enter("SELECT")
else:
_finish_case("NET-LOGIN-01", "FAIL", "configured character slot is absent")
_finish()
"entered_game":
_entered_seen = true
_on_entered_game(epoch)
"disconnected":
_on_disconnected()
"entity_moved":
if stage == "MOVE" and not stale and int(data.get("actor_vid", 0)) == _main_vid:
_check_server_correction(data.get("pos_cm", null))
"damage":
if stage == "ATTACK" and not stale and _is_target(int(data.get("target_vid", 0))) and int(data.get("amount", 0)) > 0:
_damaged = true
if stage == "SKILL" and not stale:
_skill_evidence("damage", int(data.get("target_vid", 0)))
"vitals_changed":
if stage == "ATTACK" and not stale and _is_target(int(data.get("actor_vid", 0))):
var hp := int(data.get("hp", -1))
if hp >= 0 and int(_target.get("hp", -1)) > 0 and hp < int(_target.hp):
_damaged = true
_target["hp"] = hp
"target_info":
if stage == "ATTACK" and not stale and _is_target(int(data.get("target_vid", 0))) and int(data.get("hp_percent", 100)) < 100:
_damaged = true
"entity_dead":
var dead_vid := int(data.get("target_vid", 0))
if stage == "ATTACK" and not stale and _is_target(dead_vid):
_dead = true
if stage == "SKILL" and not stale:
_skill_evidence("target_dead", dead_vid)
"entity_despawned":
if stage == "ATTACK" and not stale and _is_target(int(data.get("actor_vid", 0))) and not _dead:
_target_lost("target despawned before a server death result")
"server_motion":
if stage == "SKILL" and not stale and int(data.get("actor_vid", 0)) == _main_vid:
_skill_evidence("server_motion", int(data.get("target_vid", 0)))
"effect_cue", "fly_cue", "affect_added", "sp_changed":
if stage == "SKILL" and not stale:
var actor := int(data.get("actor_vid", 0))
if kind in ["affect_added", "sp_changed"] or actor == _main_vid or actor == int(_skill_cast.get("target_vid", -1)):
_skill_evidence(kind, int(data.get("target_vid", 0)))
"cast_started":
if stage == "SKILL" and String(data.get("source", "")) == "local":
_on_cast_started(int(data.get("skill_id", 0)), int(data.get("target_vid", 0)))
"skill_rejected":
if stage == "SKILL":
_on_skill_rejected(int(data.get("skill_id", 0)), String(data.get("code", "")))
"fx_spawned":
_on_fx_spawned(data)
if stage == "SKILL":
_skill_evidence(kind, 0)
_sample_fx_spawned(data)
"fx_finished":
if not _on_fx_finished(data):
return
if stage == "SKILL":
_skill_evidence(kind, 0)
_sample_fx_finished(data)
func tick() -> void:
if done:
return
var now := _now()
var timeout_us := int(config.get("timeout_seconds", 120)) * 1_000_000
if now - _start_us > timeout_us:
_fail_stage("overall scenario timeout")
return
if not _check_open_fx(now):
return
if stage == "SKILL":
_sample_due(now)
match stage:
"LOGIN":
if now - _stage_since > LOGIN_TIMEOUT_US:
_finish_case("NET-LOGIN-01", "FAIL", "no server char_list within 30s")
_finish()
"SELECT":
if _entered_seen:
var vid := int(client.get_main_vid()) if client.has_method("get_main_vid") else 0
var main: Dictionary = client.get_entity(vid) if vid > 0 and client.has_method("get_entity") else {}
if vid > 0 and not main.is_empty():
_main_vid = vid
_bound_epoch = _probe_epoch()
report.add_case("NET-SELECT-01", "PASS", "entered_game received and main entity is present")
_enter("WORLD")
return
if now - _stage_since > SELECT_TIMEOUT_US:
_finish_case("NET-SELECT-01", "FAIL", "entered_game/main entity not observed within 45s")
_finish()
"WORLD":
var ready := _world_ready()
if ready.ready:
if String(ready.map_key) != String(config.get("map_key", "")):
_event("map_mismatch", {"map_key": ready.map_key, "expected_map_key": config.map_key})
_finish_case("NET-WORLD-01", "FAIL", "loaded map_key differs from the configured map")
_finish()
return
_pass("NET-WORLD-01", "scene, map, player and HUD ready")
if not allow_gameplay:
_block_remaining("gameplay_not_allowed")
_finish()
return
if suite == "soak":
_begin_soak(now)
return
_begin_move(0)
elif now - _stage_since > WORLD_TIMEOUT_US:
_event("world_not_ready", ready)
_finish_case("NET-WORLD-01", "FAIL", "scene/map/player/HUD not ready within 60s")
_finish()
"MOVE":
var player: Node3D = _context().get("player", null)
if player != null and is_instance_valid(player) \
and Vector2(player.position.x, player.position.z).distance_to(Vector2(_expected_world.x, _expected_world.z)) <= ARRIVE_M:
_pass(_move_case(), "local arrival without server correction")
if _waypoint + 1 < config.waypoints_cm.size():
_begin_move(_waypoint + 1)
else:
_begin_confirm()
elif now - _stage_since > MOVE_TIMEOUT_US:
_fail_stage("waypoint not reached within 20s")
"MOVE_CONFIRM":
_tick_confirm(now)
"TARGET":
_tick_target(now)
"ATTACK":
_tick_attack(now)
"DROP":
_tick_drop(now)
"PICKUP":
_tick_pickup(now)
"SKILL":
_tick_skill(now)
"FAULT":
_tick_fault(now)
"WARP":
_tick_warp(now)
"RESIZE":
_tick_resize(now)
"REST":
_tick_rest(now)
## Cancel all pending input immediately. Safe to call repeatedly.
func cancel_inputs() -> void:
if _attack_down:
var net_play: Object = _context().get("net_play", null)
if net_play != null and is_instance_valid(net_play) and net_play.has_method("set_attack_key"):
net_play.set_attack_key(false)
_attack_down = false
_target = {}
_skill_cast = {}
# --- stages -----------------------------------------------------------------
func _enter(next: String) -> void:
_track_frames(stage)
stage = next
_stage_since = _now()
var snap: Dictionary = probe.snapshot() if probe != null and probe.has_method("snapshot") else {}
var flow_snap := _flow_snapshot()
snap["state"] = next
snap["scene_ready"] = bool(flow_snap.get("scene_ready", false))
snap["reconnecting"] = bool(flow_snap.get("reconnecting", false))
snap["character_count"] = int(flow_snap.get("character_count", 0))
_event("state_enter", snap)
func _begin_move(index: int) -> void:
_waypoint = index
var point: Array = config.waypoints_cm[index]
_expected_world = MapCoord.to_world(Vector3(float(point[0]) * 0.01, 0.0, -float(point[1]) * 0.01))
_enter("MOVE")
var controller: Object = _context().get("pc", null)
if controller == null or not is_instance_valid(controller) or not controller.has_method("walk_to"):
_fail_stage("PlayerController.walk_to is unavailable")
return
controller.walk_to(_expected_world)
_event("move_requested", {"waypoint_index": index, "pos_cm": Vector2(float(point[0]), float(point[1]))})
func _check_server_correction(pos: Variant) -> void:
var player: Node3D = _context().get("player", null)
if not (pos is Vector3) or player == null or not is_instance_valid(player):
return
var local_cm := MapCoord.to_server_cm(player.position)
var distance := local_cm.distance_to(Vector2(pos.x, pos.y))
if distance > CORRECTION_CM:
_event("server_correction", {"distance_cm": distance, "tolerance_cm": CORRECTION_CM, "waypoint_index": _waypoint})
_fail_stage("server corrected the main character position by %.0fcm" % distance)
func _begin_confirm() -> void:
var last: Array = config.waypoints_cm[config.waypoints_cm.size() - 1]
_confirm_target_cm = Vector2(float(last[0]), float(last[1]))
_arm_reconnect()
_confirm_blocked = false
_enter("MOVE_CONFIRM")
cancel_inputs()
if not client.has_method("reconnect") or not bool(client.reconnect()):
_confirm_blocked = true
_finish_case("NET-MOVE-CONFIRM-01", "BLOCKED", "reconnect is unavailable; no server position source")
if suite == "soak":
_finish_case("STB-RECONNECT-01", "BLOCKED", "reconnect is unavailable in round %d" % _round)
_finish()
return
_enter_target()
func _tick_confirm(now: int) -> void:
var bound := _rebound(now)
if bound < 0:
return
if bound > 0:
var pos: Variant = client.get_entity(_main_vid).get("pos_cm", null)
var distance := Vector2(pos.x, pos.y).distance_to(_confirm_target_cm)
_event("server_position", {"main_vid": _main_vid, "pos_cm": Vector2(pos.x, pos.y), "distance_cm": distance, "tolerance_cm": CONFIRM_CM})
if distance <= CONFIRM_CM:
_pass("NET-MOVE-CONFIRM-01", "server spawn position after re-login matches the last waypoint")
else:
_finish_case("NET-MOVE-CONFIRM-01", "FAIL", "server position after re-login is %.0fcm from the last waypoint" % distance)
_finish()
return
if suite == "soak":
_active_reconnects += 1
_event("reconnect_verified", {"round": _round, "count": _active_reconnects})
_enter_target()
return
if now - _stage_since > CONFIRM_TIMEOUT_US:
_finish_case("NET-MOVE-CONFIRM-01", "FAIL", "re-login did not return a main entity/scene within 60s")
_finish()
func _enter_target() -> void:
_target = {}
_damaged = false
_dead = false
_enter("TARGET")
func _tick_target(now: int) -> void:
var candidate := _pick_monster()
if not candidate.is_empty():
var net_play: Object = _context().get("net_play", null)
if net_play == null or not is_instance_valid(net_play) or not net_play.has_method("select_target"):
_fail_stage("NetPlay.select_target is unavailable")
return
if bool(net_play.select_target(int(candidate.vid))):
_target = candidate
_target["epoch"] = _bound_epoch
_event("target_selected", {"race": candidate.race, "pos_cm": candidate.pos_cm, "attempt": _kills + 1})
if not report.has_case("NET-TARGET-01"):
report.add_case("NET-TARGET-01", "PASS", "allowed live monster selected in the current connection epoch")
if stage == "TARGET":
_begin_attack()
return
_abandoned[int(candidate.vid)] = true
if now - _stage_since > TARGET_TIMEOUT_US:
if not report.has_case("NET-TARGET-01") or (suite == "soak" and _kills == 0):
_finish_case("NET-TARGET-01", "BLOCKED", "no allowed live monster in range (fixture)")
else:
_finish_case("NET-DROP-01", "BLOCKED", "no further allowed monster after %d kills" % _kills)
_finish()
func _pick_monster() -> Dictionary:
if not client.has_method("get_entities"):
return {}
var allowed: Array = config.get("allowed_mob_vnums", []).map(func(v): return int(v))
var main: Dictionary = client.get_entity(_main_vid) if client.has_method("get_entity") else {}
var origin: Variant = main.get("pos_cm", Vector3.ZERO)
var best := {}
var best_distance := INF
for entity in client.get_entities():
var vid := int(entity.get("vid", 0))
if vid <= 0 or _abandoned.has(vid) or bool(entity.get("dead", false)):
continue
if int(entity.get("ch_type", -1)) != MONSTER_CH_TYPE or not (int(entity.get("race", 0)) in allowed):
continue
var pos: Variant = entity.get("pos_cm", Vector3.ZERO)
var distance := Vector2(pos.x, pos.y).distance_to(Vector2(origin.x, origin.y)) if pos is Vector3 and origin is Vector3 else 0.0
if distance < best_distance:
best_distance = distance
best = {"vid": vid, "race": int(entity.get("race", 0)), "hp": int(entity.get("hp", -1)),
"pos_cm": Vector2(pos.x, pos.y) if pos is Vector3 else Vector2.ZERO}
return best
func _begin_attack() -> void:
_damaged = false
_dead = false
_pre_attack_iids = _ground_iids()
_enter("ATTACK")
var net_play: Object = _context().get("net_play", null)
if net_play == null or not net_play.has_method("set_attack_key"):
_fail_stage("NetPlay.set_attack_key is unavailable")
return
net_play.set_attack_key(true)
_attack_down = true
func _tick_attack(now: int) -> void:
if _target.is_empty():
return
var entity: Dictionary = client.get_entity(int(_target.vid)) if client.has_method("get_entity") else {}
var pos: Variant = entity.get("pos_cm", null)
if pos is Vector3:
_target["pos_cm"] = Vector2(pos.x, pos.y)
if _dead:
if not _damaged:
# A death without any HP/damage evidence is not a verified combat result.
cancel_inputs()
_fail_stage("target died without a received damage/HP change")
return
_kills += 1
_kill_pos_cm = _target.get("pos_cm", Vector2.ZERO)
_abandoned[int(_target.vid)] = true
cancel_inputs()
_event("target_killed", {"attempt": _kills, "pos_cm": _kill_pos_cm})
if not report.has_case("NET-ATTACK-01"):
report.add_case("NET-ATTACK-01", "PASS", "received damage/HP change and death for the selected target")
_drop = {}
_enter("DROP")
return
if now - _stage_since > ATTACK_TIMEOUT_US:
cancel_inputs()
_fail_stage("no server damage and death result within 90s")
## NET-04: target vanished before death -> release input, never chase the old VID.
func _target_lost(reason: String) -> void:
_event("target_lost", {"attempt": _kills + 1, "reason": reason})
_abandoned[int(_target.get("vid", 0))] = true
cancel_inputs()
_kills += 1
if _kills >= MAX_KILLS:
_finish_case("NET-ATTACK-01", "BLOCKED", "target lost before death on %d attempts" % _kills)
_finish()
return
_enter_target()
func _tick_drop(now: int) -> void:
var allowed: Array = config.get("allowed_drop_vnums", []).map(func(v): return int(v))
for item in _ground_items():
var iid := int(item.get("vid", 0))
if _pre_attack_iids.has(iid):
continue
var vnum := int(item.get("vnum", 0))
var pos_cm := _ground_pos_cm(item)
var distance := pos_cm.distance_to(_kill_pos_cm)
if vnum == Config.GOLD_VNUM:
_pre_attack_iids[iid] = true
_event("gold_drop_seen", {"iid": iid, "distance_cm": distance})
continue
if not (vnum in allowed):
continue
var owner_ok := _owner_matches(String(item.get("owner", "")))
if not owner_ok or distance > DROP_RADIUS_CM:
_pre_attack_iids[iid] = true
_event("drop_rejected", {"iid": iid, "vnum": vnum, "owner_match": owner_ok, "distance_cm": distance, "tolerance_cm": DROP_RADIUS_CM})
continue
_drop = {"iid": iid, "vnum": vnum}
_event("drop_accepted", {"iid": iid, "vnum": vnum, "owner_match": true, "distance_cm": distance})
_pass("NET-DROP-01", "new allowed ground item, pickable owner, near the killed target")
_begin_pickup()
return
if now - _stage_since > DROP_TIMEOUT_US:
if _kills < MAX_KILLS:
_event("no_drop", {"attempt": _kills})
_enter_target()
else:
_finish_case("NET-DROP-01", "BLOCKED", "no allowed pickable drop after %d kills (fixture)" % _kills)
_finish()
func _begin_pickup() -> void:
_pickup_before = _inventory_total(int(_drop.vnum))
_pickup_removed_at = -1
_enter("PICKUP")
var net_play: Object = _context().get("net_play", null)
if net_play == null or not net_play.has_method("pick_ground_item"):
_fail_stage("NetPlay.pick_ground_item is unavailable")
return
net_play.pick_ground_item(int(_drop.iid))
_event("pickup_requested", {"iid": _drop.iid, "vnum": _drop.vnum, "before": _pickup_before})
func _tick_pickup(now: int) -> void:
var present := _ground_iids().has(int(_drop.iid))
var after := _inventory_total(int(_drop.vnum))
if not present and _pickup_removed_at < 0:
_pickup_removed_at = now
if not present and after > _pickup_before:
_event("pickup_verified", {"iid": _drop.iid, "vnum": _drop.vnum, "before": _pickup_before, "after": after, "delta": after - _pickup_before})
_pass("NET-PICKUP-01", "ground item removed and inventory vnum total increased")
_after_closed_loop()
return
if _pickup_removed_at >= 0 and now - _pickup_removed_at > PICKUP_SETTLE_US:
_event("pickup_unverified", {"iid": _drop.iid, "vnum": _drop.vnum, "before": _pickup_before, "after": after})
_fail_stage("ground item vanished without an inventory increase (taken by another player or inventory full)")
return
if now - _stage_since > PICKUP_TIMEOUT_US:
_fail_stage("pickup not accepted within 15s (inventory full, ownership or range)")
func _after_closed_loop() -> void:
if suite == "soak":
_soak_continue("")
return
if suite == "full" and not config.get("skill_cases", []).is_empty():
_skill_index = 0
_begin_skill_case()
return
_finish()
# --- CBT-01: skills ------------------------------------------------------------
func _begin_skill_case() -> void:
_skill_successes = 0
_skill_cast = {}
_skill_next_try = 0
_skill_draining = false
_shots = []
_shot_phases = {}
_shot_fx = {}
_shot_end_pending = false
_enter("SKILL")
var item: Dictionary = config.skill_cases[_skill_index]
var quickbar: Object = _context().get("quickbar", null)
if quickbar == null or not is_instance_valid(quickbar) or not quickbar.has_method("assign"):
_fail_stage("Quickbar is unavailable")
return
# Local-only slot: no CG_QUICKSLOT_ADD persistence on the account.
if not bool(quickbar.assign(SKILL_SLOT, "skill", int(item.skill_id), false)):
_fail_stage("Quickbar.assign rejected the skill")
func _tick_skill(now: int) -> void:
var item: Dictionary = config.skill_cases[_skill_index]
if _skill_draining:
# The case passes only after its one-shot effects actually left the tree;
# _check_open_fx fails it once one outlives lifetime + grace.
if _open_fx_for(_case_for_stage()).is_empty():
_skill_draining = false
report.add_case(Config.skill_case_id(item), "PASS", "server evidence observed for every cast; effects cleaned up",
_shots.duplicate(), float(now - _stage_since) / 1000.0)
_skill_index += 1
if _skill_index >= config.skill_cases.size():
_finish()
else:
_begin_skill_case()
return
if not _skill_cast.is_empty():
if _skill_missing().is_empty():
_skill_successes += 1
_event("skill_verified", {"skill_id": item.skill_id, "count": _skill_successes})
_skill_cast = {}
_skill_next_try = now + SKILL_RETRY_US
if _skill_successes >= int(item.get("repeats", DEFAULT_SKILL_REPEATS)):
_skill_draining = true
_tick_skill(now)
return
if now - int(_skill_cast.started_us) > SKILL_CAST_TIMEOUT_US:
_fail_stage("cast %d missing evidence: %s" % [_skill_successes + 1, ",".join(_skill_missing())])
return
if now - _stage_since > SKILL_CAST_TIMEOUT_US * (int(item.get("repeats", DEFAULT_SKILL_REPEATS)) + 2):
_fail_stage("skill case did not complete in its time budget")
return
if now < _skill_next_try:
return
if String(item.get("target", "enemy")) == "enemy":
var target := _pick_monster()
var net_play: Object = _context().get("net_play", null)
if target.is_empty() or net_play == null or not bool(net_play.select_target(int(target.vid))):
_skill_next_try = now + SKILL_RETRY_US
return
var quickbar: Object = _context().get("quickbar", null)
if _skill_successes == 0:
_shoot("before")
_skill_cast = {"pending": true, "skill_id": int(item.skill_id), "started_us": now, "evidence": {}, "casts": 0}
quickbar.activate(SKILL_SLOT)
if _skill_cast.get("pending", false) and int(_skill_cast.casts) == 0:
# No cast_started emitted synchronously: cooldown / gate silently held it.
_skill_cast = {}
_skill_next_try = now + SKILL_RETRY_US
func _on_cast_started(skill_id: int, target_vid: int) -> void:
if _skill_cast.is_empty() and stage == "SKILL" and not _skill_draining \
and skill_id == int(config.skill_cases[_skill_index].skill_id):
# An earlier activation was out of range and reserved (__ReserveUseSkill);
# NetPlay walked in and the reserved slot fired now. Track it as this case's cast.
_skill_cast = {"pending": true, "skill_id": skill_id, "started_us": _now(), "evidence": {}, "casts": 0, "reserved": true}
if _skill_cast.is_empty() or skill_id != int(_skill_cast.skill_id):
return
_skill_cast.casts = int(_skill_cast.casts) + 1
if int(_skill_cast.casts) > 1:
_fail_stage("duplicate cast request for one activation")
return
_skill_cast.pending = false
if _skill_successes == 0:
_shoot("trigger")
# Bind the case to the target at cast time; later selection changes do not re-route evidence.
_skill_cast.target_vid = target_vid
_skill_cast.evidence["cast_started"] = true
func _on_skill_rejected(skill_id: int, code: String) -> void:
var item: Dictionary = config.skill_cases[_skill_index]
if skill_id != int(item.skill_id):
return
_skill_cast = {}
if code in FIXTURE_SKILL_CODES:
_finish_case(Config.skill_case_id(item), "BLOCKED", "skill fixture not ready: %s" % code)
_finish()
return
_skill_next_try = _now() + SKILL_RETRY_US
func _skill_evidence(kind: String, target_vid: int) -> void:
if _skill_cast.is_empty() or bool(_skill_cast.get("pending", false)):
return
if kind in ["damage", "target_dead"] and target_vid != int(_skill_cast.get("target_vid", 0)):
return
# Local kinds are recorded too, but validate() requires at least one server kind per case.
_skill_cast.evidence[kind] = true
func _skill_missing() -> Array:
var missing: Array = []
var item: Dictionary = config.skill_cases[_skill_index]
for kind in item.required_evidence:
if not _skill_cast.get("evidence", {}).has(String(kind)):
missing.append(String(kind))
return missing
# --- CBT-01: effect lifecycle ----------------------------------------------------
# Evidence comes from EffectRegistry spawn / tree-exit boundaries, never node counts.
func _on_fx_spawned(data: Dictionary) -> void:
var lifetime_ms := int(data.get("lifetime_ms", -1))
if lifetime_ms < 0 or String(data.get("source", "")) != "local":
return # looping effects live as long as their owner
_open_fx[int(data.get("fx_id", 0))] = {"effect": String(data.get("effect", "")),
"lifetime_ms": lifetime_ms, "spawned_us": _now(), "case_id": _case_for_stage()}
## Returns false when the finish violated the lifetime contract (the run is over).
func _on_fx_finished(data: Dictionary) -> bool:
var fx_id := int(data.get("fx_id", 0))
var info: Dictionary = _open_fx.get(fx_id, {})
_open_fx.erase(fx_id)
var lifetime_ms := int(data.get("lifetime_ms", -1))
var elapsed_ms := int(data.get("elapsed_ms", -1))
if lifetime_ms >= 0 and elapsed_ms > lifetime_ms + FX_GRACE_MS:
var case_id := String(info.get("case_id", _case_for_stage()))
_fx_overdue(case_id, String(data.get("effect", "")), lifetime_ms, elapsed_ms)
return false
return true
func _check_open_fx(now: int) -> bool:
for fx_id in _open_fx.keys():
var info: Dictionary = _open_fx[fx_id]
var elapsed_ms := (now - int(info.spawned_us)) / 1000
if elapsed_ms > int(info.lifetime_ms) + FX_GRACE_MS:
_open_fx.erase(fx_id)
_fx_overdue(String(info.case_id), String(info.effect), int(info.lifetime_ms), elapsed_ms)
return false
return true
func _shoot(phase: String) -> void:
if _shot_phases.has(phase) or not capture.is_valid() or _skill_index >= config.skill_cases.size():
return
_shot_phases[phase] = true
var file := String(capture.call("%s-%s" % [Config.skill_case_id(config.skill_cases[_skill_index]), phase]))
_event("skill_capture", {"phase": phase, "screenshot": file})
if not file.is_empty():
_shots.append(file)
## Peak = half of the effect's own defined lifetime, sampled on the tick it falls due.
func _sample_fx_spawned(data: Dictionary) -> void:
var lifetime_ms := int(data.get("lifetime_ms", -1))
if lifetime_ms < 0 or _skill_successes > 0 or _skill_cast.is_empty() or _shot_phases.has("end"):
return
_shot_fx[int(data.get("fx_id", 0))] = _now() + lifetime_ms * 500
func _sample_fx_finished(data: Dictionary) -> void:
var fx_id := int(data.get("fx_id", 0))
if not _shot_fx.has(fx_id):
return
if int(_shot_fx[fx_id]) > 0:
_shoot("peak") # finished before its half-life tick: take the last live frame
_shot_fx.erase(fx_id)
_shot_end_pending = _shot_fx.is_empty()
func _sample_due(now: int) -> void:
for fx_id in _shot_fx:
if int(_shot_fx[fx_id]) > 0 and now >= int(_shot_fx[fx_id]):
_shoot("peak")
_shot_fx[fx_id] = 0
if _shot_end_pending:
# One tick after the last first-cast effect left the tree.
_shot_end_pending = false
_shoot("end")
func _open_fx_for(case_id: String) -> Array:
var out: Array = []
for fx_id in _open_fx:
if String(_open_fx[fx_id].case_id) == case_id:
out.append(fx_id)
return out
func _fx_overdue(case_id: String, effect: String, lifetime_ms: int, elapsed_ms: int) -> void:
var message := "effect %s not cleaned within lifetime %dms + %dms (elapsed %dms)" % [effect, lifetime_ms, FX_GRACE_MS, elapsed_ms]
_event("fx_overdue", {"effect": effect, "lifetime_ms": lifetime_ms, "elapsed_ms": elapsed_ms,
"open_effects": _open_fx.size()})
cancel_inputs()
if report.has_case(case_id):
report.add_failure("%s: %s" % [case_id, message])
else:
_finish_case(case_id, "FAIL", message)
_finish()
# --- reconnect binding (NET-01 confirm, STB-01 soak) ------------------------------
func _arm_reconnect() -> void:
_reconnect_epoch = _probe_epoch()
_reconnect_entered = 0
_rebind_since = -1
func _on_entered_game(epoch: int) -> void:
if stage in RECONNECT_STAGES:
if suite == "soak" and stage == "FAULT" and int(_fault.get("disconnected_us", -1)) < 0:
_soak_fail("STB-DISCONNECT-01", "FAIL", "entered_game before the requested transport fault disconnected")
return
_reconnect_entered += 1
if suite == "soak" and _reconnect_entered > 1:
_soak_fail(_rebind_case(), "FAIL", "duplicate entered_game without a disconnect during %s" % stage)
return
if epoch > _reconnect_epoch:
_bound_epoch = epoch
elif suite == "soak" and stage in SOAK_PLAY_STAGES:
_fail_stage("unexpected entered_game during %s (no reconnect was requested)" % stage)
## 0 = still waiting, 1 = main VID / scene / UI re-acquired in the new epoch, -1 = run finished.
## Soak additionally waits REBIND_SETTLE_US of continuous readiness, then checks for
## duplicate entity nodes and a target / attack key left over from the old connection.
func _rebound(now: int) -> int:
if _bound_epoch <= _reconnect_epoch:
_rebind_since = -1
return 0
var vid := int(client.get_main_vid()) if client.has_method("get_main_vid") else 0
var main: Dictionary = client.get_entity(vid) if vid > 0 and client.has_method("get_entity") else {}
var pos: Variant = main.get("pos_cm", null)
var ready := _world_ready()
if vid <= 0 or not (pos is Vector3) or (pos.x == 0.0 and pos.y == 0.0) or not ready.ready \
or bool(_flow_snapshot().get("reconnecting", false)):
_rebind_since = -1
return 0
_main_vid = vid
if suite != "soak":
return 1
if _rebind_since < 0:
_rebind_since = now
if now - _rebind_since < REBIND_SETTLE_US:
return 0
var checks := _rebind_checks()
_event("rebind_checked", {"main_vid": vid, "round": _round, "duplicate_vids": checks.duplicate_vids,
"target_residue": checks.target_residue, "map_key": ready.map_key, "state": checks.status})
if checks.status != "PASS":
_soak_fail(_rebind_case(), checks.status, checks.reason)
return -1
return 1
func _rebind_checks() -> Dictionary:
var out := {"status": "PASS", "reason": "", "duplicate_vids": [], "target_residue": []}
var context := _context()
var net_world: Variant = context.get("net_world", null)
var parent: Variant = net_world.get("parent") if net_world != null and is_instance_valid(net_world) and "parent" in net_world else null
if parent == null or not is_instance_valid(parent):
out.status = "BLOCKED"
out.reason = "NetWorld entity registry is unavailable; duplicate entity check not possible"
return out
var seen := {}
for child in parent.get_children():
if child.is_queued_for_deletion() or not child.has_meta("vid"):
continue
var vid := int(child.get_meta("vid"))
if seen.has(vid) and not (vid in out.duplicate_vids):
out.duplicate_vids.append(vid)
seen[vid] = true
var net_play: Variant = context.get("net_play", null)
if net_play != null and is_instance_valid(net_play):
var target_vid := int(net_play.get("_target_vid")) if "_target_vid" in net_play else 0
if target_vid > 0 and client.get_entity(target_vid).is_empty():
out.target_residue.append("target_vid")
if "_attack_key_down" in net_play and bool(net_play.get("_attack_key_down")):
out.target_residue.append("attack_key_down")
if not out.duplicate_vids.is_empty():
out.status = "FAIL"
out.reason = "duplicate entity nodes after re-login: %s" % [out.duplicate_vids]
elif not out.target_residue.is_empty():
out.status = "FAIL"
out.reason = "old target/attack state kept after re-login: %s" % [out.target_residue]
return out
func _rebind_case() -> String:
match stage:
"FAULT": return "STB-DISCONNECT-01"
"WARP": return "STB-WARP-01"
return "STB-RECONNECT-01"
# --- STB-01: soak rounds ------------------------------------------------------------
func _soak_cfg() -> Dictionary:
var soak: Variant = config.get("soak", {})
return soak if soak is Dictionary else {}
func _begin_soak(now: int) -> void:
var checked := Config.validate_soak(config)
if not checked.ok:
_block_remaining("soak config invalid: %s" % ", ".join(checked.errors))
_finish()
return
_soak_start_us = now
_soak_wall_start_ms = _wall_ms()
_round = 0
for size in _soak_cfg().resolutions.sizes:
_resize_counts["%dx%d" % [int(size[0]), int(size[1])]] = 0
_begin_round()
func _begin_round() -> void:
_kills = 0
_abandoned = {}
_event("soak_round", {"round": _round, "wall_ms": _wall_ms()})
_begin_move(0)
## Runs the post-pickup steps in SOAK_STEPS order, starting after `after`.
func _soak_continue(after: String) -> void:
var soak := _soak_cfg()
var start := SOAK_STEPS.find(after) + 1
for i in range(start, SOAK_STEPS.size()):
match SOAK_STEPS[i]:
"FAULT":
if String(soak.faults.get("status", "")) == "confirmed" and not _next_fault_type().is_empty():
_begin_fault()
return
"WARP":
var warp: Dictionary = soak.warp
if String(warp.get("status", "")) == "confirmed" and _warps < int(warp.required):
_begin_warp(0)
return
"RESIZE":
var available: Dictionary = window.availability() if window != null and window.has_method("availability") else {}
if window == null:
_resize_blocked = "no window adapter: logical resolution switching needs a windowed display"
elif not bool(available.get("ok", false)):
_resize_blocked = "window resize unavailable: %s" % String(available.get("reason", "unknown"))
else:
_begin_resize()
return
"REST":
_begin_rest()
return
func _next_fault_type() -> String:
var per := int(_soak_cfg().faults.get("per_type", 0))
var best := ""
for type in Config.SOAK_FAULT_TYPES:
if int(_faults[type]) < per and (best.is_empty() or int(_faults[type]) < int(_faults[best])):
best = type
return best
## The fault itself is produced outside the client (script/playable_fault_proxy.mjs reads
## this event); the flow never calls reconnect() here — ReconnectUI's own retry recovers.
func _begin_fault() -> void:
var faults: Dictionary = _soak_cfg().faults
var type := _next_fault_type()
_fault = {"type": type, "disconnected_us": -1}
_arm_reconnect()
cancel_inputs()
_enter("FAULT")
_event("fault_request", {"type": type, "attempt": int(_faults[type]) + 1, "round": _round,
"timeout_ms": FAULT_DISCONNECT_TIMEOUT_US / 1000,
"unreachable_seconds": int(faults.unreachable_seconds) if type == "unreachable" else 0})
func _tick_fault(now: int) -> void:
var type := String(_fault.type)
if int(_fault.disconnected_us) < 0:
if now - _stage_since > FAULT_DISCONNECT_TIMEOUT_US:
_soak_fail("STB-DISCONNECT-01", "FAIL", "requested %s fault produced no disconnect within 30s" % type)
return
var bound := _rebound(now)
if bound < 0:
return
if bound > 0:
_faults[type] = int(_faults[type]) + 1
_event("fault_recovered", {"type": type, "round": _round, "count": _faults[type],
"elapsed_ms": (now - int(_fault.disconnected_us)) / 1000})
_fault = {}
_soak_continue("FAULT")
return
var budget := int(_soak_cfg().faults.unreachable_seconds) * 1_000_000 + FAULT_RECOVERY_GRACE_US
if now - int(_fault.disconnected_us) > budget:
_soak_fail("STB-DISCONNECT-01", "FAIL", "%s fault not recovered by the reconnect path within %ds" % [type, budget / 1_000_000])
## Out-and-back through the configured portals. Only a new server connection epoch that
## lands on the expected map counts; the client never moves nodes to fake a map change.
func _begin_warp(leg: int) -> void:
var warp: Dictionary = _soak_cfg().warp
_warp_leg = leg
var point: Array = warp.portal_cm if leg == 0 else warp.return_portal_cm
_warp_expected = String(warp.destination_map_key) if leg == 0 else String(config.map_key)
_arm_reconnect()
cancel_inputs()
_enter("WARP")
var controller: Object = _context().get("pc", null)
if controller == null or not is_instance_valid(controller) or not controller.has_method("walk_to"):
_soak_fail("STB-WARP-01", "FAIL", "PlayerController.walk_to is unavailable")
return
controller.walk_to(MapCoord.to_world(Vector3(float(point[0]) * 0.01, 0.0, -float(point[1]) * 0.01)))
_event("warp_requested", {"leg": leg, "round": _round, "portal_cm": Vector2(float(point[0]), float(point[1])),
"expected_map_key": _warp_expected, "cross_server": bool(warp.cross_server)})
func _tick_warp(now: int) -> void:
var bound := _rebound(now)
if bound < 0:
return
var map_key := String(_flow_snapshot().get("map_key", ""))
if bound > 0:
if map_key != _warp_expected:
_event("map_mismatch", {"map_key": map_key, "expected_map_key": _warp_expected, "leg": _warp_leg})
_soak_fail("STB-WARP-01", "FAIL", "server reconnect after the portal loaded %s instead of %s" % [map_key, _warp_expected])
return
_warps += 1
_event("warp_arrived", {"leg": _warp_leg, "round": _round, "count": _warps, "map_key": map_key,
"elapsed_ms": (now - _stage_since) / 1000})
if _warp_leg == 0:
_begin_warp(1)
else:
_soak_continue("WARP")
return
if now - _stage_since > WARP_TIMEOUT_US:
var why := " (map_key changed without a new server connection)" if map_key == _warp_expected else ""
_soak_fail("STB-WARP-01", "FAIL", "no server reconnect into %s within 120s%s" % [_warp_expected, why])
func _begin_resize() -> void:
_resize_index = -1
_enter("RESIZE")
_next_resize(_now())
func _next_resize(now: int) -> void:
var sizes: Array = _soak_cfg().resolutions.sizes
while true:
_resize_index += 1
if _resize_index >= sizes.size():
_soak_continue("RESIZE")
return
var size := _resize_target()
if metrics != null:
metrics.mark("resize", {"size": "%dx%d" % [size.x, size.y]})
_resize_before = window.logical_size()
_resize_since = now
if bool(window.request_logical_size(size)):
return
var key := "%dx%d" % [size.x, size.y]
if _resize_blocked.is_empty():
_resize_blocked = "logical size %s rejected by the window adapter (larger than the usable screen)" % key
_event("window_size_rejected", {"requested_size": size, "round": _round, "screen_scale": float(window.screen_scale())})
func _resize_target() -> Vector2i:
var item: Array = _soak_cfg().resolutions.sizes[_resize_index]
return Vector2i(int(item[0]), int(item[1]))
func _tick_resize(now: int) -> void:
if now - _resize_since < RESIZE_SETTLE_US:
return
var size := _resize_target()
var logical: Vector2i = window.logical_size()
var viewport: Vector2i = window.viewport_size()
if absi(logical.x - size.x) <= RESIZE_TOLERANCE_PX and absi(logical.y - size.y) <= RESIZE_TOLERANCE_PX \
and viewport.x > 0 and viewport.y > 0 and _world_ready().ready:
# Physical pixels differ from logical points on Retina; only the logical size is judged.
var switched := absi(_resize_before.x - size.x) > RESIZE_TOLERANCE_PX or absi(_resize_before.y - size.y) > RESIZE_TOLERANCE_PX
var key := "%dx%d" % [size.x, size.y]
if switched:
_resize_counts[key] = int(_resize_counts.get(key, 0)) + 1
_event("window_resized", {"requested_size": size, "logical_size": logical, "physical_size": window.physical_size(),
"viewport_size": viewport, "screen_scale": float(window.screen_scale()), "round": _round,
"count": int(_resize_counts.get(key, 0)), "accepted": switched})
_next_resize(now)
return
if now - _resize_since > RESIZE_TIMEOUT_US:
_event("window_resize_timeout", {"requested_size": size, "logical_size": logical, "viewport_size": viewport,
"physical_size": window.physical_size(), "screen_scale": float(window.screen_scale()), "round": _round})
_soak_fail("STB-RESIZE-01", "FAIL", "window stayed at logical %dx%d after requesting %dx%d" % [logical.x, logical.y, size.x, size.y])
func _begin_rest() -> void:
cancel_inputs()
_enter("REST")
_event("soak_rest", {"round": _round, "state": "start", "wall_ms": _wall_ms()})
func _tick_rest(now: int) -> void:
if now - _stage_since < int(_soak_cfg().rest_seconds) * 1_000_000:
return
_event("soak_rest", {"round": _round, "state": "end", "wall_ms": _wall_ms()})
_rounds_done = _round + 1
if now - _soak_start_us >= int(_soak_cfg().duration_seconds) * 1_000_000:
_finish()
return
_round += 1
_begin_round()
func _soak_fail(case_id: String, case_status: String, reason: String) -> void:
cancel_inputs()
_finish_case(case_id, case_status, reason)
_finish()
## §9.3: one repetition of an interaction = one stay in a stage; it is "severe" when
## a >100ms frame tagged with that phase arrived during the stay.
func _track_frames(previous: String) -> void:
if suite != "soak" or metrics == null or _soak_start_us < 0 or previous.is_empty():
return
var phase := previous.to_lower()
var total := int(metrics.severe_by_phase().get(phase, 0))
var delta := total - int(_severe_seen.get(phase, 0))
_severe_seen[phase] = total
var history: Array = _frame_history.get(phase, [])
history.append(delta > 0)
if history.size() > FRAME_HISTORY_CAP:
history.pop_front()
_frame_history[phase] = history
if delta > 0:
_event("frame_severe", {"interaction": phase, "severe": delta, "round": _round})
if history.size() >= 3 and not Metrics.repeated_severe({phase: history.slice(-3)}).is_empty():
_repeated_severe[phase] = true
func _close_soak() -> void:
var now := _now()
var soak := _soak_cfg()
var elapsed := now - _soak_start_us
var duration_s := int(soak.duration_seconds)
var failures: Array = report.failures()
if not report.has_case("STB-DURATION-01"):
if not failures.is_empty():
report.add_case("STB-DURATION-01", "FAIL", "soak ended by a failure after %d rounds (%.0fs of %ds)" % [_rounds_done, elapsed / 1e6, duration_s])
elif not _soak_blocked.is_empty():
report.add_case("STB-DURATION-01", "BLOCKED", "soak ended early: %s" % _soak_blocked)
elif elapsed < duration_s * 1_000_000:
report.add_case("STB-DURATION-01", "BLOCKED", "wall-clock %.0fs is below the %ds soak" % [elapsed / 1e6, duration_s])
elif _rounds_done < int(soak.min_rounds):
report.add_case("STB-DURATION-01", "BLOCKED", "%d rounds completed, need at least %d" % [_rounds_done, int(soak.min_rounds)])
else:
report.add_case("STB-DURATION-01", "PASS", "%d move/combat/pickup rounds over %.0fs wall clock" % [_rounds_done, elapsed / 1e6])
if not report.has_case("STB-FRAMES-01"):
if metrics == null:
report.add_case("STB-FRAMES-01", "BLOCKED", "no frame metrics attached to this run")
elif not _repeated_severe.is_empty():
report.add_case("STB-FRAMES-01", "FAIL", ">100ms frames in 2 of 3 consecutive repetitions of: %s" % ", ".join(_repeated_severe.keys()))
else:
var unattributed: Dictionary = metrics.long_unattributed_by_phase()
unattributed.erase("loading")
var count := 0
for phase in unattributed:
count += int(unattributed[phase])
if count > 0:
report.add_case("STB-FRAMES-01", "BLOCKED", "%d gameplay frames >50ms need attribution: %s" % [count, unattributed])
else:
report.add_case("STB-FRAMES-01", "PASS", "no repeated >100ms interaction; every gameplay frame >50ms has a marker")
if not report.has_case("STB-RECONNECT-01"):
var required := int(soak.reconnects)
if _active_reconnects >= required:
report.add_case("STB-RECONNECT-01", "PASS", "%d active reconnects re-acquired main VID, scene and UI without residue" % _active_reconnects)
else:
report.add_case("STB-RECONNECT-01", "BLOCKED", "%d of %d active reconnects completed" % [_active_reconnects, required])
if not report.has_case("STB-WARP-01"):
var warp: Dictionary = soak.warp
if String(warp.get("status", "")) != "confirmed":
report.add_case("STB-WARP-01", "BLOCKED", "warp route not confirmed by the environment owner (portal, destination, cross-server support)")
elif _warps >= int(warp.required):
report.add_case("STB-WARP-01", "PASS", "%d map changes through configured portals, each on a new server connection" % _warps)
else:
report.add_case("STB-WARP-01", "BLOCKED", "%d of %d portal map changes completed" % [_warps, int(warp.required)])
if not report.has_case("STB-DISCONNECT-01"):
var faults: Dictionary = soak.faults
if String(faults.get("status", "")) != "confirmed":
report.add_case("STB-DISCONNECT-01", "BLOCKED", "real transport faults (connection closed, short unreachability) need a confirmed local test proxy; active reconnect is not a substitute")
elif _next_fault_type().is_empty():
report.add_case("STB-DISCONNECT-01", "PASS", "recovered from real faults %s" % [_faults])
else:
report.add_case("STB-DISCONNECT-01", "BLOCKED", "real faults recovered %s, need %d per type" % [_faults, int(faults.per_type)])
if not report.has_case("STB-RESIZE-01"):
var short: Array = []
for key in _resize_counts:
if int(_resize_counts[key]) < int(soak.resolutions.switches_per_size):
short.append(key)
if not _resize_blocked.is_empty():
report.add_case("STB-RESIZE-01", "BLOCKED", _resize_blocked)
elif short.is_empty():
report.add_case("STB-RESIZE-01", "PASS", "switched to each logical size at least %d times: %s" % [int(soak.resolutions.switches_per_size), _resize_counts])
else:
report.add_case("STB-RESIZE-01", "BLOCKED", "logical size switches %s below %d for %s" % [_resize_counts, int(soak.resolutions.switches_per_size), short])
var interactions := {}
for phase in _frame_history:
var hits := 0
for hit in _frame_history[phase]:
hits += int(bool(hit))
interactions[phase] = {"recent": _frame_history[phase].size(), "severe": hits}
report.set_section("soak", {"duration_seconds": duration_s, "elapsed_seconds": elapsed / 1e6, "rounds": _rounds_done,
"rest_seconds": int(soak.rest_seconds), "warmup_rounds": int(soak.warmup_rounds),
"active_reconnects": _active_reconnects, "real_faults": _faults.duplicate(), "fault_mode": String(soak.faults.get("status", "")),
"warps": _warps, "warp_status": String(soak.warp.get("status", "")), "cross_server": soak.warp.get("cross_server", null),
"resizes": _resize_counts.duplicate(), "wall_start_ms": _soak_wall_start_ms, "wall_end_ms": _wall_ms(),
"interactions": interactions, "repeated_severe": _repeated_severe.keys(),
"exits": "counted by the runner (script/playable_soak.sh)",
"cold_start": "进程冷启动/系统缓存状态未知;未执行系统缓存清空"})
func _wall_ms() -> int:
return int(wall_ms.call()) if wall_ms.is_valid() else int(Time.get_unix_time_from_system() * 1000.0)
# --- failure / teardown ---------------------------------------------------------
func _on_disconnected() -> void:
if stage == "MOVE_CONFIRM" or (suite == "soak" and stage in ["WARP", "FAULT"]):
# Expected during a requested reconnect / portal / transport fault; the next
# entered_game after a disconnect is not a duplicate signal.
_reconnect_entered = 0
if stage == "FAULT" and not _fault.is_empty() and int(_fault.disconnected_us) < 0:
_fault.disconnected_us = _now()
_event("fault_disconnected", {"type": _fault.type, "round": _round,
"elapsed_ms": (int(_fault.disconnected_us) - _stage_since) / 1000})
return
cancel_inputs()
match stage:
"LOGIN":
_finish_case("NET-LOGIN-01", "FAIL", "disconnected before char_list")
"SELECT", "WORLD":
# NET-03: a disconnect while entering must end the run without half-loaded operations.
_finish_case(_case_for_stage(), "FAIL", "disconnected while entering the game")
_:
_finish_case(_case_for_stage(), "FAIL", "unexpected disconnect during %s" % stage)
_finish()
func _fail_stage(reason: String) -> void:
cancel_inputs()
_finish_case(_case_for_stage(), "FAIL", reason)
_finish()
func _finish_case(case_id: String, case_status: String, reason: String) -> void:
var soak_active := suite == "soak" and _soak_start_us >= 0
if soak_active and case_status == "BLOCKED" and _soak_blocked.is_empty():
_soak_blocked = "soak round %d %s: %s" % [_round, case_id, reason]
if report.has_case(case_id):
# A later soak round repeats cases that already have a result: record the round
# failure against the run instead of a duplicate case result.
if soak_active and case_status == "FAIL":
report.add_failure("soak round %d %s: %s" % [_round, case_id, reason])
return
var evidence: Array = []
if stage == "SKILL" and case_id == _case_for_stage():
evidence.append_array(_shots)
if capture.is_valid():
var path := String(capture.call(case_id))
if not path.is_empty():
evidence.append(path)
report.add_case(case_id, case_status, reason, evidence, float(_now() - _stage_since) / 1000.0)
## PASS result; a soak round repeating an already-passed case records nothing new.
func _pass(case_id: String, reason: String) -> void:
if suite == "soak" and report.has_case(case_id):
return
report.add_case(case_id, "PASS", reason)
func _block_remaining(reason: String) -> void:
for id in report.required_cases():
if not report.has_case(String(id)):
report.add_case(String(id), "BLOCKED", reason)
func _finish() -> void:
if done:
return
cancel_inputs()
if suite == "soak" and _soak_start_us >= 0:
_close_soak()
done = true
_event("finished", {"state": stage})
status = String(report.finish().status)
finished.emit(status)
# --- helpers ---------------------------------------------------------------------
func _case_for_stage() -> String:
match stage:
"BOOT": return "CONFIG-01"
"LOGIN": return "NET-LOGIN-01"
"SELECT": return "NET-SELECT-01"
"WORLD": return "NET-WORLD-01"
"MOVE": return _move_case()
"MOVE_CONFIRM": return "NET-MOVE-CONFIRM-01"
"TARGET": return "NET-TARGET-01" if not report.has_case("NET-TARGET-01") else "NET-DROP-01"
"ATTACK": return "NET-ATTACK-01" if not report.has_case("NET-ATTACK-01") else "NET-DROP-01"
"DROP": return "NET-DROP-01"
"PICKUP": return "NET-PICKUP-01"
"SKILL":
var skills: Array = config.get("skill_cases", [])
return Config.skill_case_id(skills[_skill_index]) if _skill_index < skills.size() else "CBT"
"FAULT": return "STB-DISCONNECT-01"
"WARP": return "STB-WARP-01"
"RESIZE": return "STB-RESIZE-01"
"REST": return "STB-DURATION-01"
return stage
func _move_case() -> String:
return "NET-MOVE-%02d" % (_waypoint + 1)
func _event(kind: String, payload: Dictionary) -> void:
report.record(kind, stage, _case_for_stage(), _probe_epoch(), _main_vid, int(_target.get("vid", 0)), payload)
func _now() -> int:
return int(clock_us.call())
func _probe_epoch() -> int:
return int(probe.connection_epoch) if probe != null else 0
## §9.1 segment tags for the live frame sampler; frames before the world is ready are "loading".
func frame_tags() -> Dictionary:
var ready := _world_ready()
var net_world: Variant = _context().get("net_world", null)
var parent: Variant = net_world.get("parent") if net_world != null and is_instance_valid(net_world) and "parent" in net_world else null
var actors: int = parent.get_child_count() if parent != null and is_instance_valid(parent) else 0
return {"map": ready.map_key, "phase": stage.to_lower() if ready.ready else "loading",
"actor_count": Metrics.actor_bucket(actors)}
func _is_target(vid: int) -> bool:
return not _target.is_empty() and vid == int(_target.vid) and int(_target.get("epoch", -1)) == _bound_epoch
func _flow_snapshot() -> Dictionary:
if flow != null and is_instance_valid(flow) and flow.has_method("get_playable_snapshot"):
return flow.get_playable_snapshot()
return {}
## Borrowed references: re-read every use, never cached across reconnect/scene reload.
func _context() -> Dictionary:
if flow != null and is_instance_valid(flow) and flow.has_method("get_playable_context"):
return flow.get_playable_context()
return {}
func _world_ready() -> Dictionary:
var snap := _flow_snapshot()
var context := _context()
var player: Variant = context.get("player", null)
var hud: Variant = context.get("hud", null)
var player_ready: bool = player != null and is_instance_valid(player)
var hud_ready: bool = hud != null and is_instance_valid(hud)
var scene_ready := bool(snap.get("scene_ready", false))
return {"ready": scene_ready and player_ready and hud_ready, "scene_ready": scene_ready,
"player_ready": player_ready, "hud_ready": hud_ready, "map_key": String(snap.get("map_key", ""))}
func _ground_items() -> Array:
return client.get_ground_items() if client.has_method("get_ground_items") else []
func _ground_iids() -> Dictionary:
var out := {}
for item in _ground_items():
out[int(item.get("vid", 0))] = true
return out
func _ground_pos_cm(item: Dictionary) -> Vector2:
var pos: Variant = item.get("pos", null)
if pos is Vector3:
return Vector2(pos.x * 100.0, -pos.z * 100.0)
return Vector2(INF, INF)
func _owner_matches(owner: String) -> bool:
if owner.is_empty():
return true
var main: Dictionary = client.get_entity(_main_vid) if client.has_method("get_entity") else {}
return String(main.get("name", "")) == owner
## Stacks are summed over every inventory cell, so a merge into an existing stack counts.
func _inventory_total(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 += maxi(1, int(item.get("count", 1)))
return total