extends SceneTree var failures: Array[String] = [] func _init() -> void: _run.call_deferred() func _run() -> void: await process_frame _check_effect_scripts_and_player_component() _check_phase6_effect_resources_have_single_stack_baseline() _check_duration_and_stat_modifier() _check_active_effect_summaries_include_runtime_debug_data() _check_effect_container_exposes_rule_modifiers() _check_dispatch_event_adds_triggered_effects() _check_event_filters_gate_triggered_effects() _check_action_and_hit_duration_expire_on_matching_events() _check_measure_and_until_event_duration() _check_burst_effect_resource_drives_stat_provider() await _check_seconds_effects_tick_from_process() await _check_beat_event_dispatches_effect_event() await _check_perfect_input_dispatches_effect_event() await _check_player_initial_effect_loadout() await process_frame _finish() func _check_effect_scripts_and_player_component() -> void: for path: String in [ "res://resources/effects/effect_definition.gd", "res://resources/effects/effect_instance.gd", "res://resources/effects/stat_modifier.gd", "res://resources/effects/defense_modifier.gd", "res://resources/effects/action_rule_modifier.gd", "res://scenes/components/effect_container.gd", ]: _expect(load(path) != null, "%s should load" % path) var player_scene: PackedScene = load("res://scenes/characters/player.tscn") var player := player_scene.instantiate() root.add_child(player) await process_frame _expect(player.get_node_or_null("EffectContainer") != null, "Player should own EffectContainer") player.queue_free() func _check_duration_and_stat_modifier() -> void: var definition_script: Script = load("res://resources/effects/effect_definition.gd") var modifier_script: Script = load("res://resources/effects/stat_modifier.gd") var container_script: Script = load("res://scenes/components/effect_container.gd") var stat_resolver: Script = load("res://scripts/resolvers/stat_resolver.gd") if definition_script == null or modifier_script == null or container_script == null or stat_resolver == null: return var definition: Resource = definition_script.new() definition.set("id", &"test_double_damage") definition.set("duration_type", &"seconds") definition.set("duration", 0.5) var modifier: Resource = modifier_script.new() modifier.set("stat", &"damage_mult") modifier.set("operation", &"multiply") modifier.set("value", 2.0) var stat_modifiers: Array[Resource] = [modifier] definition.set("stat_modifiers", stat_modifiers) var container: Node = container_script.new() root.add_child(container) container.call("add_effect", definition) _expect_int(int(container.call("active_count")), 1, "EffectContainer should add an effect instance") var damage := float(stat_resolver.call("resolve_damage", 10.0, null, {"label": "perfect"}, container, null)) _expect_float(damage, 20.0, "StatResolver should read damage multiplier from EffectContainer") container.call("tick_time", 0.6) _expect_int(int(container.call("active_count")), 0, "EffectContainer should expire seconds-based effects") container.free() func _check_active_effect_summaries_include_runtime_debug_data() -> void: var definition_script: Script = load("res://resources/effects/effect_definition.gd") var container_script: Script = load("res://scenes/components/effect_container.gd") if definition_script == null or container_script == null: return var definition: Resource = definition_script.new() definition.set("id", &"test_debug_stack") definition.set("duration_type", &"seconds") definition.set("duration", 2.0) definition.set("max_stacks", 3) var container: Node = container_script.new() root.add_child(container) if _method_arg_count(container, "add_effect") >= 2: container.call("add_effect", definition, &"test_source") container.call("add_effect", definition, &"test_source") else: failures.append("EffectContainer.add_effect should accept a source argument") container.call("add_effect", definition) container.call("add_effect", definition) _expect(container.has_method("active_effect_summaries"), "EffectContainer should expose active_effect_summaries for debug UI") if container.has_method("active_effect_summaries"): var summaries: Array = container.call("active_effect_summaries") _expect_int(summaries.size(), 1, "Stacked effects should produce one summary") if not summaries.is_empty() and summaries[0] is Dictionary: var summary: Dictionary = summaries[0] _expect_equal(summary.get("id"), &"test_debug_stack", "Effect summary should include id") _expect_equal(summary.get("duration_type"), &"seconds", "Effect summary should include duration_type") _expect_int(int(summary.get("stacks", 0)), 2, "Effect summary should include stack count") _expect_float(float(summary.get("remaining", 0.0)), 2.0, "Effect summary should include remaining duration") _expect_string(str(summary.get("source", "")), "test_source", "Effect summary should include source") container.free() func _check_effect_container_exposes_rule_modifiers() -> void: var definition_script: Script = load("res://resources/effects/effect_definition.gd") var defense_modifier_script: Script = load("res://resources/effects/defense_modifier.gd") var action_rule_modifier_script: Script = load("res://resources/effects/action_rule_modifier.gd") var container_script: Script = load("res://scenes/components/effect_container.gd") if definition_script == null or defense_modifier_script == null or action_rule_modifier_script == null or container_script == null: return var definition: Resource = definition_script.new() definition.set("id", &"test_rule_modifiers") definition.set("duration_type", &"infinite") var defense_modifier: Resource = defense_modifier_script.new() defense_modifier.set("defense_state", &"Invincible") var defense_modifiers: Array[Resource] = [defense_modifier] definition.set("defense_modifiers", defense_modifiers) var action_rule_modifier: Resource = action_rule_modifier_script.new() var blocked_tags: Array[StringName] = [&"spell"] action_rule_modifier.set("blocked_action_tags", blocked_tags) var action_rule_modifiers: Array[Resource] = [action_rule_modifier] definition.set("action_rule_modifiers", action_rule_modifiers) var container: Node = container_script.new() root.add_child(container) container.call("add_effect", definition) _expect(container.has_method("defense_modifiers"), "EffectContainer should expose active defense modifiers") _expect(container.has_method("action_rule_modifiers"), "EffectContainer should expose active action-rule modifiers") if container.has_method("defense_modifiers"): _expect_int(container.call("defense_modifiers").size(), 1, "EffectContainer.defense_modifiers should return active defense modifiers") if container.has_method("action_rule_modifiers"): _expect_int(container.call("action_rule_modifiers").size(), 1, "EffectContainer.action_rule_modifiers should return active action-rule modifiers") container.free() func _check_dispatch_event_adds_triggered_effects() -> void: var definition_script: Script = load("res://resources/effects/effect_definition.gd") var modifier_script: Script = load("res://resources/effects/stat_modifier.gd") var container_script: Script = load("res://scenes/components/effect_container.gd") var stat_resolver: Script = load("res://scripts/resolvers/stat_resolver.gd") if definition_script == null or modifier_script == null or container_script == null or stat_resolver == null: return var reward: Resource = definition_script.new() reward.set("id", &"test_perfect_reward_damage") reward.set("duration_type", &"infinite") var modifier: Resource = modifier_script.new() modifier.set("stat", &"damage_mult") modifier.set("operation", &"multiply") modifier.set("value", 1.5) var stat_modifiers: Array[Resource] = [modifier] reward.set("stat_modifiers", stat_modifiers) var listener: Resource = definition_script.new() listener.set("id", &"test_on_perfect_listener") listener.set("duration_type", &"infinite") listener.set("trigger_event", &"on_perfect") var triggered_effects: Array[Resource] = [reward] listener.set("trigger_effects", triggered_effects) var container: Node = container_script.new() root.add_child(container) container.call("add_effect", listener) _expect(container.has_method("dispatch_event"), "EffectContainer should expose dispatch_event(event_name, context)") if container.has_method("dispatch_event"): container.call("dispatch_event", &"on_perfect", {}) _expect_int(int(container.call("active_count")), 2, "dispatch_event should add triggered effects") var damage := float(stat_resolver.call("resolve_damage", 10.0, null, {"label": "perfect"}, container, null)) _expect_float(damage, 15.0, "Triggered reward effect should contribute modifiers") container.free() func _check_phase6_effect_resources_have_single_stack_baseline() -> void: for path: String in _phase6_effect_resource_paths(): var definition: Resource = load(path) _expect(definition != null, "%s should load" % path) if definition != null: _expect_int(int(definition.get("max_stacks")), 1, "%s max_stacks should stay at the migration baseline" % path) func _check_event_filters_gate_triggered_effects() -> void: var definition_script: Script = load("res://resources/effects/effect_definition.gd") var container_script: Script = load("res://scenes/components/effect_container.gd") if definition_script == null or container_script == null: return var reward: Resource = definition_script.new() reward.set("id", &"test_melee_hit_reward") reward.set("duration_type", &"infinite") var listener: Resource = definition_script.new() listener.set("id", &"test_melee_hit_listener") listener.set("duration_type", &"infinite") listener.set("trigger_event", &"on_hit") var required_tags: Array[StringName] = [&"melee"] listener.set("required_event_action_tags", required_tags) var triggered_effects: Array[Resource] = [reward] listener.set("trigger_effects", triggered_effects) var container: Node = container_script.new() root.add_child(container) container.call("add_effect", listener) container.call("dispatch_event", &"on_hit", {"action": _make_action_with_tags([&"projectile"])}) _expect_int(int(container.call("active_count")), 1, "Filtered trigger should ignore non-matching action tags") container.call("dispatch_event", &"on_hit", {"action": _make_action_with_tags([&"melee"])}) _expect_int(int(container.call("active_count")), 2, "Filtered trigger should add rewards for matching action tags") container.free() func _check_action_and_hit_duration_expire_on_matching_events() -> void: var definition_script: Script = load("res://resources/effects/effect_definition.gd") var container_script: Script = load("res://scenes/components/effect_container.gd") if definition_script == null or container_script == null: return var action_limited: Resource = definition_script.new() action_limited.set("id", &"test_next_action_only") action_limited.set("duration_type", &"actions") action_limited.set("duration", 1.0) var hit_limited: Resource = definition_script.new() hit_limited.set("id", &"test_next_melee_hit_only") hit_limited.set("duration_type", &"hits") hit_limited.set("duration", 1.0) var required_tags: Array[StringName] = [&"melee"] hit_limited.set("required_event_action_tags", required_tags) var container: Node = container_script.new() root.add_child(container) container.call("add_effect", action_limited) container.call("dispatch_event", &"on_action_start", {"action": _make_action_with_tags([&"skill"])}) _expect_bool(not container.call("active_effect_ids").has(&"test_next_action_only"), true, "Action-duration effects should expire after matching action events") container.call("add_effect", hit_limited) container.call("dispatch_event", &"on_hit", {"action": _make_action_with_tags([&"projectile"])}) _expect_bool(container.call("active_effect_ids").has(&"test_next_melee_hit_only"), true, "Hit-duration effects should ignore non-matching action tags") container.call("dispatch_event", &"on_hit", {"action": _make_action_with_tags([&"melee"])}) _expect_bool(not container.call("active_effect_ids").has(&"test_next_melee_hit_only"), true, "Hit-duration effects should expire after matching hit events") container.free() func _check_measure_and_until_event_duration() -> void: var definition_script: Script = load("res://resources/effects/effect_definition.gd") var container_script: Script = load("res://scenes/components/effect_container.gd") if definition_script == null or container_script == null: return var measure_limited: Resource = definition_script.new() measure_limited.set("id", &"test_two_measure_buff") measure_limited.set("duration_type", &"measures") measure_limited.set("duration", 2.0) var until_landed: Resource = definition_script.new() until_landed.set("id", &"test_until_landed_buff") until_landed.set("duration_type", &"until_event") until_landed.set("until_event", &"on_landed") var container: Node = container_script.new() root.add_child(container) container.call("add_effect", measure_limited) container.call("dispatch_event", &"on_measure_start", {}) _expect_bool(container.call("active_effect_ids").has(&"test_two_measure_buff"), true, "Measure-duration effects should survive until all measures are consumed") container.call("dispatch_event", &"on_measure_start", {}) _expect_bool(not container.call("active_effect_ids").has(&"test_two_measure_buff"), true, "Measure-duration effects should expire after N measure-start events") container.call("add_effect", until_landed) container.call("tick_time", 999.0) container.call("dispatch_event", &"on_hurt", {}) _expect_bool(container.call("active_effect_ids").has(&"test_until_landed_buff"), true, "UntilEvent effects should ignore time and unrelated events") container.call("dispatch_event", &"on_landed", {}) _expect_bool(not container.call("active_effect_ids").has(&"test_until_landed_buff"), true, "UntilEvent effects should expire when their configured event fires") container.free() func _check_burst_effect_resource_drives_stat_provider() -> void: var container_script: Script = load("res://scenes/components/effect_container.gd") var stat_resolver: Script = load("res://scripts/resolvers/stat_resolver.gd") var action_script: Script = load("res://resources/action_data.gd") var burst_effect: Resource = load("res://resources/effects/effect_burst_power.tres") if container_script == null or stat_resolver == null or action_script == null: return _expect(burst_effect != null, "Burst power should be represented as an EffectDefinition resource") if burst_effect == null: return var action: Resource = action_script.new() action.set("base_cost", 10.0) var container: Node = container_script.new() root.add_child(container) container.call("add_effect", burst_effect, &"burst") var damage := float(stat_resolver.call("resolve_damage", 10.0, action, {"label": "perfect"}, container, null)) var cost := float(stat_resolver.call("resolve_cost", action, container)) _expect_float(damage, 12.0, "Burst effect resource should boost damage through EffectContainer stat modifiers") _expect_float(cost, 0.0, "Burst effect resource should make action costs free through EffectContainer stat modifiers") container.free() func _check_seconds_effects_tick_from_process() -> void: var definition_script: Script = load("res://resources/effects/effect_definition.gd") var container_script: Script = load("res://scenes/components/effect_container.gd") if definition_script == null or container_script == null: return var definition: Resource = definition_script.new() definition.set("id", &"test_runtime_seconds_expire") definition.set("duration_type", &"seconds") definition.set("duration", 0.02) var container: Node = container_script.new() root.add_child(container) await process_frame container.call("add_effect", definition) await create_timer(0.08).timeout await process_frame _expect_int(int(container.call("active_count")), 0, "Seconds-based effects should expire automatically while EffectContainer is in the scene tree") container.free() func _check_beat_event_dispatches_effect_event() -> void: var event_bus := _ensure_event_bus() var container_script: Script = load("res://scenes/components/effect_container.gd") if event_bus == null or container_script == null: return var container: Node = container_script.new() root.add_child(container) await process_frame container.call("add_effect", _make_event_listener_effect(&"on_beat", &"test_on_beat_reward")) event_bus.emit_signal("beat_ticked", 8) _expect_int(int(container.call("active_count")), 2, "EffectContainer should dispatch on_beat when EventBus beat_ticked fires") container.free() func _check_perfect_input_dispatches_effect_event() -> void: var player_scene: PackedScene = load("res://scenes/characters/player.tscn") var definition_script: Script = load("res://resources/effects/effect_definition.gd") if player_scene == null or definition_script == null: return var player := player_scene.instantiate() root.add_child(player) await process_frame var effect_container: Node = player.get_node("EffectContainer") var controller: Node = player.get_node("ActionController") effect_container.call("add_effect", _make_event_listener_effect(&"on_perfect", &"test_perfect_runtime_reward")) controller.call("submit_intent", _perfect_intent()) var active_ids: Array = effect_container.call("active_effect_ids") _expect_bool(active_ids.has(&"test_perfect_runtime_reward"), true, "Perfect input should dispatch on_perfect to EffectContainer") _expect_bool(active_ids.has(&"effect_perfect_damage_buff"), true, "Player loadout should turn Perfect input into the configured damage reward") player.free() func _check_player_initial_effect_loadout() -> void: var player_scene: PackedScene = load("res://scenes/characters/player.tscn") if player_scene == null: return var player := player_scene.instantiate() root.add_child(player) await process_frame var effect_container: Node = player.get_node("EffectContainer") var ids: Array = effect_container.call("active_effect_ids") _expect_bool(ids.has(&"effect_on_perfect_damage_reward"), true, "Player should start with the Perfect reward listener as a real Effect loadout") _expect_bool(ids.has(&"effect_on_landed_haste"), true, "Player should start with the landed Haste listener as a real Effect loadout") effect_container.call("dispatch_event", &"on_landed", {}) _expect_bool(effect_container.call("active_effect_ids").has(&"effect_haste"), true, "Player landed listener should apply Haste through EffectContainer") player.free() func _make_event_listener_effect(event_name: StringName, reward_id: StringName) -> Resource: var definition_script: Script = load("res://resources/effects/effect_definition.gd") var reward: Resource = definition_script.new() reward.set("id", reward_id) reward.set("duration_type", &"infinite") var listener: Resource = definition_script.new() listener.set("id", StringName("listener_%s" % event_name)) listener.set("duration_type", &"infinite") listener.set("trigger_event", event_name) var triggered_effects: Array[Resource] = [reward] listener.set("trigger_effects", triggered_effects) return listener func _phase6_effect_resource_paths() -> Array[String]: return [ "res://resources/effects/effect_root.tres", "res://resources/effects/effect_slow.tres", "res://resources/effects/effect_haste.tres", "res://resources/effects/effect_silence.tres", "res://resources/effects/effect_temporary_invincible.tres", "res://resources/effects/effect_temporary_super_armor.tres", "res://resources/effects/effect_burst_power.tres", "res://resources/effects/effect_on_landed_haste.tres", "res://resources/effects/effect_perfect_damage_buff.tres", "res://resources/effects/effect_on_perfect_damage_reward.tres", ] func _ensure_event_bus() -> Node: var existing := root.get_node_or_null("EventBus") if existing != null: return existing var bus_script: Script = load("res://autoload/event_bus.gd") if bus_script == null: failures.append("EventBus script should load") return null var bus: Node = bus_script.new() bus.name = "EventBus" root.add_child(bus) return bus func _perfect_intent() -> RefCounted: var intent_script: Script = load("res://scenes/components/input_intent.gd") var intent: RefCounted = intent_script.call("create", &"A", &"a", &"pressed", float(Time.get_ticks_msec())) intent.set("judgement", {"label": "perfect", "diff": 0.0, "abs_diff": 0.0}) return intent func _make_action_with_tags(tags: Array[StringName]) -> Resource: var action: Resource = load("res://resources/action_data.gd").new() action.set("action_tags", tags) return action func _method_arg_count(object: Object, method_name: String) -> int: for method: Dictionary in object.get_method_list(): if str(method.get("name", "")) != method_name: continue var args = method.get("args", []) return args.size() if args is Array else 0 return 0 func _expect(condition: bool, label: String) -> void: if not condition: failures.append(label) func _expect_int(actual: int, expected: int, label: String) -> void: if actual != expected: failures.append("%s: expected %d, got %d" % [label, expected, actual]) func _expect_float(actual: float, expected: float, label: String) -> void: if not is_equal_approx(actual, expected): failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual]) func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: if actual != expected: failures.append("%s: expected %s, got %s" % [label, expected, actual]) func _expect_string(actual: String, expected: String, label: String) -> void: if actual != expected: failures.append("%s: expected %s, got %s" % [label, expected, actual]) func _expect_bool(actual: bool, expected: bool, label: String) -> void: if actual != expected: failures.append("%s: expected %s, got %s" % [label, expected, actual]) func _finish() -> void: if failures.is_empty(): print("PASS effect container") quit(0) else: for failure: String in failures: push_error(failure) quit(1)