extends SceneTree ## Focused Phase 4 Final Bugs regression checks. Run with the project console ## executable and `--headless --script res://tests/test_phase4_final_bugs.gd`. const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd") const GIZMOS := preload("res://scripts/stage/stage_gizmos.gd") const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd") const ACTION_EDITOR_SCENE := preload("res://scenes/editors/stage/action_editor.tscn") const STAGE_SCENE := preload("res://scenes/stage/sandbox_stage.tscn") const STICKMAN_FACTORY := preload("res://scripts/characters/stickman/stickman_factory.gd") var _checks: int = 0 var _failures: int = 0 func _initialize() -> void: call_deferred("_run") func _run() -> void: _test_action_roles_and_payloads() await _test_action_editor_reactive_option() _test_grid_snap_api() await _test_stage_prop_drag_and_rotation() await _test_reactive_runtime_lane() await _test_spawn_pose_survives_first_solve() await _test_left_walk_finish_pose() await _test_stop_clears_head_reaction() _test_source_contracts() if _failures == 0: print("RESULT: ALL PASSED (%d checks)" % _checks) quit(0) else: print("RESULT: %d failures of %d checks" % [_failures, _checks]) quit(1) func _test_action_roles_and_payloads() -> void: _check(ACTION_REGISTRY.reactive_role({ "type": "walk_to" }) == ACTION_REGISTRY.ReactiveRole.SEQUENTIAL, "walk is sequential") _check(ACTION_REGISTRY.reactive_role({ "type": "speak", "params": {} }) == ACTION_REGISTRY.ReactiveRole.OVERLAY, "old speak rules default overlay") _check(ACTION_REGISTRY.reactive_role({ "type": "speak", "params": { "stop_current": true } }) == ACTION_REGISTRY.ReactiveRole.SEQUENTIAL, "promoted speak is sequential") _check(ACTION_REGISTRY.reactive_role({ "type": "ragdoll" }) == ACTION_REGISTRY.ReactiveRole.TAKEOVER, "ragdoll is takeover") var rule: Dictionary = ACTION_REGISTRY.to_rule_action({ "type": "speak", "text": "Ouch", "duration": 1.0, "stop_current": true }, 42) _check(bool((rule.get("params", {}) as Dictionary).get("stop_current", false)), "stop-current survives rule conversion") var flat: Dictionary = ACTION_REGISTRY.from_rule_action(rule) _check(bool(flat.get("stop_current", false)), "stop-current survives flat conversion") _check(ACTION_REGISTRY.summarize(rule).contains("stops current"), "summary identifies promoted speech") var walk_rule: Dictionary = ACTION_REGISTRY.to_rule_action({ "type": "walk_to", "target": Vector2(123.0, -45.0) }, 42) _check(ACTION_REGISTRY.summarize(walk_rule) == "Walk To (123, -45)", "rule walk summary uses params.target instead of the actor instance id") func _test_grid_snap_api() -> void: var gizmos := GIZMOS.new() gizmos.set_snap_size(-4.0) _check(is_zero_approx(gizmos.snap_size), "negative snap size clamps to zero") gizmos.set_snap_size(35.0) _check(is_equal_approx(gizmos.snap_size, 35.0), "gizmo stores stage grid snap size") var target := Node2D.new() root.add_child(target) var targets: Array[Node2D] = [target] gizmos.set_targets(targets) gizmos.begin_translate_drag(target, Vector2.ZERO) gizmos.drag_to(Vector2(5.0, 5.0)) gizmos.drag_to(Vector2(10.0, 10.0)) gizmos.drag_to(Vector2(15.0, 15.0)) _check(target.global_position == Vector2.ZERO, "sub-grid drag events accumulate without premature movement") gizmos.drag_to(Vector2(20.0, 20.0)) _check(target.global_position == Vector2(35.0, 35.0), "translation snap uses configured grid, not object bounds") target.free() gizmos.free() func _test_stage_prop_drag_and_rotation() -> void: root.size = Vector2i(1280, 720) var stage := STAGE_SCENE.instantiate() root.add_child(stage) await process_frame var prop := stage._spawner.spawn("prop", stage._camera.get_screen_center_position()) as PropBlock _check(prop != null, "stage spawns an editable prop") if prop == null: stage.free() return stage._finalize_placed_node(prop) await physics_frame stage._gizmos.set_snap_size(0.0) var canvas: Transform2D = stage.get_viewport().get_canvas_transform() var press := InputEventMouseButton.new() press.button_index = MOUSE_BUTTON_LEFT press.pressed = true press.position = canvas * prop.global_position var hover_motion := InputEventMouseMotion.new() hover_motion.position = press.position Input.parse_input_event(hover_motion) await process_frame Input.parse_input_event(press) await process_frame _check(stage._gizmos.is_dragging(), "ordinary prop click begins a translate drag") var translated := prop.global_position + Vector2(48.0, 27.0) var motion := InputEventMouseMotion.new() motion.position = canvas * translated motion.relative = motion.position - press.position stage._input(motion) var release := InputEventMouseButton.new() release.button_index = MOUSE_BUTTON_LEFT release.pressed = false release.position = motion.position stage._handle_world_click(release) _check(prop.global_position.is_equal_approx(translated), "prop follows the translate gizmo immediately") var saved_after_drag: Dictionary = stage._authored[prop.get_instance_id()] _check((saved_after_drag["position"] as Vector2).is_equal_approx(translated), "drag commit updates authored prop position") _check(stage._restore_frames_left == 0, "drag start cancels deferred authored restoration") await physics_frame await process_frame _check(prop.global_position.is_equal_approx(translated), "frozen prop keeps its dragged physics transform (got %s expected %s)" % [prop.global_position, translated]) var rect: Rect2 = STAGE_SELECTION.get_world_aabb(prop) var center: Vector2 = rect.get_center() var rotate_handle: Vector2 = stage._gizmos._rotate_handle_position(rect) var starting_vector: Vector2 = rotate_handle - center press.position = canvas * rotate_handle stage._handle_world_click(press) _check(stage._gizmos.is_dragging(), "prop rotation handle begins a rotate drag") motion.position = canvas * (center + starting_vector.rotated(PI * 0.5)) motion.relative = motion.position - press.position stage._input(motion) release.position = motion.position stage._handle_world_click(release) _check(absf(angle_difference(prop.global_rotation, PI * 0.5)) < 0.01, "prop follows the rotation gizmo") await physics_frame await process_frame _check(absf(angle_difference(prop.global_rotation, PI * 0.5)) < 0.01, "frozen prop keeps its rotated physics transform") stage.free() await process_frame func _test_action_editor_reactive_option() -> void: var editor: ActionEditor = ACTION_EDITOR_SCENE.instantiate() root.add_child(editor) await process_frame editor.open_new(false) editor._select_type("speak") editor._rebuild_params("speak") _check(editor._stop_current_check == null, "queue speak has no stop-current checkbox") editor.open_edit({ "type": "speak", "text": "Ouch", "duration": 1.0, "stop_current": true }, true) _check(editor._stop_current_check != null, "rule speak shows stop-current checkbox") _check(editor._stop_current_check.button_pressed, "rule speak checkbox pre-fills saved value") editor.free() func _test_reactive_runtime_lane() -> void: var rig: StickmanRig = STICKMAN_FACTORY.spawn_from_data({}) root.add_child(rig) await process_frame rig.action_queue = [ { "type": "wait", "duration": 10.0 }, { "type": "wait", "duration": 20.0 }, ] var finished: Array[Dictionary] = [] rig.action_finished.connect(func(action: Dictionary, index: int): finished.append({ "action": action, "index": index }) ) rig.start_queue() var runner: StickmanActionRunner = rig.get_node("RigComponents/ActionRunner") as StickmanActionRunner runner.tick(0.0, int(rig.state), rig.is_ragdoll_at_rest()) _check(runner._action_phase == runner.ActionPhase.WAITING, "authored primary wait starts") rig.enqueue_reactive([{ "type": "speak", "text": "One", "duration": 5.0 }]) _check(runner._action_phase == runner.ActionPhase.WAITING, "overlay speak leaves primary wait running") _check(String(runner._overlay_speech.get("text", "")) == "One", "overlay speak occupies the single overlay slot") rig.enqueue_reactive([{ "type": "speak", "text": "Two", "duration": 5.0 }]) _check(String(runner._overlay_speech.get("text", "")) == "Two", "new overlay speak replaces the previous overlay") _check(finished.is_empty(), "overlay replacement emits no primary completion") rig.enqueue_reactive([{ "type": "wait", "duration": 1.0 }]) _check(runner._action_phase == runner.ActionPhase.NONE, "sequential reactive action cancels the active primary") _check(finished.is_empty(), "sequential interruption emits no false action_finished") _check(runner._run_queue.size() == 2, "sequential interrupt retains only injected plus authored future") _check(String((runner._run_queue[0]["action"] as Dictionary).get("type", "")) == "wait" and bool(runner._run_queue[0].get("reactive", false)), "injected sequential action leads the runtime lane") _check(int(runner._run_queue[1].get("queue_index", -1)) == 1, "canceled authored action is abandoned and the next authored item is retained") # A takeover must discard the canceled/current item and any pending reactive # primary work, while retaining only the authored future. rig.stop_queue() rig.action_queue = [ { "type": "wait", "duration": 10.0 }, { "type": "wait", "duration": 20.0 }, ] rig.start_queue() runner.tick(0.0, int(rig.state), rig.is_ragdoll_at_rest()) rig.enqueue_reactive([ { "type": "wait", "duration": 1.0 }, { "type": "ragdoll" }, ]) _check(runner._run_queue.size() == 2, "takeover retains itself plus authored future only") _check(String((runner._run_queue[0]["action"] as Dictionary).get("type", "")) == "ragdoll", "takeover supersedes earlier same-batch primary work") _check(int(runner._run_queue[1].get("queue_index", -1)) == 1, "takeover does not replay the completed or canceled authored item") _check(runner._overlay_speech.is_empty(), "takeover cancels the active overlay") _check(rig.action_queue.size() == 2, "reactive work never mutates the authored queue") rig.free() func _test_spawn_pose_survives_first_solve() -> void: var rig: StickmanRig = STICKMAN_FACTORY.spawn_from_data({}) var skeleton := rig.get_node("Skeleton2D") as Skeleton2D var bone_paths: Array[String] = [ "Torso", "Torso/Head", "Torso/LeftUpperArm/LeftLowerArm", "Torso/RightUpperArm/RightLowerArm", "Torso/LeftUpperLeg/LeftLowerLeg", "Torso/RightUpperLeg/RightLowerLeg", ] var before: Dictionary = {} for path: String in bone_paths: var bone := skeleton.get_node(path) as Bone2D before[path] = { "pos": bone.global_position, "rot": bone.global_rotation } root.add_child(rig) await process_frame await physics_frame await physics_frame for path: String in bone_paths: var solved := skeleton.get_node(path) as Bone2D var expected: Dictionary = before[path] var position_delta: float = solved.global_position.distance_to(expected["pos"]) var rotation_delta: float = absf(angle_difference(solved.global_rotation, float(expected["rot"]))) _check(position_delta <= 1.0, "spawn solve preserves ghost joint position: %s (delta %.3f)" % [path, position_delta]) _check(rotation_delta <= 0.02, "spawn solve preserves ghost bone rotation: %s (delta %.4f)" % [path, rotation_delta]) var ragdoll := rig.get_node("RigComponents/Ragdoll") as StickmanRagdoll _check(ragdoll._spawn_pose.size() == StickmanRagdoll.IK_TARGET_PATHS.size(), "factory captures all six spawn-pose markers") rig.free() func _test_left_walk_finish_pose() -> void: var rig: StickmanRig = STICKMAN_FACTORY.spawn_from_data({}) root.add_child(rig) await process_frame rig.set_facing_profile(rig.FacingProfile.LEFT) var locomotion: StickmanLocomotion = rig.get_locomotion() locomotion._walking = true rig._set_limb_mods_enabled(true) var stopped_frame: Dictionary = {} for joint: String in rig._limb_bones: var bones: Dictionary = rig._limb_bones[joint] (bones["upper"] as Bone2D).rotation += 0.7 (bones["lower"] as Bone2D).rotation -= 0.9 stopped_frame[joint] = { "upper": (bones["upper"] as Bone2D).rotation, "lower": (bones["lower"] as Bone2D).rotation, } var hand_marker := rig.get_node("IK_Targets/Left_Hand") as Marker2D var stopped_marker_position := hand_marker.position + Vector2(17.0, -23.0) hand_marker.position = stopped_marker_position locomotion._finish_walk() _check(hand_marker.position.is_equal_approx(stopped_marker_position), "walk finish preserves the current animation marker frame") for joint: String in stopped_frame: var bones: Dictionary = rig._limb_bones[joint] var expected: Dictionary = stopped_frame[joint] _check(absf(angle_difference((bones["upper"] as Bone2D).rotation, float(expected["upper"]))) < 0.001, "LEFT walk finish preserves current upper-limb frame: %s" % joint) _check(absf(angle_difference((bones["lower"] as Bone2D).rotation, float(expected["lower"]))) < 0.001, "LEFT walk finish preserves current lower-limb frame: %s" % joint) for modification: SkeletonModification2DTwoBoneIK in rig._bend_modifications.values(): _check(not modification.enabled, "LEFT idle disables mirrored limb solver") var left_arm: Dictionary = rig._limb_bones["LeftArm"] var live_upper := left_arm["upper"] as Bone2D var live_rotation: float = live_upper.rotation + 0.33 live_upper.rotation = live_rotation locomotion._walking = true rig._set_limb_mods_enabled(true) locomotion.cancel_walk(false) _check(absf(angle_difference(live_upper.rotation, live_rotation)) < 0.001, "ragdoll-style walk cancellation preserves the current solved limb pose") _check((rig._bend_modifications["LeftArm"] as SkeletonModification2DTwoBoneIK).enabled, "ragdoll-style walk cancellation keeps IK live for pose capture") rig.free() func _test_stop_clears_head_reaction() -> void: var stage := STAGE_SCENE.instantiate() root.add_child(stage) await process_frame var rig := stage._spawner.spawn("stickman", Vector2.ZERO) as StickmanRig stage._finalize_placed_node(rig) var locomotion: StickmanLocomotion = rig.get_locomotion() var head := locomotion.reaction_bones.get("Head") as Bone2D var base_rotation: float = head.rotation rig.apply_collision_reaction("head", Vector2.DOWN, 500.0) locomotion.apply_impact_reactions(0.01) _check(not locomotion.impact_reactions.is_empty(), "head collision starts a flinch reaction") stage.set_mode(stage.StageMode.PLAY) stage.set_mode(stage.StageMode.EDIT) _check(locomotion.impact_reactions.is_empty(), "STOP clears the active head reaction") _check(absf(angle_difference(head.rotation, base_rotation)) < 0.001, "STOP restores the head without a recovery tween") var stale_prop := PropBlock.new() var stale_proxy := AnimatableBody2D.new() stale_proxy.set_meta("stickman_rig", rig) stale_proxy.set_meta("part_name", "head") stage._on_prop_collided(stale_proxy, Vector2.DOWN, Vector2.ZERO, stale_prop) _check(locomotion.impact_reactions.is_empty(), "post-STOP collision callback cannot recreate the flinch") stale_prop.free() stale_proxy.free() stage.free() await process_frame func _test_source_contracts() -> void: var stage_source: String = FileAccess.get_file_as_string("res://scripts/editors/stage/sandbox_stage.gd") var rig_source: String = FileAccess.get_file_as_string("res://scripts/characters/stickman/stickman_rig.gd") var ragdoll_source: String = FileAccess.get_file_as_string("res://scripts/characters/stickman/stickman_ragdoll.gd") _check(stage_source.contains("TERRAIN_DRAG_THRESHOLD_PX"), "terrain uses a drag threshold") _check(stage_source.contains("PhysicsServer2D.body_set_state"), "frozen prop commits synchronize physics") _check(stage_source.contains("transform_started.connect"), "gizmo start cancels deferred restoration") _check(stage_source.contains("if current_mode != StageMode.PLAY:"), "collision callbacks are gated to PLAY mode") _check(stage_source.contains("_speak_stop_current_check") and stage_source.contains("\"stop_current\": _speak_stop_current_check.button_pressed"), "legacy rule speak persists stop-current") var runner_source: String = FileAccess.get_file_as_string("res://scripts/characters/stickman/stickman_action_runner.gd") _check(runner_source.contains("_primary_speech") and runner_source.contains("_overlay_speech"), "runner separates primary and overlay speech") _check(rig_source.contains("_sync_speech_bubble_mirror"), "rig counter-mirrors LEFT speech") _check(rig_source.contains("initialize_spawn_pose_from_skeleton"), "rig captures factory spawn pose") _check(ragdoll_source.contains("bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle)))"), "spawn endpoints include Bone2D bone_angle") _check(ragdoll_source.contains("_stand_up_generation") and ragdoll_source.contains("_on_stand_up_finished.bind(generation)"), "stale recovery tween callbacks are generation-guarded") func _check(condition: bool, label: String) -> void: _checks += 1 if condition: print("PASS: %s" % label) else: _failures += 1 push_error("FAIL: %s" % label)