# Input Intent and Action Phase Fix Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Fix duplicate/missed inputs, silent action failures, repeated Space-chain failures, and missing startup/active/recovery timing by inserting a timestamped intent and action adjudication layer between input collection and action execution. **Architecture:** Keep the existing `RhythmManager`, `ComboWindow`, `ActionResolver`, `ActionExecutor`, `MotionExecutor`, and `CombatManager` architecture. Add one central `ActionController` as the only component allowed to turn input intent into actions. Do not add a traditional fighting-game `InputBuffer`; store already-judged intents only so rhythm timing remains tied to the original key press. **Tech Stack:** Godot 4.6 GDScript, SceneTree headless tests, `.tres` `ActionData` resources, existing EventBus/Autoload services. --- ## Current Problem Summary The current implementation has some input cleanup already: - Raw `KEY_*` matching has been removed from `Player`. - `player_space` has been removed from `InputMap`. - Echo key events are filtered in `InputComponent`. - Combo/action data now lives in `ActionData` resources. The remaining architecture problem is that `Player` still receives a key event and immediately calls `submit_combo_input()`, `ActionResolver`, and `ActionExecutor`. This keeps the old "press key -> do action now" behavior alive inside a componentized shell. The fix is to make this the only legal path: ```text InputComponent -> InputIntent(symbol, rhythm_action, event_type, timestamp_ms) -> ActionController.submit_intent(intent) -> RhythmManager.judge(intent.timestamp_ms) -> ComboWindow record/clear -> ActionResolver with full context -> ActionController phase/state adjudication -> ActionExecutor / MotionExecutor / Player visual hooks ``` Important project rule: `ComboWindow` must not auto-fill `Ø` on empty beats. `Ø` remains an explicit Miss placeholder only. ## File Responsibility Map Create: - `scenes/components/input_intent.gd` - A small `RefCounted` value object for one physical input event. - Carries symbol, rhythm action, press/release type, timestamp, and rhythm judgement. - `scenes/components/action_controller.gd` - The central action adjudicator. - Owns action phase: `idle`, `startup`, `active`, `recovery`. - Receives `InputIntent`, judges rhythm, updates `ComboWindow`, resolves actions, executes allowed actions, stores at most one already-judged pending intent for cancel-window consumption. - Builds full `ActionResolver` context for Space priority rules. - `tests/test_input_component_intents.gd` - Covers echo filtering, exact action matching, timestamped intent emission, and no duplicate intent for one event. - `tests/test_action_controller_flow.gd` - Covers Miss clearing, repeated `S+SP`, energy failure cleanup, action phase lockout, and cancel-window consumption. Modify: - `resources/action_data.gd` - Add phase fields in beats: `startup_beats`, `active_beats`, `recovery_beats`, `cancel_from`. - `resources/actions/*.tres` - Add explicit phase defaults. - Mark Space/projectile chain resources with `can_chain` where needed. - `scenes/components/input_component.gd` - Replace `combo_pressed` / `combo_released` as Player-facing API with `intent_created(intent)`. - Keep old signals only temporarily during migration tests if needed, then remove Player usage. - `scenes/characters/player.gd` - Stop calling `submit_combo_input()` directly from `_on_combo_pressed`. - Delegate all discrete input to `ActionController`. - Keep visual orchestration hooks: animation playback, projectile request, charge UI events. - `scenes/characters/player.tscn` - Add `ActionController` node. - Wire exported NodePaths to existing components. - `scenes/components/state_machine.gd` - Remain a thin state name provider owned by `ActionController` for this plan. Stop treating it as the action phase authority. - `tests/test_player_combo_input.gd` - Update tests to assert Player routes input through `ActionController`. - `tests/test_rhythm_action_architecture.gd` - Add architecture assertions that Player no longer performs direct action resolution/execution from input callbacks. ## Design Decisions ### No Traditional InputBuffer Do not implement "press early, consume after delay, then judge at consumption time." That would change rhythm timing. Allowed: - Capture original `timestamp_ms` at input event time. - Judge immediately or before any possible delay using that timestamp. - Store the already-judged intent briefly if action phase cannot consume it yet. - Consume it during a cancel window without changing its judgement. Not allowed: - Re-judging a buffered input at consumption time. - Moving a player's early input onto a different beat. ### One Pending Intent, Not a Queue The first implementation should store at most one pending intent. Reason: - This is enough to remove common "pressed during recovery and got ignored" feel. - It avoids building a general-purpose combo buffer that fights the rhythm design. - It keeps testing simple. Pending intent replacement rule: - If a new valid intent arrives while one is pending, replace the older pending intent. - Emit `intent_rejected(old_intent, &"replaced")` for observability. ### Action Phases Use Beats Add these fields to `ActionData`: ```gdscript @export var startup_beats := 0.25 @export var active_beats := 0.25 @export var recovery_beats := 0.5 @export var cancel_from := 0.5 ``` Interpretation: - `startup_beats`: action has begun visually, but hitbox/projectile has not fired. - `active_beats`: action is allowed to apply damage/spawn projectile. - `recovery_beats`: action is cooling down. - `cancel_from`: fraction of recovery after which one pending intent may be consumed. Default total is one beat. Individual action resources can override these values in the same resource-normalization pass. ### ActionController Owns Adjudication, Player Owns Presentation `ActionController` decides if an action starts. `Player` responds to signals: - `action_started(action, judgement)` - `action_active_started(action)` - `action_active_finished(action)` - `action_finished(action)` - `action_rejected(intent, reason)` Player may play animation, request projectile, or update visual state in response. Player must not resolve actions directly. --- ## Task 1: Input Intent Value Object **Files:** - Create: `scenes/components/input_intent.gd` - Create: `tests/test_input_component_intents.gd` - [ ] **Step 1: Write the failing test** Create `tests/test_input_component_intents.gd`: ```gdscript extends SceneTree var failures: Array[String] = [] var intents: Array = [] func _init() -> void: _run.call_deferred() func _run() -> void: var component: Node = load("res://scenes/components/input_component.gd").new() root.add_child(component) await process_frame if not component.has_signal("intent_created"): failures.append("InputComponent should expose intent_created") else: component.connect("intent_created", _on_intent_created) var normal := InputEventKey.new() normal.pressed = true normal.physical_keycode = KEY_A var handled := component.call("handle_input_event", normal) _expect_bool(handled, true, "A press should be handled") _expect_int(intents.size(), 1, "A press should emit one intent") if intents.size() == 1: _expect_string(str(intents[0].get("symbol")), "A", "intent symbol") _expect_string(str(intents[0].get("rhythm_action")), "a", "intent rhythm action") _expect_string(str(intents[0].get("event_type")), "pressed", "intent event type") _expect_bool(float(intents[0].get("timestamp_ms")) > 0.0, true, "intent timestamp") var echo := InputEventKey.new() echo.pressed = true echo.echo = true echo.physical_keycode = KEY_A handled = component.call("handle_input_event", echo) _expect_bool(handled, false, "echo press should not be handled") _expect_int(intents.size(), 1, "echo press should not emit another intent") var release := InputEventKey.new() release.pressed = false release.physical_keycode = KEY_A handled = component.call("handle_input_event", release) _expect_bool(handled, true, "A release should be handled") _expect_int(intents.size(), 2, "A release should emit one release intent") if intents.size() == 2: _expect_string(str(intents[1].get("event_type")), "released", "release event type") component.free() _finish() func _on_intent_created(intent) -> void: intents.append(intent) 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_int(actual: int, expected: int, label: String) -> void: if actual != expected: failures.append("%s: expected %d, got %d" % [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 _finish() -> void: if failures.is_empty(): print("PASS input component intents") quit(0) else: for failure: String in failures: push_error(failure) quit(1) ``` - [ ] **Step 2: Run the test to verify it fails** Run: ```bash /Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_input_component_intents.gd ``` Expected: FAIL because `intent_created` and `InputIntent` do not exist yet. - [ ] **Step 3: Create `InputIntent`** Create `scenes/components/input_intent.gd`: ```gdscript class_name InputIntent extends RefCounted var symbol: StringName var rhythm_action: StringName var event_type: StringName var timestamp_ms := 0.0 var judgement: Dictionary = {} static func create(next_symbol: StringName, next_rhythm_action: StringName, next_event_type: StringName, next_timestamp_ms: float) -> InputIntent: var intent := InputIntent.new() intent.symbol = next_symbol intent.rhythm_action = next_rhythm_action intent.event_type = next_event_type intent.timestamp_ms = next_timestamp_ms return intent func is_pressed() -> bool: return event_type == &"pressed" func is_released() -> bool: return event_type == &"released" func with_judgement(next_judgement: Dictionary) -> InputIntent: var copy := InputIntent.create(symbol, rhythm_action, event_type, timestamp_ms) copy.judgement = next_judgement.duplicate() return copy ``` - [ ] **Step 4: Update `InputComponent` to emit intents** Modify `scenes/components/input_component.gd`: ```gdscript class_name InputComponent extends Node signal intent_created(intent: InputIntent) signal combo_pressed(symbol: StringName, rhythm_action: StringName) signal combo_released(symbol: StringName) const COMBO_ACTIONS: Dictionary = { &"combo_w": [&"W", &"w"], &"combo_a": [&"A", &"a"], &"combo_d": [&"D", &"d"], &"combo_s": [&"S", &"s"], &"combo_space": [&"SP", &"space"], } const COMBO_ACTION_ORDER: Array[StringName] = [ &"combo_w", &"combo_a", &"combo_d", &"combo_s", &"combo_space", ] var _suppressed_movement: Dictionary = { &"move_left": false, &"move_right": false, } func handle_input_event(event: InputEvent) -> bool: var key_event := event as InputEventKey if key_event != null and key_event.echo: return false for action_name: StringName in COMBO_ACTION_ORDER: if event.is_action_pressed(action_name, false, true): var data: Array = COMBO_ACTIONS[action_name] var intent := InputIntent.create(data[0], data[1], &"pressed", float(Time.get_ticks_msec())) intent_created.emit(intent) combo_pressed.emit(data[0], data[1]) return true if event.is_action_released(action_name, true): var data: Array = COMBO_ACTIONS[action_name] var intent := InputIntent.create(data[0], data[1], &"released", float(Time.get_ticks_msec())) intent_created.emit(intent) combo_released.emit(data[0]) return true return false func set_direction_suppressed(symbol: StringName, suppressed: bool) -> void: if symbol == &"A": _suppressed_movement[&"move_left"] = suppressed elif symbol == &"D": _suppressed_movement[&"move_right"] = suppressed func get_horizontal_axis() -> float: var axis := 0.0 if Input.is_action_pressed(&"move_left") and not bool(_suppressed_movement.get(&"move_left", false)): axis -= 1.0 if Input.is_action_pressed(&"move_right") and not bool(_suppressed_movement.get(&"move_right", false)): axis += 1.0 return axis ``` This step keeps old signals temporarily so existing Player tests can still pass before Task 5 migrates Player. - [ ] **Step 5: Verify** Run: ```bash /Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_input_component_intents.gd ``` Expected: PASS. --- ## Task 2: ActionData Phase Fields **Files:** - Modify: `resources/action_data.gd` - Modify: `resources/actions/*.tres` - Modify: `tests/test_rhythm_action_architecture.gd` - [ ] **Step 1: Add failing architecture assertions** In `tests/test_rhythm_action_architecture.gd`, extend `_check_action_data()` so it expects these fields: ```gdscript for property_name: String in [ "id", "display_name", "input_pattern", "required_state", "base_cost", "damage_mult", "move_mult_x", "move_mult_y", "action_beats", "hit_type", "range", "target_type", "armor_level", "clear_window", "can_chain", "special", "startup_beats", "active_beats", "recovery_beats", "cancel_from", ]: _expect(_has_property(action, property_name), "ActionData should expose %s" % property_name) ``` - [ ] **Step 2: Run test to verify it fails** Run: ```bash /Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_action_architecture.gd ``` Expected: FAIL because the phase fields are absent. - [ ] **Step 3: Add fields to `ActionData`** Modify `resources/action_data.gd`: ```gdscript @export var startup_beats := 0.25 @export var active_beats := 0.25 @export var recovery_beats := 0.5 @export_range(0.0, 1.0, 0.05) var cancel_from := 0.5 ``` Keep the existing `cancel_window` compatibility field until all code and tests stop relying on it. Do not remove existing fields in this task. - [ ] **Step 4: Normalize resource defaults** For each file in `resources/actions/*.tres`, add explicit values when absent: ```ini startup_beats = 0.25 active_beats = 0.25 recovery_beats = 0.5 cancel_from = 0.5 ``` For projectile chain resources: ```ini can_chain = true ``` Apply that to: - `resources/actions/skill_s_projectile_1.tres` - `resources/actions/skill_s_projectile_2.tres` - `resources/actions/skill_s_projectile_3.tres` - [ ] **Step 5: Verify** Run: ```bash /Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_action_architecture.gd ``` Expected: PASS. --- ## Task 3: ActionController Skeleton and Miss Cleanup **Files:** - Create: `scenes/components/action_controller.gd` - Modify: `scenes/characters/player.tscn` - Create: `tests/test_action_controller_flow.gd` - [ ] **Step 1: Write the failing test** Create `tests/test_action_controller_flow.gd`: ```gdscript extends SceneTree 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 := InputIntent.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"), [], "Miss intent should clear ComboWindow immediately") _expect_string(str(rejected[rejected.size() - 1]), "miss", "Miss intent rejection reason") player.free() _finish() func _on_action_rejected(_intent: InputIntent, reason: StringName) -> void: rejected.append(reason) func _on_action_started(action: Resource, _intent: InputIntent) -> 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 _finish() -> void: if failures.is_empty(): print("PASS action controller flow") quit(0) else: for failure: String in failures: push_error(failure) quit(1) ``` - [ ] **Step 2: Run test to verify it fails** Run: ```bash /Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_action_controller_flow.gd ``` Expected: FAIL because `ActionController` does not exist on Player. - [ ] **Step 3: Create `ActionController` skeleton** Create `scenes/components/action_controller.gd`: ```gdscript class_name ActionController extends Node signal action_started(action: Resource, intent: InputIntent) signal action_active_started(action: Resource) signal action_active_finished(action: Resource) signal action_finished(action: Resource) signal action_rejected(intent: InputIntent, reason: StringName) enum Phase { IDLE, STARTUP, ACTIVE, RECOVERY } @export var combo_window_path: NodePath @export var action_resolver_path: NodePath @export var action_executor_path: NodePath @export var state_machine_path: NodePath @export var burst_component_path: NodePath @onready var combo_window: Node = get_node_or_null(combo_window_path) @onready var action_resolver: Node = get_node_or_null(action_resolver_path) @onready var action_executor: Node = get_node_or_null(action_executor_path) @onready var state_machine: Node = get_node_or_null(state_machine_path) @onready var burst_component: Node = get_node_or_null(burst_component_path) var phase := Phase.IDLE var current_action: Resource var current_intent: InputIntent var pending_intent: InputIntent var phase_elapsed := 0.0 var phase_duration := 0.0 func submit_intent(intent: InputIntent) -> void: if intent == null: return var judged_intent := _ensure_judged(intent) if judged_intent.is_released(): action_rejected.emit(judged_intent, &"release_not_action") return if _judgement_label(judged_intent) == &"miss": _record_miss_and_clear(judged_intent) action_rejected.emit(judged_intent, &"miss") return if phase == Phase.IDLE: _consume_intent(judged_intent) return if _can_cancel_now(): _consume_intent(judged_intent) return _store_pending_intent(judged_intent) func _physics_process(delta: float) -> void: if phase == Phase.IDLE: return phase_elapsed += delta if phase_elapsed < phase_duration: return match phase: Phase.STARTUP: _enter_phase(Phase.ACTIVE) action_active_started.emit(current_action) Phase.ACTIVE: action_active_finished.emit(current_action) _enter_phase(Phase.RECOVERY) Phase.RECOVERY: var finished_action := current_action _reset_to_idle() action_finished.emit(finished_action) if pending_intent != null: var next_intent := pending_intent pending_intent = null submit_intent(next_intent) func _consume_intent(intent: InputIntent) -> void: if combo_window == null or action_resolver == null or action_executor == null: action_rejected.emit(intent, &"missing_component") return _record_intent_symbol(intent) var action: Resource = action_resolver.resolve_window(combo_window, state_machine, _resolver_context()) if action == null: action_rejected.emit(intent, &"unresolved") return if not action_executor.execute(action, StringName(str(intent.judgement.get("label", "perfect"))), burst_component): combo_window.flush_pending_clear() combo_window.clear(&"action_failed") action_rejected.emit(intent, &"execution_failed") return current_action = action current_intent = intent _enter_phase(Phase.STARTUP) action_started.emit(action, intent) if bool(action.get("clear_window")): combo_window.queue_clear(StringName("skill:%s" % action.get("id"))) func _record_intent_symbol(intent: InputIntent) -> void: if combo_window.has_pending_clear(): if str(combo_window.pending_clear_reason).begins_with("skill:"): combo_window.cancel_pending_clear() else: combo_window.flush_pending_clear() combo_window.record(intent.symbol) func _record_miss_and_clear(intent: InputIntent) -> void: if combo_window != null: combo_window.record(&"Ø") combo_window.flush_pending_clear() func _store_pending_intent(intent: InputIntent) -> void: if pending_intent != null: action_rejected.emit(pending_intent, &"replaced") pending_intent = intent func _enter_phase(next_phase: Phase) -> void: phase = next_phase phase_elapsed = 0.0 phase_duration = _phase_duration_seconds(next_phase) func _phase_duration_seconds(next_phase: Phase) -> float: if current_action == null: return 0.0 var beat_time := _beat_time() match next_phase: Phase.STARTUP: return maxf(0.01, float(current_action.get("startup_beats")) * beat_time) Phase.ACTIVE: return maxf(0.01, float(current_action.get("active_beats")) * beat_time) Phase.RECOVERY: return maxf(0.01, float(current_action.get("recovery_beats")) * beat_time) return 0.0 func _can_cancel_now() -> bool: if phase != Phase.RECOVERY or current_action == null: return false var duration := maxf(0.01, phase_duration) var progress := clampf(phase_elapsed / duration, 0.0, 1.0) return progress >= clampf(float(current_action.get("cancel_from")), 0.0, 1.0) func _reset_to_idle() -> void: phase = Phase.IDLE current_action = null current_intent = null phase_elapsed = 0.0 phase_duration = 0.0 func _ensure_judged(intent: InputIntent) -> InputIntent: if not intent.judgement.is_empty(): return intent var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null if rhythm != null and rhythm.has_method("judge"): return intent.with_judgement(rhythm.call("judge", intent.timestamp_ms)) return intent.with_judgement({"label": "perfect", "diff": 0.0, "abs_diff": 0.0}) func _judgement_label(intent: InputIntent) -> StringName: return StringName(str(intent.judgement.get("label", "miss"))) func _resolver_context() -> Dictionary: var state := &"any" if state_machine != null and state_machine.has_method("get_current_state_name"): state = StringName(str(state_machine.call("get_current_state_name"))) return { "state": state, "blade_chain_active": _blade_chain_active(), } func _blade_chain_active() -> bool: if current_action == null: return false return bool(current_action.get("can_chain")) func _beat_time() -> float: var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null if rhythm != null: return float(rhythm.get("beat_time")) return 0.5 ``` - [ ] **Step 4: Add `ActionController` to Player scene** Add a node to `scenes/characters/player.tscn` under `Player`: ```ini [ext_resource type="Script" path="res://scenes/components/action_controller.gd" id="18_action_controller"] [node name="ActionController" type="Node" parent="."] script = ExtResource("18_action_controller") combo_window_path = NodePath("../ComboWindow") action_resolver_path = NodePath("../ActionResolver") action_executor_path = NodePath("../ActionExecutor") state_machine_path = NodePath("../StateMachine") burst_component_path = NodePath("../BurstComponent") ``` Use the next available ext_resource id if `18_action_controller` is already taken. - [ ] **Step 5: Verify** Run: ```bash /Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_action_controller_flow.gd ``` Expected: PASS. --- ## Task 4: Route Player Input Through ActionController **Files:** - Modify: `scenes/characters/player.gd` - Modify: `tests/test_player_combo_input.gd` - Modify: `tests/test_rhythm_action_architecture.gd` - [ ] **Step 1: Write failing architecture assertion** In `tests/test_rhythm_action_architecture.gd`, add checks to `_check_no_legacy_runtime_architecture()`: ```gdscript _expect(player_source.contains("intent_created.connect"), "Player should connect InputComponent intents to ActionController") _expect(not player_source.contains("action_resolver.resolve_window"), "Player should not resolve actions directly") _expect(not player_source.contains("action_executor.execute"), "Player should not execute actions directly") ``` - [ ] **Step 2: Run test to verify it fails** Run: ```bash /Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_action_architecture.gd ``` Expected: FAIL because Player still calls resolver/executor directly. - [ ] **Step 3: Modify Player references and signal wiring** In `scenes/characters/player.gd`, add: ```gdscript @onready var action_controller: Node = $ActionController ``` In `_ready()`, replace direct combo press wiring with intent wiring: ```gdscript input_component.intent_created.connect(_on_input_intent_created) action_controller.action_started.connect(_on_action_started) action_controller.action_active_started.connect(_on_action_active_started) action_controller.action_finished.connect(_on_action_finished) action_controller.action_rejected.connect(_on_action_rejected) ``` Leave release handling for charge in place only until Task 7 moves charge mode into controller context. - [ ] **Step 4: Add Player handlers** Add to `scenes/characters/player.gd`: ```gdscript func _on_input_intent_created(intent: InputIntent) -> void: if intent.symbol == &"A": heading = Vector2.LEFT elif intent.symbol == &"D": heading = Vector2.RIGHT action_controller.submit_intent(intent) if intent.is_pressed() and (intent.symbol == &"A" or intent.symbol == &"D"): input_component.set_direction_suppressed(intent.symbol, true) if intent.is_released() and (intent.symbol == &"A" or intent.symbol == &"D"): input_component.set_direction_suppressed(intent.symbol, false) _finish_charge_hold(intent.symbol) func _on_action_started(action: Resource, intent: InputIntent) -> void: current_energy = energy_component.current last_requested_skill_id = str(action.get("id")) current_skill_animation = str(action.get("animation")) skill_requested.emit(last_requested_skill_id) _event_bus().emit_signal("skill_executed", action, StringName(str(intent.judgement.get("label", "perfect")))) _play_skill_animation(current_skill_animation, _skill_displacement_direction(action), action) func _on_action_active_started(action: Resource) -> void: if bool(action.get("spawns_projectile")): _request_projectile(action) func _on_action_finished(_action: Resource) -> void: pass func _on_action_rejected(_intent: InputIntent, _reason: StringName) -> void: pass ``` - [ ] **Step 5: Replace `submit_combo_input()` implementation for tests** Keep `submit_combo_input(symbol, forced_rating)` as a test helper, but route it through `ActionController`: ```gdscript func submit_combo_input(symbol: String, forced_rating := "") -> String: var data := _symbol_to_intent_data(symbol) if data.is_empty(): return "" var intent := InputIntent.create(data["symbol"], data["rhythm_action"], &"pressed", float(Time.get_ticks_msec())) if not forced_rating.is_empty(): intent.judgement = _rating_result(StringName(forced_rating), 0.0) action_controller.submit_intent(intent) return last_requested_skill_id func _symbol_to_intent_data(symbol: String) -> Dictionary: match symbol: "W": return {"symbol": &"W", "rhythm_action": &"w"} "A": return {"symbol": &"A", "rhythm_action": &"a"} "D": return {"symbol": &"D", "rhythm_action": &"d"} "S": return {"symbol": &"S", "rhythm_action": &"s"} "SP": return {"symbol": &"SP", "rhythm_action": &"space"} return {} ``` - [ ] **Step 6: Remove direct input execution** Remove or stop using: - `_on_combo_pressed` - `_record_combo_symbol` - `_record_rated_combo_input` - `_record_combo_input` - `_execute_combo_skill` Keep helper methods used by presentation: - `_play_skill_animation` - `_skill_displacement_direction` - `_request_projectile` - `_rating_result` - [ ] **Step 7: Verify** Run: ```bash /Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_player_combo_input.gd /Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_action_architecture.gd ``` Expected: both PASS. --- ## Task 5: Phase Lockout and Cancel Window **Files:** - Modify: `tests/test_action_controller_flow.gd` - Modify: `scenes/components/action_controller.gd` - [ ] **Step 1: Add failing test for no immediate reentry** Append to `tests/test_action_controller_flow.gd` inside `_run()` after the Miss check: ```gdscript started.clear() player.get_node("EnergyComponent").set_current(10) var first := InputIntent.create(&"A", &"a", &"pressed", float(Time.get_ticks_msec())) first.judgement = {"label": "perfect", "diff": 0.0} controller.call("submit_intent", first) var second := InputIntent.create(&"A", &"a", &"pressed", float(Time.get_ticks_msec())) second.judgement = {"label": "perfect", "diff": 0.0} controller.call("submit_intent", second) _expect_int(started.size(), 1, "Second input during startup should not start a second action immediately") ``` Add helper: ```gdscript func _expect_int(actual: int, expected: int, label: String) -> void: if actual != expected: failures.append("%s: expected %d, got %d" % [label, expected, actual]) ``` - [ ] **Step 2: Run test to verify it fails or exposes current behavior** Run: ```bash /Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_action_controller_flow.gd ``` Expected before controller phase is complete: FAIL if two actions start immediately, or PASS if Task 3 already blocks this path. - [ ] **Step 3: Add test for cancel-window consumption** Append: ```gdscript await create_timer(0.45).timeout _expect_bool(started.size() >= 2, true, "Pending input should start after phase completes or cancel window opens") ``` This uses the default one-beat timing at 120 BPM. If project BPM differs in test setup, instantiate a test `RhythmManager` with `beat_time = 0.5`. - [ ] **Step 4: Implement pending intent consumption during recovery** Update `ActionController._physics_process(delta)` so recovery can consume pending intent as soon as cancel window opens: ```gdscript if phase == Phase.RECOVERY and pending_intent != null and _can_cancel_now(): var next_intent := pending_intent pending_intent = null _reset_to_idle() submit_intent(next_intent) return ``` Place this before the `phase_elapsed < phase_duration` return. - [ ] **Step 5: Verify** Run: ```bash /Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_action_controller_flow.gd ``` Expected: PASS. --- ## Task 6: Repeated `S+SP` and Blade Chain Restart **Files:** - Modify: `tests/test_action_controller_flow.gd` - Modify: `scenes/components/action_controller.gd` - Modify: `resources/actions/skill_s_projectile_1.tres` - Modify: `resources/actions/skill_s_projectile_2.tres` - Modify: `resources/actions/skill_s_projectile_3.tres` - [ ] **Step 1: Add failing test for repeated `S+SP`** Append to `tests/test_action_controller_flow.gd`: ```gdscript player.get_node("ComboWindow").clear(&"test-reset") player.get_node("EnergyComponent").set_current(10) started.clear() controller.call("submit_intent", _perfect_intent(&"S", &"s")) controller.call("submit_intent", _perfect_intent(&"SP", &"space")) await process_frame controller.call("submit_intent", _perfect_intent(&"S", &"s")) controller.call("submit_intent", _perfect_intent(&"SP", &"space")) await process_frame _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") ``` Add helper: ```gdscript func _perfect_intent(symbol: StringName, rhythm_action: StringName) -> InputIntent: var intent := InputIntent.create(symbol, rhythm_action, &"pressed", float(Time.get_ticks_msec())) intent.judgement = {"label": "perfect", "diff": 0.0} return intent ``` - [ ] **Step 2: Run test to verify it fails** Run: ```bash /Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_action_controller_flow.gd ``` Expected: FAIL because `[S, SP, S, SP]` does not resolve. - [ ] **Step 3: Implement chain restart rule** Add to `ActionController.submit_intent()` before normal phase handling: ```gdscript if _should_restart_blade_chain(judged_intent): combo_window.clear(&"blade_chain_restart") ``` Add helper: ```gdscript func _should_restart_blade_chain(intent: InputIntent) -> bool: if combo_window == null: return false if intent.symbol != &"S": return false var pattern := combo_window.get_contiguous_pattern() return pattern == "SSP" or pattern == "SSPSP" or pattern == "SSPSPSP" ``` This preserves `S+SP+SP` chaining while allowing a new `S` to begin a fresh chain. - [ ] **Step 4: Verify chain resources** Ensure projectile chain resources contain: ```ini can_chain = true clear_window = false ``` for: - `skill_s_projectile_1.tres` - `skill_s_projectile_2.tres` - `skill_s_projectile_3.tres` - [ ] **Step 5: Verify** Run: ```bash /Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_action_controller_flow.gd /Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_combo_window.gd ``` Expected: both PASS. --- ## Task 7: Space Priority Context **Files:** - Modify: `scenes/components/action_controller.gd` - Modify: `tests/test_action_controller_flow.gd` - Create: action resources only if the referenced action id does not exist. - [ ] **Step 1: Add resolver-context test** In `tests/test_action_controller_flow.gd`, add a direct context assertion: ```gdscript 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") ``` - [ ] **Step 2: Run test to verify it fails** Run: ```bash /Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_action_controller_flow.gd ``` Expected: FAIL because context is incomplete. - [ ] **Step 3: Expand `_resolver_context()`** Use this implementation: ```gdscript func _resolver_context() -> Dictionary: var state := &"any" if state_machine != null and state_machine.has_method("get_current_state_name"): state = StringName(str(state_machine.call("get_current_state_name"))) return { "state": state, "charge_release_action_id": _charge_release_action_id(), "burst_action_id": _burst_action_id(), "counter_action_id": _counter_action_id(), "counter_ready": _counter_ready(), "blade_chain_action_id": _blade_chain_action_id(), "blade_chain_active": _blade_chain_active(), } func _charge_release_action_id() -> StringName: return &"" func _burst_action_id() -> StringName: if burst_component != null and bool(burst_component.get("burst_ready")): return &"skill_burst_activate" return &"" func _counter_action_id() -> StringName: return &"skill_s_counter_projectile" func _counter_ready() -> bool: return false func _blade_chain_action_id() -> StringName: if _blade_chain_active(): return &"skill_s_projectile_2" return &"" ``` The empty charge release id is intentional until Task 8 moves charge release into the same intent path. - [ ] **Step 4: Verify missing action ids** Run: ```bash rg -n 'skill_burst_activate|skill_s_counter_projectile' resources/actions scenes tests ``` Expected: If no resources exist for those ids yet, tests must not expect them to resolve. They are context keys reserved for future states. - [ ] **Step 5: Verify** Run: ```bash /Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_action_controller_flow.gd ``` Expected: PASS. --- ## Task 8: Player Presentation Cleanup **Files:** - Modify: `scenes/characters/player.gd` - Modify: `tests/test_rhythm_action_architecture.gd` - [ ] **Step 1: Add source hygiene assertions** In `tests/test_rhythm_action_architecture.gd`, add: ```gdscript _expect(not player_source.contains("func _record_combo_symbol"), "Player should not own combo symbol adjudication") _expect(not player_source.contains("func _execute_combo_skill"), "Player should not own action execution") _expect(player_source.contains("func _on_action_started"), "Player should present actions from ActionController") ``` - [ ] **Step 2: Run test to verify it fails** Run: ```bash /Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_action_architecture.gd ``` Expected: FAIL while old Player methods remain. - [ ] **Step 3: Remove direct adjudication methods from Player** Delete these methods after tests have moved to `ActionController`: - `_record_combo_symbol` - `_record_rated_combo_input` - `_record_combo_input` - `_execute_combo_skill` - `_resolve_full_window_fallback` - `_is_projectile_space_chain` Keep: - `submit_combo_input` - `_symbol_to_intent_data` - `_play_skill_animation` - `_skill_displacement_direction` - `_request_projectile` `submit_combo_input` remains only as a test/helper API and must route through `ActionController`. - [ ] **Step 4: Verify** Run: ```bash /Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_action_architecture.gd /Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_player_combo_input.gd ``` Expected: both PASS. --- ## Task 9: Full Regression and Residue Scan **Files:** - Modify tests only if assertions need to follow the new architecture. - [ ] **Step 1: Run full test suite with strict error scan** Run: ```bash for test in tests/*.gd; do output=$(/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s "res://$test" 2>&1) rc=$? printf '## %s exit=%s\n' "$test" "$rc" printf '%s\n' "$output" | rg 'PASS|FAIL|SCRIPT ERROR|ERROR:' || true if [ "$rc" -ne 0 ] || printf '%s\n' "$output" | rg -q 'SCRIPT ERROR|ERROR:'; then printf '%s\n' "$output" exit 1 fi done ``` Expected: every test exits `0`, every test prints `PASS`, and no `SCRIPT ERROR` or `ERROR:` appears. - [ ] **Step 2: Scan for direct input/action coupling** Run: ```bash rg -n 'KEY_|is_action_just_pressed|action_resolver\.resolve_window|action_executor\.execute|func _record_combo_symbol|func _execute_combo_skill' scenes/characters/player.gd scenes/components scenes/combat tests || true ``` Expected: - No `KEY_` in Player production code. - No `is_action_just_pressed` for combo actions. - No direct `action_resolver.resolve_window` or `action_executor.execute` inside Player. - `action_resolver.resolve_window` and `action_executor.execute` may exist inside `ActionController`. - [ ] **Step 3: Scan for forbidden empty-beat auto `Ø`** Run: ```bash rg -n 'beat_ticked\.connect|_on_beat|input_this_beat|record\(&"Ø"\)|append\(&"Ø"\)' scenes/components/combo_window.gd scenes/characters/player.gd scenes/components/action_controller.gd tests || true ``` Expected: - No beat-empty auto `Ø` in `ComboWindow`. - `Ø` appears only in explicit Miss tests or explicit Miss handling. - [ ] **Step 4: Manual playtest checklist** Run the game from the editor and verify: - Pressing `A` once produces one combo input and one action. - Holding `A` does not repeat through echo. - Pressing `A` during startup/active does not interrupt the current attack immediately. - Pressing `A` during recovery is consumed only when the cancel window opens. - Miss clears the combo window immediately. - With enough energy, `S+SP`, `S+SP+SP`, and `S+SP+SP+SP` work. - After one `S+SP`, pressing `S+SP` again starts a new projectile chain. - With insufficient energy, `S+SP` fails visibly and does not leave a dirty combo window. --- ## Acceptance Criteria The repair is complete when all of these are true: - `InputComponent` emits timestamped `InputIntent` objects and remains the only discrete input collector. - Player no longer directly resolves or executes actions from input callbacks. - `ActionController` is the only component that judges rhythm, records combo symbols, resolves action patterns, and starts action execution. - Miss clears the combo window immediately. - Echo key events do not produce input intents. - One physical input event produces at most one intent. - Action startup/active/recovery phases exist and block immediate reentry. - One already-judged pending intent can be consumed during recovery cancel window. - `S+SP` can chain into additional Space projectiles and can restart cleanly when the player presses `S` again. - Full test suite passes with no Godot `SCRIPT ERROR` or `ERROR:`. ## Known Deferred Work These are intentionally outside this repair plan: - Full BuffContainer implementation. - EnemyBrain beat scheduling. - Final burst/counter/charge action resource set. - Visual tuning for every individual animation. The plan adds context hooks for those systems without requiring all of them to be finished in the same change set.