Files
stickman/plans/PHASE_3c_SPEC.md
T
ryan 1f91f3d2e5 Add headless regression tests for Phase 4b features
- Implement test for popup anchor behavior in rule-builder menus to ensure consistent anchor positioning during menu transitions.
- Create tests for stage logic, including mode transitions, toolbar visibility, and status bar updates.
- Add terrain drag-painting tests to verify correct block placement behavior and conflict handling.
- Introduce walk waypoint tests to check for arrival conditions and position stability after navigation.
2026-09-04 15:08:08 -04:00

32 KiB
Raw Blame History

Phase 3c — Editor Tools: Action & Rule Editing (Spec)

Status: SPEC (pending implementation) Related plan: plans/PHASE_3c_EDITOR.md Target: Godot 4.7 (project.godot:19 declares config/features=PackedStringArray("4.7", "Forward Plus")).


1. Overview & Scope

Phase 3c makes the Sandbox Stage Director's action queues and the Event System rules fully editable (edit / delete / reorder / insert), replacing the current append-only + create/delete-only behavior. It is built on two registry-driven tables (action + trigger) so the UI is generated from data rather than hard-coded match statements, matching the existing StageSpawner._registry / PropLibrary patterns.

In scope

  • Action + trigger registries (action_registry.gd, trigger_registry.gd).
  • Action Queue Panel — view / edit / delete / reorder / add / clear a stickman's queue.
  • Action visual editing — waypoint right-click context menu (edit walk / delete walk / insert before / insert after).
  • Rule Panel — view / edit / delete / reorder / add / clear rules (filtered by source stickman).
  • Rule Editor — full edit (trigger type + target + actions) and consequence-only edit (actions only, trigger read-only).
  • Rule visual editing — rule-label click → consequence-only editor (already partially present via _begin_edit_rule); waypoint → "Edit Trigger Rules".

Out of scope / untouched (must not regress)

  • StickmanRig runner execution semantics — the action runner (_begin_action, _update_runner, the 5 action phases) is not changed. Editing mutates queue/rule data; the runner already consumes any of those shapes.
  • Event engine matching (_rule_matches, _update_area_entry, _update_stickman_prop_collision) — unchanged. Rule reordering changes evaluation order (array order) but not the per-rule matching logic.
  • Navigation / walk steering (_update_walking, mode latch) — unchanged.
  • Terrain drag-painting, selection, gizmos, placement ghost, asset selector — unchanged.
  • No .stk / settings.json format changes. Queues and rules remain session-only (persist across EDIT ⇄ DIRECT ⇄ PLAY, reset on scene reload) — no disk save, matching the Phase 3a/4 decision.
  • No new action types / trigger types are implemented (only the machinery to add them cleanly). The registry is the extension point; wiring a genuinely new action still requires a runner case in StickmanRig._begin_action (see §6 and §13).

