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

566 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
```gdscript
{ "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`)
```gdscript
{
"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`:
```gdscript
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`)
```gdscript
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`)
```gdscript
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:
```gdscript
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:
```gdscript
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.gd` — `QueuePanel extends PopupPanel`
Signals (all emitted to the stage, which mutates data):
```gdscript
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:
```gdscript
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.gd` — `RulePanel extends PopupPanel`
Signals:
```gdscript
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:
```gdscript
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.gd` — `RuleEditor extends PopupPanel`
Signals:
```gdscript
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:
```gdscript
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 Foundation** — `action_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 Panel** — `queue_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 Panel** — `rule_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-generation** — `ActionRegistry.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).