From 04e0f1bea2835910c3b70da283ee4a6beb84b2b2 Mon Sep 17 00:00:00 2001 From: Ryan Kegel Date: Fri, 11 Sep 2026 15:43:08 -0400 Subject: [PATCH] feat: Enhance Trigger Area Reliability in Sandbox Stage - Updated `_rule_matches` to ensure `entered_area` checks both source and target instance IDs for accurate event triggering. - Refined `_update_area_entry` to utilize `_movable_overlaps_area`, ensuring that visible stickman/prop geometry is considered for area overlap detection. - Introduced a new regression test suite in `tests/test_phase4c_area.gd` to validate the functionality of `entered_area` rules, covering various edge cases and ensuring correct behavior for source-specific and wildcard rules. - Documented the architectural changes and validation results in `specs/phase_4c_area.md`, detailing the scope, data contracts, and design decisions made during implementation. - Added new agent configurations for architect, developer, and writer roles to streamline the development process and improve documentation practices. --- .codex/agents/architect.toml | 24 ++++ .codex/agents/developer.toml | 22 +++ .codex/agents/writer.toml | 19 +++ .codex/skills/spec-pipeline/SKILL.md | 23 ++++ AGENTS.md | 15 +- README.md | 14 +- docs/tech_debt_and_optimizations.md | 4 +- scripts/sandbox_stage.gd | 16 ++- specs/phase_4c_area.md | 163 ++++++++++++++++++++++ tests/test_phase4c_area.gd | 197 +++++++++++++++++++++++++++ tests/test_phase4c_area.gd.uid | 1 + 11 files changed, 489 insertions(+), 9 deletions(-) create mode 100644 .codex/agents/architect.toml create mode 100644 .codex/agents/developer.toml create mode 100644 .codex/agents/writer.toml create mode 100644 .codex/skills/spec-pipeline/SKILL.md create mode 100644 specs/phase_4c_area.md create mode 100644 tests/test_phase4c_area.gd create mode 100644 tests/test_phase4c_area.gd.uid diff --git a/.codex/agents/architect.toml b/.codex/agents/architect.toml new file mode 100644 index 0000000..f713365 --- /dev/null +++ b/.codex/agents/architect.toml @@ -0,0 +1,24 @@ +name = "architect" +description = "Defines system requirements and technical specifications for Godot features." +model = "gpt-5.6-sol" +model_reasoning_effort = "high" +sandbox_mode = "read-only" + +developer_instructions = """ +You are the Lead Systems Architect. Refer to `AGENTS.md` for project directory standards and coding rules. + +## Responsibilities + +1. **Analyze:** Read existing code patterns before designing. +2. **Design:** Define API payload shapes, GDScript type hints, and signal flows. +3. **Log:** Append trade-offs to `docs/tech_debt_and_optimizations.md`. + +## Workflow + +1. **Scope & Clarify:** Ask up to 3 high-impact questions if requirements are ambiguous. +2. **Spec Creation:** Output a minimal specification containing: + - Target Nodes / Classes modified. + - Signal interfaces & static type contracts. + - Layout/Theme updates required. +3. **Handoff:** Save to `specs/[feature-name].md` and stop for user approval. +""" \ No newline at end of file diff --git a/.codex/agents/developer.toml b/.codex/agents/developer.toml new file mode 100644 index 0000000..fc9fe73 --- /dev/null +++ b/.codex/agents/developer.toml @@ -0,0 +1,22 @@ +name = "developer" +description = "Implements GDScript logic and scene adjustments." +model = "gpt-5.6-terra" +model_reasoning_effort = "medium" +sandbox_mode = "workspace-write" + +developer_instructions = """ +You are a Godot 4 / GDScript 2.0 implementation developer. Adhere to coding standards in `AGENTS.md`. + +## Rules + +- **Targeted Edits Only:** Do NOT output full unchanged files. Output only modified functions, concise diffs, or specific node configurations. +- **Static Typing:** Enforce explicit type hints for all signatures and variables. +- **Decoupling:** Follow "call down, signal up". +- **Validation:** Run syntax verification command when finished: + `& "C:\\Godot4\\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://path/to/script.gd" --path "C:\\Godot4\\stickman"` + +## Output Format + +- **Summary:** 1-2 lines on changes made. +- **Code Edits:** Show only modified snippet/function blocks with surrounding context lines. +""" \ No newline at end of file diff --git a/.codex/agents/writer.toml b/.codex/agents/writer.toml new file mode 100644 index 0000000..6bf0428 --- /dev/null +++ b/.codex/agents/writer.toml @@ -0,0 +1,19 @@ +name = "writer" +description = "Drafts and updates technical documentation, GDScript APIs, and architecture specs." +model = "gpt-5.6-luna" +sandbox_mode = "read-only" + +developer_instructions = """ +You are a technical writer for Godot 4 / GDScript projects. You document game architectures, class APIs, and node systems with pinpoint precision. + +### Deliverables + +- **Architecture & System Guides:** Maintain concise READMEs, scene hierarchy overviews, and system runbooks. +- **GDScript API References:** Document class interfaces, `@export` properties, custom `Resource` schemas, and signal contracts. +- **Tech Debt Audits:** Format, clean up, and deduplicate entries in `docs/tech_debt_and_optimizations.md`. + +### Execution Rules + +1. **Targeted Edits Only:** Touch only the specific markdown sections affected by recent changes. Never output full unchanged files. +2. **Scannable & Technical:** Lead directly with code blocks, tables, and structured lists. Eliminate marketing fluff or generic introductions. +""" \ No newline at end of file diff --git a/.codex/skills/spec-pipeline/SKILL.md b/.codex/skills/spec-pipeline/SKILL.md new file mode 100644 index 0000000..9561436 --- /dev/null +++ b/.codex/skills/spec-pipeline/SKILL.md @@ -0,0 +1,23 @@ +--- +name: spec-pipeline +description: "Lean implementation loop: Architect -> Developer -> User Verification." +--- + +## Execution Logic + +1. **Phase 1: Architecture (Optional for small edits)** + - `@architect` defines signatures, node structures, or spec updates in `specs/[feature-name].md`. + - **STOP:** Wait for user approval on design choices before invoking developer. + +2. **Phase 2: Implementation & Engine Validation** + - `@developer` updates code and scenes using targeted diffs/snippets. + - `@developer` runs headless syntax verification: + `& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://path/to/script.gd" --path "C:\Godot4\project"` + - If static check fails, `@developer` fixes the code immediately. + +3. **Phase 3: User Hand-off (Stop)** + - Present the changes to the user to test inside the Godot Editor. + - Do NOT update READMEs or documentation yet. + +4. **Phase 4: Documentation (Triggered manually by user)** + - Only call `@writer` after the user confirms: _"Feature verified and working."_ diff --git a/AGENTS.md b/AGENTS.md index 7080e2a..43e3847 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -598,7 +598,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and stickman-vs-prop separately). - `scripts/trigger_area.gd` — `class_name TriggerArea`, `extends Node2D`; a **placeable sensor** (Phase 4, **not used by the editor**). `@export size: Vector2` (default 96×96), `get_area_rect() - -> Rect2` (centered on the node's global position), and `_draw()` (translucent green fill + + -> Rect2` (centered local-space rectangle), and `_draw()` (translucent green fill + dashed border). **No physics and no signals** — it is a pure geometric region evaluated by `sandbox_stage.gd`'s event engine (`_update_area_entry`) for `entered_area` rules. - `scripts/prop_utils.gd` — `class_name PropUtils`, `extends RefCounted`; static factory @@ -715,8 +715,9 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and `action_finished`, `speech_finished`, `entered_area`, `collided`; action types reuse the Phase 3a set: `walk_to`/`speak`/`wait`/`ragdoll`/`recover`). Rules persist across Play/Edit mode toggles but are **not** saved to disk (Phase 5). A geometric **event engine** runs in PLAY - (`_update_area_entry` tests a movable's feet/position against each `TriggerArea.get_area_rect()`; - `_update_stickman_prop_collision` tests a stickman's feet point against prop AABBs and vice-versa), + (`_update_area_entry` tests each movable's visible world AABB against the current transformed + `TriggerArea` world AABB; `_update_stickman_prop_collision` continues to test a stickman's feet + point against prop AABBs and vice-versa), with **edge-triggered** dicts (already-fired events) reset on mode entry. A **rule-builder UI state machine** (`RuleStep` enum: `IDLE`, `SELECT_TRIGGER`, `TRIGGER_TARGET`, `SELECT_ACTION`, `ACTION_TARGET`, `ACTION_POSITION`, `PARAMS`) is driven from a **"⚡ When..."** item in the Direct @@ -733,6 +734,14 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and confirm (`_finalize_rule`), cancel (`_cancel_rule_build`), or Direct-mode/flow exit (`_clear_director_pending`), but **not** by `_reset_rule_builder()` (the Back-to-actions path intentionally reuses it). + - **Phase 4c area-entry reliability:** `entered_area` matching requires both the source movable + instance ID and target area instance ID; `source == -1` remains the explicit wildcard. Area + entry uses visible world-AABB overlap for ANIMATED stickmen and unfrozen props, so an object can + trigger when a visible leg/body part enters even if its feet anchor is still outside. The + `_area_overlap` edge state is independent per area/movable instance-ID pair: it fires once on + outside→overlap, does not repeat while overlapping, and re-arms after exit. The area loop's prop + enumeration is correctly scoped. Verified by `tests/test_phase4c_area.gd` (19 assertions, + all passed) and manual editor verification of A entering an area causing B's speech bubble. - **Phase 3b asset-library selector integration:** the **Stickman** and **Prop** palette buttons no longer place directly — pressing them opens a modal **selector grid** (`_selector: AssetSelector`, an instantiated `scenes/asset_selector.tscn` `PopupPanel`, added to the UI `CanvasLayer` in diff --git a/README.md b/README.md index c0d611d..404dd62 100644 --- a/README.md +++ b/README.md @@ -548,11 +548,11 @@ The **Director Tool** (Phase 3a) turns the Sandbox Stage into a mini director's **Phase 4** adds **reactive storytelling** to the Sandbox Stage. While Phase 3a gave directors *sequential* control (actions in a fixed order per stickman), Phase 4 adds *reactive* control — **"When X happens, do Y"** — via event rules that span objects. It is **not wired into the editor** — run via **F6** on `res://scenes/sandbox_stage.tscn`. -The rule concept is **When → Then**: a *trigger* (an event on some object) fires a list of *actions* on a target object. Rules are stored as `_event_rules: Array[Dictionary]` of `{id, trigger, actions}` in `sandbox_stage.gd`, where `trigger` is `{type, target, ...}` and `actions` is an array of Phase 3a action dicts (`{"type":"walk_to","target":...}`, etc.). +The rule concept is **When → Then**: a *trigger* (an event on some object) fires a list of *actions* on a target object. Rules are stored as `_event_rules: Array[Dictionary]` of `{id, trigger, actions}` in `sandbox_stage.gd`, where `trigger` carries `{type, source, target, ...}` instance references and `actions` is an array of Phase 3a action dicts (`{"type":"walk_to","target":...}`, etc.). | File | Purpose | |---|---| -| `res://scripts/trigger_area.gd` | NEW `class_name TriggerArea`, `extends Node2D` — a placeable sensor (`@export size: Vector2`, default 96×96) with `get_area_rect() -> Rect2` and a translucent green `_draw()` fill + dashed border. **No physics, no signals** — it is a pure geometric region evaluated by the stage. | +| `res://scripts/trigger_area.gd` | NEW `class_name TriggerArea`, `extends Node2D` — a placeable sensor (`@export size: Vector2`, default 96×96) with a centered **local-space** `get_area_rect() -> Rect2` and a translucent green `_draw()` fill + dashed border. **No physics, no signals** — it is a pure geometric region evaluated by the stage. | | `res://scripts/sandbox_stage.gd` | Extended with the rule system: `_event_rules`, the geometric event engine (`_update_area_entry`, `_update_stickman_prop_collision`), the rule-builder UI state machine, and rule auto-cleanup. | | `res://scripts/stickman_rig.gd` | `arrived` gained a `target: Vector2` payload; new `enqueue_reactive(actions)` appends reactive actions without replaying the queue. | | `res://scripts/prop_block.gd` | NEW `signal collided(other: Node)` (physics-based, prop-vs-prop). | @@ -567,7 +567,7 @@ The rule concept is **When → Then**: a *trigger* (an event on some object) fir | `arrived_at_waypoint` | Stickman | The stickman completes a `walk_to` (the rig's `arrived` signal). | | `action_finished` | Stickman | A queue action completes (the rig's `action_finished` signal). | | `speech_finished` | Stickman | A `speak` bubble auto-hides (the rig's `speech_finished` signal). | -| `entered_area` | Trigger area | A movable (stickman/prop) enters the area's `get_area_rect()` (geometric, `_update_area_entry`). | +| `entered_area` | Trigger area | A movable (stickman/prop) visibly overlaps the area's transformed world AABB (geometric, `_update_area_entry`). | | `collided` | Stickman/Prop | A stickman's feet point enters another object's AABB (geometric, `_update_stickman_prop_collision`) **or** a prop physically collides with another prop (`PropBlock.collided` physics signal). | **Action types** are the Phase 3a set, reused verbatim: `walk_to`, `speak`, `wait`, `ragdoll`, `recover`. Actions always target a **stickman**. @@ -591,6 +591,14 @@ All rule-builder context menus are **session-anchored**: the first popup in a fl **`enqueue_reactive` semantics:** `StickmanRig.enqueue_reactive(actions: Array[Dictionary]) -> void` appends reactive actions to the rig's action queue, **tagging each with `reactive = true`**. If the runner is `IDLE` it **resumes at the first newly-appended action** — the already-consumed prefix of the queue is not replayed. Sequential Phase 3a queues are untouched; reactive actions are a cross-object addition. Because they are tagged, `clear_reactive_actions()` can strip them on return to Edit/Direct (see §19), so reactive actions injected across multiple Play sessions never accumulate on top of the authored sequential queue. +### 20.1 Phase 4c — Trigger Area Reliability + +Phase 4c fixes `entered_area` source matching and visible overlap detection. In PLAY, `_update_area_entry()` enumerates committed `TriggerArea` nodes and ANIMATED stickmen plus unfrozen props. Each movable is tested against the area's current transformed world AABB, using the visible object's world AABB (with a point fallback only for a future movable with no bounds). This means a stickman's leg or another visible part can enter an area even when its feet anchor remains outside; the area itself still supports move/rotate/scale transforms. + +The event remains edge-triggered per `(area instance ID, movable instance ID)` pair: one `entered_area` event is emitted when that pair changes from outside to overlapping, no event repeats while it remains overlapping, and leaving then re-entering emits again. Rule matching requires both the trigger's source ID and target area ID; `source = -1` remains the explicit wildcard for any movable. The stage's prop enumeration is correctly scoped so unfrozen props participate without accidentally changing the area loop. + +Verified coverage in `tests/test_phase4c_area.gd`: **19 assertions passed**, including source-specific and wildcard matching, one-shot/re-entry edges, independent area/movable pairs, transformed areas, visible leg-level overlap with feet outside, speak queue normalization and visible speech execution, no-rule no-op behavior, stale action targets, and deletion cleanup. The editor verification also confirmed that `When Stickman A enters Area → Stickman B speaks` fires when A walks through the area. + **Prop collision detection (two mechanisms):** - **Geometric (stage-driven):** `_update_stickman_prop_collision` tests whether a stickman's **feet point** lies inside another object's world AABB (and vice-versa), edge-triggered so `collided` fires once per entry. diff --git a/docs/tech_debt_and_optimizations.md b/docs/tech_debt_and_optimizations.md index 2b23e16..15650d3 100644 --- a/docs/tech_debt_and_optimizations.md +++ b/docs/tech_debt_and_optimizations.md @@ -23,7 +23,7 @@ This document tracks known technical debt, optimization opportunities, and minor | 11 | **Stage Freeze Abstraction** — Sandbox Stage EDIT‑mode freezing is type‑specific: `RigidBody2D.freeze_mode = FREEZE_MODE_KINEMATIC` for props, `StickmanRig.set_ragdoll(false)` for stickmen, nothing for `StaticBody2D` terrain. There is no unified "freeze" abstraction over the mixed physics population. | Low | Open | A future physics type (e.g. `Area2D`‑based sensors) will need another case in `scripts/sandbox_stage.gd` `_enter_edit_mode()` / `_enter_play_mode()`. Consider a duck‑typed `set_simulating(bool)` interface once more physical object kinds appear. (2026‑08‑27) | | 12 | **Stage AABB Selection Precision** — `StageSelection.get_world_aabb` uses conservative world‑space AABBs (polygon point union / fixed rig rect), not point‑in‑polygon. | Low | Open | Clicks in the bounding‑box corners of large or rotated terrain may select a block even outside its polygon, and overlapping blocks can mis‑select. Refine with `Geometry2D.is_point_in_polygon()` for `TerrainBlock`/`PropBlock` polygons (and circle distance for ball props) once selection precision matters. (2026‑08‑27) | | 13 | **Slope-aware walking physics & dynamic obstacle avoidance (deferred)** — Phase 3a bakes a real navigation mesh (per-`TerrainBlock` polygon decomposition into a code-built `NavigationRegion2D`) and `walk_to` follows `NavigationAgent2D` paths, but the figure walks the path with an upright pose (no tilt to the slope, no physics sliding) and `avoidance_enabled = false`, so it can path through props and other stickmen. | Medium | Open | A stickman crossing a ramp/stair follows the sloped footprint but looks flat-footed, and does not avoid moving props or each other. Future: tilt/rotate the figure to the path slope and enable RVO avoidance (`avoidance_enabled`, avoidance layers, `velocity` handling) once props/other stickmen are registered as obstacles. (2026‑08‑29) | -| 14 | **Phase 4 event engine is O(rigs×props + areas×movables) per physics frame** — `sandbox_stage.gd` `_update_area_entry()` / `_update_stickman_prop_collision()` run all-pairs geometric tests every physics frame in PLAY. | Low | Open | Fine at sandbox scale, but degrades quadratically with dozens of dynamic objects. A cell-keyed spatial broadphase (e.g. `_grid_cells`) can cull candidate pairs before the AABB/feet-point tests. Phase 4b implemented that index (see #16, now Resolved) but the event engine does **not yet query it** — #14 remains Open until `_update_area_entry()` / `_update_stickman_prop_collision()` use it. (2026‑08‑30 / note updated 2026‑09‑02) | +| 14 | **Phase 4 event engine is O(rigs×props + areas×movables) per physics frame** — `sandbox_stage.gd` `_update_area_entry()` / `_update_stickman_prop_collision()` run all-pairs geometric tests every physics frame in PLAY. | Low | Open | Fine at sandbox scale, but degrades quadratically with dozens of dynamic objects. A cell-keyed spatial broadphase (e.g. `_grid_cells`) can cull candidate pairs before the visible-AABB/feet-point tests. Phase 4b implemented that index (see #16, now Resolved) but the event engine does **not yet query it** — #14 remains Open until `_update_area_entry()` / `_update_stickman_prop_collision()` use it. (2026‑08‑30 / note updated 2026‑09‑02) | | 15 | **Walk arrival jitter (mode re-evaluated per frame + arrival-radius mismatch)** — `StickmanRig._update_walking()` re-computes `_walk_mode` from `is_target_reachable()` every physics frame, and finishes via `is_navigation_finished()` (`NAV_TARGET_DESIRED_DISTANCE` 12 px, feet-space) *or* the `ARRIVE_DISTANCE` 8 px root-space guard. Near the nav-mesh boundary (clicked waypoints often sit just off placed terrain) `is_target_reachable()` can flip, swapping `root_target` between the nav path point and the raw waypoint — two points with a small vertical offset → up/down jitter. | Medium | ✅ Resolved | Phase 4b: `StickmanRig._update_walking()` now **latches `_walk_mode` once per walk** (probes up to `LATCH_PROBE_MAX_FRAMES` = 6 after the map syncs, latching `"nav"` when reachable or `"direct"` on the bound) so it can never flip between frames, unifies arrival on the **final target** at `ARRIVE_DISTANCE` 8 px (snap-on-arrive), steers straight at the final target when within `2 × ARRIVE_DISTANCE`, and re-asserts the standing markers one extra physics frame after stop (`_settle_walk_markers()`) to clear any residual body-bob. Exactly one `arrive` fires; `global_position` is unchanged after arrival. See `plans/PHASE_4b_SPEC.md` §3. (2026-09-02) | | 16 | **No grid spatial dictionary** — terrain placement (`sandbox_stage.gd` `_place_at`) is single-click with no occupancy tracking; the 3-state "empty/same-type/blocked" drag-paint query and any spatial broadphase need a cell→nodes index. | Low | ✅ Resolved | Phase 4b adds an **advisory** grid spatial dictionary `_grid_cells` (cell `Vector2i` @ `TERRAIN_GRID_SIZE` 16 → `Array[Node2D]`) plus `_rasterize_aabb_to_cells()` to index every world AABB's covered cells; it drives the terrain drag-painting 3-state ghost query and the director target-validity test (skipping cells whose nodes include a `TerrainBlock`). It is populated on place, rebuilt on move/rotate/delete, and is **never authoritative** (the `World` tree is). It is **not yet wired into the Phase 4 event engine** — #14 stays Open for that. See `plans/PHASE_4b_SPEC.md` §2.6. (2026-09-02) | | 17 | **Thumbnail caching has no eviction / cap; render is deferred one-per-frame** — Phase 3b (`stickman_library.gd` / `prop_library.gd` + `thumbnails/*`) caches rig/prop thumbnails to `user://thumbnails/` keyed by stickman basename+mtime and prop `id_v`. | Low | Open | PNGs accumulate unboundedly on disk (only `clean_stale_stickmen` prunes superseded basenames; nothing caps total bytes), and in-headless the renderers return `null` → a placeholder is shown until a **manual F6 run** populates real captures. Future: a disk-size/LRU eviction policy, a version/cleanup sweep on stage open, and an explicit cache-warm pass (or skip-the-placeholder note) for headless/CI. (2026-09-03) | @@ -33,6 +33,7 @@ This document tracks known technical debt, optimization opportunities, and minor | 21 | **Phase 3c drag-reorder uses nearest-row-center, not an insertion point** — `QueuePanel` / `RulePanel` reorder computes the drop target as the row whose vertical center is nearest the mouse, then moves the dragged item to that index. | Low | Open | Functional, but the drop position can read off-by-one near row boundaries (it snaps to a whole row rather than an edge between rows). A true insertion-point indicator (a line drawn between rows) would be clearer; not worth the complexity until reordering long queues/rules is common. (2026-09-04) | | 22 | **`--check-only --script` needs a prior `--editor` rescan for new `class_name` scripts** — a freshly added `class_name` (e.g. `QueuePanel`) is not resolvable by `--check-only --script res://scripts/sandbox_stage.gd` until `--headless --editor --quit` regenerates `.godot/global_script_class_cache.cfg`. | Low | Open | Verification-workflow gotcha only (not a runtime bug): in-editor the cache is always current. CI/headless scripts that add new `class_name`s should run an `--editor` pass before per-script `--check-only`. (2026-09-04) | | 23 | **Phase 3c confirmation dialog is popped over an exclusive panel** — Queue delete / Clear All (`_on_queue_panel_delete_requested` / `_on_queue_panel_clear_requested`) and Rule delete / Clear All (`_on_rule_panel_delete_requested` / `_on_rule_panel_clear_requested`) call the shared `ConfirmationDialog` (`_ask_confirm`) **without first hiding the `exclusive = true` `QueuePanel` / `RulePanel`**. | Low | Open | Distinct facet of #19 (host-visibility discipline): popping a second exclusive window over a visible exclusive panel logs a **non-fatal engine warning** and the confirmation may render **non-modal**. It still functions (the panel is re-shown after the callback) — cosmetic. A fix would hide the host panel around `_ask_confirm` and re-show it in the confirm callback (and on the dialog's cancel/hide). Deliberate non-debt note: plan §11.2's separate confirmation dialog for editing `ragdoll`/`recover` actions was implemented instead as a paramless `ActionEditor` pre-fill — recorded in `docs/phase_3c_editor_spec.md` §2 decision 5, not as a debt row. (2026-09-04) | +| 24 | **Sandbox trigger rules use session-only instance IDs** — `entered_area` source/target references and action targets are `Object.get_instance_id()` integers. | Low | Open | Correct for the current in-memory rule engine and shared by the builder, matcher, visualizer, cleanup, and action executor. Rules survive `EDIT ⇄ DIRECT ⇄ PLAY` but are not serialized; a future saved-stage format needs stable authored object IDs instead of runtime instance IDs. (2026‑09‑11) | --- @@ -73,6 +74,7 @@ This document tracks known technical debt, optimization opportunities, and minor | 2026-09-03 | **Selector UI bugfix round** (5 bugs, documented via `tests/test_phase3b_ui_fixes.gd` + docs in `docs/phase_3b_asset_grid_spec.md` / `README.md` / `AGENTS.md`): `AssetSelector.open()` drops its selection params (`selected_path`/`selected_id`) — no cell is pre-highlighted on open (selected stylebox removed); the selector re-centers on window resize (`size_changed` → `popup_centered()`); the stage adds a dim backdrop `_selector_dim` (`SELECTOR_DIM_ALPHA` 0.5) behind the grid; the selector's `popup_hide` routes to `_on_selector_cancelled()` (idempotency-guarded) so an outside-click un-presses the palette button; and the Direct-mode action popup opens **right of the clicked stickman** (`_world_to_screen` + 24 px) instead of at the cursor. Reviewed #17 and #18 — neither is obsolete (both concern thumbnail-cache growth/headless placeholders and session-only *persistence*, orthogonal to these UI fixes), so both remain **Open** unchanged; no duplicate rows introduced. | | 2026-09-03 | **Rule-builder popup-anchor bugfix round** (`sandbox_stage.gd`, documented in `README.md` / `AGENTS.md`): the Phase 4 rule-builder context menus used to re-pop at the live mouse position on every re-open, so cycling "⚡ When…" → "⬅ Back to actions" → "When…" walked the menu down the screen. Now a **session anchor** records the first context menu's screen position (`_popup_anchor: Rect2i` / `_popup_anchor_set`; Direct first menu = right of the clicked stickman; rule-label edit entry = the click position) and all child popups reuse it via `_set_popup_anchor(rect)` / `_clear_popup_anchor()` / `_popup_anchor_rect()`, until cleared on confirm (`_finalize_rule`), cancel (`_cancel_rule_build`), or Direct-mode/flow exit (`_clear_director_pending`) — but **not** on `_reset_rule_builder()` (Back-to-actions reuses it). No numbered debt row described the old cursor-following behavior, so no row was flipped to Resolved and no duplicates introduced; recorded here in the Change Log only. | | 2026-09-04 | Phase 3c (Editor Tools — Action & Rule Editing) implemented: `scripts/action_registry.gd` / `trigger_registry.gd` (extensible action/trigger templates), `scripts/action_editor.gd` / `rule_editor.gd` / `queue_panel.gd` / `rule_panel.gd` + `scenes/*.tscn` shells, `scripts/waypoint_context.gd`, plus integration in `sandbox_stage.gd` (unified `CaptureKind` target-capture, "Edit Queue…/Edit Rules…" + right-click stickman/waypoint context menus, confirmation dialog, consequence-only rule editor) and `stage_director_visuals.gd` (`hit_test_waypoint_action`, waypoint edit highlight). Logged #19 (ad-hoc popup host-visibility discipline), #20 ("Add Rule" has no source in waypoint-filtered panels), #21 (drag-reorder nearest-row-center), #22 (`--check-only --script` needs a prior `--editor` rescan), and later the same day #23 (Phase 3c confirmation dialog popped over an exclusive panel — the delete/clear-all confirm paths do not hide the `QueuePanel`/`RulePanel` first). Documented the plan-§11.2 deviation (paramless `ActionEditor` pre-fill instead of a separate ragdoll/recover confirmation dialog) in `docs/phase_3c_editor_spec.md` §2 decision 5 — a design decision, so no debt row. Static-verified via headless `--editor --quit` + per-script `--check-only`; runtime F6 flows (queue/rule panel edit-delete-reorder, waypoint context menu, visual walk edit, full vs consequence-only rule editor) still need manual verification. | +| 2026-09-11 | Phase 4c (Trigger Area Reliability) verified: `sandbox_stage.gd` `entered_area` now requires source + area instance IDs and detects visible world-AABB overlap for ANIMATED stickmen/unfrozen props, edge-triggered per area/movable pair. The prop enumeration scope bug was corrected. `tests/test_phase4c_area.gd` passed all 19 assertions, including transformed-area, leg-level overlap, wildcard/re-entry, speech consequence, stale-target, and deletion-cleanup coverage; editor verification confirmed the speech bubble fires for the configured source stickman. Logged #24 (session-only instance-ID rule references). | --- diff --git a/scripts/sandbox_stage.gd b/scripts/sandbox_stage.gd index 865a7c4..6ba470c 100644 --- a/scripts/sandbox_stage.gd +++ b/scripts/sandbox_stage.gd @@ -2436,7 +2436,7 @@ func _rule_matches(rule: Dictionary, event: Dictionary) -> bool: "speech_finished": return _ids_match(source_id, source) "entered_area": - return _ids_match(target_id, target) + return _ids_match(source_id, source) and _ids_match(target_id, target) "collided": return _ids_match(source_id, source) and _ids_match(target_id, target) _: @@ -2492,7 +2492,7 @@ func _update_area_entry() -> void: for node: Node2D in movables: var point := _movable_point(node) var key := "%d:%d" % [area.get_instance_id(), node.get_instance_id()] - var inside: bool = area_rect.has_point(point) + var inside: bool = _movable_overlaps_area(node, area_rect) var was: bool = bool(_area_overlap.get(key, false)) if inside and not was: _area_overlap[key] = true @@ -2533,6 +2533,18 @@ func _movable_point(node: Node2D) -> Vector2: return node.global_position +## Trigger entry follows the visible object, not only its origin/feet anchor. +## StageSelection already owns the mounted-geometry world AABB contract used by +## selection and gizmos, so reuse it here to keep what the user sees and what +## the event engine detects in agreement. Point-like fallbacks retain the old +## behavior for any future movable without visual bounds. +func _movable_overlaps_area(node: Node2D, area_rect: Rect2) -> bool: + var bounds := STAGE_SELECTION.get_world_aabb(node).abs() + if bounds.size.is_zero_approx(): + return area_rect.has_point(_movable_point(node)) + return area_rect.intersects(bounds, true) + + func _show_toast(msg: String) -> void: _toast_text = msg _toast_timer = 2.0 diff --git a/specs/phase_4c_area.md b/specs/phase_4c_area.md new file mode 100644 index 0000000..dff527d --- /dev/null +++ b/specs/phase_4c_area.md @@ -0,0 +1,163 @@ +# Phase 4c — Trigger Area Reliability + +## Scope + +Repair and regression-test `entered_area` rules in the standalone Sandbox Stage. This is a targeted correction to the existing geometric event engine; it does not add trigger types, persistence, physics sensors, or new UI. + +## Architecture diagnosis + +Read-only inspection and a Godot 4.7.1 `--check-only` pass establish that the diagnostic plan was written against contracts that differ from the current implementation: + +- `SandboxStage._physics_process()` already calls `_update_area_entry()` only in `StageMode.PLAY`. +- `StageSpawner._spawn_area()` already adds the committed `TriggerArea` directly to `World`. +- `_update_area_entry()` already keys overlap state by both area and movable instance IDs (`":"`) and clears that state on PLAY/EDIT transitions. +- `TriggerArea.get_area_rect()` intentionally returns a **local-space** centered `Rect2`; `_update_area_entry()` converts it once with `area.global_transform`. Changing the getter to world space would double-transform detection and make `_draw()` incorrect. +- Rules intentionally store integer instance IDs. The rule builder, `RuleEditor`, cleanup, visualizer, filters, and action executor all share this representation. Replacing only an area target with `WeakRef` would violate the rule payload contract and break `int(trigger.target)`, `instance_from_id()`, deep-copy/edit, and deletion-cleanup paths. +- The stickman event position is already its feet. `StickmanRig.FOOT_OFFSET` is defined as `Vector2(0, -385)` in the feet-to-root equation `root = feet + FOOT_OFFSET`; therefore the inverse is `feet = root - FOOT_OFFSET`. The diagnostic plan's proposed `root + FOOT_OFFSET` has the wrong sign. Entry containment itself uses the movable's visible world AABB, while this feet point remains the event payload and point-like fallback. +- Both rule creation paths already commit `entered_area` as a rule in `_event_rules`. + +The verified correctness defects were in two parts of the area path. `SandboxStage._rule_matches()` checked only `trigger.target` (the area) and ignored `trigger.source` (the selected stickman/prop), allowing another movable to consume the apparent entry moment and run the consequence before A arrived. `_update_area_entry()` also tested only the movable's origin/feet point, so a visible leg or body could pass through an area while its anchor remained outside. The final implementation matches both IDs and tests visible world-AABB overlap; the corrected prop enumeration scope keeps unfrozen props in the movable set without changing the area loop. The new end-to-end regression suite and editor verification cover these boundaries. + +## Data contracts + +Keep the existing transient rule payload: + +```gdscript +{ + "id": int, + "trigger": { + "type": "entered_area", + "source": int, # Object.get_instance_id(); -1 means any movable + "target": int, # TriggerArea.get_instance_id() + "params": Dictionary, + }, + "actions": Array[Dictionary], +} +``` + +Keep the emitted internal event payload: + +```gdscript +{ + "type": "entered_area", + "source": Node2D, # movable that crossed the edge + "target": TriggerArea, # crossed area + "position": Vector2, # feet for StickmanRig; global_position otherwise + "action": Dictionary, +} +``` + +No serialized format or public signal changes are required. + +## Target files and changes + +### `scripts/sandbox_stage.gd` + +1. Preserve the PLAY-only call in `_physics_process(_delta: float) -> void`. +2. Preserve `_update_area_entry() -> void` as the owner of area discovery, movable discovery, transformed world-AABB overlap, pairwise edge state, and event emission. +3. Correct `_rule_matches(rule: Dictionary, event: Dictionary) -> bool` so `entered_area` matches **both** ends of the trigger: + + ```gdscript + "entered_area": + return _ids_match(source_id, source) and _ids_match(target_id, target) + ``` + + This retains the existing `-1` wildcard behavior through `_ids_match()` while ensuring a rule authored from Stickman A does not fire for Stickman B or a prop. +4. Keep `_movable_point(node: Node2D) -> Vector2` unchanged for stickmen: + + ```gdscript + return node.global_position - StickmanRig.FOOT_OFFSET + ``` + +5. Use `StageSelection.get_world_aabb(node)` for visible stickman/prop overlap. If a future movable has no bounds, fall back to `area_rect.has_point(_movable_point(node))`. Keep the area's local rectangle contract and transform it once with `area.global_transform` before comparing AABBs. + +6. Keep instance IDs as the single rule-reference representation. Stale action targets remain null-guarded by `instance_from_id(...) as StickmanRig`; deleted stage objects continue to remove referencing rules through `_cleanup_rules_for_nodes()`. +7. Do not ship the four unconditional diagnostic prints proposed in `plans/PHASE_4c_AREA.md`. Automated assertions cover each boundary without per-physics-frame console spam. If interactive tracing is temporarily needed during development, remove it before hand-off or guard it behind a default-false debug constant. + +### `tests/test_phase4c_area.gd` (new) + +Add a headless regression suite that instantiates `res://scenes/sandbox_stage.tscn`, uses real `StageSpawner` objects, and drives the event engine deterministically. Tests may call internal methods, matching the existing phase suites. + +Required cases: + +1. Spawning `"area"` returns a `TriggerArea` whose parent is `stage._world`. +2. An `entered_area` rule built with live instance IDs is retained in `_event_rules` with non-empty actions. +3. A source stickman crossing from outside to inside adds exactly one reactive action to the configured target rig. +4. Repeated polls while still inside do not add another action; an outside poll followed by another inside poll adds one more. +5. A different stickman or an unfrozen prop entering the same area does **not** match a source-specific rule. +6. A `source == -1` rule remains a deliberate wildcard and fires independently for multiple movables. +7. Two areas and two source stickmen use independent pair keys; entering one pair does not suppress another. +8. Moving/rotating/scaling an area uses its current global transform for containment. +9. A stickman's event `position` equals `rig.global_position - StickmanRig.FOOT_OFFSET` (feet), not its root or a bone position; containment uses the visible world AABB so the tested overlap can be leg/body-level. +10. An area with no matching rule is a no-op with no errors. +11. A stale/deleted action target is skipped without a crash. Deleting through the stage path removes rules referencing the deleted area. +12. Exercise at least one visible consequence payload (`speak`) and assert that the correct rig receives a normalized reactive queue action; the editor user-verification pass confirms the speech bubble itself. + +Tests must restore/avoid global side effects and queue-free their instantiated stage. + +## Nodes/classes affected + +- `SandboxStage` (`scenes/sandbox_stage.tscn` root): source+target matcher correction, visible-AABB overlap helper, and prop-enumeration scope correction; no node-tree change. +- `TriggerArea`: exercised but not modified. +- `StageSpawner`, `RuleEditor`, `StageDirectorVisuals`, and `StickmanRig`: exercised through their existing contracts; no implementation change expected. + +## Signal flow + +```text +PLAY physics tick + -> SandboxStage._update_area_entry() + -> new (area_id, movable_id) containment edge + -> SandboxStage._handle_event(event) + -> SandboxStage._rule_matches(rule, event) + source instance ID AND area instance ID must match + -> SandboxStage._execute_rule_actions(rule) + -> StickmanRig.enqueue_reactive(actions) + -> existing StickmanRig queue runner / speech bubble +``` + +No new signals are introduced. Existing queue signals (`queue_changed`, `action_started`, `action_finished`, `queue_finished`) continue unchanged. + +## Layout/theme + +No layout, scene, cursor, label, or theme changes are required. Trigger area drawing remains a local-space translucent rectangle with a dashed outline. + +## Validation + +Developer validation: + +```powershell +& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://scripts/sandbox_stage.gd" --path "C:\Godot4\stickman" +& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script "res://tests/test_phase4c_area.gd" +``` + +The baseline syntax check passed before implementation. Post-implementation validation passed as follows: + +- `sandbox_stage.gd` and `tests/test_phase4c_area.gd` passed Godot `--check-only` syntax validation. +- `tests/test_phase4c_area.gd` reported `RESULT: ALL PASSED (19 assertions)`, covering source-specific and wildcard matching, one-shot/re-entry edges, independent pairs, transformed areas, visible leg-level overlap with feet outside, speech queue normalization and visible speech execution, no-rule no-op behavior, stale targets, and deletion cleanup. +- Existing Phase 4b suites remained passing: 16/16 logic assertions and 53/53 stage/UI assertions. + +User verification in the Godot editor (`scenes/sandbox_stage.tscn`, F6) completed successfully: + +1. Place Stickman A, Stickman B, and an Area. +2. In DIRECT, queue A to walk through the Area. +3. Author `When A enters Area -> B speaks`. +4. Enter PLAY: B speaks once as A's visible body overlaps the area, even when the feet anchor remains outside. +5. Keep A inside for five seconds: no repeat. +6. Return to EDIT/DIRECT, author a route that exits and re-enters, then PLAY: B speaks once on each entry. +7. Add another stickman/area pair and confirm independent firing with no warnings/errors. + +The reported editor result was that Stickman A walking completely through the area caused Stickman B's configured speech bubble to appear, confirming visible-body overlap rather than feet-only containment. + +## Design decisions and trade-offs + +- **Instance IDs, not weakrefs:** consistent with all current rule UI/runtime consumers and adequate for session-only rules. A future persisted-stage format will need stable authored object IDs; that is outside Phase 4c. +- **Local area rectangle, transformed by the stage:** keeps drawing, hit-testing, AABB calculation, and overlap detection on one local geometry contract and supports moved/rotated/scaled areas. +- **Visible AABB overlap, not physics shape overlap:** a stickman or prop enters when its visible world AABB overlaps the area's transformed world AABB. This catches leg/body-level crossings while keeping the event engine geometric and lightweight; exact polygon/collision-shape overlap remains out of scope. +- **Source-specific by default, wildcard explicitly supported:** UI-authored rules retain the selected source. `-1` remains available for existing/programmatic "any movable" rules. + +## Out of scope / documentation follow-up + +- `exited_area` and `stay_in_area` triggers. +- Physics `Area2D` conversion or exact polygon/collision-shape overlap. +- Disk persistence/stable IDs for rules. +- Project documentation was updated after user verification; the stable-ID persistence limitation is recorded in `docs/tech_debt_and_optimizations.md`. diff --git a/tests/test_phase4c_area.gd b/tests/test_phase4c_area.gd new file mode 100644 index 0000000..1ead346 --- /dev/null +++ b/tests/test_phase4c_area.gd @@ -0,0 +1,197 @@ +# Phase 4c headless regression checks for geometric entered_area rules. +# Run with: +# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script "res://tests/test_phase4c_area.gd" + +extends SceneTree + +const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn") +const STICKMAN_RIG := preload("res://scripts/stickman_rig.gd") +const STAGE_SELECTION := preload("res://scripts/stage_selection.gd") + +var _checks: int = 0 +var _failures: int = 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("\n=== PHASE 4c AREA TEST ===") + await _test_spawner_and_rule_contract() + await _test_source_specific_edges() + await _test_wildcard_and_pair_independence() + await _test_transforms_points_and_cleanup() + print("----------------------------------------") + if _failures == 0: + print("RESULT: ALL PASSED (%d assertions)" % _checks) + quit(0) + else: + print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks]) + quit(1) + + +func _new_stage() -> Node2D: + var stage: Node2D = STAGE_SCENE.instantiate() + root.add_child(stage) + await process_frame + return stage + + +func _free_stage(stage: Node2D) -> void: + stage.queue_free() + await process_frame + + +func _spawn_area(stage: Node2D, position: Vector2) -> TriggerArea: + return stage._spawner.spawn("area", position) as TriggerArea + + +func _spawn_rig(stage: Node2D, feet: Vector2) -> StickmanRig: + var rig := stage._spawner.spawn("stickman", feet) as StickmanRig + stage._finalize_placed_node(rig) + return rig + + +func _move_rig_body_center(rig: StickmanRig, world_center: Vector2) -> void: + var bounds: Rect2 = STAGE_SELECTION.get_world_aabb(rig) + rig.global_position += world_center - bounds.get_center() + + +func _add_rule(stage: Node2D, source: Node2D, area: TriggerArea, target: StickmanRig, text: String = "area") -> Dictionary: + var rule: Dictionary = { + "trigger": { + "type": "entered_area", + "source": source.get_instance_id() if source != null else -1, + "target": area.get_instance_id(), + "params": {}, + }, + "actions": [{ "type": "speak", "target": target.get_instance_id(), "params": { "text": text, "duration": 1.0 } }], + } + stage._on_rule_editor_committed(rule) + return stage._event_rules[stage._event_rules.size() - 1] + + +func _clear_actions(rig: StickmanRig) -> void: + rig.action_queue.clear() + rig.stop_queue() + + +func _test_spawner_and_rule_contract() -> void: + var stage: Node2D = await _new_stage() + var area := _spawn_area(stage, Vector2(100.0, 100.0)) + var source := _spawn_rig(stage, Vector2(-200.0, 100.0)) + var target := _spawn_rig(stage, Vector2(400.0, 100.0)) + _check(area != null and area.get_parent() == stage._world, "area spawns directly under World") + var rule := _add_rule(stage, source, area, target, "contract") + _check(stage._event_rules.size() == 1 and not (rule.get("actions", []) as Array).is_empty(), "live-ID entered_area rule is retained with actions") + _check(int((rule["trigger"] as Dictionary).get("source", -1)) == source.get_instance_id(), "rule preserves source instance ID") + await _free_stage(stage) + + +func _test_source_specific_edges() -> void: + var stage: Node2D = await _new_stage() + var area := _spawn_area(stage, Vector2.ZERO) + area.size = Vector2(120.0, 120.0) + var source := _spawn_rig(stage, Vector2(-1500.0, 0.0)) + var other := _spawn_rig(stage, Vector2(-1500.0, 0.0)) + var target := _spawn_rig(stage, Vector2(1500.0, 0.0)) + _add_rule(stage, source, area, target, "source only") + stage._update_area_entry() + _move_rig_body_center(source, area.global_position) + var world_area_rect: Rect2 = (area.global_transform * area.get_area_rect()).abs() + _check(not world_area_rect.has_point(stage._movable_point(source)), "regression setup keeps feet outside the leg-level area") + stage._update_area_entry() + _check(target.action_queue.size() == 1, "source crossing queues exactly one consequence") + var action: Dictionary = target.action_queue[0] + _check(action.get("type", "") == "speak" and action.get("text", "") == "source only" and bool(action.get("reactive", false)), "speak consequence is normalized as a reactive action") + # SceneTree.physics_frame is emitted before node _physics_process callbacks; + # wait through the following tick so the runner can begin the action. + await physics_frame + await physics_frame + _check(target._speech_active and target._speech_bubble != null and target._speech_bubble.visible, "queued consequence executes as a visible speech bubble") + stage._update_area_entry() + _check(target.action_queue.size() == 1, "polling while inside does not repeat") + _move_rig_body_center(source, Vector2(-1500.0, 0.0)) + stage._update_area_entry() + _move_rig_body_center(source, area.global_position) + stage._update_area_entry() + _check(target.action_queue.size() == 2, "exit then re-entry queues one additional consequence") + _clear_actions(target) + _move_rig_body_center(other, area.global_position) + stage._update_area_entry() + _check(target.action_queue.is_empty(), "different stickman cannot trigger source-specific rule") + var prop := stage._spawner.spawn("prop", Vector2(-1500.0, 0.0)) as PropBlock + stage._finalize_placed_node(prop) + prop.freeze = false + prop.global_position = Vector2.ZERO + stage._update_area_entry() + _check(target.action_queue.is_empty(), "unfrozen prop cannot trigger source-specific rule") + await _free_stage(stage) + + +func _test_wildcard_and_pair_independence() -> void: + var stage: Node2D = await _new_stage() + var area_a := _spawn_area(stage, Vector2.ZERO) + var area_b := _spawn_area(stage, Vector2(500.0, 0.0)) + var source_a := _spawn_rig(stage, Vector2(-1500.0, 0.0)) + var source_b := _spawn_rig(stage, Vector2(-1500.0, 0.0)) + var target := _spawn_rig(stage, Vector2(2000.0, 0.0)) + _add_rule(stage, null, area_a, target, "wildcard") + stage._update_area_entry() + _move_rig_body_center(source_a, area_a.global_position) + stage._update_area_entry() + _move_rig_body_center(source_a, Vector2(-1500.0, 0.0)) + stage._update_area_entry() + _move_rig_body_center(source_b, area_a.global_position) + stage._update_area_entry() + _check(target.action_queue.size() == 2, "wildcard source fires independently for multiple movables") + _clear_actions(target) + _add_rule(stage, source_b, area_b, target, "second pair") + _move_rig_body_center(source_b, area_b.global_position) + stage._update_area_entry() + _check(target.action_queue.size() == 1, "second area/source pair has independent edge state") + await _free_stage(stage) + + +func _test_transforms_points_and_cleanup() -> void: + var stage: Node2D = await _new_stage() + var area := _spawn_area(stage, Vector2(300.0, 200.0)) + area.size = Vector2(160.0, 80.0) + area.rotation = deg_to_rad(35.0) + area.scale = Vector2(1.4, 0.8) + var source := _spawn_rig(stage, Vector2(-300.0, 0.0)) + var target := _spawn_rig(stage, Vector2(2000.0, 0.0)) + _add_rule(stage, source, area, target, "transformed") + _check(stage._movable_point(source) == source.global_position - STICKMAN_RIG.FOOT_OFFSET, "stickman overlap point is feet, not root") + _move_rig_body_center(source, area.global_position) + stage._update_area_entry() + _check(target.action_queue.size() == 1, "current transformed area overlaps visible stickman geometry") + _clear_actions(target) + var no_rule_area := _spawn_area(stage, Vector2(-500.0, 0.0)) + source.global_position = no_rule_area.global_position + STICKMAN_RIG.FOOT_OFFSET + stage._update_area_entry() + _check(target.action_queue.is_empty(), "area with no matching rule is a no-op") + var stale_rule := _add_rule(stage, source, area, target, "stale") + var stale_id := target.get_instance_id() + target.free() + _move_rig_body_center(source, Vector2(-1500.0, 0.0)) + stage._update_area_entry() + _move_rig_body_center(source, area.global_position) + stage._update_area_entry() + _check(not is_instance_id_valid(stale_id), "stale action target is invalid and skipped safely") + _check(stage._event_rules.has(stale_rule), "stale target does not corrupt retained rule payload") + stage._selection.select_only(area) + stage.delete_selected() + await process_frame + _check(not stage._event_rules.any(func(rule: Dictionary) -> bool: return int((rule.get("trigger", {}) as Dictionary).get("target", -1)) == area.get_instance_id()), "stage deletion removes rules referencing deleted area") + await _free_stage(stage) + + +func _check(condition: bool, message: String) -> void: + _checks += 1 + if condition: + print("PASS: " + message) + else: + _failures += 1 + print("FAIL: " + message) diff --git a/tests/test_phase4c_area.gd.uid b/tests/test_phase4c_area.gd.uid new file mode 100644 index 0000000..cba2b32 --- /dev/null +++ b/tests/test_phase4c_area.gd.uid @@ -0,0 +1 @@ +uid://csig3ktbmuurl