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

42 KiB

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:

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:

@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:

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:

/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:

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:

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:

/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:

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:

/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:

@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:

startup_beats = 0.25
active_beats = 0.25
recovery_beats = 0.5
cancel_from = 0.5

For projectile chain resources:

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:

/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:

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:

/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:

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:

[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:

/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():

_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:

/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:

@onready var action_controller: Node = $ActionController

In _ready(), replace direct combo press wiring with intent wiring:

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:

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:

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:

/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:

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:

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:

/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:

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:

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:

/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:

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:

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:

/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:

if _should_restart_blade_chain(judged_intent):
	combo_window.clear(&"blade_chain_restart")

Add helper:

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:

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:

/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:

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:

/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:

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:

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:

/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:

_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:

/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:

/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:

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:

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:

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.