# 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.stop_queue() rig.clear_queue() var runner := rig.get_node("RigComponents/ActionRunner") as StickmanActionRunner runner.hide_speech() 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() var runner := target.get_node("RigComponents/ActionRunner") as StickmanActionRunner _check(not runner._overlay_speech.is_empty(), "source crossing starts one overlay consequence") _check(String(runner._overlay_speech.get("text", "")) == "source only", "speak consequence is normalized into overlay speech") # 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(runner._speech_bubble != null and runner._speech_bubble.visible, "queued consequence executes as a visible speech bubble") runner._overlay_speech["time_left"] = 0.5 stage._update_area_entry() _check(is_equal_approx(float(runner._overlay_speech.get("time_left", 0.0)), 0.5), "polling while inside does not repeat") _move_rig_body_center(source, Vector2(-1500.0, 0.0)) stage._update_area_entry() # The swept-bounds detector deliberately retains the previous pose for one # poll, so a second far-away sample commits the exit edge. stage._update_area_entry() _move_rig_body_center(source, area.global_position) stage._update_area_entry() _check(is_equal_approx(float(runner._overlay_speech.get("time_left", 0.0)), 1.0), "exit then re-entry restarts the consequence") _clear_actions(target) _move_rig_body_center(other, area.global_position) stage._update_area_entry() _check(runner._overlay_speech.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(runner._overlay_speech.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() var runner := target.get_node("RigComponents/ActionRunner") as StickmanActionRunner runner._overlay_speech["time_left"] = 0.25 _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(is_equal_approx(float(runner._overlay_speech.get("time_left", 0.0)), 1.0), "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(String(runner._overlay_speech.get("text", "")) == "second pair", "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 - StickmanLocomotion.FOOT_OFFSET, "stickman overlap point is feet, not root") _move_rig_body_center(source, area.global_position) stage._update_area_entry() var runner := target.get_node("RigComponents/ActionRunner") as StickmanActionRunner _check(String(runner._overlay_speech.get("text", "")) == "transformed", "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 + StickmanLocomotion.FOOT_OFFSET stage._update_area_entry() _check(runner._overlay_speech.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)