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:
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://csig3ktbmuurl
|
||||
Reference in New Issue
Block a user