# test_phase3c_editor.gd # Headless logic checks for the Phase 3c Editor Tools (Action & Rule Editing): # # 1. Registries: ActionRegistry / TriggerRegistry template lookups # (types/label/icon/target_type/summarize for all 5 actions + 5 triggers) # and graceful unknown-type handling. # 2. Scene shells instantiate (queue_panel / rule_panel / action_editor / # rule_editor / waypoint_context script) and build their UI. # 3. ActionEditor: open_new/open_edit pre-fill (walk target, speak text / # duration, wait duration), committed/cancelled/target_requested signals, # OK-without-target requests a stage target capture. # 4. RuleEditor: full vs consequence-only modes, trigger read-only in # consequence mode, action add/edit/remove, get_action(), rule id # preservation on commit, action_type dropdown sync for action_finished. # 5. QueuePanel: set_source/refresh shows all queue actions in order; stage # add/edit/delete/clear/reorder flows mutate the rig queue via its API. # 6. RulePanel: filtered rule list (by source stickman and by waypoint), # edit/delete/add/clear/reorder. # 7. WaypointContext: item ids for Edit/Delete/Insert-before/after plus the # trigger-rules entry enabled/disabled by rule count. # 8. StageDirectorVisuals.hit_test_waypoint_action() returns rig/index/pos. # 9. sandbox_stage Phase 3c capture: begin/cancel/resolve + Esc priority; # right-click waypoint/rig context entry points. # 10. Backward compatibility: pre-existing queues/rules display correctly and # editing preserves action types/params and rule ids. # # Run with: # & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase3c_editor.gd --path . # # Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL. extends SceneTree const ACTION_REGISTRY := preload("res://scripts/action_registry.gd") const TRIGGER_REGISTRY := preload("res://scripts/trigger_registry.gd") const QUEUE_PANEL_SCENE := preload("res://scenes/queue_panel.tscn") const RULE_PANEL_SCENE := preload("res://scenes/rule_panel.tscn") const ACTION_EDITOR_SCENE := preload("res://scenes/action_editor.tscn") const RULE_EDITOR_SCENE := preload("res://scenes/rule_editor.tscn") const WAYPOINT_CONTEXT := preload("res://scripts/waypoint_context.gd") const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn") const STAGE_SELECTION := preload("res://scripts/stage_selection.gd") var _checks := 0 var _failures := 0 func _initialize() -> void: call_deferred("_run") func _run() -> void: # Watchdog: if a runtime error aborts _run before quit(), force a FAIL exit # instead of hanging the headless process forever. var watchdog := create_timer(180.0) watchdog.timeout.connect(func() -> void: print("FAIL: watchdog timeout - test run aborted before quit()") quit(2)) print("") print("========================================================") print(" PHASE 3c EDITOR TOOLS TEST (headless)") print("========================================================") _test_action_registry() _test_trigger_registry() _test_rule_action_converters() await _test_scene_shells() await _test_action_editor() await _test_rule_editor() await _test_queue_panel_flows() await _test_rule_panel_flows() await _test_stage_capture_and_esc() await _test_waypoint_context() await _test_visuals_hit_test() await _test_stage_context_entries() await _test_backward_compat() print("--------------------------------------------------------") if _failures == 0: print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks) quit(0) else: print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks]) quit(1) # --------------------------------------------------------------------------- # 1. Action registry # --------------------------------------------------------------------------- func _test_action_registry() -> void: print("") print("--- ActionRegistry templates ---") var types: Array[String] = ACTION_REGISTRY.types() _check(types == ["walk_to", "speak", "wait", "ragdoll", "recover"], "action types in registry order (got %s)" % str(types)) _check(types is Array[String], "types() returns a typed Array[String]") for t: String in ["walk_to", "speak", "wait", "ragdoll", "recover"]: _check(ACTION_REGISTRY.has_type(t), "has_type('%s') true" % t) _check(not ACTION_REGISTRY.has_type("bogus"), "has_type('bogus') false") _check(ACTION_REGISTRY.label("walk_to") == "Walk To", "walk_to label == 'Walk To'") _check(ACTION_REGISTRY.label("speak") == "Speak", "speak label == 'Speak'") _check(ACTION_REGISTRY.label("wait") == "Wait", "wait label == 'Wait'") _check(ACTION_REGISTRY.label("ragdoll") == "Ragdoll", "ragdoll label == 'Ragdoll'") _check(ACTION_REGISTRY.label("recover") == "Recover", "recover label == 'Recover'") _check(ACTION_REGISTRY.label("bogus") == "bogus", "unknown action label falls back to type") _check(ACTION_REGISTRY.icon("walk_to") == "🚶", "walk_to icon") _check(ACTION_REGISTRY.icon("speak") == "💬", "speak icon") _check(ACTION_REGISTRY.icon("wait") == "⏳", "wait icon") _check(ACTION_REGISTRY.icon("ragdoll") == "💥", "ragdoll icon") _check(ACTION_REGISTRY.icon("recover") == "🔄", "recover icon") _check(ACTION_REGISTRY.icon("bogus") == "", "unknown action icon is empty") # Summaries - flat queue action shape. _check(ACTION_REGISTRY.summarize({ "type": "walk_to", "target": Vector2(10, -20) }) == "Walk To (10, -20)", "walk_to summarize uses target (got '%s')" % ACTION_REGISTRY.summarize({ "type": "walk_to", "target": Vector2(10, -20) })) _check(ACTION_REGISTRY.summarize({ "type": "speak", "text": "Hello", "duration": 2.0 }) == "Speak \"Hello\" (2s)", "speak summarize text+duration (got '%s')" % ACTION_REGISTRY.summarize({ "type": "speak", "text": "Hello", "duration": 2.0 })) _check(ACTION_REGISTRY.summarize({ "type": "wait", "duration": 1.5 }) == "Wait 1.5s", "wait summarize duration (got '%s')" % ACTION_REGISTRY.summarize({ "type": "wait", "duration": 1.5 })) _check(ACTION_REGISTRY.summarize({ "type": "ragdoll" }) == "Ragdoll", "ragdoll summarize") _check(ACTION_REGISTRY.summarize({ "type": "recover" }) == "Recover", "recover summarize") _check(ACTION_REGISTRY.summarize({ "type": "mystery" }) == "mystery", "unknown summarize returns type") # Summaries tolerate the rule-action shape (params nested + target actor id). _check(ACTION_REGISTRY.summarize({ "type": "speak", "target": 123, "params": { "text": "Hi", "duration": 1.0 } }) == "Speak \"Hi\" (1s)", "speak summarize reads rule-action params (got '%s')" % ACTION_REGISTRY.summarize({ "type": "speak", "target": 123, "params": { "text": "Hi", "duration": 1.0 } })) # Parameter templates (extensibility: per-type params). _check((ACTION_REGISTRY.ACTION_TEMPLATES["walk_to"]["params"] as Array).size() == 1, "walk_to has 1 param") _check((ACTION_REGISTRY.ACTION_TEMPLATES["speak"]["params"] as Array).size() == 2, "speak has 2 params") _check((ACTION_REGISTRY.ACTION_TEMPLATES["wait"]["params"] as Array).size() == 1, "wait has 1 param") _check((ACTION_REGISTRY.ACTION_TEMPLATES["ragdoll"]["params"] as Array).is_empty(), "ragdoll has no params") _check((ACTION_REGISTRY.ACTION_TEMPLATES["recover"]["params"] as Array).is_empty(), "recover has no params") # --------------------------------------------------------------------------- # 2. Trigger registry # --------------------------------------------------------------------------- func _test_trigger_registry() -> void: print("") print("--- TriggerRegistry templates ---") var types: Array[String] = TRIGGER_REGISTRY.types() _check(types == ["arrived_at_waypoint", "action_finished", "speech_finished", "entered_area", "collided"], "trigger types in registry order (got %s)" % str(types)) for t: String in types: _check(TRIGGER_REGISTRY.has_type(t), "has_type('%s') true" % t) _check(not TRIGGER_REGISTRY.has_type("nope"), "has_type('nope') false") _check(TRIGGER_REGISTRY.label("arrived_at_waypoint") == "Arrives at waypoint", "arrived label") _check(TRIGGER_REGISTRY.label("action_finished") == "Completes any action", "action_finished label") _check(TRIGGER_REGISTRY.label("speech_finished") == "Finishes speaking", "speech_finished label") _check(TRIGGER_REGISTRY.label("entered_area") == "Enters trigger area", "entered_area label") _check(TRIGGER_REGISTRY.label("collided") == "Collides with something", "collided label") _check(TRIGGER_REGISTRY.label("nope") == "nope", "unknown trigger label falls back to type") _check(TRIGGER_REGISTRY.target_type("arrived_at_waypoint") == "waypoint", "arrived target_type waypoint") _check(TRIGGER_REGISTRY.target_type("action_finished") == "action_type", "action_finished target_type action_type") _check(TRIGGER_REGISTRY.target_type("speech_finished") == "none", "speech_finished target_type none") _check(TRIGGER_REGISTRY.target_type("entered_area") == "area", "entered_area target_type area") _check(TRIGGER_REGISTRY.target_type("collided") == "prop", "collided target_type prop") _check(TRIGGER_REGISTRY.target_type("nope") == "none", "unknown trigger target_type none") _check(TRIGGER_REGISTRY.icon("entered_area") == "🎯", "entered_area icon") _check(TRIGGER_REGISTRY.summarize({ "type": "arrived_at_waypoint" }) == "📍 Arrives at waypoint", "arrived summarize (got '%s')" % TRIGGER_REGISTRY.summarize({ "type": "arrived_at_waypoint" })) # --------------------------------------------------------------------------- # 3. Rule-action converters (registry roundtrip) # --------------------------------------------------------------------------- func _test_rule_action_converters() -> void: print("") print("--- ActionRegistry to_rule_action / from_rule_action ---") var flat_walk := { "type": "walk_to", "target": Vector2(30, 40) } var ra_walk := ACTION_REGISTRY.to_rule_action(flat_walk, 999) _check(String(ra_walk["type"]) == "walk_to" and int(ra_walk["target"]) == 999, "to_rule_action(walk) nests target actor id") _check((ra_walk["params"] as Dictionary).get("target", Vector2.ZERO) == Vector2(30, 40), "to_rule_action(walk) nests walk position in params") var back_walk := ACTION_REGISTRY.from_rule_action(ra_walk) _check(String(back_walk["type"]) == "walk_to" and (back_walk["target"] as Vector2) == Vector2(30, 40), "from_rule_action roundtrips walk_to") var flat_speak := { "type": "speak", "text": "Yo", "duration": 3.0 } var ra_speak := ACTION_REGISTRY.to_rule_action(flat_speak, 5) _check(String(ra_speak["params"].get("text", "")) == "Yo" and float(ra_speak["params"].get("duration", 0.0)) == 3.0, "to_rule_action(speak) nests text/duration") var back_speak := ACTION_REGISTRY.from_rule_action(ra_speak) _check(String(back_speak["text"]) == "Yo" and float(back_speak["duration"]) == 3.0, "from_rule_action roundtrips speak") var flat_wait := { "type": "wait", "duration": 2.5 } var back_wait := ACTION_REGISTRY.from_rule_action(ACTION_REGISTRY.to_rule_action(flat_wait, 5)) _check(String(back_wait["type"]) == "wait" and float(back_wait["duration"]) == 2.5, "from_rule_action roundtrips wait") var back_rag := ACTION_REGISTRY.from_rule_action({ "type": "ragdoll", "target": 5, "params": {} }) _check(String(back_rag["type"]) == "ragdoll" and back_rag.size() == 1, "from_rule_action keeps paramless ragdoll minimal") # --------------------------------------------------------------------------- # 4. Scene shells instantiate # --------------------------------------------------------------------------- func _test_scene_shells() -> void: print("") print("--- Phase 3c scene shells load + _ready ---") _check(load("res://scenes/queue_panel.tscn") != null, "queue_panel.tscn loads") _check(load("res://scenes/rule_panel.tscn") != null, "rule_panel.tscn loads") _check(load("res://scenes/action_editor.tscn") != null, "action_editor.tscn loads") _check(load("res://scenes/rule_editor.tscn") != null, "rule_editor.tscn loads") var qp: QueuePanel = QUEUE_PANEL_SCENE.instantiate() root.add_child(qp) _check(qp._list != null and qp._title_label != null, "queue_panel builds its UI") _check(qp._empty_label.visible, "queue_panel with no rig shows empty label") var wp_menu: PopupMenu = WAYPOINT_CONTEXT.new() root.add_child(wp_menu) _check(wp_menu.item_count == 6, "waypoint context has 6 items (got %d)" % wp_menu.item_count) _check(wp_menu.get_item_id(0) == WAYPOINT_CONTEXT.EDIT_WALK, "waypoint item 0 is EDIT_WALK") _check(wp_menu.get_item_id(1) == WAYPOINT_CONTEXT.DELETE_WALK, "waypoint item 1 is DELETE_WALK") _check(wp_menu.get_item_id(2) == WAYPOINT_CONTEXT.INSERT_BEFORE, "waypoint item 2 is INSERT_BEFORE") _check(wp_menu.get_item_id(3) == WAYPOINT_CONTEXT.INSERT_AFTER, "waypoint item 3 is INSERT_AFTER") _check(wp_menu.get_item_id(4) == -1, "waypoint item 4 is a separator") _check(wp_menu.get_item_id(5) == WAYPOINT_CONTEXT.EDIT_TRIGGER_RULES, "waypoint item 5 is EDIT_TRIGGER_RULES") wp_menu.popup_for(Rect2i(10, 10, 0, 0), 2) _check(not wp_menu.is_item_disabled(5) and wp_menu.get_item_text(5).contains("2 rules"), "trigger-rules entry enabled with count (got '%s')" % wp_menu.get_item_text(5)) wp_menu.popup_for(Rect2i(10, 10, 0, 0), 0) _check(wp_menu.is_item_disabled(5), "trigger-rules entry disabled with no rules") qp.queue_free() wp_menu.queue_free() await process_frame var rp: RulePanel = RULE_PANEL_SCENE.instantiate() root.add_child(rp) _check(rp._list != null and rp._title_label != null, "rule_panel builds its UI") rp.queue_free() await process_frame var ae: ActionEditor = ACTION_EDITOR_SCENE.instantiate() root.add_child(ae) _check(ae._type_option != null, "action_editor builds its UI") ae.hide() var re: RuleEditor = RULE_EDITOR_SCENE.instantiate() root.add_child(re) _check(re._type_option != null and re._trigger_readonly_label != null, "rule_editor builds its UI") re.hide() await process_frame # --------------------------------------------------------------------------- # 5. ActionEditor standalone flows # --------------------------------------------------------------------------- func _test_action_editor() -> void: print("") print("--- ActionEditor open_new / open_edit / commit ---") var ae: ActionEditor = ACTION_EDITOR_SCENE.instantiate() root.add_child(ae) # open_new defaults to walk_to with no target. ae.open_new() _check(ae._mode == "new", "open_new sets mode 'new'") _check(ae._current_type() == "walk_to", "open_new selects walk_to (got '%s')" % ae._current_type()) _check(ae._has_target == false, "open_new has no target") _check(ae.title == "Add Action", "open_new title == 'Add Action'") # OK without a target emits target_requested (not committed). var committed: Array = [] var targets: Array = [] ae.committed.connect(func(a): committed.append(a)) ae.target_requested.connect(func(): targets.append(true)) ae._on_ok_pressed() _check(targets.size() == 1, "walk_to OK without target emits target_requested") _check(committed.is_empty(), "walk_to OK without target does NOT commit") # Set the walk target then commit. ae.set_walk_target(Vector2(120, -80)) _check(ae._has_target, "set_walk_target marks target present") _check(ae._target_label != null and ae._target_label.text.contains("120"), "walk target label updates") ae._on_ok_pressed() _check(committed.size() == 1, "walk_to OK with target commits once") if committed.size() == 1: var a0: Dictionary = committed[0] _check(String(a0["type"]) == "walk_to", "committed walk_to preserves type") _check((a0["target"] as Vector2) == Vector2(120, -80), "committed walk_to carries target") # A second open_new resets the target. ae.open_new() _check(ae._has_target == false, "open_new resets the target for a fresh add") # Speak edit pre-fill. ae.open_edit({ "type": "speak", "text": "Hello there", "duration": 2.5 }) _check(ae._mode == "edit", "open_edit sets mode 'edit'") _check(ae.title == "Edit Action", "open_edit title == 'Edit Action'") _check(ae._current_type() == "speak", "open_edit selects speak") _check(ae._text_edit.text == "Hello there", "speak text pre-filled") _check(is_equal_approx(ae._duration_spin.value, 2.5), "speak duration pre-filled (%.2f)" % ae._duration_spin.value) ae._text_edit.text = "Changed" ae._duration_spin.value = 1.0 ae._on_ok_pressed() _check(committed.size() == 2, "speak edit commits") if committed.size() == 2: var a1: Dictionary = committed[1] _check(String(a1["type"]) == "speak" and String(a1["text"]) == "Changed" and float(a1["duration"]) == 1.0, "speak edit commits type/text/duration") # Wait edit pre-fill. ae.open_edit({ "type": "wait", "duration": 3.0 }) _check(ae._current_type() == "wait", "open_edit selects wait") _check(is_equal_approx(ae._duration_spin.value, 3.0), "wait duration pre-filled") ae._duration_spin.value = 4.0 ae._on_ok_pressed() if committed.size() == 3: var a2: Dictionary = committed[2] _check(String(a2["type"]) == "wait" and float(a2["duration"]) == 4.0, "wait edit commits new duration") # Type switching to a paramless action (ragdoll) then commit. ae.open_new() var rag_idx: int = ACTION_REGISTRY.types().find("ragdoll") ae._type_option.select(rag_idx) ae._on_type_selected(rag_idx) _check(ae._current_type() == "ragdoll", "type switch to ragdoll") ae._on_ok_pressed() if committed.size() == 4: var a3: Dictionary = committed[3] _check(String(a3["type"]) == "ragdoll" and a3.size() == 1, "ragdoll commits type-only") # Cancel via Esc. var cancelled: Array = [] ae.cancelled.connect(func(): cancelled.append(true)) var esc := InputEventKey.new() esc.keycode = KEY_ESCAPE esc.pressed = true ae._unhandled_input(esc) _check(cancelled.size() == 1, "Esc emits cancelled") ae.queue_free() await process_frame # --------------------------------------------------------------------------- # 6. RuleEditor standalone flows # --------------------------------------------------------------------------- func _test_rule_editor() -> void: print("") print("--- RuleEditor full / consequence-only ---") var re: RuleEditor = RULE_EDITOR_SCENE.instantiate() root.add_child(re) var sample_rule := { "id": 7, "trigger": { "type": "arrived_at_waypoint", "source": -1, "target": -1, "params": { "waypoint_pos": Vector2(50, 60) }, }, "actions": [ { "type": "speak", "target": 1, "params": { "text": "hi", "duration": 1.0 } }, { "type": "wait", "target": 1, "params": { "duration": 2.0 } }, ], } var committed: Array = [] re.committed.connect(func(r): committed.append(r)) # Full mode loads trigger + actions + id. re.open_full(sample_rule) _check(re._mode == "full", "open_full sets mode 'full'") _check(re._rule_id == 7, "open_full preserves rule id (got %d)" % re._rule_id) _check(re._actions.size() == 2, "open_full loads 2 actions (got %d)" % re._actions.size()) _check(re._current_type() == "arrived_at_waypoint", "open_full selects trigger type") var a0: Dictionary = re.get_action(0) _check(String(a0.get("type", "")) == "speak" and String(a0.get("params", {}).get("text", "")) == "hi", "get_action(0) returns speak action copy") _check(re.get_action(9).is_empty(), "get_action out-of-range returns {}") _check(re._type_option.visible, "full mode shows trigger dropdown") _check(not re._trigger_readonly_label.visible, "full mode hides read-only trigger label") _check(re._trigger_readonly_label.visible == false, "full mode read-only label hidden") # Trigger type change to entered_area via the dropdown + commit. var ea_idx: int = TRIGGER_REGISTRY.types().find("entered_area") re._type_option.select(ea_idx) re._on_type_selected(ea_idx) _check(re._current_type() == "entered_area", "trigger switch to entered_area") re._on_ok_pressed() _check(committed.size() == 1, "full mode commit emits") if committed.size() == 1: var c0: Dictionary = committed[0] _check(int(c0["id"]) == 7, "full commit preserves id") _check(String(c0["trigger"].get("type", "")) == "entered_area", "full commit stores new trigger type") _check((c0["actions"] as Array).size() == 2, "full commit keeps actions") # action_finished trigger shows the action_type dropdown and syncs on OK. var af_rule := { "id": 8, "trigger": { "type": "action_finished", "source": -1, "target": -1, "params": { "action_type": "speak" } }, "actions": [], } re.open_full(af_rule) _check(re._current_type() == "action_finished", "action_finished trigger loads") _check(re._action_type_option.visible, "action_finished shows the action-type dropdown") _check(re._action_type_option.selected == 2, "action_type dropdown pre-fills 'speak' (sel=%d)" % re._action_type_option.selected) # Switch to "wait" (index 3: Any/walk/speak/wait) then commit syncs params. re._action_type_option.select(3) re._on_ok_pressed() if committed.size() == 2: var c1: Dictionary = committed[1] _check(String(c1["trigger"].get("params", {}).get("action_type", "")) == "wait", "action_finished commit syncs action_type param (got '%s')" % str(c1["trigger"].get("params", {}))) # Consequence-only mode: trigger is read-only, actions editable. re.open_consequence(sample_rule) _check(re._mode == "consequence", "open_consequence sets mode 'consequence'") _check(not re._type_option.visible, "consequence hides trigger dropdown") _check(not re._target_row.visible, "consequence hides target row") _check(re._trigger_readonly_label.visible, "consequence shows read-only trigger label") _check(re._trigger_readonly_label.text.contains("Arrives at waypoint"), "read-only label summarizes trigger (got '%s')" % re._trigger_readonly_label.text) _check(re._actions.size() == 2, "consequence loads actions") # Consequence commit must NOT change the trigger type even if dropdown differs. re._type_option.select(0) re._on_ok_pressed() if committed.size() == 3: var c2: Dictionary = committed[2] _check(int(c2["id"]) == 7, "consequence commit preserves id") _check(String(c2["trigger"].get("type", "")) == "arrived_at_waypoint", "consequence commit leaves trigger type untouched") # set_action appends / replaces. re.open_consequence(sample_rule) re.set_action(-1, { "type": "ragdoll", "target": 1, "params": {} }) _check(re._actions.size() == 3, "set_action(-1) appends (got %d)" % re._actions.size()) re.set_action(0, { "type": "wait", "target": 1, "params": { "duration": 9.0 } }) _check(re._actions.size() == 3 and float(re.get_action(0).get("params", {}).get("duration", 0.0)) == 9.0, "set_action(index) replaces in place") re._remove_action(0) _check(re._actions.size() == 2, "_remove_action removes (got %d)" % re._actions.size()) _check(String(re.get_action(0).get("type", "")) == "wait", "removal shifts remaining actions") # Add + commit after remove preserves the whole edited action list. re._on_ok_pressed() if committed.size() == 4: var c3: Dictionary = committed[3] _check((c3["actions"] as Array).size() == 2, "commit carries edited action list") # Esc emits cancelled. var cancelled: Array = [] re.cancelled.connect(func(): cancelled.append(true)) var esc := InputEventKey.new() esc.keycode = KEY_ESCAPE esc.pressed = true re._unhandled_input(esc) _check(cancelled.size() == 1, "RuleEditor Esc emits cancelled") # Unknown trigger type falls back to a known one. re.open_full({ "id": 9, "trigger": { "type": "mystery_trigger" }, "actions": [] }) _check(re._current_type() == "arrived_at_waypoint", "unknown trigger type falls back to arrived_at_waypoint") re.queue_free() await process_frame # --------------------------------------------------------------------------- # 7. QueuePanel + stage queue flows # --------------------------------------------------------------------------- func _test_queue_panel_flows() -> void: print("") print("--- QueuePanel + stage queue edit/delete/clear/add/reorder ---") var stage := _new_stage() var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0)) _check(rig != null, "stickman rig spawns for queue flows") if rig == null: await _free_stage(stage) return rig.queue_action({ "type": "walk_to", "target": Vector2(100, -100) }) rig.queue_action({ "type": "speak", "text": "Hello", "duration": 2.0 }) rig.queue_action({ "type": "wait", "duration": 3.0 }) rig.queue_action({ "type": "ragdoll" }) # Open queue panel: rows reflect the queue in order. stage._open_queue_panel(rig) _check(stage._queue_panel.rig == rig, "queue panel rig attached") _check(stage._queue_panel._rows.size() == 4, "queue panel shows 4 rows (got %d)" % stage._queue_panel._rows.size()) _check(not stage._queue_panel._empty_label.visible, "queue panel hides empty label") _check(stage._queue_panel._title_label.text.contains(String(rig.name)), "queue panel title names the rig (got '%s')" % stage._queue_panel._title_label.text) var row_summaries := _queue_panel_summaries(stage._queue_panel) _check(row_summaries[0].contains("Walk To") and row_summaries[1].contains("Speak") and row_summaries[2].contains("Wait") and row_summaries[3].contains("Ragdoll"), "queue rows show action types in order (got %s)" % str(row_summaries)) # Edit a speak action through the panel -> ActionEditor queue_edit. stage._on_queue_panel_edit_requested(1) _check(stage._action_editor_kind == "queue_edit", "edit requested opens queue_edit editor (got '%s')" % stage._action_editor_kind) _check(stage._action_editor_index == 1, "queue_edit editor targets index 1") _check(stage._action_editor._current_type() == "speak", "queue_edit editor pre-fills speak") _check(stage._action_editor._text_edit.text == "Hello", "queue_edit editor pre-fills speak text") # Commit an edited speak. stage._action_editor._text_edit.text = "Edited!" stage._action_editor._duration_spin.value = 1.5 stage._action_editor._on_ok_pressed() var q: Array[Dictionary] = rig.get_queue() _check(q.size() == 4, "edit keeps queue size") _check(q[1].get("type", "") == "speak" and q[1].get("text", "") == "Edited!" and float(q[1].get("duration", 0.0)) == 1.5, "edit replaces action in place with same type + params") stage._clear_director_pending() # Edit a walk action enters visual re-placement capture. stage._open_queue_panel(rig) stage._on_queue_panel_edit_requested(0) _check(stage._walk_edit_rig == rig and stage._walk_edit_index == 0, "walk edit arms rig + index") _check(stage._capture_kind == stage.CaptureKind.POSITION, "walk edit begins POSITION capture") _check(stage._director_visuals._edit_waypoint == Vector2(100, -100), "walk edit highlights the waypoint") # Resolve capture at a new position. stage._resolve_capture(Vector2(555, -555)) q = rig.get_queue() _check((q[0].get("target", Vector2.ZERO) as Vector2) == Vector2(555, -555), "walk edit updates the action target") _check(stage._capture_kind == stage.CaptureKind.NONE, "walk edit clears capture after resolve") _check(stage._walk_edit_rig == null, "walk edit clears edit state") stage._clear_director_pending() # Cancel capture keeps the waypoint unchanged. stage._open_queue_panel(rig) stage._on_queue_panel_edit_requested(0) stage._cancel_capture() q = rig.get_queue() _check((q[0].get("target", Vector2.ZERO) as Vector2) == Vector2(555, -555), "cancel walk edit leaves target") _check(stage._walk_edit_rig == null, "cancel walk edit clears edit state") stage._clear_director_pending() # Add action appends through the ActionEditor. stage._open_queue_panel(rig) stage._on_queue_panel_add_requested() _check(stage._action_editor_kind == "queue_add", "add requested opens queue_add editor") # switch to wait and commit var wait_idx: int = ACTION_REGISTRY.types().find("wait") stage._action_editor._type_option.select(wait_idx) stage._action_editor._on_type_selected(wait_idx) stage._action_editor._duration_spin.value = 7.0 stage._action_editor._on_ok_pressed() q = rig.get_queue() _check(q.size() == 5, "add appends to queue (got %d)" % q.size()) _check(q[4].get("type", "") == "wait" and float(q[4].get("duration", 0.0)) == 7.0, "added action at tail") stage._clear_director_pending() # Delete action with confirmation. stage._open_queue_panel(rig) stage._on_queue_panel_delete_requested(0) _check(stage._confirm_callback.is_valid(), "delete action asks for confirmation") stage._on_confirm_confirmed() q = rig.get_queue() _check(q.size() == 4, "delete removes one action (got %d)" % q.size()) _check(q[0].get("type", "") == "speak", "delete shifts remaining queue (index 0 now %s)" % String(q[0].get("type", ""))) stage._clear_director_pending() # Reorder through the panel's drag-commit. The implementation removes then # re-inserts the dragged action so a downward move lands it just above the # highlighted target row (move 0 -> 2 puts the item at final index 1). stage._open_queue_panel(rig) var before := rig.get_queue() stage._queue_panel._drag_index = 0 stage._queue_panel._drag_target = 2 stage._queue_panel._commit_drag() q = rig.get_queue() _check(q.size() == before.size(), "reorder keeps size") _check(q[0].get("type", "") == "wait" and q[1].get("type", "") == "speak", "reorder moves the action down to index 1 (got %s)" % str(_type_list(q))) stage._clear_director_pending() # Clear with confirmation empties the queue. stage._open_queue_panel(rig) stage._on_queue_panel_clear_requested() stage._on_confirm_confirmed() q = rig.get_queue() _check(q.is_empty(), "clear queue removes all actions") _check(stage._queue_panel._rows.is_empty(), "queue panel refresh shows no rows after clear") stage._clear_director_pending() # Empty queue panel shows the empty label. stage._open_queue_panel(rig) _check(stage._queue_panel._empty_label.visible, "queue panel shows empty label after clear") stage._clear_director_pending() await _free_stage(stage) # --------------------------------------------------------------------------- # 8. RulePanel + stage rule flows # --------------------------------------------------------------------------- func _test_rule_panel_flows() -> void: print("") print("--- RulePanel filter/edit/delete/add/clear/reorder ---") var stage := _new_stage() var rigA: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0)) var rigB: StickmanRig = stage._spawner.spawn("stickman", Vector2(400, 0)) _check(rigA != null and rigB != null, "two stickmen spawn for rule flows") if rigA == null or rigB == null: await _free_stage(stage) return var wp1 := Vector2(100, -100) var wp2 := Vector2(300, -300) var rule0 := { "id": 0, "trigger": { "type": "arrived_at_waypoint", "source": rigA.get_instance_id(), "target": -1, "params": { "waypoint_pos": wp1 } }, "actions": [{ "type": "speak", "target": rigB.get_instance_id(), "params": { "text": "Hi", "duration": 1.0 } }], } var rule1 := { "id": 1, "trigger": { "type": "action_finished", "source": rigA.get_instance_id(), "target": -1, "params": {} }, "actions": [{ "type": "wait", "target": rigB.get_instance_id(), "params": { "duration": 2.0 } }], } var rule2 := { "id": 2, "trigger": { "type": "arrived_at_waypoint", "source": rigB.get_instance_id(), "target": -1, "params": { "waypoint_pos": wp2 } }, "actions": [{ "type": "recover", "target": rigA.get_instance_id(), "params": {} }], } var rules_arr0: Array[Dictionary] = [rule0, rule1, rule2] stage._event_rules = rules_arr0 # Filter by source rig. stage._open_rules_panel_for_rig(rigA) var ids: Array[int] = [] for r: Dictionary in stage._rule_panel._rules: ids.append(int(r.get("id", -1))) _check(ids == [0, 1], "rule panel filtered to source rig ids [0,1] (got %s)" % str(ids)) _check(stage._rule_panel_filter_ids == [0, 1], "filter-id set matches the display list") _check(stage._rule_panel._title_label.text.contains(String(rigA.name)), "rule panel titles the source rig") _check(stage._rule_panel._rows.size() == 2, "rule panel builds 2 rows") # Row 0's trigger summary names the rig and the trigger label. var trig_text := _rule_panel_trigger_text(stage._rule_panel, 0) _check(trig_text.contains("Arrives at waypoint"), "rule row shows trigger label (got '%s')" % trig_text) # Filter by waypoint. stage._open_trigger_rules_panel(wp1) ids.clear() for r: Dictionary in stage._rule_panel._rules: ids.append(int(r.get("id", -1))) _check(ids == [0], "waypoint filter returns only matching rule (got %s)" % str(ids)) stage._clear_director_pending() # Edit requested opens the full editor from the panel. stage._open_rules_panel_for_rig(rigA) stage._on_rule_panel_edit_requested(0) _check(stage._rule_editor_from_panel, "edit from panel marks restore flag") _check(stage._rule_editor._mode == "full" and stage._rule_editor._rule_id == 0, "edit opens full editor with id 0") _check(not stage._rule_panel.visible, "panel hides while editor is open") stage._clear_director_pending() # Full editor commit preserves the rule id and updates _event_rules. stage._open_rule_editor_full(rule0, false) stage._rule_editor._actions[0]["params"]["text"] = "Edited rule action" stage._rule_editor._on_ok_pressed() # _on_ok_pressed emits committed -> _on_rule_editor_committed (connected). _check(stage._event_rules.size() == 3, "rule commit keeps rule count") _check(int(stage._event_rules[0].get("id", -1)) == 0, "rule edit preserves id 0") _check(String(stage._event_rules[0].get("actions", [])[0].get("params", {}).get("text", "")) == "Edited rule action", "rule edit updates the action in the registry") _check(String(stage._event_rules[0].get("trigger", {}).get("type", "")) == "arrived_at_waypoint", "rule edit preserves the trigger type") stage._clear_director_pending() # Consequence-only edit via the rule label path (_begin_edit_rule). stage._begin_edit_rule(1) _check(stage._rule_editor._mode == "consequence", "label edit opens consequence-only editor") _check(stage._rule_editor._rule_id == 1, "label edit loads rule id 1") stage._clear_director_pending() # Reorder filtered rules: panel shows rigA rules [0,1]; move 0 below 1. stage._open_rules_panel_for_rig(rigA) var ordered_ids: Array[int] = [1, 0] stage._on_rule_panel_reorder_requested(ordered_ids) var order_ids: Array[int] = [] for r: Dictionary in stage._event_rules: order_ids.append(int(r.get("id", -1))) # rule2 (source rigB, id 2) stays at its own slot; rigA's rules swap within [0,2]. _check(order_ids == [1, 0, 2], "filtered reorder preserves unfiltered rule slot (got %s)" % str(order_ids)) stage._clear_director_pending() # Add Rule from the panel launches the (Phase 4) rule builder for that source. stage._open_rules_panel_for_rig(rigA) stage._on_rule_panel_add_requested() _check(stage._rule_build_from_panel, "add from panel marks rule_build_from_panel") _check(int(stage._rule_step) == stage.RuleStep.SELECT_TRIGGER, "add rule opens the builder at SELECT_TRIGGER") _check(stage._rule_builder.get("trigger", {}).get("source", -1) == rigA.get_instance_id(), "add rule seeds the source rig") stage._clear_director_pending() _check(stage._rule_build_from_panel == false, "mode-exit clears the panel-restore flag") # Add Rule while filtering by waypoint (no stickman source) shows a toast, no build. stage._open_trigger_rules_panel(wp1) stage._on_rule_panel_add_requested() _check(int(stage._rule_step) == stage.RuleStep.IDLE, "add rule without a source is a no-op") stage._clear_director_pending() # Delete rule with confirmation removes it from the registry. stage._open_rules_panel_for_rig(rigA) stage._on_rule_panel_delete_requested(1) stage._on_confirm_confirmed() var remaining_ids: Array[int] = [] for r: Dictionary in stage._event_rules: remaining_ids.append(int(r.get("id", -1))) _check(remaining_ids == [0, 2], "rule delete removes the filtered rule (got %s)" % str(remaining_ids)) stage._clear_director_pending() # Clear All removes every *filtered* rule only. var rules_arr2: Array[Dictionary] = [rule0, rule1, rule2] stage._event_rules = rules_arr2 stage._open_rules_panel_for_rig(rigA) stage._on_rule_panel_clear_requested() stage._on_confirm_confirmed() remaining_ids.clear() for r: Dictionary in stage._event_rules: remaining_ids.append(int(r.get("id", -1))) _check(remaining_ids == [2], "clear-all removes only filtered rules (got %s)" % str(remaining_ids)) await _free_stage(stage) # --------------------------------------------------------------------------- # 9. Capture state + Esc priority # --------------------------------------------------------------------------- func _test_stage_capture_and_esc() -> void: print("") print("--- Capture begin/cancel/resolve + Esc priority ---") var stage := _new_stage() var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0)) if rig == null: await _free_stage(stage) return rig.queue_action({ "type": "walk_to", "target": Vector2(300, -300) }) var resolved: Array = [] var cancelled: Array = [] # POSITION capture resolves to the (snap-adjusted) click position. stage._snap_enabled = false stage._begin_capture(stage.CaptureKind.POSITION, "hint", func(v): resolved.append(v), func(): cancelled.append(true)) _check(stage._capture_kind == stage.CaptureKind.POSITION, "begin_capture sets kind") _check(stage._capture_hint == "hint", "begin_capture stores hint") _check(stage._capture_callback.is_valid() and stage._capture_cancel.is_valid(), "begin_capture stores callbacks") stage._resolve_capture(Vector2(12.0, -34.0)) _check(stage._capture_kind == stage.CaptureKind.NONE, "resolve clears capture") _check(resolved.size() == 1 and (resolved[0] as Vector2) == Vector2(12.0, -34.0), "POSITION resolve returns click") _check(cancelled.is_empty(), "resolve does not call cancel") # Cancel invokes the on-cancel callback. stage._begin_capture(stage.CaptureKind.POSITION, "hint2", func(v): resolved.append(v), func(): cancelled.append(true)) stage._cancel_capture() _check(cancelled.size() == 1, "cancel invokes on-cancel callback") _check(stage._capture_kind == stage.CaptureKind.NONE and stage._capture_hint == "", "cancel clears capture state") _check(stage._capture_callback.is_valid() == false, "cancel clears the callback") # WAYPOINT capture resolves when clicking a real waypoint dot. rig.queue_action({ "type": "walk_to", "target": Vector2(300, -300) }) stage._open_queue_panel(rig) stage._clear_director_pending() stage._begin_capture(stage.CaptureKind.WAYPOINT, "pick wp", func(v): resolved.append(v), func(): cancelled.append(true)) stage._resolve_capture(Vector2(300, -300)) _check(resolved.size() == 2 and (resolved[1] as Vector2) == Vector2(300, -300), "WAYPOINT resolve returns waypoint pos") # A click away from any waypoint does not resolve and keeps capture active. stage._begin_capture(stage.CaptureKind.WAYPOINT, "pick wp", func(v): resolved.append(v), func(): cancelled.append(true)) stage._resolve_capture(Vector2(9999, 9999)) _check(resolved.size() == 2, "WAYPOINT resolve on empty space is a no-op") _check(stage._capture_kind == stage.CaptureKind.WAYPOINT, "capture stays active after a miss") stage._cancel_capture() # STICKMAN capture resolves on a rig's visual AABB. stage._begin_capture(stage.CaptureKind.STICKMAN, "pick actor", func(v): resolved.append(v), func(): cancelled.append(true)) var rig_aabb: Rect2 = STAGE_SELECTION.get_world_aabb(rig) stage._resolve_capture(rig_aabb.get_center()) _check(resolved.size() == 3 and (resolved[2] as Node2D) == rig, "STICKMAN capture resolves the rig") stage._cancel_capture() # AREA capture resolves on a spawned TriggerArea. var area: Node2D = stage._spawner.spawn("area", Vector2(900, -900)) _check(area != null, "trigger area spawns") stage._begin_capture(stage.CaptureKind.AREA, "pick area", func(v): resolved.append(v), func(): cancelled.append(true)) stage._resolve_capture(Vector2(900, -900)) _check(resolved.size() == 4 and (resolved[3] as Node2D) == area, "AREA capture resolves the area") # Esc priority: capture cancels before the rule builder. stage._begin_capture(stage.CaptureKind.POSITION, "x", func(v): pass, func(): cancelled.append(true)) stage._rule_step = stage.RuleStep.TRIGGER_TARGET stage._rule_builder = { "trigger": { "type": "arrived_at_waypoint" } } var esc := InputEventKey.new() esc.keycode = KEY_ESCAPE esc.pressed = true stage._unhandled_key_input(esc) _check(stage._capture_kind == stage.CaptureKind.NONE, "Esc while capture cancels capture first") _check(int(stage._rule_step) == stage.RuleStep.TRIGGER_TARGET, "Esc while capture leaves the rule builder untouched") # Second Esc then cancels the rule build. stage._unhandled_key_input(esc) _check(int(stage._rule_step) == stage.RuleStep.IDLE, "second Esc cancels the rule build") _check(stage._rule_builder.is_empty(), "second Esc empties the builder") # Esc while in a mode transition closes no editor popups when nothing active (still EDIT). stage._clear_director_pending() stage._unhandled_key_input(esc) _check(int(stage.current_mode) == stage.StageMode.EDIT, "Esc with nothing pending keeps EDIT mode") await _free_stage(stage) # --------------------------------------------------------------------------- # 10. Waypoint context through the stage # --------------------------------------------------------------------------- func _test_waypoint_context() -> void: print("") print("--- Waypoint context open/insert/delete ---") var stage := _new_stage() var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0)) if rig == null: await _free_stage(stage) return var wpA := Vector2(100, -100) var wpB := Vector2(200, -200) rig.queue_action({ "type": "walk_to", "target": wpA }) rig.queue_action({ "type": "walk_to", "target": wpB }) # Stage counts rules targeting the waypoint for the context menu. var wp_rule := { "id": 5, "trigger": { "type": "arrived_at_waypoint", "source": rig.get_instance_id(), "target": -1, "params": { "waypoint_pos": wpA } }, "actions": [], } var wp_rules: Array[Dictionary] = [wp_rule] stage._event_rules = wp_rules _check(stage._count_rules_for_waypoint(wpA) == 1, "count rules for waypoint == 1") _check(stage._count_rules_for_waypoint(wpB) == 0, "count rules for a different waypoint == 0") # Context menu records rig/index/pos and shows the count. stage._open_waypoint_context(wpA, { "rig": rig, "index": 0, "pos": wpA }) _check(stage._ctx_waypoint_rig == rig and stage._ctx_waypoint_index == 0 and stage._ctx_waypoint_pos == wpA, "open_waypoint_context records hit state") _check(stage._waypoint_context.visible, "waypoint context menu pops up") _check(not stage._waypoint_context.is_item_disabled(5), "waypoint context enables trigger rules with count") stage._waypoint_context.hide() # Delete this Walk removes the walk action. stage._ctx_waypoint_rig = rig stage._ctx_waypoint_index = 0 stage._on_waypoint_context_id_pressed(WaypointContext.DELETE_WALK) var q: Array[Dictionary] = rig.get_queue() _check(q.size() == 1 and (q[0].get("target", Vector2.ZERO) as Vector2) == wpB, "delete walk removes the action (got %d)" % q.size()) # Insert before/after opens the ActionEditor with a queue_insert kind. stage._ctx_waypoint_rig = rig stage._ctx_waypoint_index = 0 stage._ctx_waypoint_pos = wpB stage._on_waypoint_context_id_pressed(WaypointContext.INSERT_BEFORE) _check(stage._action_editor_kind == "queue_insert" and stage._action_editor_index == 0, "insert-before opens queue_insert at index 0") var wait_idx: int = ACTION_REGISTRY.types().find("wait") stage._action_editor._type_option.select(wait_idx) stage._action_editor._on_type_selected(wait_idx) stage._action_editor._duration_spin.value = 0.5 stage._action_editor._on_ok_pressed() q = rig.get_queue() _check(q.size() == 2 and q[0].get("type", "") == "wait", "insert-before inserts ahead of the waypoint walk") stage._clear_director_pending() # Insert after a real walk action. After the insert-before above the queue is # [wait(0), walkB(1)], so the walk to insert after lives at index 1. stage._ctx_waypoint_rig = rig stage._ctx_waypoint_index = 1 stage._ctx_waypoint_pos = wpB stage._on_waypoint_context_id_pressed(WaypointContext.INSERT_AFTER) _check(stage._action_editor_kind == "queue_insert" and stage._action_editor_index == 2, "insert-after opens queue_insert at walk index + 1") stage._action_editor._type_option.select(wait_idx) stage._action_editor._on_type_selected(wait_idx) stage._action_editor._duration_spin.value = 0.25 stage._action_editor._on_ok_pressed() q = rig.get_queue() _check(q.size() == 3 and q[2].get("type", "") == "wait", "insert-after appends after the walk") stage._clear_director_pending() # Edit this Walk begins the visual walk-edit capture. stage._ctx_waypoint_rig = rig stage._ctx_waypoint_index = 1 stage._on_waypoint_context_id_pressed(WaypointContext.EDIT_WALK) _check(stage._walk_edit_rig == rig and stage._walk_edit_index == 1, "edit walk arms rig + index") stage._cancel_capture() # EDIT_TRIGGER_RULES opens the rule panel filtered to that waypoint. stage._ctx_waypoint_rig = rig stage._ctx_waypoint_pos = wpA stage._on_waypoint_context_id_pressed(WaypointContext.EDIT_TRIGGER_RULES) var ids: Array[int] = [] for r: Dictionary in stage._rule_panel._rules: ids.append(int(r.get("id", -1))) _check(ids == [5], "edit trigger rules filters to the waypoint rule (got %s)" % str(ids)) stage._clear_director_pending() await _free_stage(stage) # --------------------------------------------------------------------------- # 11. StageDirectorVisuals waypoint hit-testing # --------------------------------------------------------------------------- func _test_visuals_hit_test() -> void: print("") print("--- StageDirectorVisuals.hit_test_waypoint_action ---") var stage := _new_stage() stage._camera.position = Vector2.ZERO stage._camera.zoom = Vector2(1.0, 1.0) var rigA: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0)) var rigB: StickmanRig = stage._spawner.spawn("stickman", Vector2(500, 0)) _check(rigA != null and rigB != null, "two rigs spawn for hit testing") if rigA == null or rigB == null: await _free_stage(stage) return var pa := Vector2(300, -300) var pb := Vector2(400, -400) var pc := Vector2(-200, -200) rigA.queue_action({ "type": "walk_to", "target": pa }) rigA.queue_action({ "type": "speak", "text": "hi", "duration": 1.0 }) rigA.queue_action({ "type": "walk_to", "target": pb }) rigB.queue_action({ "type": "walk_to", "target": pc }) var visuals = stage._director_visuals var hit: Dictionary = visuals.hit_test_waypoint_action(pa) _check(not hit.is_empty() and hit["rig"] == rigA and int(hit["index"]) == 0 and (hit["pos"] as Vector2) == pa, "hit_test_waypoint_action finds rigA walk 0") hit = visuals.hit_test_waypoint_action(pb) _check(not hit.is_empty() and hit["rig"] == rigA and int(hit["index"]) == 2, "hit_test_waypoint_action skips non-walk actions and finds rigA walk 2") hit = visuals.hit_test_waypoint_action(pc) _check(not hit.is_empty() and hit["rig"] == rigB and int(hit["index"]) == 0, "hit_test_waypoint_action finds rigB walk 0") _check(visuals.hit_test_waypoint_action(Vector2(99999, 99999)).is_empty(), "hit test misses far away") # hit_test_waypoint returns just the position. var wp: Vector2 = visuals.hit_test_waypoint(pa) _check(wp == pa, "hit_test_waypoint returns the position") _check(visuals.hit_test_waypoint(Vector2(99999, 99999)) == Vector2.INF, "hit_test_waypoint returns INF on miss") # set_edit_waypoint/clear highlight state (used by visual walk editing). visuals.set_edit_waypoint(pa) _check(visuals._edit_waypoint == pa, "set_edit_waypoint stores the position") visuals.clear_edit_waypoint() _check(not visuals._edit_waypoint.is_finite(), "clear_edit_waypoint resets to INF") await _free_stage(stage) # --------------------------------------------------------------------------- # 12. Stage action-popup / rig context entries (Phase 3c entry points) # --------------------------------------------------------------------------- func _test_stage_context_entries() -> void: print("") print("--- Stage context-menu entry points (Edit Queue / Edit Rules) ---") var stage := _new_stage() var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0)) if rig == null: await _free_stage(stage) return # The Director action popup has Edit Queue…/Edit Rules… items. var found_edit_queue := false var found_edit_rules := false for i: int in stage._action_popup.item_count: var id: int = stage._action_popup.get_item_id(i) if id == stage.ACT_EDIT_QUEUE: found_edit_queue = true if id == stage.ACT_EDIT_RULES: found_edit_rules = true _check(found_edit_queue and found_edit_rules, "action popup gains Edit Queue/Rules items") # ACT_EDIT_QUEUE opens the queue panel for the context rig. stage._context_rig = rig stage._on_action_popup_id_pressed(stage.ACT_EDIT_QUEUE) _check(stage._queue_panel.rig == rig, "Edit Queue opens the queue panel for the context rig") stage._clear_director_pending() # ACT_EDIT_RULES opens the rule panel filtered to the context rig. stage._context_rig = rig stage._on_action_popup_id_pressed(stage.ACT_EDIT_RULES) _check(stage._rule_panel_source_id == rig.get_instance_id(), "Edit Rules seeds the source filter") stage._clear_director_pending() # The stickman right-click context popup has both items. var r0: int = stage._rig_context_popup.get_item_id(0) var r1: int = stage._rig_context_popup.get_item_id(1) _check(r0 == stage.RIG_CTX_EDIT_QUEUE and r1 == stage.RIG_CTX_EDIT_RULES, "rig context popup items map to Edit Queue/Rules (got %d,%d)" % [r0, r1]) stage._panel_rig = rig stage._on_rig_context_id_pressed(stage.RIG_CTX_EDIT_QUEUE) _check(stage._queue_panel.rig == rig, "rig context Edit Queue opens the queue panel") stage._clear_director_pending() stage._panel_rig = rig stage._on_rig_context_id_pressed(stage.RIG_CTX_EDIT_RULES) _check(stage._rule_panel_source_id == rig.get_instance_id(), "rig context Edit Rules seeds the filter") # Mode exit hides the editor popups (no stale exclusive window). stage._open_queue_panel(rig) stage._open_rules_panel_for_rig(rig) stage._open_rule_editor_full({ "id": 1, "trigger": { "type": "action_finished", "source": rig.get_instance_id(), "params": {} }, "actions": [] }, false) stage.set_mode(stage.StageMode.PLAY) _check(not stage._queue_panel.visible and not stage._rule_panel.visible and not stage._rule_editor.visible, "entering PLAY hides Phase 3c popups") stage.set_mode(stage.StageMode.EDIT) await _free_stage(stage) # --------------------------------------------------------------------------- # 13. Backward compatibility # --------------------------------------------------------------------------- func _test_backward_compat() -> void: print("") print("--- Backward compatibility: existing queues/rules edit without loss ---") var stage := _new_stage() var rigA: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0)) var rigB: StickmanRig = stage._spawner.spawn("stickman", Vector2(400, 0)) if rigA == null or rigB == null: await _free_stage(stage) return # A "pre-existing" queue authored with the legacy Phase 3a append API shows # in the panel and every action type/param survives an edit round-trip. # (Duration 1.5 is a 0.1-step-representable value, matching what the legacy # SpinBox UI could author; the editor's own duration spin uses step 0.1.) rigA.queue_action({ "type": "walk_to", "target": Vector2(60, -60) }) rigA.queue_action({ "type": "speak", "text": "Legacy", "duration": 1.5 }) rigA.queue_action({ "type": "wait", "duration": 2.0 }) var snapshot: Array[Dictionary] = rigA.get_queue() _check(snapshot.size() == 3, "legacy queue has 3 actions") stage._open_queue_panel(rigA) _check(stage._queue_panel._rows.size() == 3, "legacy queue displays 3 rows") # Edit the legacy speak action preserving type/params except the edited text. stage._on_queue_panel_edit_requested(1) _check(stage._action_editor._current_type() == "speak", "legacy speak pre-fills type") _check(stage._action_editor._text_edit.text == "Legacy", "legacy speak pre-fills text") _check(is_equal_approx(stage._action_editor._duration_spin.value, 1.5), "legacy speak pre-fills duration") stage._action_editor._text_edit.text = "Legacy v2" stage._action_editor._on_ok_pressed() var q: Array[Dictionary] = rigA.get_queue() _check(q.size() == 3, "legacy queue size preserved after edit") _check(String(q[1].get("text", "")) == "Legacy v2" and float(q[1].get("duration", 0.0)) == 1.5, "legacy speak edit keeps duration and replaces text") _check((q[0].get("target", Vector2.ZERO) as Vector2) == Vector2(60, -60), "legacy walk target preserved") stage._clear_director_pending() # A "pre-existing" Phase 4 rule (rule-action shape) displays and edits with # its rule id preserved. var legacy_rule := { "id": 0, "trigger": { "type": "action_finished", "source": rigA.get_instance_id(), "target": -1, "params": {}, }, "actions": [ { "type": "speak", "target": rigB.get_instance_id(), "params": { "text": "old", "duration": 1.0 } }, ], } var legacy_rules: Array[Dictionary] = [legacy_rule] stage._event_rules = legacy_rules stage._open_rules_panel_for_rig(rigA) _check(stage._rule_panel._rows.size() == 1, "legacy rule displays in the panel") # Editing the legacy rule through the full editor preserves its id + trigger. stage._on_rule_panel_edit_requested(0) _check(stage._rule_editor._rule_id == 0, "legacy rule edit preserves id in editor") stage._rule_editor.set_action(0, { "type": "wait", "target": rigB.get_instance_id(), "params": { "duration": 4.0 } }) stage._rule_editor._on_ok_pressed() _check(stage._event_rules.size() == 1, "legacy rule count preserved") _check(int(stage._event_rules[0].get("id", -1)) == 0, "legacy rule id preserved on commit") var edited_action: Dictionary = (stage._event_rules[0].get("actions", []) as Array)[0] _check(String(edited_action.get("type", "")) == "wait" and float(edited_action.get("params", {}).get("duration", 0.0)) == 4.0, "legacy rule action type/params updated in place") _check(String(stage._event_rules[0].get("trigger", {}).get("type", "")) == "action_finished", "legacy rule trigger type preserved") stage._clear_director_pending() await _free_stage(stage) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- func _new_stage() -> Node2D: var stage: Node2D = STAGE_SCENE.instantiate() root.add_child(stage) stage._snap_enabled = false return stage func _free_stage(stage: Node2D) -> void: stage.queue_free() await process_frame func _queue_panel_summaries(panel: QueuePanel) -> Array[String]: var out: Array[String] = [] for row: PanelContainer in panel._rows: var hbox := row.get_child(0) as HBoxContainer var summary := hbox.get_child(1) as Label out.append(summary.text) return out func _type_list(queue: Array[Dictionary]) -> Array[String]: var out: Array[String] = [] for a: Dictionary in queue: out.append(String(a.get("type", ""))) return out func _rule_panel_trigger_text(panel: RulePanel, row_index: int) -> String: var row: PanelContainer = panel._rows[row_index] var hbox := row.get_child(0) as HBoxContainer var body := hbox.get_child(1) as VBoxContainer return (body.get_child(0) as Label).text func _check(condition: bool, message: String) -> void: _checks += 1 if condition: print("PASS: " + message) else: _failures += 1 print("FAIL: " + message)