338 lines
16 KiB
GDScript
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/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 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/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)
|