Files
mtgodot-poc/project/playable_metrics_test.gd
shenleiandClaude Opus 5 f39a55fdd5 feat(playable): 首个 Mac 联网内测版 STB-01 soak 工具与发布状态文档
- 新增 script/playable_soak.sh:先做 soak 配置/资源校验,无 --allow-gameplay
  只校验不启动客户端;N(>=10) 次独立正常退出运行 + 墙钟 soak,失败即停止后续
  运行并记 BLOCKED;写本批次 release-manifest.json 后聚合
- run_client_gate.sh:确认的故障代理对所有 suite 启动(共享场景的退出运行也经代理);
  soak 超时下限只约束 soak 客户端(validate_soak 增加 soak_client 参数)
- 新增本地 127.0.0.1 故障代理、RSS 采样/内存判定、窗口/指标/流程模块及其测试
- forest_mob_render_test 输出 PASS/FAIL 标记,供 rendering_batch_test.sh 识别
- 新增 docs/FIRST-MAC-PLAYABLE-STATUS.md:如实记录 PASS/BLOCKED、已知问题与环境需求
- .gitignore 排除本地场景配置、凭据文件与运行输出

离线回归:rendering_batch_test.sh failures=0,playable_gate_test.sh PASS,
node 夹具测试 PASS。未联网运行,未重建候选包。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
2026-09-11 22:06:48 +09:00

137 lines
7.1 KiB
GDScript

