extends SceneTree const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd") const STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd") const STAGE_SCENE_IO := preload("res://scripts/stage/stage_scene_io.gd") const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd") const PROP_BLOCK_SCRIPT := preload("res://scripts/props/prop_block.gd") const PROP_INTERACTOR_SCRIPT := preload("res://scripts/characters/stickman/stickman_prop_interactor.gd") const PROP_ANCHOR_DATA := preload("res://scripts/props/prop_anchor_data.gd") const STICKMAN_RIG_SCENE := preload("res://scenes/characters/stickman/stickman_rig.tscn") var _checks: int = 0 var _failures: int = 0 var _pickup_test_prop: PropBlock = null func _initialize() -> void: call_deferred("_run") func _run() -> void: _test_role_defaults_and_matrix() _test_legacy_migration() _test_set_down_serialization() _test_unknown_preview() await _test_runtime_profiles() await _test_set_down_owner_collision_grace() await _test_reserved_pickup_collision_protection() await _test_start_still_support_contact() await _test_set_down_clearance_and_release() await _test_set_down_preserves_pickup_orientation() await _test_set_down_low_reach_pose() await _test_set_down_prop_follows_approaching_actor() print("----------------------------------------") if _failures == 0: print("RESULT: ALL PASSED (%d checks)" % _checks) quit(0) else: print("RESULT: %d FAILURE(S) out of %d checks" % [_failures, _checks]) quit(1) func _expect(condition: bool, message: String) -> void: _checks += 1 if condition: print("PASS: " + message) else: _failures += 1 push_error("Phase 17a check failed: " + message) func _test_role_defaults_and_matrix() -> void: var roles: Dictionary = PROP_STAGE_ROLES.new_placement(false) _expect(roles["depth"] == PROP_STAGE_ROLES.DEPTH_BEHIND, "new props start behind characters") _expect(roles["start_motion"] == PROP_STAGE_ROLES.START_STILL, "new props start still") _expect(not bool(roles["can_pick_up"]), "props without a grip are not pickable") _expect(bool(roles["released_blocks_stickmen"]) and bool(roles["released_bumps_other_props"]), "released contacts default On") var table_roles: Dictionary = roles.duplicate(true) table_roles["holds_other_props"] = true var pot_roles: Dictionary = PROP_STAGE_ROLES.new_placement(true) _expect(PROP_STAGE_ROLES.props_should_collide(table_roles, 1, pot_roles, 2), "start still support catches a physics prop") table_roles["holds_other_props"] = false _expect(not PROP_STAGE_ROLES.props_should_collide(table_roles, 1, pot_roles, 2), "start still prop without support capability does not catch props") var released_roles: Dictionary = pot_roles.duplicate(true) released_roles["released_bumps_other_props"] = false _expect(not PROP_STAGE_ROLES.props_should_collide(released_roles, 4, table_roles, 2), "released other prop Off wins over dynamic contacts") _expect(PROP_STAGE_ROLES.props_should_collide(pot_roles, 2, table_roles, 2), "two physics props can collide") _expect(not PROP_STAGE_ROLES.props_should_collide(pot_roles, 3, table_roles, 2), "held props never collide") func _test_legacy_migration() -> void: var legacy := { "version": "1.0", "objects": [ {"file_id": "character_a", "type": "stickman"}, {"file_id": "prop_a", "type": "prop"}, {"file_id": "character_b", "type": "stickman"}, ], } var migrated: Dictionary = STAGE_SCENE_IO._migrate_to_current(legacy) var prop_entry: Dictionary = migrated["objects"][1] var roles: Dictionary = prop_entry["roles"] _expect(migrated["version"] == STAGE_SCENE_IO.FILE_VERSION, "old stage version migrates to the current schema") _expect(prop_entry["stage_object_id"] == "prop_a", "1.0 prop IDs migrate from file IDs") _expect(String(roles["start_motion"]) == PROP_STAGE_ROLES.START_PHYSICS, "legacy prop keeps dynamic start behavior") _expect(bool(roles["blocks_stickmen"]) and bool(roles["legacy_draw_order"]), "legacy prop keeps collisions and draw order") var current_incomplete := {"version": "1.2", "objects": [{"file_id": "p", "type": "prop", "stage_object_id": "p"}]} var repaired: Dictionary = STAGE_SCENE_IO._migrate_to_current(current_incomplete) _expect(bool(repaired["objects"][0]["roles"]["legacy_draw_order"]), "1.2 prop missing roles receives legacy behavior") var invalid := {"objects": [{"file_id": "p", "type": "prop", "stage_object_id": "p", "roles": {"depth": 5}}]} _expect(not bool(STAGE_SCENE_IO._validate_root(invalid)["ok"]), "malformed present role data is rejected") func _test_set_down_serialization() -> void: var position := Vector2(420.0, -260.0) var encoded_queue: Dictionary = STAGE_SCENE_IO.encode_queue([{"type": "set_down_prop", "target_position": position}]) var queue_entries: Array = encoded_queue["actions"] var decoded_queue: Dictionary = STAGE_SCENE_IO.decode_queue(queue_entries) var queue_action: Dictionary = decoded_queue["actions"][0] _expect(queue_action["type"] == "set_down_prop" and queue_action["target_position"] == position, "Set Down queue target round-trips") var flat_action := {"type": "set_down_prop", "target_position": position} var rule_action: Dictionary = ACTION_REGISTRY.to_rule_action(flat_action, 7) var rules: Array[Dictionary] = [{ "id": 1, "trigger": {"type": "speech_finished", "source": 7, "target": -1, "params": {}}, "actions": [rule_action], }] var runtime_to_file := {7: "actor_file"} var encoded_rules: Dictionary = STAGE_SCENE_IO.encode_rules(rules, runtime_to_file) var file_to_runtime := {"actor_file": 7} var decoded_rules: Dictionary = STAGE_SCENE_IO.decode_rules(encoded_rules["rules"], file_to_runtime, {}) var decoded_action: Dictionary = decoded_rules["rules"][0]["actions"][0] _expect(decoded_action["type"] == "set_down_prop" and decoded_action["params"]["target_position"] == position, "Set Down rule target round-trips") _expect(String(ACTION_REGISTRY.summarize(flat_action)).contains("Set Down Prop"), "Set Down queue summary is available") func _test_unknown_preview() -> void: var interactor: Node = PROP_INTERACTOR_SCRIPT.new() root.add_child(interactor) var empty_prefix: Array[Dictionary] = [] var preview: Dictionary = interactor.call("preview_set_down", Vector2(30.0, 60.0), empty_prefix) _expect(not bool(preview.get("known", true)), "unknown held prop preview remains conditional") _expect(String(preview.get("reason", "")) == "Prop depends on earlier actions", "unknown preview shows the conditional cue") interactor.queue_free() func _test_runtime_profiles() -> void: var prop: PropBlock = PROP_BLOCK_SCRIPT.new() root.add_child(prop) await process_frame var roles: Dictionary = PROP_STAGE_ROLES.new_placement(true) roles["blocks_stickmen"] = true roles["released_blocks_stickmen"] = false prop.set_stage_roles(roles) prop.apply_runtime_role(PropBlock.RuntimeRole.START_STILL) _expect(prop.freeze and prop.runtime_role == PropBlock.RuntimeRole.START_STILL, "start still profile freezes the body") _expect((prop.collision_layer & STAGE_PHYSICS.PROP_LAYER) != 0, "start still prop keeps its physical shape active") prop.apply_runtime_role(PropBlock.RuntimeRole.HELD) _expect(prop.freeze and prop.collision_layer == 0 and prop.collision_mask == 0, "held profile removes physics contacts") prop.apply_runtime_role(PropBlock.RuntimeRole.RELEASED) _expect(not prop.freeze and prop.runtime_role == PropBlock.RuntimeRole.RELEASED, "released profile wakes a dynamic body") _expect((prop.collision_layer & STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER) == 0, "released actor collision setting is independent of initial blocking") prop.queue_free() await process_frame func _test_set_down_owner_collision_grace() -> void: var world := Node2D.new() root.add_child(world) var owner_rig := STICKMAN_RIG_SCENE.instantiate() as Node2D world.add_child(owner_rig) var other_rig := STICKMAN_RIG_SCENE.instantiate() as Node2D other_rig.position = Vector2(1000.0, 0.0) world.add_child(other_rig) await physics_frame var owner_bodies: Dictionary = owner_rig.call("get_collision_bodies") var torso: AnimatableBody2D = owner_bodies.get("torso") as AnimatableBody2D _expect(torso != null, "Set Down grace fixture has an animated torso collider") if torso == null: world.queue_free() await process_frame return var prop: PropBlock = PROP_BLOCK_SCRIPT.new() prop.polygon_points = PackedVector2Array([Vector2(-20.0, -20.0), Vector2(20.0, -20.0), Vector2(20.0, 20.0), Vector2(-20.0, 20.0)]) prop.gravity_scale = 0.0 world.add_child(prop) await physics_frame prop.global_position = torso.global_position prop.set_stage_roles(PROP_STAGE_ROLES.new_placement(true)) prop.apply_runtime_role(PropBlock.RuntimeRole.HELD) prop.interaction_owner_id = owner_rig.get_instance_id() var interactor: Node = owner_rig.get_node(NodePath("RigComponents/PropInteractor")) interactor.set("_held_prop", prop) interactor.call("_release_held", "set_down", false, Vector2.ZERO, 0.0, true, false) for _frame: int in 4: await physics_frame _expect(prop.runtime_role == PropBlock.RuntimeRole.RELEASED and prop.is_actor_blocking(), "Set Down grace preserves the prop's authored released collision role") _expect(prop.is_owner_collision_grace_active_for(owner_rig), "Set Down release starts grace for the releasing stickman") _expect(not prop.is_owner_collision_grace_active_for(other_rig), "Set Down grace does not apply to a different stickman") var owner_exceptions: Array[PhysicsBody2D] = torso.get_collision_exceptions() _expect(owner_exceptions.has(prop) and prop.get_collision_exceptions().has(torso), "Set Down grace installs a symmetric exception for the releasing stickman's collider") var other_bodies: Dictionary = other_rig.call("get_collision_bodies") var other_torso: AnimatableBody2D = other_bodies.get("torso") as AnimatableBody2D _expect(other_torso != null and not other_torso.get_collision_exceptions().has(prop), "other stickmen keep ordinary prop collision") var locomotion: Node = owner_rig.call("get_locomotion") as Node var owner_sweep: Dictionary = locomotion.call("_sweep_actor_blocking_props", Vector2(40.0, 0.0)) _expect(float(owner_sweep.get("fraction", 0.0)) >= 0.999, "the releasing stickman's movement sweep skips its grace prop") owner_rig.global_position += Vector2(1000.0, 0.0) await physics_frame _expect(prop.is_owner_collision_grace_active_for(owner_rig), "Set Down grace waits for a second clear physics frame") await physics_frame _expect(not prop.is_owner_collision_grace_active_for(owner_rig), "Set Down grace ends after the releasing stickman's collision shapes clear the prop") _expect(not torso.get_collision_exceptions().has(prop) and not prop.get_collision_exceptions().has(torso), "clearance removes both sides of the temporary collision exception") prop.apply_runtime_role(PropBlock.RuntimeRole.HELD) interactor.set("_held_prop", prop) interactor.call("_release_held", "manual", false, Vector2.ZERO, 0.0, true, false) _expect(not prop.is_owner_collision_grace_active_for(owner_rig), "Drop does not receive Set Down collision grace") world.queue_free() await process_frame func _test_reserved_pickup_collision_protection() -> void: var world := Node2D.new() root.add_child(world) var rig := STICKMAN_RIG_SCENE.instantiate() as Node2D world.add_child(rig) await physics_frame var collision_bodies: Dictionary = rig.call("get_collision_bodies") var torso: AnimatableBody2D = collision_bodies.get("torso") as AnimatableBody2D _expect(torso != null, "pickup protection fixture has an animated torso collider") if torso == null: world.queue_free() await process_frame return var prop: PropBlock = PROP_BLOCK_SCRIPT.new() prop.stage_object_id = "pickup-protection-test-prop" prop.polygon_points = PackedVector2Array([Vector2(-24.0, -20.0), Vector2(24.0, -20.0), Vector2(24.0, 20.0), Vector2(-24.0, 20.0)]) prop.gravity_scale = 0.0 world.add_child(prop) await physics_frame prop.global_position = torso.global_position + Vector2(800.0, 0.0) prop.set_interaction_anchors(PROP_ANCHOR_DATA.preset_layout("one_handed", Rect2(Vector2(-24.0, -20.0), Vector2(48.0, 40.0)))) var roles: Dictionary = PROP_STAGE_ROLES.new_placement(true) roles["blocks_stickmen"] = true roles["released_blocks_stickmen"] = true prop.set_stage_roles(roles) prop.apply_runtime_role(PropBlock.RuntimeRole.RELEASED) _pickup_test_prop = prop var interactor: Node = rig.get_node(NodePath("RigComponents/PropInteractor")) interactor.set("_resolver", Callable(self, "_resolve_pickup_test_prop")) var action := {"type": "pick_up_prop", "target_prop_id": prop.stage_object_id, "hand": "auto"} var started: bool = bool(interactor.call("start_action", action)) _expect(started and int(interactor.get("_phase")) == PROP_INTERACTOR_SCRIPT.Phase.APPROACHING, "pickup reserves a distant prop and enters the approach phase") _expect(prop.interaction_reserved_by == rig.get_instance_id() and prop.is_owner_collision_grace_active_for(rig), "pickup reservation starts protection for its target") for _frame: int in 5: await physics_frame _expect(prop.is_owner_collision_grace_active_for(rig), "reserved pickup protection stays active while the stickman is far away") var locomotion: Node = rig.call("get_locomotion") as Node var approach_sweep: Dictionary = locomotion.call("_sweep_actor_blocking_props", Vector2(1000.0, 0.0)) _expect(float(approach_sweep.get("fraction", 0.0)) >= 0.999, "the owner's approach sweep skips its reserved pickup target") var hand_marker: Marker2D = interactor.call("_hand_marker") as Marker2D interactor.call("_commit_pickup", hand_marker) _expect(prop.runtime_role == PropBlock.RuntimeRole.HELD and not prop.is_owner_collision_grace_active_for(rig), "successful pickup clears target protection through the held profile") interactor.call("reset_for_scene_exit") await physics_frame var torso_local: Vector2 = rig.to_local(torso.global_position) locomotion.call("cancel_walk") rig.global_position = prop.global_position - torso_local await physics_frame var retry_started: bool = bool(interactor.call("start_action", action)) _expect(retry_started and prop.is_owner_collision_grace_active_for(rig), "a retry protects its target before approach begins") locomotion.call("cancel_walk") interactor.call("cancel_action") _expect(prop.interaction_reserved_by == 0 and prop.is_owner_collision_grace_active_for(rig), "cancel releases the reservation but keeps exit protection") for _frame: int in 4: await physics_frame _expect(prop.is_owner_collision_grace_active_for(rig), "cancel protection remains while the stickman still overlaps the target") rig.global_position += Vector2(1000.0, 0.0) await physics_frame _expect(prop.is_owner_collision_grace_active_for(rig), "cancel protection waits for a second clear frame") await physics_frame _expect(not prop.is_owner_collision_grace_active_for(rig), "cancel protection clears after the stickman leaves the target") _pickup_test_prop = null world.queue_free() await process_frame func _resolve_pickup_test_prop(prop_id: String) -> PropBlock: return _pickup_test_prop if _pickup_test_prop != null and _pickup_test_prop.stage_object_id == prop_id else null func _test_start_still_support_contact() -> void: var world := Node2D.new() root.add_child(world) var support: PropBlock = PROP_BLOCK_SCRIPT.new() support.position = Vector2(0.0, 100.0) support.polygon_points = PackedVector2Array([Vector2(-60.0, -10.0), Vector2(60.0, -10.0), Vector2(60.0, 10.0), Vector2(-60.0, 10.0)]) world.add_child(support) await process_frame var support_roles: Dictionary = PROP_STAGE_ROLES.new_placement(false) support_roles["holds_other_props"] = true support.set_stage_roles(support_roles) support.apply_runtime_role(PropBlock.RuntimeRole.START_STILL) var falling: PropBlock = PROP_BLOCK_SCRIPT.new() falling.position = Vector2(0.0, 55.0) falling.polygon_points = PackedVector2Array([Vector2(-10.0, -10.0), Vector2(10.0, -10.0), Vector2(10.0, 10.0), Vector2(-10.0, 10.0)]) world.add_child(falling) await process_frame var falling_roles: Dictionary = PROP_STAGE_ROLES.new_placement(false) falling_roles["start_motion"] = PROP_STAGE_ROLES.START_PHYSICS falling.set_stage_roles(falling_roles) falling.apply_runtime_role(PropBlock.RuntimeRole.START_PHYSICS) var contact: Dictionary = {"other": false} falling.collided.connect(func(other: Node, _normal: Vector2, _position: Vector2) -> void: if other == support: contact["other"] = true ) for _frame: int in 45: await physics_frame _expect(support.freeze and support.freeze_mode == RigidBody2D.FREEZE_MODE_STATIC, "start still support remains a static frozen collider") _expect(bool(contact["other"]) and falling.get_colliding_bodies().has(support), "dynamic prop makes an ordinary body contact with the frozen support") _expect(falling.global_position.y >= 78.0 and falling.global_position.y <= 82.0, "dynamic prop rests on the frozen support geometry") world.queue_free() await process_frame func _test_set_down_clearance_and_release() -> void: var world := Node2D.new() root.add_child(world) var support: PropBlock = PROP_BLOCK_SCRIPT.new() support.position = Vector2(0.0, 100.0) support.polygon_points = PackedVector2Array([Vector2(-100.0, -50.0), Vector2(100.0, -50.0), Vector2(100.0, 50.0), Vector2(-100.0, 50.0)]) world.add_child(support) await process_frame var support_roles: Dictionary = PROP_STAGE_ROLES.new_placement(false) support_roles["holds_other_props"] = true support.set_stage_roles(support_roles) support.apply_runtime_role(PropBlock.RuntimeRole.START_STILL) var held: PropBlock = PROP_BLOCK_SCRIPT.new() held.polygon_points = PackedVector2Array([Vector2(-10.0, -10.0), Vector2(10.0, -10.0), Vector2(10.0, 10.0), Vector2(-10.0, 10.0)]) world.add_child(held) await process_frame var held_roles: Dictionary = PROP_STAGE_ROLES.new_placement(true) held.set_stage_roles(held_roles) held.apply_runtime_role(PropBlock.RuntimeRole.HELD) var interactor: Node = PROP_INTERACTOR_SCRIPT.new() world.add_child(interactor) await physics_frame var requested := Vector2(0.0, 120.0) var resolved: Dictionary = interactor.call("_resolve_set_down_release_transform", held, requested) _expect(bool(resolved.get("valid", false)), "Set Down raises a point embedded in a support prop") var release_transform: Transform2D = resolved.get("release_transform", Transform2D.IDENTITY) _expect(float(resolved.get("clearance_lift", 0.0)) > 50.0 and release_transform.origin.y < requested.y, "support clearance moves only the release origin upward") _expect(is_equal_approx(release_transform.origin.x, requested.x), "support clearance preserves the clicked horizontal point") var above_support: Vector2 = Vector2(0.0, 25.0) var unchanged: Dictionary = interactor.call("_resolve_set_down_release_transform", held, above_support) var unchanged_transform: Transform2D = unchanged.get("release_transform", Transform2D.IDENTITY) _expect(bool(unchanged.get("valid", false)) and unchanged_transform.origin.is_equal_approx(above_support), "a clear point above the support stays exactly where clicked") var open_air := Vector2(300.0, -200.0) var open_air_result: Dictionary = interactor.call("_resolve_set_down_release_transform", held, open_air) var open_air_transform: Transform2D = open_air_result.get("release_transform", Transform2D.IDENTITY) _expect(bool(open_air_result.get("valid", false)) and open_air_transform.origin.is_equal_approx(open_air), "an open-air release stays exactly where clicked") _expect(held.collision_layer == 0 and held.collision_mask == 0, "Set Down keeps the object non-collidable while held") if bool(resolved.get("valid", false)): held.global_transform = release_transform held.apply_runtime_role(PropBlock.RuntimeRole.RELEASED) held.sleeping = false for _frame: int in 45: await physics_frame _expect(held.runtime_role == PropBlock.RuntimeRole.RELEASED and (held.collision_layer & STAGE_PHYSICS.PROP_LAYER) != 0, "release enables the configured prop collisions") _expect(held.get_colliding_bodies().has(support), "released object falls onto the support") _expect(absf(held.global_position.y - 40.0) <= 3.0, "released object settles on top of the crate") interactor.queue_free() world.queue_free() await process_frame func _test_set_down_low_reach_pose() -> void: var rig := STICKMAN_RIG_SCENE.instantiate() as Node2D root.add_child(rig) await physics_frame var interactor := rig.get_node(NodePath("RigComponents/PropInteractor")) var torso_marker := rig.get_node(NodePath("IK_Targets/Torso")) as Marker2D var upper_leg := rig.get_node(NodePath("Skeleton2D/Torso/RightUpperLeg")) as Bone2D var start_position := torso_marker.position var start_rotation := torso_marker.rotation var start_leg_rotation := upper_leg.rotation interactor.set("_set_down_facing_left", false) interactor.call("_prepare_set_down_crouch", rig.to_global(Vector2(60.0, 430.0))) interactor.call("_update_pickup_crouch", 0.5) await physics_frame _expect(torso_marker.position.y > start_position.y and torso_marker.rotation != start_rotation, "low Set Down reach lowers and leans the torso smoothly") _expect(absf(wrapf(upper_leg.rotation - start_leg_rotation, -PI, PI)) > 0.01, "the lowered torso lets leg IK bend a knee") interactor.call("_update_pickup_crouch", 0.0) await physics_frame _expect(torso_marker.position.is_equal_approx(start_position), "Set Down body recovery returns the torso to its starting pose") rig.queue_free() await process_frame func _test_set_down_preserves_pickup_orientation() -> void: var world := Node2D.new() root.add_child(world) var prop: PropBlock = PROP_BLOCK_SCRIPT.new() world.add_child(prop) await process_frame prop.global_transform = Transform2D(PI * 0.5, Vector2(0.0, -100.0)) prop.set_stage_roles(PROP_STAGE_ROLES.new_placement(true)) prop.apply_runtime_role(PropBlock.RuntimeRole.HELD) var pickup_transform := prop.global_transform var interactor: Node = PROP_INTERACTOR_SCRIPT.new() world.add_child(interactor) interactor.set("_held_prop", prop) interactor.set("_pickup_transform", pickup_transform) interactor.set("_pickup_transform_valid", true) prop.global_transform = Transform2D(0.0, Vector2(0.0, -100.0)) var resolved: Dictionary = interactor.call("_resolve_set_down_release_transform", prop, Vector2(1000.0, -300.0)) var resolved_transform: Transform2D = resolved.get("release_transform", Transform2D.IDENTITY) _expect(bool(resolved.get("valid", false)), "orientation-preserving Set Down target remains valid") _expect(absf(wrapf(resolved_transform.get_rotation() - pickup_transform.get_rotation(), -PI, PI)) < 0.001, "Set Down restores the prop's pickup orientation") interactor.set("_held_anchor", {"position": Vector2(20.0, 0.0)}) interactor.set("_set_down_facing_left", false) interactor.set("_set_down_reach_start_transform", Transform2D(PI, Vector2.ZERO)) var hand_position := Vector2(80.0, 120.0) var eased_transform: Transform2D = interactor.call("_set_down_reach_attachment_transform", hand_position, 1.0) _expect(absf(wrapf(eased_transform.get_rotation() - pickup_transform.get_rotation(), -PI, PI)) < 0.001, "Set Down reach smoothly returns the prop to its pickup rotation") _expect((eased_transform * Vector2(20.0, 0.0)).distance_to(hand_position) < 0.001, "restored prop orientation keeps its grip anchor at the hand") interactor.queue_free() world.queue_free() await process_frame func _test_set_down_prop_follows_approaching_actor() -> void: var rig := STICKMAN_RIG_SCENE.instantiate() as Node2D root.add_child(rig) await physics_frame var prop: PropBlock = PROP_BLOCK_SCRIPT.new() prop.polygon_points = PackedVector2Array([Vector2(-20.0, -20.0), Vector2(20.0, -20.0), Vector2(20.0, 20.0), Vector2(-20.0, 20.0)]) prop.position = Vector2(1400.0, 900.0) root.add_child(prop) await process_frame prop.set_stage_roles(PROP_STAGE_ROLES.new_placement(true)) prop.apply_runtime_role(PropBlock.RuntimeRole.HELD) var interactor := rig.get_node(NodePath("RigComponents/PropInteractor")) interactor.set("_held_prop", prop) interactor.set("_held_anchor", {"position": Vector2.ZERO}) interactor.set("_phase", PROP_INTERACTOR_SCRIPT.Phase.SET_DOWN_APPROACHING) var before := prop.global_position interactor.call("tick", 0.016) _expect(prop.global_position.distance_to(before) > 500.0, "a held prop follows the hand while the actor walks to Set Down") _expect(prop.runtime_role == PropBlock.RuntimeRole.HELD and prop.collision_layer == 0 and prop.collision_mask == 0, "the following prop remains collision-free during the approach") interactor.set("_held_prop", null) interactor.set("_phase", PROP_INTERACTOR_SCRIPT.Phase.IDLE) prop.queue_free() rig.queue_free() await process_frame