Files
stickman/tests/test_phase4_final_bugs.gd
T
ryan db4ff8d939 Implement Phase 8: Rig Relocation and Component Modularization
- Moved the rig scene from `res://master_rig.tscn` to `res://scenes/stickman_rig.tscn`, preserving its UID.
- Split the `StickmanRig` controller into three components: `StickmanLocomotion`, `StickmanRagdoll`, and `StickmanActionRunner`.
- Updated the architecture to ensure `StickmanRig` acts as the public facade for the new components.
- Created a new test suite `test_phase8_rig_modularization.gd` to verify the integrity of the relocated rig and its components.
- Added checks for scene loading, component resolution, action queue delegation, and state authority.
2026-09-18 14:11:53 -04:00

338 lines
16 KiB
GDScript

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/action_registry.gd")
const GIZMOS := preload("res://scripts/stage_gizmos.gd")
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
const ACTION_EDITOR_SCENE := preload("res://scenes/action_editor.tscn")
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const STICKMAN_FACTORY := preload("res://scripts/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 radius: float = stage._gizmos._rotate_ring_radius(rect)
press.position = canvas * (center + Vector2(radius, 0.0))
stage._handle_world_click(press)
_check(stage._gizmos.is_dragging(), "prop rotation ring begins a rotate drag")
motion.position = canvas * (center + Vector2(0.0, radius))
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/sandbox_stage.gd")
var rig_source: String = FileAccess.get_file_as_string("res://scripts/stickman_rig.gd")
var ragdoll_source: String = FileAccess.get_file_as_string("res://scripts/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/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)