Files
rthythm_archor_game/tests/test_effect_container.gd
2026-07-06 22:59:47 -07:00

459 lines
22 KiB
GDScript

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)