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.
This commit is contained in:
2026-09-04 15:08:08 -04:00
parent d77b5e5441
commit 1f91f3d2e5
54 changed files with 7960 additions and 162 deletions
+565
View File
@@ -0,0 +1,565 @@
# 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).