# Phase 3c — Editor Tools: Action & Rule Editing (Implementation Spec) Status: IMPLEMENTED + TESTED (12 headless suites, 667 assertions; `tests/test_phase3c_editor.gd` alone: 253 assertions) Related plan: `plans/PHASE_3c_EDITOR.md` Target: Godot **4.7** (`project.godot:19` declares `config/features=PackedStringArray("4.7", ...)`; the Phase 3c test header names `Godot_v4.7.1-stable_win64_console.exe`). --- ## 1. Overview & Scope Phase 3c adds **full editing** for the Sandbox Stage's two director-facing authoring artifacts that previously had no in-place editing: - **Action queues** (per-`StickmanRig`, Phase 3a): actions could only be *appended*. Phase 3c adds a **Queue Panel** (view / edit / delete / drag-reorder / add / clear-all) and a **waypoint context menu** with visual walk re-placement and insert-before/after. - **Event rules** (`_event_rules` on `SandboxStage`, Phase 4): rules could only be *created or deleted*. Phase 3c adds a **Rule Panel** (view / edit / delete / drag-reorder / add / clear-all), a **full rule editor** (trigger + target + actions) and a **consequence-only rule editor** (trigger read-only; actions editable). Everything is **registry-driven**: two const registries (`ActionRegistry`, `TriggerRegistry`) are the single source of truth for the action/trigger templates, and the new editors + panels generate their UI from them. Adding a new action or trigger type is a one-entry registry append (plan §4/§10) — no other code changes. ### In scope - `scripts/action_registry.gd` / `trigger_registry.gd` — extensible template registries. - `scripts/queue_panel.gd` + `scenes/queue_panel.tscn` — Action Queue panel. - `scripts/rule_panel.gd` + `scenes/rule_panel.tscn` — Rule list panel. - `scripts/action_editor.gd` + `scenes/action_editor.tscn` — single-action property editor. - `scripts/rule_editor.gd` + `scenes/rule_editor.tscn` — full / consequence-only rule editor. - `scripts/waypoint_context.gd` — waypoint right-click menu. - `scripts/sandbox_stage.gd` — Phase 3c wiring: "Edit Queue…"/"Edit Rules…" entry points, right-click context menus, the **unified target-capture system** (`CaptureKind`), the shared confirmation dialog, and consequence-only rule editing on rule-label click. - `scripts/stage_director_visuals.gd` — `hit_test_waypoint_action()`, `set/clear_edit_waypoint` with the pulsing edit highlight, and rule-label → editor routing. - `tests/test_phase3c_editor.gd` — 253-assertion headless suite. ### Out of scope / untouched (must not regress) - **`.stk` format / the editor** — no changes. - **Queue / rule disk persistence** — still in-memory across `EDIT ⇄ DIRECT ⇄ PLAY` toggles, reset on scene reload (matches Phase 3a/4 scope). - **The queue runner** (`StickmanRig._process_queue`, Phase 3a) and the **event engine** (Phase 4) — read only; they already consume the same action/rule dict shapes. - **Rule-builder flows** (Phase 4 `RuleStep` state machine) — unchanged; Phase 3c's editors are additive alongside them. The panel "Add Rule" reuses `_begin_rule_build()`. --- ## 2. Recorded User / Implementation Decisions 1. **Registries are const dictionaries with static accessors.** Each action/trigger template is one const dict entry. Because `Dictionary.keys()` is untyped at runtime, the registries expose `static func types() -> Array[String]` (a genuinely typed array) rather than exposing `.keys()` directly, so callers can store the type list in typed locals (`ActionEditor._current_type`, `RuleEditor._select_action_type`). 2. **Panels/editors are `PopupPanel`s with `exclusive = true` + `popup_window = true`** built in code. The four `.tscn` files are **minimal shells** (bare `PopupPanel` + root script); all UI is constructed in `_ready()` (consistent with the Phase 3b `asset_selector.tscn` pattern). Because they are exclusive, the stage must hide a host popup before entering a stage-click capture or opening a nested editor, and re-show it on resolve/cancel (see Known Limitations, §10 #19). 3. **Rule-action shape ↔ flat queue-action conversions live on the registry.** A rule action nests params under `params` and adds `target` (the actor's instance id); a flat queue action (Phase 3a) inlines them. `ActionRegistry.to_rule_action()` / `from_rule_action()` convert, and `ActionRegistry.summarize()` / `TriggerRegistry.summarize()` produce the one-line labels used by both the panels and the editors (they `get()` with fallbacks tolerant of either key layout). 4. **Waypoint "Edit this Walk" is a visual stage edit** (a `POSITION` capture), not a numeric dialog. The target waypoint is highlighted with a **pulsing amber ring** drawn by `StageDirectorVisuals` while the capture is pending. 5. **`ragdoll` / `recover` "Edit" = a paramless `ActionEditor` pre-fill.** Plan §11.2 originally specified a separate *confirmation dialog* for editing these no-parameter actions; the implementation instead reuses the generic `ActionEditor` (open, then OK) — fewer special cases, and the type is unchanged unless the user changes it. This is a deliberate deviation from the plan, not a defect (recorded in the tech-debt Change Log only; no debt row). 6. **Confirmations use one shared `ConfirmationDialog`** (`_ask_confirm(title, message, cb)`). Queue delete / clear-all and Rule delete / clear-all confirm before mutating. 7. **Reordering is nearest-row-center drop semantics** (see Known Limitations, §10 #21): a drag targets the row whose vertical center is nearest the pointer. 8. **"Edit Queue…" / "Edit Rules…" entry points are content-gated.** In the Direct action popup and the stickman right-click menu, "📋 Edit Queue…" is hidden (or disabled) when the stickman's queue is empty, and "⚡ Edit Rules…" is hidden (or disabled) when the stickman is the source of no rule in `_event_rules`. The waypoint menu's "⚡ Edit Trigger Rules" entry already follows this pattern — `WaypointContext.popup_for(rect, count)` enables it (and shows the count) only when `count > 0`. Rationale: a menu item that opens an empty panel is noise; gating it signals that there is nothing to edit. --- ## 3. New Files | File | `class_name` / extends | Responsibility | |---|---|---| | `res://scripts/action_registry.gd` | `ActionRegistry` / `RefCounted` | `ACTION_TEMPLATES` (5 actions), static accessors + queue⇄rule conversions + summaries | | `res://scripts/trigger_registry.gd` | `TriggerRegistry` / `RefCounted` | `TRIGGER_TEMPLATES` (5 triggers), static accessors + summaries | | `res://scripts/queue_panel.gd` | `QueuePanel` / `PopupPanel` | Action Queue editor popup (root of `queue_panel.tscn`) | | `res://scripts/rule_panel.gd` | `RulePanel` / `PopupPanel` | Rule list editor popup (root of `rule_panel.tscn`) | | `res://scripts/action_editor.gd` | `ActionEditor` / `PopupPanel` | Single-action add/edit editor (root of `action_editor.tscn`) | | `res://scripts/rule_editor.gd` | `RuleEditor` / `PopupPanel` | Full + consequence-only rule editor (root of `rule_editor.tscn`) | | `res://scripts/waypoint_context.gd` | `WaypointContext` / `PopupMenu` | Waypoint right-click menu | | `res://scenes/queue_panel.tscn` / `rule_panel.tscn` / `action_editor.tscn` / `rule_editor.tscn` | `PopupPanel` roots | Minimal shells; UI built in code | --- ## 4. Public API Signatures (GDScript) ### 4.1 `ActionRegistry` (`res://scripts/action_registry.gd`) ```gdscript class_name ActionRegistry extends RefCounted const ACTION_TEMPLATES := { "walk_to": { "label": "Walk To", "icon": "🚶", "params": [ { "key": "target", "type": "position", "required": true } ] }, "speak": { "label": "Speak", "icon": "💬", "params": [ { "key": "text", "type": "text", "required": true }, { "key": "duration", "type": "float", "default": 2.0 } ] }, "wait": { "label": "Wait", "icon": "⏳", "params": [ { "key": "duration", "type": "float", "required": true } ] }, "ragdoll": { "label": "Ragdoll", "icon": "💥", "params": [] }, "recover": { "label": "Recover", "icon": "🔄", "params": [] }, } static func types() -> Array[String] # genuinely typed (Dictionary.keys() is untyped) static func has_type(type: String) -> bool static func label(type: String) -> String # template label, else the type itself static func icon(type: String) -> String # template icon, else "" static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary # flat queue action -> rule-action shape: { "type", "target": target_id, # "params": { target | text+duration | duration } } (walk_to/speak/wait only; others -> no params) static func from_rule_action(rule_action: Dictionary) -> Dictionary # rule-action shape -> flat queue action (drops target, inlines params) static func summarize(action: Dictionary) -> String # one-line label, e.g. 'Walk To (123, 456)', 'Speak "Hello" (2s)', 'Wait 1s'; tolerates # both the flat and rule-action key layouts via get() fallbacks. ``` ### 4.2 `TriggerRegistry` (`res://scripts/trigger_registry.gd`) ```gdscript class_name TriggerRegistry extends RefCounted const TRIGGER_TEMPLATES := { "arrived_at_waypoint": { "label": "Arrives at waypoint", "icon": "📍", "target_type": "waypoint" }, "action_finished": { "label": "Completes any action", "icon": "✅", "target_type": "action_type" }, "speech_finished": { "label": "Finishes speaking", "icon": "💬", "target_type": "none" }, "entered_area": { "label": "Enters trigger area", "icon": "🎯", "target_type": "area" }, "collided": { "label": "Collides with something", "icon": "💥", "target_type": "prop" }, } static func types() -> Array[String] static func has_type(type: String) -> bool static func label(type: String) -> String static func icon(type: String) -> String static func target_type(type: String) -> String # "waypoint" | "action_type" | "none" | "area" | "prop" static func summarize(trigger: Dictionary) -> String # "