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.
This commit is contained in:
2026-09-11 15:43:08 -04:00
parent 31282313be
commit 04e0f1bea2
11 changed files with 489 additions and 9 deletions
+197
View File
@@ -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)