Files
Fighting_Rthythm_game/docs/2026-07-02-action-intent-phase-fix-plan.md
2026-07-02 09:47:52 -07:00

1320 lines
42 KiB
Markdown

# 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.