extends SceneTree var failures: Array[String] = [] var started_actions: Array[StringName] = [] func _init() -> void: _run.call_deferred() func _run() -> void: await process_frame _check_scripts_and_scene() _check_boss_action_resources() await _check_enemy_driver_uses_action_controller() await _check_chart_event_reaches_boss_driver() _check_chart_event_action_id() _finish() func _check_scripts_and_scene() -> void: for path: String in [ "res://scenes/components/ai_intent.gd", "res://scenes/enemies/enemy_action_driver.gd", "res://scenes/enemies/enemy.tscn", "res://scenes/enemies/boss.tscn", ]: _expect(load(path) != null, "%s should load" % path) var action_dir := DirAccess.open("res://resources/actions/enemies") _expect(action_dir != null, "Enemy action resource directory should exist") func _check_boss_action_resources() -> void: var resolver_script: Script = load("res://scenes/combat/action_resolver.gd") _expect(resolver_script != null, "ActionResolver should load for enemy resources") if resolver_script == null: return resolver_script.clear_cache() for action_id: StringName in _boss_action_ids(): var action: Resource = resolver_script.get_action(action_id) _expect(action != null, "%s should be a real Boss ActionData resource" % action_id) if action == null: continue _expect_bool(str(action.resource_path).begins_with("res://resources/actions/enemies/"), true, "%s should load from the enemy action directory" % action_id) _expect_bool(Array(action.get("input_pattern")).is_empty(), true, "%s should not expose a player input pattern" % action_id) _expect_bool(Array(action.get("action_tags")).has(&"enemy"), true, "%s should carry the enemy action tag" % action_id) _expect_bool(Array(action.get("action_tags")).has(&"boss"), true, "%s should carry the boss action tag" % action_id) _expect(float(action.get("startup_beats")) > 0.0, "%s should have a startup phase" % action_id) _expect(float(action.get("active_beats")) > 0.0, "%s should have an active phase" % action_id) _expect(float(action.get("recovery_beats")) > 0.0, "%s should have a recovery phase" % action_id) _expect_bool(str(action.get("id")).begins_with("skill_"), false, "%s should not keep legacy skill_* naming" % action_id) var stab: Resource = resolver_script.get_action(&"boss_lunging_stab") if stab != null: _expect_float(float(stab.get("damage_mult")), 3.0, "boss_lunging_stab damage") _expect_float(float(stab.get("move_mult_x")), 2.0, "boss_lunging_stab lunge distance") _expect_bool(Array(stab.get("defense_tags")).has(&"super_armor"), true, "boss_lunging_stab should carry super_armor defense tag") var shoot: Resource = resolver_script.get_action(&"boss_shoot_1") if shoot != null: _expect_equal(shoot.get("hit_type"), &"projectile", "boss_shoot_1 should use projectile hit_type") _expect_bool(resolver_script.resolve_pattern("boss_combo_1") == null, true, "Boss action ids should not resolve as player combo patterns") resolver_script.clear_cache() func _check_enemy_driver_uses_action_controller() -> void: var scene: PackedScene = load("res://scenes/enemies/enemy.tscn") if scene == null: return var enemy := scene.instantiate() root.add_child(enemy) await process_frame var controller: Node = enemy.get_node("ActionController") var driver: Node = enemy.get_node("EnemyActionDriver") controller.connect("action_started", _on_action_started) started_actions.clear() driver.call("start_action", &"boss_combo_1") _expect(started_actions.has(&"boss_combo_1"), "EnemyActionDriver should start Boss actions through ActionController") _expect_int(int(controller.get("phase")), 1, "Enemy action should enter Startup, not direct Active") enemy.queue_free() await process_frame func _check_chart_event_reaches_boss_driver() -> void: started_actions.clear() var actors := Node2D.new() actors.name = "ActorsContainer" root.add_child(actors) var boss_scene: PackedScene = load("res://scenes/enemies/boss.tscn") if boss_scene == null: actors.queue_free() return var boss := boss_scene.instantiate() boss.name = "Boss" actors.add_child(boss) await process_frame boss.get_node("ActionController").connect("action_started", _on_action_started) var chart_script: Script = load("res://resources/beat_chart.gd") var track_script: Script = load("res://resources/chart_track.gd") var chart: Resource = chart_script.new() var track: Resource = track_script.new() chart.set("chart_id", &"boss_dispatch_chart") track.set("track_id", &"track_boss_melee") track.set("track_type", &"boss") track.set("events", [_make_event(1, &"Boss", &"boss_combo_1")]) chart.set("tracks", [track]) var runner: Node = load("res://scenes/chart/chart_runner.gd").new() root.add_child(runner) runner.set("beat_time_override", 0.5) runner.set("actors_container_path", runner.get_path_to(actors)) runner.call("set_chart", chart) runner.call("update_for_song_time", 0.5) _expect(started_actions.has(&"boss_combo_1"), "ChartRunner should dispatch Boss action_id to Boss EnemyActionDriver") runner.queue_free() actors.queue_free() await process_frame func _check_chart_event_action_id() -> void: var event_script: Script = load("res://resources/chart_event.gd") var event: Resource = event_script.new() _expect(_has_property(event, "action_id"), "ChartEvent should expose action_id for AIIntent dispatch") event.set("action_id", &"boss_2way_shoot") _expect_equal(event.get("action_id"), &"boss_2way_shoot", "ChartEvent action_id should be data-driven") func _make_event(beat: int, target_id: StringName, action_id: StringName) -> Resource: var event: Resource = load("res://resources/chart_event.gd").new() event.set("beat_index", beat) event.set("event_type", &"enemy_action") event.set("target_id", target_id) event.set("lead_beats", 0.0) event.set("action_id", action_id) return event func _boss_action_ids() -> Array[StringName]: return [ &"boss_combo_1", &"boss_combo_2", &"boss_combo_3", &"boss_lunging_stab", &"boss_dash", &"boss_shoot_1", &"boss_shoot_2", &"boss_2way_shoot", ] func _on_action_started(action: Resource, _intent) -> void: started_actions.append(StringName(str(action.get("id")))) func _has_property(object: Object, property_name: String) -> bool: for property: Dictionary in object.get_property_list(): if str(property.get("name", "")) == property_name: return true return false func _expect(condition: bool, label: String) -> void: if not condition: failures.append(label) 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_bool(actual: bool, expected: bool, label: String) -> void: if actual != expected: failures.append("%s: expected %s, got %s" % [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_int(actual: int, expected: int, label: String) -> void: if actual != expected: failures.append("%s: expected %d, got %d" % [label, expected, actual]) func _finish() -> void: if failures.is_empty(): print("PASS enemy action pipeline") quit(0) else: for failure: String in failures: push_error(failure) quit(1)