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