extends SceneTree const MainScene = preload("res://main/main.tscn") var _failures: Array[String] = [] func _initialize() -> void: _run.call_deferred() func _run() -> void: root.size = Vector2i(1280, 720) var main := MainScene.instantiate() root.add_child(main) for _frame: int in 4: await process_frame if not bool(main.get("_application_initialized")): main.call("_activate_selected_data_path", "", true) for _frame: int in 8: await process_frame _expect( bool(main.get("_application_initialized")), "application initialization failed", ) var save_manager := main.get("_save_manager") as PlayerSaveManager var world_layout: StringName = WorldLayout.GENERATED var world_seed: int = PlayerSaveManager.DEFAULT_WORLD_SEED _expect( bool(main.call("_apply_world", world_layout, world_seed, true)), "generated-world preparation failed", ) _expect( bool(main.call("_prepare_host_world", world_layout, world_seed)), "host-world preparation failed", ) _expect( bool(main.call("_prepare_private_host")), "private host preparation failed", ) _expect( save_manager != null and save_manager.initialize_new_game(world_seed, world_layout), "new-game initialization failed", ) main.call("_enter_gameplay") for _frame: int in 8: await process_frame var player := main.get("_player") as Player var interaction := main.get("_shop_interaction") as FishingShopInteraction var decor_interaction := main.get( "_decor_shop_interaction" ) as DecorShopInteraction var game_ui := main.get("_game_ui") as GameUI var chat_service := main.get_node( "%NetworkChatService" ) as NetworkChatService _expect(player != null, "local player is unavailable") _expect(interaction != null, "shop interaction is unavailable") _expect(decor_interaction != null, "decor interaction is unavailable") _expect(game_ui != null, "game UI is unavailable") _expect(chat_service != null, "network chat service is unavailable") if ( player != null and interaction != null and game_ui != null and chat_service != null ): player.global_position = interaction.global_position for _frame: int in 6: await physics_frame _expect( interaction.is_local_player_in_range(), "local player did not enter the shop interaction range", ) var shop_prompt := game_ui.get_node("%ShopPrompt") as Control var shop_message := game_ui.get_node("%ShopPromptMessage") as Label _expect( not bool(game_ui.get("_shop_npc_dialogue_active")) and not shop_message.visible, "shopkeeper spoke before the player initiated conversation", ) _expect( shop_prompt.size == Vector2(30.0, 30.0), "nearby shop prompt was not reduced to the compact interaction glyph", ) var fixed_area_transform := interaction.global_transform var shopkeeper := interaction.get_node("../Shopkeeper") as WorldCharacterDisplay var shopkeeper_start := shopkeeper.position var camera_yaw := player.get_node("CameraYaw") as Node3D var camera_pitch := player.get_node("CameraYaw/CameraPitch") as Node3D var spring_arm := player.get_node( "CameraYaw/CameraPitch/SpringArm3D" ) as SpringArm3D var initial_camera_yaw_transform := camera_yaw.transform var initial_camera_pitch := camera_pitch.rotation var initial_spring_length := spring_arm.spring_length # Route planning treats the player as a live obstacle. Move the validation # avatar clear before forcing an ambient patrol leg. player.global_position = interaction.global_position + Vector3(20.0, 0.0, 0.0) interaction.call("_refresh_local_player_proximity") interaction.call("_choose_roaming_destination") var roaming_destination: Vector3 = interaction.get( "_roaming_destination" ) var safe_route: Dictionary = interaction.call( "_resolve_safe_route_destination", roaming_destination, ) _expect( not safe_route.is_empty(), "shopkeeper accepted a destination that failed its terrain route probe", ) var initial_route_offset := roaming_destination - shopkeeper.position shopkeeper.rotation.y = ( atan2(initial_route_offset.x, initial_route_offset.z) + PI ) interaction.call("_process", 0.05) _expect( shopkeeper.position.is_equal_approx(shopkeeper_start) and not shopkeeper.is_locomotion_walking(), "shopkeeper translated before finishing its turn-in-place", ) # A patrol leg deliberately starts by turning in place. Advance in small # slices until the body is aligned and translation begins. for _turn_frame: int in 20: interaction.call("_process", 0.05) if shopkeeper.position.distance_to(shopkeeper_start) > 0.001: break _expect( interaction.global_transform.is_equal_approx(fixed_area_transform), "ambient shopkeeper roaming moved the authoritative interaction area", ) _expect( shopkeeper.position.distance_to(shopkeeper_start) > 0.001 and shopkeeper.is_locomotion_walking(), "shopkeeper did not walk toward an ambient roaming destination", ) var roaming_direction := ( shopkeeper.position - shopkeeper_start ).normalized() var roaming_origin: Vector3 = interaction.get("_roaming_origin") _expect( roaming_destination.distance_to(shopkeeper_start) >= interaction.minimum_route_distance and roaming_destination.distance_to(shopkeeper_start) <= interaction.maximum_route_distance + 0.001 and roaming_destination.distance_to(roaming_origin) <= interaction.roaming_radius + 0.001 and shopkeeper.basis.z.normalized().dot(roaming_direction) > 0.95, ( "shopkeeper patrol leg was unsafe, too short, or did not face " + "its travel direction" ), ) # Ambient visuals may roam well beyond their original compact trigger, but # the trigger itself must remain fixed for multiplayer authority. Local UI # eligibility follows the moving character, while the host validates a # conservative authored-origin envelope that covers the full patrol. var captured_roaming_origin: Vector3 = interaction.get( "_roaming_origin" ) interaction.roaming_enabled = false shopkeeper.position = captured_roaming_origin + Vector3(2.6, 0.0, 0.0) interaction.setup_local_player(player) _expect( (interaction.get("_roaming_origin") as Vector3).is_equal_approx( captured_roaming_origin ), "rebinding the player recaptured a wandered position as the patrol origin", ) player.global_position = shopkeeper.global_position + Vector3(1.4, 0.0, 0.0) interaction.call("_refresh_local_player_proximity") _expect( interaction.is_local_player_in_range() and interaction.is_avatar_in_range(player), "moving-shopkeeper reach was not covered locally and authoritatively", ) player.global_position = interaction.call("_get_roaming_origin_global") interaction.call("_refresh_local_player_proximity") _expect( not interaction.is_local_player_in_range() and interaction.is_avatar_in_range(player), "local proximity still followed the fixed authored Area3D", ) var authority_radius := interaction.get_authority_interaction_radius() player.global_position = ( interaction.call("_get_roaming_origin_global") + Vector3(authority_radius + 0.1, 0.0, 0.0) ) _expect( not interaction.is_avatar_in_range(player), "shop authority accepted a player beyond the fixed patrol envelope", ) shopkeeper.position = shopkeeper_start interaction.roaming_enabled = true player.global_position = ( shopkeeper.global_position + Vector3(0.0, 0.0, -1.0) ) interaction.call("_refresh_local_player_proximity") _expect( interaction.global_transform.is_equal_approx(fixed_area_transform), "proximity validation moved the authoritative interaction area", ) var character_calls: Array[String] = [] var capture_call := func( _peer_id: int, call_id: String, _pitch_scale: float, ) -> void: character_calls.append(call_id) chat_service.character_call_received.connect(capture_call) var press := InputEventJoypadButton.new() press.device = 0 press.button_index = JOY_BUTTON_Y press.pressed = true _expect( press.is_action_pressed("interact"), "Y does not resolve to interact", ) _expect( press.is_action_pressed("character_call"), "Y does not resolve to character call", ) game_ui.call("_input", press) main.call("_unhandled_input", press) await process_frame _expect( not game_ui.get_fishing_shop().visible and main.get("_engaged_shop_npc_interaction") == interaction and interaction.is_engaged(), "first Y press did not enter shopkeeper dialogue without opening", ) _expect( bool(game_ui.get("_shop_npc_dialogue_active")) and shop_message.visible and shop_message.text == FishingShopInteraction.DIALOGUE_TEXT, "engaged shopkeeper dialogue was not presented", ) _expect( not bool(player.call("_is_movement_input_enabled")) and not bool(player.call("_is_camera_input_enabled")) and player.is_shop_conversation_camera_active(), "shopkeeper dialogue did not lock movement and hold its camera frame", ) _expect( character_calls.is_empty(), "Y played a character call instead of starting shopkeeper dialogue", ) await create_timer(0.5).timeout var player_to_shopkeeper := ( shopkeeper.global_position - player.global_position ) player_to_shopkeeper.y = 0.0 player_to_shopkeeper = player_to_shopkeeper.normalized() _expect( camera_yaw.global_basis.x.normalized().dot(player_to_shopkeeper) > 0.9 and spring_arm.spring_length < initial_spring_length, "conversation camera did not frame player-left and NPC-right", ) _expect( shopkeeper.global_basis.z.normalized().dot( (player.global_position - shopkeeper.global_position).normalized() ) > 0.9 and not shopkeeper.is_locomotion_walking(), "engaged shopkeeper did not stop and face the player", ) main.call("_input", press) await process_frame _expect( game_ui.get_fishing_shop().visible and bool(main.get("_shop_conversation_shop_opened")), "second Y press did not dismiss dialogue and open the shop", ) _expect( main.get("_engaged_shop_npc_interaction") == interaction and not bool(player.call("_is_camera_input_enabled")), "shop opening released the conversation frame before close", ) player.global_position = interaction.global_position + Vector3(20.0, 0.0, 0.0) for _frame: int in 6: await physics_frame for _frame: int in 60: if not game_ui.get_fishing_shop().visible: break await process_frame for _frame: int in 40: if not player.is_shop_conversation_camera_active(): break await process_frame if player.is_shop_conversation_camera_active(): await create_timer(0.55).timeout _expect( not interaction.is_local_player_in_range(), "local player did not leave the shop interaction range", ) _expect( not game_ui.get_fishing_shop().visible, "shop did not close after leaving its interaction range", ) _expect( main.get("_engaged_shop_npc_interaction") == null and bool(player.call("_is_movement_input_enabled")) and bool(player.call("_is_camera_input_enabled")), ( "shop close did not restore conversation controls and camera " + "(engaged=%s movement=%s camera=%s restoring=%s)" ) % [ main.get("_engaged_shop_npc_interaction"), player.call("_is_movement_input_enabled"), player.call("_is_camera_input_enabled"), main.get("_shop_conversation_camera_restoring"), ], ) _expect( camera_yaw.transform.is_equal_approx( initial_camera_yaw_transform ) and camera_pitch.rotation.is_equal_approx(initial_camera_pitch) and is_equal_approx(spring_arm.spring_length, initial_spring_length), "conversation camera did not restore its exact pre-dialogue frame", ) game_ui.call("_input", press) await process_frame _expect( character_calls.size() == 1, "Y did not play a character call away from an interaction", ) # Deliberately overlap two shop margins to validate the transition case. # Whichever character the player is actually closest to must own both the # dialogue/prompt and the interaction button, regardless of shop type. if decor_interaction != null: var fishing_root := interaction.get_parent() as Node3D var decor_root := decor_interaction.get_parent() as Node3D var original_decor_transform := decor_root.global_transform interaction.roaming_enabled = false decor_interaction.roaming_enabled = false (interaction.get_node("../Shopkeeper") as Node3D).position = Vector3.ZERO (decor_interaction.get_node("../Shopkeeper") as Node3D).position = Vector3.ZERO decor_root.global_position = ( fishing_root.global_position + Vector3(1.1, 0.0, 0.0) ) player.global_position = decor_root.global_position for _frame: int in 6: await physics_frame _expect( interaction.is_local_player_in_range() and decor_interaction.is_local_player_in_range(), "overlapping shop transition margins were not established", ) _expect( main.call("_get_active_shop_npc_interaction") == decor_interaction, "nearest decor NPC did not own the overlapping interaction", ) _expect( int(game_ui.get("_shop_npc_source_id")) == decor_interaction.get_instance_id() and shop_message.text == DecorShopInteraction.DECOR_DIALOGUE_TEXT, "decor dialogue did not follow the active NPC handoff", ) player.global_position = ( fishing_root.global_position + Vector3(0.57, 0.0, 0.0) ) for _frame: int in 3: await physics_frame _expect( main.call("_get_active_shop_npc_interaction") == decor_interaction, "shop handoff hysteresis did not stabilize a near-tie", ) player.global_position = ( fishing_root.global_position + Vector3(0.45, 0.0, 0.0) ) for _frame: int in 3: await physics_frame _expect( main.call("_get_active_shop_npc_interaction") == interaction, "shop ownership did not switch after a clear distance lead", ) _expect( int(game_ui.get("_shop_npc_source_id")) == interaction.get_instance_id() and shop_message.text == FishingShopInteraction.DIALOGUE_TEXT, "fishing dialogue did not replace the prior NPC cleanly", ) player.global_position = decor_root.global_position for _frame: int in 3: await physics_frame game_ui.call("_input", press) main.call("_unhandled_input", press) await process_frame _expect( not game_ui.get_decor_shop().visible and main.get("_engaged_shop_npc_interaction") == decor_interaction, "first Y did not engage the nearest decor NPC in an overlap", ) main.call("_input", press) await process_frame _expect( game_ui.get_decor_shop().visible and main.get("_engaged_shop_npc_interaction") == decor_interaction, "second Y did not open the engaged decor shop", ) _expect( character_calls.size() == 1, "Y emitted a character call instead of opening the decor shop", ) game_ui.get_decor_shop().close_shop() for _frame: int in 40: if not player.is_shop_conversation_camera_active(): break await process_frame if player.is_shop_conversation_camera_active(): await create_timer(0.55).timeout player.global_position = fishing_root.global_position for _frame: int in 6: await physics_frame _expect( main.call("_get_active_shop_npc_interaction") == interaction, "shop ownership did not hand back to the nearer fishing NPC", ) decor_root.global_transform = original_decor_transform # Idle visuals can still begin a frame overlapped (for example after two # independently planned routes converge). They must separate without # moving either authoritative Area3D, deadlocking on their reservations, # or waiting for one NPC to already be in the walking state. var fishing_visual := interaction.get_node( "../Shopkeeper" ) as WorldCharacterDisplay var decor_visual := decor_interaction.get_node( "../Shopkeeper" ) as WorldCharacterDisplay var fishing_visual_transform := fishing_visual.global_transform var decor_visual_transform := decor_visual.global_transform var fishing_area_transform := interaction.global_transform var decor_area_transform := decor_interaction.global_transform player.global_position = fishing_visual.global_position + Vector3(20.0, 0.0, 0.0) interaction.roaming_enabled = true decor_interaction.roaming_enabled = true interaction.call("_schedule_idle") decor_interaction.call("_schedule_idle") var crossing_center := fishing_visual.global_position decor_visual.global_position = ( crossing_center + Vector3(1.0, 0.0, -1.0) ) decor_interaction.set( "_roaming_destination", decor_visual.get_parent_node_3d().to_local( crossing_center + Vector3(1.0, 0.0, 1.0) ), ) decor_interaction.set("_roaming_walking", true) _expect( bool(interaction.call( "_route_conflicts_with_reserved_corridors", crossing_center, crossing_center + Vector3(2.0, 0.0, 0.0), )), "crossing shopkeeper route corridors were not reserved", ) decor_interaction.call("_schedule_idle") var fishing_priority: String = interaction.call( "_traffic_priority_key" ) var decor_priority: String = decor_interaction.call( "_traffic_priority_key" ) var blocked_outward_direction := ( Vector3.RIGHT if fishing_priority < decor_priority else Vector3.LEFT ) fishing_visual.global_position = ( interaction.call("_get_roaming_origin_global") + blocked_outward_direction * interaction.roaming_radius ) decor_visual.global_position = fishing_visual.global_position var overlapped_position := fishing_visual.global_position for _separation_frame: int in 80: interaction.call("_process", 0.05) decor_interaction.call("_process", 0.05) var separation := fishing_visual.global_position - decor_visual.global_position separation.y = 0.0 if separation.length() >= interaction.other_shopkeeper_clearance: break var resolved_separation := ( fishing_visual.global_position - decor_visual.global_position ) resolved_separation.y = 0.0 _expect( resolved_separation.length() >= interaction.other_shopkeeper_clearance - 0.02 and fishing_visual.global_position.distance_to(overlapped_position) > 0.1, "idle-overlapped shopkeepers did not separate and resume safely", ) _expect( interaction.global_transform.is_equal_approx(fishing_area_transform) and decor_interaction.global_transform.is_equal_approx( decor_area_transform ), "live shopkeeper separation moved an authoritative Area3D", ) fishing_visual.global_transform = fishing_visual_transform decor_visual.global_transform = decor_visual_transform chat_service.character_call_received.disconnect(capture_call) var session := main.get_node("%NetworkSession") as NetworkSession if session != null: session.disconnect_session("Controller interaction validation complete.") main.queue_free() for _frame: int in 4: await process_frame await create_timer(0.1).timeout if _failures.is_empty(): print("Controller world interaction validation: PASS") quit(0) return for failure: String in _failures: push_error(failure) quit(1) func _expect(condition: bool, message: String) -> void: if not condition: _failures.append(message)