2. Recorded Decisions

  1. Registries are static tables, not singletons. action_registry.gd / trigger_registry.gd are class_name-less-optional, RefCounted scripts exposing static const tables + static func accessors (mirroring PropLibrary). sandbox_stage.gd preloads them like the other preload consts. No autoload, no instance state.
  2. All new UI is code-built; no .tscn files. The plan lists queue_panel.tscn, rule_panel.tscn, action_editor.tscn, rule_editor.tscn. The codebase builds all stage UI in code inside _build_ui() (top bar, popups, dialogs); only AssetSelector uses a .tscn shell, and it has authored content. These panels/editors are dynamic (row lists change every mutation), so they are PopupPanel-based controller scripts constructed in code. Dropped: action_editor.gd/action_editor.tscn (action param editing reuses the existing _speak_dialog/_wait_dialog + pending-target machinery) and waypoint_context.gd (the waypoint menu is a code-built PopupMenu, exactly like _trigger_popup). See §7 file list.
  3. Reorder UX = Move Up / Move Down buttons, not drag. A [≡] drag handle in a PopupPanel requires hand-rolled _gui_input drag/reorder hit-testing; up/down buttons are simpler, keyboard/gamepad accessible (matches the architecture's focus-navigation rule), and testable headlessly. Each row shows / (disabled at the ends) plus /. Drag reorder is logged as deferred (tech debt §13).
  4. Waypoint → action mapping returns (rig, index, pos), not just a position. StageDirectorVisuals.hit_test_waypoint() currently returns only the nearest Vector2 (used by the rule builder for arrived_at_waypoint). A new hit_test_waypoint_action() returns {rig, index, pos} so the context menu knows which rig's queue and which walk_to action index to edit/delete/insert around. The existing position-only method is kept for the rule builder (rules reference waypoint positions, not indices).
  5. Insert before/after maps directly to StickmanRig.insert_action(index, action). "Before waypoint i" → insert_action(i, action); "after" → insert_action(i + 1, action). i is the queue index of the walk_to action (equal to the waypoint ordinal 1 because walk_to is the only waypoint-producing action). insert_action already clamps to [0, size].
  6. Edit-walk visual flow reuses the pending target-capture machinery. A new edit state (_pending_walk_edit_index >= 0) reuses _pending_walk_target's cursor/hint/trajectory and the _handle_direct_click/_handle_world_click routing; the next stage click replaces the existing walk_to.target instead of appending. The edited waypoint is highlighted in the director visuals.
  7. Rule reorder = array order; id is immutable and preserved. _event_rules is iterated in order by _handle_event (all matching rules execute — no short-circuit). Reordering swaps array entries; editing preserves id (already the case in _finalize_rule). _next_rule_id stays monotonic (no id reuse on delete).
  8. Rule Panel filter = trigger.source. "Edit Rules" on a stickman shows rules whose trigger.source equals that stickman's instance id. The waypoint "Edit Trigger Rules" filter matches by params.waypoint_pos proximity (WAYPOINT_MATCH_EPSILON), because rules store waypoint positions, not queue indices.
  9. Delete confirmation policy (deliberately asymmetric). Panel-initiated deletes and "Clear All" (queue and rules) confirm via ConfirmationDialog. The on-stage rule-label and the waypoint "Delete this Walk" stay immediate (current low-friction behavior, not regressed). Documented in §13.
  10. Ragdoll/Recover have no editable parameters. Their is hidden in the panel and their "edit" is a no-op; "Add Action" for them appends immediately. (Plan §11.2's "Edit opens confirmation dialog" is corrected — there is nothing to edit.)
  11. The full rule editor drives the existing RuleStep state machine. Trigger-type change re-enters SELECT_TRIGGER; trigger-target change re-enters TRIGGER_TARGET; "Add Action" uses the existing SELECT_ACTION → ACTION_TARGET → (PARAMS | ACTION_POSITION) → rule-more flow. The editor panel is a view; sandbox_stage.gd remains the controller owning _rule_builder/_rule_step/_event_rules.

3. Discrepancies: Plan vs. Actual Code

# Plan claim Reality (verified) Resolution
1 Panels/editors are new .tscn scenes (queue_panel.tscn, rule_panel.tscn, action_editor.tscn, rule_editor.tscn, waypoint_context.gd). Stage UI is built entirely in code (_build_ui); only AssetSelector has a .tscn shell. Code-built PopupPanel scripts (§2.2); drop action_editor.gd + waypoint_context.gd (§2.2).
2 Registry params with key/type/default drives the editor generically. Action data lives in two shapes: queue action (walk_to.target top-level; speak.text/.duration top-level; wait.duration top-level) vs. rule action ({type, target, params:{...}}). No generic param model exists. Registry describes logical params; get_fields/make_action normalize the two shapes (§5.3).
3 "New action type = append to registry; no code changes" (§10.1, §11.8). StickmanRig._begin_action hard-codes the 5 types; a new type also needs a runner case + _action_for_rig + _action_desc. Corrected: registry removes UI changes; runner changes still required (§6, §12.7).
4 hit_test_waypoint() returns an index. It returns only Vector2 (nearest waypoint position); no rig/index identity. Add hit_test_waypoint_action() returning {rig, index, pos} (§2.4, §9.2).
5 "Rule label click → consequence-only edit already exists" (implied complete). _begin_edit_rule(id) pre-populates and jumps straight to the add-action popup (SELECT_ACTION), preserving the trigger but offering no actions list / remove / edit and no trigger readout UI. New RuleEditor (consequence-only mode) formalizes this; _begin_edit_rule routes to it (§11.4).
6 action_finished trigger has a target_type: "action_type" (registry). The builder does not expose an action-type selector for action_finished (only "completes any action"); _rule_matches reads optional params.action_type. Registry marks action_type as optional; the full editor exposes it only if cheap — otherwise the trigger keeps "any action" and the field is documented as future (deferred, §13).
7 Rule Panel "shows all rules for a source stickman" and reorders. _event_rules is a flat stage-level array (no per-rig grouping); source is trigger.source (instance id). Filter by trigger.source (§2.8); reorder operates on the flat array (§2.7).
8 §11 acceptance criteria formatting: stray backticks and - prefixes. Cosmetic markdown errors in the plan. Rewritten cleanly in §12.
9 Plan §11.2 "Ragdoll/Recover: Edit opens confirmation dialog." Ragdoll/recover are param-less; editing is meaningless. Corrected (§2.10).
10 Plan §11.8 "unknown keys displayed as read-only / editable." No generic editor exists; the runner ignores unknown keys. Corrected: unknown keys are preserved (round-tripped) on edit, never dropped; no generic widget (§12.7).
11 Waypoint context menu / stickman right-click are new. Right-click is currently fully consumed by the EDIT placement-cancel branch (_handle_world_click returns on RMB). Insert waypoint + stickman right-click routing in that branch before the placement-cancel (§10.1).
12 Godot "4.4" (task prompt). project.godot:19 and the test runner reference 4.7. Spec targets 4.7.

4. New Files

File class_name / extends Responsibility
res://scripts/action_registry.gd ActionRegistry / RefCounted Static action template table + static helpers (labels/icons/params/describe/normalize).
res://scripts/trigger_registry.gd TriggerRegistry / RefCounted Static trigger template table + static helpers.
res://scripts/queue_panel.gd QueuePanel / PopupPanel Code-built panel listing one stickman's queue; emits edit/delete/move/add/clear signals.
res://scripts/rule_panel.gd RulePanel / PopupPanel Code-built panel listing rules (filtered by source); emits edit/delete/move/add/clear signals.
res://scripts/rule_editor.gd RuleEditor / PopupPanel Code-built full + consequence-only rule editor; drives the stage's rule-builder state machine via signals.

No .tscn files are added (decision 2). action_editor.gd and waypoint_context.gd from the plan are not created — their responsibilities are absorbed into sandbox_stage.gd (existing dialogs + a code-built PopupMenu).


5. Data Contracts

5.1 Queue action dict (consumed by StickmanRig runner — top-level keys)

{ "type": "walk_to", "target": Vector2 }                # target = feet/ground world position
{ "type": "speak",   "text": String, "duration": float }
{ "type": "wait",    "duration": float }
{ "type": "ragdoll" }
{ "type": "recover" }
# optional, ignored by the editor: "speed": float (walk_to), "reactive": bool (event-injected)

5.2 Rule dict (stored in SandboxStage._event_rules)

{
  "id": int,                                     # immutable, monotonic from _next_rule_id
  "trigger": {
    "type": String,                              # arrived_at_waypoint | action_finished |
                                                 # speech_finished | entered_area | collided
    "source": int,                               # instance id of the triggering stickman
    "target": int,                               # instance id (entered_area area | collided prop);
                                                 # -1 otherwise
    "params": {
      # arrived_at_waypoint -> { "waypoint_pos": Vector2 }
      # action_finished     -> { "action_type": String }   (optional; "" == any)
      # else                -> {}
    },
  },
  "actions": [ { "type": String, "target": int, "params": {...} } ],
}

Rule action params:

walk_to -> { "target": Vector2 }                # destination (the action's `target` = walking stickman id)
speak   -> { "text": String, "duration": float }
wait    -> { "duration": float }
ragdoll / recover -> {}

Key asymmetry (documented): in a queue action walk_to, target is the destination. In a rule action, target is the stickman instance id and the destination is params.target. The registry normalizes this via get_fields/make_action (§5.3).

5.3 ActionRegistry API (action_registry.gd)

static func get_types() -> Array[String]
    # ["walk_to", "speak", "wait", "ragdoll", "recover"] (stable order == popup order)
static func get_label(type: String) -> String       # "Walk To", "Speak", "Wait", "Ragdoll", "Recover"
static func get_icon(type: String) -> String        # "🚶","💬","⏳","💥","🔄"
static func get_params(type: String) -> Array[Dictionary]
    # [{ "key": "target",   "kind": "position", "required": true }]
    # [{ "key": "text",     "kind": "text",     "required": true  },
    #  { "key": "duration", "kind": "float",    "default": 2.0    }]
    # [{ "key": "duration", "kind": "float",    "required": true  }]
    # [] for ragdoll/recover
static func has_params(type: String) -> bool
static func get_fields(action: Dictionary) -> Dictionary
    # reads each param key from action top-level, falling back to action.params
    # (walk_to.target top-level in queue, params.target in rule -> both yield {"target": v})
static func make_action(type: String, fields: Dictionary, for_rule: bool) -> Dictionary
    # for_rule=false -> { "type": type, ...top-level keys }
    # for_rule=true  -> { "type": type, "target": -1, "params": {...keys} }
static func describe(action: Dictionary) -> String
    # "Walks", "Speak 'Hello'", "Wait 2s", "Ragdolls", "Recovers"
    # (semantics identical to StageDirectorVisuals._action_desc; the registry is the new home)
static func row_summary(action: Dictionary) -> String
    # panel row text: "<icon> <label> <param summary>"

5.4 TriggerRegistry API (trigger_registry.gd)

static func get_types() -> Array[String]
    # ["arrived_at_waypoint", "action_finished", "speech_finished", "entered_area", "collided"]
static func get_label(type: String) -> String
static func get_icon(type: String) -> String
static func get_target_kind(type: String) -> String
    # "waypoint" | "action_type" | "none" | "area" | "prop"
static func needs_target(type: String) -> bool
    # true for arrived_at_waypoint (waypoint), entered_area (area), collided (prop)
static func describe(trigger: Dictionary) -> String
    # "arrives", "completes an action", "finishes speaking", "enters area", "collides"

speech_finished (none) and action_finished (action_type) are "no world target" triggers. action_finished's optional params.action_type filter is not surfaced in the Phase 3c editor (the builder's current "completes any action" behavior is preserved) — deferred §13.


6. Extensibility Contract (corrected from plan §10)

  • New action type = (1) append a template to ActionRegistry (label/icon/params), (2) add a _begin_action case + (if it has params) a _action_for_rig mapping in StickmanRig, (3) add a params dialog if it has new param kinds. The popups/panels/rows then auto-generate. The runner case is not optional (plan §11.8 "no code changes" is false).
  • New trigger type = (1) append a template to TriggerRegistry, (2) emit a matching event in SandboxStage and add a _rule_matches case, (3) add a target-capture branch if it has a world target. UI auto-generates from the registry.
  • New action property = add a param entry + a widget; the editor must be taught the widget. Unknown keys on load/edit are preserved (round-tripped), never dropped.

7. File-by-File Change List

7.1 scripts/stickman_rig.gd (modified — additive only)

  • func move_action(from: int, to: int) -> void — bounds-check both (push_warning + return on out-of-range), remove-at + insert-at (reusing remove_action/insert_action semantics but emitting queue_changed once). Used by reorder up/down.
  • func replace_action(index: int, action: Dictionary) -> void — bounds-check, action_queue[index] = action, emit queue_changed. Used by edit (speak/wait/walk-retarget/ragdoll/recover).

No runner-phase changes; get_queue(), clear_queue(), insert_action, remove_action, enqueue_reactive, clear_reactive_actions are reused as-is.

7.2 scripts/stage_director_visuals.gd (modified — additive)

  • func _collect_waypoint_refs() -> Array[Dictionary] — mirror _collect_waypoints exactly but return { "rig": StickmanRig, "index": int, "pos": Vector2 } (index = queue index of the walk_to action). Same world-child iteration order.
  • func hit_test_waypoint_action(world_pos: Vector2) -> Dictionary — nearest ref within WAYPOINT_HIT_RADIUS_PX / _zoom(); returns {} on miss. Keeps hit_test_waypoint() (still used by the rule builder).
  • var edit_waypoint: Vector2 = Vector2.INF + func set_edit_waypoint(pos: Vector2) -> void (mark_dirty()). While finite, _draw_waypoint draws that dot with an accent outline/ring (blinking is optional — a static accent + thicker ring is sufficient for the acceptance test; a _process-driven blink is deferred). Cleared by the stage when edit-capture ends.

7.3 scripts/sandbox_stage.gd (modified — the controller)

New consts:

const ACT_EDIT_QUEUE := 7      # item ids added to _action_popup (after ACT_WHEN=5 / RULE_ACTION_DONE=6)
const ACT_EDIT_RULES := 8
const QUEUE_ADD_WALK := ACT_WALK    # reuse 0..4 for the add-action popup
# ... (ACT_SPEAK/WAIT/RAGDOLL/RECOVER reused)
const WP_EDIT_WALK := 0
const WP_DELETE_WALK := 1
const WP_INSERT_BEFORE := 2
const WP_INSERT_AFTER := 3
const WP_EDIT_RULES := 4
const RIG_EDIT_QUEUE := 0
const RIG_EDIT_RULES := 1

New state:

var _pending_walk_edit_index: int = -1          # >=0 while retargeting an existing walk_to
var _editing_action_index: int = -1             # queue index being edited via speak/wait dialog
var _queue_add_context: StickmanRig = null      # target rig for "Add Action" from the queue panel
var _waypoint_context: Dictionary = {}          # {rig, index} for the open waypoint context menu
var _queue_panel: QueuePanel = null
var _rule_panel: RulePanel = null
var _rule_editor: RuleEditor = null
var _rig_context_popup: PopupMenu = null
var _waypoint_context_popup: PopupMenu = null
var _queue_add_popup: PopupMenu = null
var _confirm_dialog: ConfirmationDialog = null   # shared, repurposed per action (with a pending closure)
var _confirm_action: Callable = Callable()       # what to run on confirm

New signal wiring in _build_ui():

  • Add _rig_context_popup (PopupMenu: "📋 Edit Queue…", "📋 Edit Rules…"), _waypoint_context_popup (PopupMenu: "✎ Edit this Walk", "✕ Delete this Walk", "⬆ Insert action before", "⬇ Insert action after", " Edit Trigger Rules (N)"), _queue_add_popup (PopupMenu: the 5 action types), and a shared _confirmation_dialog. Apply _apply_popup_theme to the popups.
  • Append _action_popup.add_separator() + add_item("📋 Edit Queue…", ACT_EDIT_QUEUE) + add_item("📋 Edit Rules…", ACT_EDIT_RULES).
  • Instantiate _queue_panel, _rule_panel, _rule_editor (code-built; add to the ui CanvasLayer) and connect their signals (below). Apply _ui_font/_emoji_font via their apply_font methods.

Handlers (new/changed):

  • _handle_world_click right-click branch (before placement-cancel): when _rule_step == IDLE and mode is EDIT or DIRECT, hit-test waypoint → _waypoint_context_popup; else _selection.hit_test a StickmanRig_rig_context_popup. Both record _popup_anchor from the click's screen rect.
  • _on_action_popup_id_pressed: new ACT_EDIT_QUEUE/ACT_EDIT_RULES cases → open panels for _context_rig.
  • Queue-panel signal handlers: _queue_edit(index), _queue_delete(index) (confirm), _queue_move(index, dir), _queue_add(), _queue_clear() (confirm).
  • Rule-panel signal handlers: _rule_edit(id), _rule_delete(id) (confirm), _rule_move(index, dir), _rule_add(), _rules_clear() (confirm).
  • Waypoint-context handlers: _waypoint_edit_walk(), _waypoint_delete_walk(), _waypoint_insert(before: bool), _waypoint_edit_rules().
  • Rig-context handlers: _rig_edit_queue(), _rig_edit_rules().
  • _pending_walk_edit_index consumed in _handle_direct_click (and the EDIT left-click path): when >= 0, replace _context_rig's queue action at that index with the new walk_to target (via replace_action), clear the edit state + edit_waypoint, refresh cursor/status/visuals.
  • _move_rule(from: int, to: int) helper (bounds-check, swap, _director_visuals.set_rules).
  • _open_rule_editor(id, consequence_only: bool) — populate RuleEditor and show; RuleEditor drives _rule_builder/_rule_step through signals back to the stage.

Esc priority (_unhandled_key_input): insert _rule_editor/_queue_panel/_rule_panel close and _pending_walk_edit_index cancel into the existing chain (above placement/selection clear).


8. New File Detail

8.1 scripts/queue_panel.gdQueuePanel extends PopupPanel

Signals (all emitted to the stage, which mutates data):

signal edit_requested(index: int)
signal delete_requested(index: int)
signal move_requested(index: int, dir: int)       # dir: -1 up, +1 down
signal add_requested()
signal clear_requested()
signal closed()

API:

func open_for(rig_name: String, queue: Array[Dictionary]) -> void   # store + _rebuild + popup_centered()
func refresh(queue: Array[Dictionary]) -> void                       # re-render after a mutation
func apply_font(ui_font: Font, emoji_font: Font) -> void

Rows (one per action, in order): order number, ActionRegistry.row_summary(action), then (hidden for ragdoll/recover), , , (disabled at ends). Footer: [Add Action] [Clear All] [Close]. exclusive = true; Esc → closed.emit(). Rebuilt on every refresh.

8.2 scripts/rule_panel.gdRulePanel extends PopupPanel

Signals:

signal edit_requested(id: int)
signal delete_requested(id: int)
signal move_requested(index: int, dir: int)
signal add_requested()
signal clear_requested()
signal closed()

API:

func open_for(source_name: String, rules: Array[Dictionary]) -> void
func refresh(rules: Array[Dictionary]) -> void
func apply_font(ui_font: Font, emoji_font: Font) -> void

Rows (one per rule): order number + StageDirectorVisuals.rule_summary(rule) (or TriggerRegistry.describe + action summaries), then , , , . Footer: [Add Rule] [Clear All] [Close].

8.3 scripts/rule_editor.gdRuleEditor extends PopupPanel

Signals:

signal trigger_type_changed(type: String)
signal trigger_target_requested()
signal action_add_requested()
signal action_edit_requested(index: int)
signal action_remove_requested(index: int)
signal done_requested()
signal cancelled()

API:

func open_full(trigger: Dictionary, actions: Array[Dictionary], trigger_types: Array[String]) -> void
func open_consequence(trigger_summary: String, actions: Array[Dictionary]) -> void
func apply_font(ui_font: Font, emoji_font: Font) -> void
  • Full mode: trigger OptionButton (registry types) + [Set target…] (hidden for speech_finished/action_finished) + target readout; actions list (summary + ); [Add Action]; [Cancel] [OK].
  • Consequence-only mode: read-only trigger line; actions list; [Add Action]; [Done] [Cancel].

9. UI Flows

9.1 Action Queue Panel

Entry: Direct-click stickman → action popup → "📋 Edit Queue…"; or right-click stickman → "Edit Queue…". Panel lists the queue (order numbers, type, params). routes to the existing edit machinery (§9.2); /Clear All confirm; / call move_action; [Add Action] opens _queue_add_popup (appends via the same per-type flow, targeting the queue's rig).

9.2 Action editing (reuses existing dialogs)

  • walk_to_pending_walk_edit_index = index; edit_waypoint highlights the dot; next stage click → replace_action(index, {type:"walk_to", target: new_pos}).
  • speak — pre-fill _speak_edit.text/duration; confirm (with _editing_action_index >= 0) → replace_action(index, {type:"speak", text, duration}).
  • wait — pre-fill _wait_spin.value; confirm → replace_action.
  • ragdoll/recover — no ; nothing to edit.

_on_speak_confirmed/_on_wait_confirmed gain an _editing_action_index >= 0 branch (prefixed above the existing _rule_step == PARAMS branch).

9.3 Waypoint context menu (EDIT/DIRECT, right-click on a dot)

Items: ✎ Edit this Walk, ✕ Delete this Walk, ⬆ Insert action before, ⬇ Insert action after, ⚡ Edit Trigger Rules (N) (N = count of rules referencing that waypoint position; hidden when 0). Edit → §9.2 walk flow; delete → remove_action(index); insert → _queue_add_popup then insert_action(index | index+1, action); Edit Trigger Rules → RulePanel filtered by params.waypoint_pos proximity.

9.4 Rule Panel

Entry: right-click stickman → "Edit Rules…" (filter trigger.source == rig id); or action popup → "Edit Rules…". Rows show rule_summary; → full RuleEditor; /Clear All confirm; /_move_rule; [Add Rule] → fresh build (ACT_WHEN flow, source = panel rig).

9.5 Rule Editor

Full ( in panel): trigger dropdown + target button + actions list (/) + [Add Action] + [OK]. Consequence-only (rule-label click on stage): trigger read-only + actions list + [Add Action] + [Done]. OK/Done_finalize_rule() (preserves id); Cancel_cancel_rule_build().


10. Entry-Point Integration (existing code hooks)

  • Action popup items appended in _build_ui after the ACT_WHEN separator (_action_popup currently ends at ACT_WHEN).
  • Right-click routing in _handle_world_click (RMB branch) gains waypoint → stickman hit tests before the EDIT placement-cancel, gated on _rule_step == IDLE and mode != PLAY.
  • Rule-label click (_handle_world_click LMB path already calls _begin_edit_rule) now routes to _open_rule_editor(id, true) instead of the bare add-action popup.
  • _clear_director_pending also resets _pending_walk_edit_index, _editing_action_index, edit_waypoint, and hides the new panels/popups (mode exit must cancel edit flows).

11. Acceptance Criteria (corrected from plan §11)

11.1 Action Queue Panel

  • "Edit Queue…" opens the panel from the action popup and from stickman right-click.
  • Panel lists every action in queue order with an order number + type + parameter summary.
  • deletes an action after a confirmation dialog.
  • re-opens the action editor with values pre-filled (hidden for ragdoll/recover).
  • / move an action (order numbers and waypoint connectors update; first/last disabled).
  • "Add Action" appends via the add-action popup.
  • "Clear All" removes all actions after a confirmation dialog.

11.2 Edit Action

  • walk_to: edit enters target-capture mode; clicking a new spot moves the waypoint (dot + dashed connector update; order numbers unchanged).
  • speak: text + duration pre-filled; confirm updates the action + badge summary.
  • wait: duration pre-filled; confirm updates.
  • ragdoll/recover: no edit (param-less); hidden.

11.3 Waypoint Context Menu

  • Right-click a waypoint dot opens the context menu (EDIT and DIRECT).
  • "Edit this Walk" enters target placement (highlighted dot) and moves the waypoint.
  • "Delete this Walk" removes the walk action (immediate).
  • "Insert action before/after" inserts at index / index + 1 (connectors reconnect, order numbers renumber).

11.4 Rule Panel

  • "Edit Rules…" opens the panel filtered to rules whose trigger.source is the stickman.
  • Each rule shows a summary (trigger verb + action summaries) + order number.
  • deletes after confirmation; / reorders; "Add Rule" starts the rule builder; "Clear All" confirms and clears.

11.5 Rule Editor

  • Full: trigger type dropdown works; trigger target can be re-clicked; action list supports add/remove; parameters (text/duration/walk target) editable; OK updates the rule preserving id.
  • Consequence-only: trigger read-only; actions editable; Done updates the rule preserving id.
  • Visual connectors update immediately on save.

11.6 Visual Updates

  • Waypoint dots move on walk edit; speech badge text + wait duration reflect edits; dashed connectors re-render after insert/delete/reorder; rule labels refresh; rule connectors re-target on trigger/action target edits.

11.7 Backward Compatibility

  • Existing queues/rules load and display unchanged.
  • Editing preserves action types and unknown keys; editing a rule preserves id.
  • Deleting a rule/action leaves no dangling references (_cleanup_rules_for_nodes on node delete already covers object deletion; queue/rule edits only mutate their own data).

11.8 Extensibility (corrected)

  • New action UI requires only a registry entry; new action runtime still needs a _begin_action case (documented in §6).
  • New trigger UI requires only a registry entry; new trigger runtime needs an event emission + _rule_matches case.
  • Unknown action keys are preserved on edit (not dropped); no generic unknown-key widget.

12. Implementation Order (matches the 5 sub-phases)

  1. 3c.1 Foundationaction_registry.gd + trigger_registry.gd; StickmanRig.move_action / replace_action; StageDirectorVisuals._collect_waypoint_refs / hit_test_waypoint_action / edit_waypoint. (No behavior change yet — existing popups may optionally read labels/icons from the registry, but the plan allows keeping the hard-coded popup strings until 3c.2+.)
  2. 3c.2 Action Queue Panelqueue_panel.gd; _action_popup items; _rig_context_popup; queue edit/delete/move/add/clear handlers; _confirmation_dialog.
  3. 3c.3 Action Visual Editing_waypoint_context_popup; _pending_walk_edit_index + edit_waypoint highlight; insert before/after; edit-walk target capture.
  4. 3c.4 Rule Panelrule_panel.gd; rule_editor.gd (full + consequence-only); _move_rule; rewire _begin_edit_rule → consequence-only editor; rule add/delete/clear.
  5. 3c.5 Rule Visual Editing — waypoint "Edit Trigger Rules (N)" filter; waypoint→rule proximity matching; final Esc-priority / _clear_director_pending cleanup.

Each sub-phase is independently testable via the headless SceneTree suite pattern (tests/test_phase4b_*.gd: instantiate sandbox_stage.tscn, drive handlers directly, assert on _event_rules / rig.get_queue() / popup visibility).


13. Tech-Debt / Deferred

  • Drag reorder — up/down buttons chosen over [≡] drag (decision 3). Revisit if a touch/pointer drag reorder is wanted.
  • Generic param-field auto-generationActionRegistry.get_params describes params, but widgets (LineEdit/SpinBox/position click) remain hand-built per type. A registry→widget factory is future work.
  • action_finished type filter — the full editor does not expose params.action_type; the trigger remains "completes any action" (§3.6).
  • StageDirectorVisuals.rule_summary/_action_desc duplication — the registry's describe now duplicates rule_summary's summary logic; a follow-up can make rule_summary delegate to the registry to remove the copy.
  • Asymmetric delete confirmation — panel deletes confirm; on-stage rule and waypoint "Delete this Walk" stay immediate (decision 9).