# 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: "