537 lines
19 KiB
GDScript
537 lines
19 KiB
GDScript
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)
|