extends SceneTree const InputIntentScript := preload("res://scenes/components/input_intent.gd") var failures: Array[String] = [] var rejected: Array[StringName] = [] var started: Array[StringName] = [] func _init() -> void: _run.call_deferred() func _run() -> void: var scene: PackedScene = load("res://scenes/characters/player.tscn") var player := scene.instantiate() root.add_child(player) await process_frame var controller := player.get_node_or_null("ActionController") _expect_bool(controller != null, true, "Player should have ActionController") if controller == null: player.free() _finish() return controller.connect("action_rejected", _on_action_rejected) controller.connect("action_started", _on_action_started) player.get_node("ComboWindow").record(&"S") var miss: RefCounted = InputIntentScript.create(&"A", &"a", &"pressed", float(Time.get_ticks_msec())) miss.judgement = {"label": "miss", "diff": 0.0} controller.call("submit_intent", miss) _expect_array(player.call("get_combo_slots"), [&"S", &"Ø"], "Miss intent should enter ComboWindow as an explicit placeholder") _expect_string(str(rejected[rejected.size() - 1]), "miss", "Miss intent rejection reason") await create_timer(0.4).timeout await process_frame _expect_array(player.call("get_combo_slots"), [&"S", &"Ø"], "Miss intent should not clear ComboWindow by itself") player.get_node("ComboWindow").clear(&"test-reset") player.get_node("ComboWindow").clear(&"test-reset") player.get_node("EnergyComponent").set_current(10) controller.call("_reset_to_idle") rejected.clear() controller.call("submit_intent", _perfect_intent(&"A", &"a")) var startup_miss: RefCounted = InputIntentScript.create(&"D", &"d", &"pressed", float(Time.get_ticks_msec())) startup_miss.judgement = {"label": "miss", "diff": 0.0} controller.call("submit_intent", startup_miss) _expect_array(player.call("get_combo_slots"), [&"A", &"Ø"], "Miss during an action should enter ComboWindow immediately") if rejected.is_empty(): failures.append("Miss during an action should be rejected immediately: no rejection emitted") else: _expect_string(str(rejected[rejected.size() - 1]), "miss", "Miss during an action should be rejected immediately") await create_timer(0.4).timeout await process_frame _expect_array(player.call("get_combo_slots"), [&"A", &"Ø"], "Miss during an action should not clear ComboWindow by itself") player.get_node("ComboWindow").clear(&"test-reset") started.clear() player.get_node("EnergyComponent").set_current(0) var first: RefCounted = InputIntentScript.create(&"A", &"a", &"pressed", float(Time.get_ticks_msec())) first.judgement = {"label": "perfect", "diff": 0.0} controller.call("submit_intent", first) _expect_int(player.call("get_energy"), 0, "A reward should wait until active phase") var second: RefCounted = InputIntentScript.create(&"A", &"a", &"pressed", float(Time.get_ticks_msec())) second.judgement = {"label": "perfect", "diff": 0.0} controller.call("submit_intent", second) _expect_array(player.call("get_combo_slots"), [&"A"], "Input during startup should wait outside ComboWindow") _expect_int(started.size(), 1, "Second input during startup should not start a second action immediately") await create_timer(0.2).timeout await physics_frame _expect_int(player.call("get_energy"), 1, "A reward should apply once active phase begins") await create_timer(0.48).timeout await physics_frame _expect_array(player.call("get_combo_slots"), [&"A", &"A"], "Pending input should enter ComboWindow only when cancel window opens") _expect_bool(started.size() >= 2, true, "Pending input should start after phase completes or cancel window opens") player.get_node("ComboWindow").clear(&"test-reset") player.get_node("EnergyComponent").set_current(10) controller.call("_reset_to_idle") rejected.clear() started.clear() controller.call("submit_intent", _perfect_intent(&"A", &"a")) controller.call("submit_intent", _perfect_intent(&"D", &"d")) controller.call("submit_intent", _perfect_intent(&"S", &"s")) _expect_array(player.call("get_combo_slots"), [&"A"], "Rapid inputs should not advance ComboWindow at raw input speed") _expect_bool(rejected.has(&"replaced"), true, "Rapid inputs should replace the single pending intent") await create_timer(0.5).timeout await physics_frame _expect_array(player.call("get_combo_slots"), [&"A", &"S"], "Only the latest pending intent should enter ComboWindow at cancel time") _expect_int(started.size(), 2, "Replaced rapid inputs should start only the initial action and the latest pending fallback") _expect_bool(started.has(&"skill_s"), true, "Latest pending S should fall back to normal S when A+S is unresolved") player.get_node("ComboWindow").clear(&"test-reset") player.get_node("EnergyComponent").set_current(10) controller.call("_reset_to_idle") started.clear() controller.call("submit_intent", _perfect_intent(&"A", &"a")) controller.call("submit_intent", _perfect_intent(&"SP", &"space")) await create_timer(0.55).timeout await physics_frame _expect_array(player.call("get_combo_slots"), [&"A", &"SP"], "Clear-window action should show its full input pattern") await create_timer(0.2).timeout await physics_frame _expect_array(player.call("get_combo_slots"), [&"A", &"SP"], "Clear-window action should not clear from a stale display timer before it finishes") await create_timer(0.4).timeout await physics_frame _expect_array(player.call("get_combo_slots"), [], "Clear-window action should clear when its action finishes") player.get_node("ComboWindow").clear(&"test-reset") player.get_node("EnergyComponent").set_current(10) player.get_node("ChargeComponent").cancel() controller.call("_reset_to_idle") started.clear() player.call("_on_input_intent_created", _perfect_intent(&"D", &"d")) player.call("_on_input_intent_created", _release_intent(&"D", &"d")) player.call("_on_input_intent_created", _perfect_intent(&"D", &"d")) player.call("_on_input_intent_created", _release_intent(&"D", &"d")) await create_timer(0.55).timeout await physics_frame _expect_array(player.call("get_combo_slots"), [&"D", &"D"], "Released pending D should still be adjudicated into DD at cancel time") await create_timer(0.8).timeout await process_frame _expect_bool(player.call("is_charge_active"), false, "Released pending D should not leave player stuck charging") _expect_int(int(round(float(player.call("get_charge")))), 0, "Released pending D should not accumulate charge") player.get_node("ComboWindow").clear(&"test-reset") player.get_node("EnergyComponent").set_current(10) controller.call("_reset_to_idle") started.clear() controller.call("submit_intent", _perfect_intent(&"S", &"s")) controller.call("_reset_to_idle") controller.call("submit_intent", _perfect_intent(&"SP", &"space")) controller.call("_reset_to_idle") controller.call("submit_intent", _perfect_intent(&"S", &"s")) controller.call("_reset_to_idle") controller.call("submit_intent", _perfect_intent(&"SP", &"space")) _expect_bool(started.has(&"skill_s_projectile_1"), true, "First S+SP should start projectile") _expect_bool(started.count(&"skill_s_projectile_1") >= 2, true, "Second S+SP should restart projectile chain") player.get_node("ComboWindow").clear(&"test-reset") player.get_node("EnergyComponent").set_current(10) controller.call("_reset_to_idle") started.clear() rejected.clear() player.get_node("ComboWindow").record(&"A") player.get_node("ComboWindow").record(&"D") controller.call("submit_intent", _perfect_intent(&"S", &"s")) controller.call("_reset_to_idle") controller.call("submit_intent", _perfect_intent(&"SP", &"space")) _expect_bool(started.has(&"skill_s_projectile_1"), true, "S+SP should start projectile from the trailing two slots") _expect_bool(not rejected.has(&"unresolved"), true, "Trailing S+SP should not be rejected as unresolved") var context: Dictionary = controller.call("_resolver_context") _expect_bool(context.has("state"), true, "Resolver context should include state") _expect_bool(context.has("blade_chain_active"), true, "Resolver context should include blade_chain_active") _expect_bool(context.has("counter_ready"), true, "Resolver context should include counter_ready") _expect_bool(context.has("burst_action_id"), true, "Resolver context should include burst_action_id") player.free() _finish() func _on_action_rejected(_intent, reason: StringName) -> void: rejected.append(reason) func _on_action_started(action: Resource, _intent) -> void: started.append(StringName(str(action.get("id")))) 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_string(actual: String, expected: String, label: String) -> void: if actual != expected: failures.append("%s: expected %s, got %s" % [label, expected, actual]) func _expect_array(actual: Array, expected: Array, label: String) -> void: if actual != expected: failures.append("%s: expected %s, got %s" % [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 _perfect_intent(symbol: StringName, rhythm_action: StringName) -> RefCounted: var intent: RefCounted = InputIntentScript.create(symbol, rhythm_action, &"pressed", float(Time.get_ticks_msec())) intent.judgement = {"label": "perfect", "diff": 0.0} return intent func _release_intent(symbol: StringName, rhythm_action: StringName) -> RefCounted: var intent: RefCounted = InputIntentScript.create(symbol, rhythm_action, &"released", float(Time.get_ticks_msec())) intent.judgement = {"label": "perfect", "diff": 0.0} return intent func _finish() -> void: if failures.is_empty(): print("PASS action controller flow") quit(0) else: for failure: String in failures: push_error(failure) quit(1)