- 新增 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
130 lines
5.7 KiB
GDScript
130 lines
5.7 KiB
GDScript
extends SceneTree
|
|
|
|
const EffectPlayer = preload("res://fx/effect_player.gd")
|
|
const EffectRegistry = preload("res://fx/effect_registry.gd")
|
|
var failures := 0
|
|
|
|
func check(ok: bool, label: String) -> void:
|
|
if not ok:
|
|
failures += 1
|
|
printerr("FAIL: " + label)
|
|
|
|
func _init() -> void:
|
|
call_deferred("run")
|
|
|
|
func fixture():
|
|
var fx := EffectPlayer.new()
|
|
fx.build({"particles": [{"start_time": 0.2,
|
|
"emitter": {"CycleLength": 1.0, "CycleLoopEnable": 1}}],
|
|
"lights": [{"duration": 0.8, "diffuse": [1.0, 0.5, 0.2, 1.0]}]})
|
|
# A synthetic two-frame mesh isolates playback from MDE asset decoding.
|
|
var host := Node3D.new()
|
|
var mesh := MeshInstance3D.new()
|
|
mesh.material_override = StandardMaterial3D.new()
|
|
host.add_child(mesh)
|
|
fx.add_child(host)
|
|
fx._mesh_nodes.append(host)
|
|
fx._mesh_states.append({"node": host, "clock": 0.0, "start_time": 0.0,
|
|
"frame_delay": 0.5, "duration": 1.0, "children": [{"node": mesh,
|
|
"frames": [{"visibility": 1.0}, {"visibility": 0.5}],
|
|
"meshes": [ArrayMesh.new(), ArrayMesh.new()], "frame": -1}]})
|
|
root.add_child(fx)
|
|
fx.set_process(false) # Advance deterministically; SceneTree timers still run.
|
|
return fx
|
|
|
|
func run() -> void:
|
|
var fx = fixture()
|
|
var emitter: GPUParticles3D = fx._emitters[0]
|
|
check(not emitter.emitting, "build does not emit before play")
|
|
fx.play(true)
|
|
check(not emitter.emitting, "play respects delay immediately")
|
|
fx._process(0.25)
|
|
check(emitter.emitting, "particle starts on playback clock")
|
|
fx._process(0.35)
|
|
check(fx._mesh_states[0].children[0].frame == 1, "mesh reached second frame")
|
|
fx.stop()
|
|
fx._process(0.01)
|
|
check(not fx._lights[0].visible, "stopped light stays hidden")
|
|
check(not fx._mesh_nodes[0].visible, "stopped mesh stays hidden")
|
|
check(is_equal_approx(fx._light_states[0].clock, 0.6), "stop freezes CPU clock")
|
|
await create_timer(0.3).timeout
|
|
check(not emitter.emitting, "old delayed start cannot revive stopped emitter")
|
|
fx.play(false)
|
|
check(not emitter.one_shot, "normal replay restores source loop mode")
|
|
check(is_zero_approx(fx._mesh_states[0].clock), "mesh replay resets clock")
|
|
check(is_zero_approx(fx._light_states[0].clock), "light replay resets clock")
|
|
check(fx._mesh_states[0].children[0].frame == 0, "mesh replay restores first frame")
|
|
check(fx._lights[0].visible, "light replay restores visibility")
|
|
fx._process(0.1)
|
|
check(not emitter.emitting, "replay uses new start delay")
|
|
fx._process(0.1)
|
|
check(emitter.emitting, "replay starts at delay boundary")
|
|
# Old forced-one-shot cleanup must not delete a later persistent playback.
|
|
await create_timer(2.5).timeout
|
|
check(is_instance_valid(fx), "old auto-free cannot delete replay")
|
|
if is_instance_valid(fx):
|
|
fx.free()
|
|
var once = fixture()
|
|
once.play(true)
|
|
once._process(once._longest_life() + 0.6)
|
|
check(once.is_queued_for_deletion(), "current one-shot still cleans up")
|
|
if not once.is_queued_for_deletion():
|
|
once.free()
|
|
await process_frame
|
|
# Exercise the actual SceneTree process path, not just manual clock updates.
|
|
var live = fixture()
|
|
live.set_process(true)
|
|
live.play()
|
|
await create_timer(0.35).timeout
|
|
check(live._emitters[0].emitting, "SceneTree starts delayed emission")
|
|
live.stop()
|
|
var stopped_clock: float = live._particle_states[0].clock
|
|
await create_timer(0.1).timeout
|
|
check(not live._emitters[0].emitting and not live._lights[0].visible,
|
|
"SceneTree does not reactivate stopped effect")
|
|
check(is_equal_approx(live._particle_states[0].clock, stopped_clock),
|
|
"SceneTree leaves stopped clock unchanged")
|
|
live.free()
|
|
await registry_lifecycle()
|
|
print("effect_playback_test: failures=%d" % failures)
|
|
quit(1 if failures else 0)
|
|
|
|
# CBT-01 evidence: EffectRegistry reports the real spawn and tree-exit boundaries.
|
|
func registry_lifecycle() -> void:
|
|
var registry := EffectRegistry.new()
|
|
var spec := {"lights": [{"duration": 0.8, "diffuse": [1.0, 0.5, 0.2, 1.0]}]}
|
|
registry._path_cache["synthetic"] = "synthetic.mse"
|
|
registry._spec_cache["synthetic.mse"] = spec
|
|
var spawned: Array = []
|
|
var finished: Array = []
|
|
registry.fx_spawned.connect(func(effect: String, id: int, lifetime: int) -> void: spawned.append([effect, id, lifetime]))
|
|
registry.fx_finished.connect(func(effect: String, id: int, lifetime: int, elapsed: int, reason: String) -> void:
|
|
finished.append([effect, id, lifetime, elapsed, reason]))
|
|
var parent := Node3D.new()
|
|
root.add_child(parent)
|
|
var once := registry.spawn("synthetic", parent, true)
|
|
check(spawned.size() == 1 and spawned[0][0] == "synthetic" and spawned[0][2] == 1000,
|
|
"one-shot spawn reports its MSE-defined lifetime: %s" % [spawned])
|
|
check(finished.is_empty(), "spawn alone is not a finish")
|
|
await create_timer(1.8).timeout
|
|
check(not is_instance_valid(once), "one-shot effect left the tree on its own clock")
|
|
check(finished.size() == 1 and finished[0][1] == spawned[0][1] and finished[0][4] == "cleanup",
|
|
"tree exit reports the same id with reason cleanup: %s" % [finished])
|
|
if finished.size() == 1:
|
|
check(int(finished[0][3]) >= 1000 and int(finished[0][3]) <= 1000 + 1000,
|
|
"cleanup happened inside lifetime + 1s (elapsed %dms)" % int(finished[0][3]))
|
|
var looping := registry.spawn("synthetic", parent, false)
|
|
check(spawned.size() == 2 and spawned[1][2] == -1 and spawned[1][1] != spawned[0][1],
|
|
"looping spawn has a fresh id and no defined lifetime")
|
|
var early := registry.spawn("synthetic", parent, true)
|
|
parent.remove_child(early)
|
|
early.free()
|
|
check(finished.size() == 2 and finished[1][4] == "removed", "owner removal before cleanup reports reason removed")
|
|
parent.free()
|
|
check(finished.size() == 3 and finished[2][1] == spawned[1][1], "scene teardown closes the looping effect")
|
|
var orphan := Node3D.new()
|
|
check(registry.spawn("missing", orphan, true) == null and spawned.size() == 3,
|
|
"unresolvable effect emits no spawn event")
|
|
orphan.free()
|
|
await process_frame
|