# playable_metrics_test —— STB-01 §9.1 帧间隔/长帧指标的离线自检(注入时钟,不等真实时间)。
# godot --headless --path project --script playable_metrics_test.gd
extends SceneTree
const Metrics = preload("res://testing/playable_metrics.gd")
var _fail := 0
var _done := false
var _now := 0
func _ck(ok: bool, message: String) -> void:
if not ok:
_fail += 1
printerr("FAIL: " + message)
func _init() -> void:
_run()
_ck(_done, "all metrics assertions executed")
print("playable_metrics_test: failures=%d" % _fail)
if _fail == 0:
print("PASS: playable_metrics_test")
quit(1 if _fail else 0)
func _metrics(options := {}) -> RefCounted:
var m: RefCounted = Metrics.new()
m.clock_us = func() -> int: return _now
m.configure(options)
return m
## Advance the injected clock by `ms` and close one frame.
func _frame(m: RefCounted, ms: float) -> void:
_now += int(ms * 1000.0)
m.frame()
func _run() -> void:
# Percentiles are nearest-rank over the window samples; max and counts are exact.
var p: RefCounted = _metrics({"window_seconds": 3600})
p.set_tags({"map": "m", "phase": "play", "actor_count": 8, "gpu_skin": true})
p.frame()
for i in range(1, 101):
_frame(p, float(i))
var s: Dictionary = p.summary()
var w: Dictionary = s.windows[0]
_ck(w.count == 100 and w.sampled == 100 and not w.truncated, "one window with 100 samples: %s" % [w])
_ck(w.p50_ms == 50.0 and w.p95_ms == 95.0 and w.p99_ms == 99.0 and w.max_ms == 100.0,
"nearest-rank p50/p95/p99/max: %s" % [w])
_ck(w.over_50ms == 50 and w.over_100ms == 0, ">50ms and >100ms are strict thresholds: %s" % [w])
_ck(s.print_frames == false and p.print_frames == false, "per-frame printing is off by default")
_ck(s.long_frames.size() == 50 and s.long_frames[0].ms == 51.0, "only samples above 50ms become long-frame events")
_ck(s.long_frames[0].tags == {"map": "m", "phase": "play", "actor_count": "8", "gpu_skin": "true"},
"long frames carry map/phase/actor_count/gpu_skin tags: %s" % [s.long_frames[0].tags])
_ck(s.frames == 100, "first frame() only arms the clock")
# Fixed-size ring: a window longer than the capacity keeps exact counters but
# bounded sample storage, and says so.
var ring: RefCounted = _metrics({"capacity": 64, "window_seconds": 3600, "max_long_frames": 8, "max_markers": 4})
ring.set_tags({"phase": "play"})
ring.frame()
for i in 10000:
_frame(ring, 120.0 if i == 17 else (60.0 if i % 1000 == 0 else 16.0))
if i % 100 == 0:
ring.mark("entity_batch", {"count": i})
_ck(ring.stored_sample_count() <= 64, "ring buffer never grows past capacity (%d)" % ring.stored_sample_count())
var rs: Dictionary = ring.summary()
var rw: Dictionary = rs.windows[0]
_ck(rw.count == 10000 and rw.sampled == 64 and rw.truncated, "overlong window is marked truncated: %s" % [rw])
_ck(rw.max_ms == 120.0 and rw.over_100ms == 1 and rw.over_50ms == 11, "counters stay exact beyond capacity: %s" % [rw])
_ck(rs.long_frames.size() == 8 and rs.long_frames_dropped == 3, "long-frame events are bounded with a drop count")
_ck(rs.markers.size() == 4 and rs.markers_dropped == 96, "markers are bounded with a drop count")
_ck(ring.long_unattributed_by_phase() == {"play": 11} and rs.long_frames.size() == 8,
"unattributed counts stay exact while the long-frame list is bounded: %s" % [ring.long_unattributed_by_phase()])
# Wall-clock windows close on the injected clock, not on a frame count.
var timed: RefCounted = _metrics({"window_seconds": 1, "max_windows": 3})
timed.set_tags({"phase": "play"})
timed.frame()
for i in 400:
_frame(timed, 16.0)
var ts: Dictionary = timed.summary()
_ck(ts.windows.size() == 3 and ts.windows_dropped >= 3, "closed windows are bounded (%d kept, %d dropped)" % [ts.windows.size(), ts.windows_dropped])
_ck(ts.windows.all(func(x: Dictionary) -> bool: return x.end_ms - x.start_ms <= 1016), "each window spans about one wall-clock second")
# A tag change closes the window and opens a separate segment; segments are capped.
var seg: RefCounted = _metrics({"max_segments": 2, "window_seconds": 3600})
seg.set_tags({"phase": "loading", "map": "a"})
seg.frame()
_frame(seg, 300.0)
seg.set_tags({"phase": "play", "map": "a"})
_frame(seg, 16.0)
seg.set_tags({"phase": "play", "map": "b"})
_frame(seg, 16.0)
var ss: Dictionary = seg.summary()
_ck(ss.windows.size() == 3 and ss.windows[0].tags.phase == "loading", "tag changes split windows: %s" % [ss.windows])
_ck(ss.segments.size() == 2 and ss.unsegmented_frames == 1, "segment table is capped: %s" % [ss.segments])
_ck(ss.segments[0].max_ms == 300.0 and ss.segments[0].over_100ms == 1, "loading segment is reported separately")
_ck(seg.severe_by_phase() == {"loading": 1}, "severe counts by phase: %s" % [seg.severe_by_phase()])
# Markers from the frame that was measured (and the one before it) attribute a long frame.
var att: RefCounted = _metrics({"window_seconds": 3600})
att.set_tags({"phase": "play"})
att.frame()
_frame(att, 16.0)
att.mark("model_build", {"race": 2301, "ms": 70.0})
_frame(att, 16.0)
_frame(att, 80.0)
_frame(att, 16.0)
_frame(att, 16.0)
_frame(att, 75.0)
var at: Dictionary = att.summary()
_ck(at.long_frames.size() == 2 and at.long_frames[0].markers == ["model_build"], "long frame attributed to the recent marker: %s" % [at.long_frames])
_ck(at.long_frames[1].markers.is_empty(), "later long frame without a marker stays unattributed")
_ck(att.unattributed_long_frames().size() == 1, "unattributed long frames are listed for analysis")
_ck(att.long_unattributed_by_phase() == {"play": 1} and at.long_unattributed_by_phase == {"play": 1},
"unattributed long frames are counted per phase: %s" % [att.long_unattributed_by_phase()])
_ck(at.markers[0].detail == {"race": 2301, "ms": 70.0}, "marker detail keeps only whitelisted keys")
att.mark("model_build", {"name": "Hero", "count": 2})
_ck(not att.summary().markers[-1].detail.has("name"), "non-whitelisted marker detail is dropped")
_ck([0, 1, 8, 9, 16, 17, 64, 65].map(func(n: int) -> String: return Metrics.actor_bucket(n))
== ["0", "1-8", "1-8", "9-16", "9-16", "17-32", "33-64", "65+"], "actor counts are bucketed")
# §9.3: the same interaction repeated 3 times with >100ms in at least 2 blocks.
_ck(Metrics.repeated_severe({"ATTACK": [true, false, true], "MOVE": [true, false, false, true], "DROP": [false, true, true]})
== ["ATTACK", "DROP"], "2-of-3 consecutive repetitions with a >100ms frame are flagged")
_ck(Metrics.repeated_severe({"ATTACK": [true, true]}).is_empty(), "fewer than 3 repetitions cannot be judged yet")
# Memory: MEMORY_STATIC is recorded; GPU memory is never reported as 0.
var mem: Dictionary = Metrics.memory_snapshot()
_ck(mem.memory_static_kib is int and mem.memory_static_kib > 0, "MEMORY_STATIC in KiB: %s" % [mem])
_ck(mem.gpu_memory_kib == null and mem.gpu_memory_status == "unavailable", "GPU memory is null/unavailable")
_ck(mem.godot_video_mem_kib == null or int(mem.godot_video_mem_kib) > 0, "Godot video allocation is null when unknown, not 0")
# Probe cost is measured, not assumed.
var cost: Dictionary = Metrics.measure_overhead(2000)
_ck(cost.iterations == 2000 and cost.per_sample_us > 0.0 and cost.per_sample_us < 1000.0, "per-sample probe cost measured: %s" % [cost])
_done = true