459 lines
22 KiB
GDScript
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)
|