straywild/tests/fishing_multiplayer_validation.gd

536 lines
18 KiB
GDScript

extends SceneTree
const MainScene = preload("res://main/main.tscn")
const FishingSpotType = preload("res://fishing/fishing_spot.gd")
const TEST_SEED: int = 864209
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var arguments: PackedStringArray = OS.get_cmdline_user_args()
if arguments.has("host"):
await _run_host()
return
if arguments.has("client"):
await _run_client()
return
push_error("Fishing multiplayer validation requires host or client mode.")
quit(1)
func _run_host() -> void:
var main: Node = await _create_initialized_main()
assert(bool(main.call(
"_apply_world",
WorldLayout.STARTER_ISLAND,
TEST_SEED,
true,
)))
assert(bool(main.call(
"_prepare_host_world",
WorldLayout.STARTER_ISLAND,
TEST_SEED,
)))
assert(bool(main.call("_prepare_private_host")))
var save_manager := main.get("_save_manager") as PlayerSaveManager
assert(save_manager.initialize_new_game(
TEST_SEED,
WorldLayout.STARTER_ISLAND,
))
main.call("_enter_gameplay")
var session := main.get_node("%NetworkSession") as NetworkSession
assert(session.set_host_open(true))
var service := main.get_node(
"%NetworkFishingService"
) as NetworkFishingService
var join_deadline: int = Time.get_ticks_msec() + 60000
while (
Time.get_ticks_msec() < join_deadline
and session.get_authenticated_peer_ids().size() < 2
):
await process_frame
assert(session.get_authenticated_peer_ids().size() == 2)
var remote_peer_id: int = 0
for peer_id: int in session.get_authenticated_peer_ids():
if peer_id != session.get_local_peer_id():
remote_peer_id = peer_id
break
assert(remote_peer_id > 1)
var spawn_service := main.get_node(
"%PlayerSpawnService"
) as PlayerSpawnService
var network_home := main.get_node(
"%NetworkHomeService"
) as NetworkHomeService
var home_world := main.get_node("%HomeWorldService") as HomeWorldService
var remote_avatar: Player = spawn_service.get_avatar(remote_peer_id)
assert(remote_avatar != null)
assert(remote_avatar.bag.get_quantity(&"worms") == 0)
var remote_record := session.get_peer_record(remote_peer_id)
assert(remote_record != null)
assert(await _wait_for_home(
network_home, remote_record.identity_fingerprint
))
var exterior_spawn: Transform3D = home_world.get_exterior_spawn_transform(
remote_record.identity_fingerprint
)
var exterior_deadline: int = Time.get_ticks_msec() + 5000
while (
Time.get_ticks_msec() < exterior_deadline
and remote_avatar.global_position.distance_to(exterior_spawn.origin) > 0.25
):
await physics_frame
assert(remote_avatar.global_position.distance_to(exterior_spawn.origin) <= 0.25)
# Give the client enough local frames to observe the initial RV-door spawn
# before this test publishes its separate fishing-position teleport.
for _frame: int in 30:
await physics_frame
var cast_setup: Dictionary = await _find_clear_fishing_setup(
main, remote_avatar
)
assert(not cast_setup.is_empty(), "no clear fishing setup was found")
var expected_position: Vector3 = cast_setup["position"]
var expected_direction: Vector3 = cast_setup["direction"]
session.publish_authoritative_teleport(remote_peer_id)
var aiming_deadline: int = Time.get_ticks_msec() + 15000
while (
Time.get_ticks_msec() < aiming_deadline
and int(remote_avatar.get("_fishing_visual_phase"))
!= Player.FishingVisualPhase.CASTING
):
await process_frame
assert(
int(remote_avatar.get("_fishing_visual_phase"))
== Player.FishingVisualPhase.CASTING
)
var remote_position_error := (
remote_avatar.global_position - expected_position
)
remote_position_error.y = 0.0
assert(
remote_position_error.length() <= 0.25,
"remote casting position drifted: %s" % remote_avatar.global_position
)
assert(
remote_avatar.get_facing_direction().dot(expected_direction) > 0.99,
"remote casting facing drifted: %s"
% remote_avatar.get_facing_direction()
)
var saw_remote_attempt: bool = false
var remote_presentation: RemoteFishingPresentation
var attempt_deadline: int = Time.get_ticks_msec() + 12000
while Time.get_ticks_msec() < attempt_deadline:
await process_frame
var attempts: Dictionary = service.get("_attempts")
for peer_id: int in attempts:
if peer_id == session.get_local_peer_id():
continue
var attempt: NetworkFishingAttempt = attempts[peer_id]
assert(attempt.target.is_finite())
assert(attempt.bobber_position.distance_to(attempt.target) <= 0.001)
var presentations: Dictionary = service.get("_remote_presentations")
remote_presentation = presentations.get(peer_id)
assert(remote_presentation != null)
var remote_bobber := remote_presentation.get(
"_bobber"
) as MeshInstance3D
assert(remote_bobber.visible)
saw_remote_attempt = true
if saw_remote_attempt:
break
assert(saw_remote_attempt)
assert(
int(remote_avatar.get("_fishing_visual_phase"))
in [
Player.FishingVisualPhase.RELEASE,
Player.FishingVisualPhase.FISHING,
]
)
var sitting_deadline: int = Time.get_ticks_msec() + 10000
while Time.get_ticks_msec() < sitting_deadline and not remote_avatar.is_sitting():
await process_frame
assert(remote_avatar.is_sitting())
var fishing_deadline: int = Time.get_ticks_msec() + 5000
while (
Time.get_ticks_msec() < fishing_deadline
and int(remote_avatar.get("_fishing_visual_phase"))
!= Player.FishingVisualPhase.FISHING
):
await process_frame
assert(
int(remote_avatar.get("_fishing_visual_phase"))
== Player.FishingVisualPhase.FISHING
)
var remote_animation_player := remote_avatar.get_node(
"Visuals/CharacterRig/AnimationPlayer"
) as AnimationPlayer
var return_deadline: int = Time.get_ticks_msec() + 5000
while (
Time.get_ticks_msec() < return_deadline
and service.has_peer_attempt(remote_peer_id)
):
await process_frame
assert(not service.has_peer_attempt(remote_peer_id))
assert(
int(remote_avatar.get("_fishing_visual_phase"))
== Player.FishingVisualPhase.RETRACT
)
var retract_animation_deadline: int = Time.get_ticks_msec() + 3000
while (
Time.get_ticks_msec() < retract_animation_deadline
and remote_animation_player.current_animation != &"retract_sit"
):
await process_frame
assert(remote_animation_player.current_animation == &"retract_sit")
var retract_completion_deadline: int = Time.get_ticks_msec() + 5000
while (
Time.get_ticks_msec() < retract_completion_deadline
and not remote_avatar.is_retract_visual_complete()
):
await process_frame
assert(remote_avatar.is_retract_visual_complete())
# Give the client that owns the same presentation a frame boundary to finish
# local cleanup before the host intentionally tears down this test session.
for _frame: int in 4:
await physics_frame
print("Fishing multiplayer host validation: PASS")
session.disconnect_session("")
main.queue_free()
for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit()
func _run_client() -> void:
var main: Node = await _create_initialized_main()
main.call("_on_title_join_game_requested", "127.0.0.1:7777")
var session := main.get_node("%NetworkSession") as NetworkSession
var joined: bool = false
var join_deadline: int = Time.get_ticks_msec() + 20000
while Time.get_ticks_msec() < join_deadline:
await process_frame
if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY:
main.call("_confirm_server_trust")
if session.is_joined_client() and bool(main.get("_gameplay_started")):
joined = true
break
assert(joined)
var network_home := main.get_node(
"%NetworkHomeService"
) as NetworkHomeService
var home_world := main.get_node("%HomeWorldService") as HomeWorldService
var local_fingerprint: String = session.get_local_identity_fingerprint()
assert(await _wait_for_home(network_home, local_fingerprint))
var player := main.get("_player") as Player
var exterior_spawn: Transform3D = (
home_world.get_exterior_spawn_transform(local_fingerprint)
)
var home_spawn_deadline: int = Time.get_ticks_msec() + 5000
while (
Time.get_ticks_msec() < home_spawn_deadline
and player.global_position.distance_to(exterior_spawn.origin) > 0.25
):
await physics_frame
assert(player.global_position.distance_to(exterior_spawn.origin) <= 0.25)
var service := main.get_node(
"%NetworkFishingService"
) as NetworkFishingService
var cast_rejections: Array[String] = []
service.local_cast_rejected.connect(
func(message: String) -> void:
cast_rejections.append(message)
)
var fishing_spot := main.get_node("%FishingSpot") as FishingSpotType
var item_catalog := main.get("item_catalog") as ItemCatalog
var worms: ItemData = item_catalog.get_item_by_id(&"worms")
if player.bag.get_quantity(&"worms") == 0:
assert(player.bag.add_item(&"worms", 1))
assert(player.equip_bait(worms))
var worms_before_cast: int = player.bag.get_quantity(&"worms")
var character_animation_player := player.get_node(
"Visuals/CharacterRig/AnimationPlayer"
) as AnimationPlayer
assert(character_animation_player.has_animation(&"retract_sit"))
# The host chooses and publishes a clear target after both RV manifests have
# settled. Waiting for that authoritative teleport also coordinates the
# animation-observation window between these two independent processes.
var placement_deadline: int = Time.get_ticks_msec() + 10000
while (
Time.get_ticks_msec() < placement_deadline
and player.global_position.distance_to(exterior_spawn.origin) <= 1.0
):
await physics_frame
assert(player.global_position.distance_to(exterior_spawn.origin) > 1.0)
for _frame: int in 2:
await physics_frame
var expected_position: Vector3 = player.global_position
var expected_direction: Vector3 = player.get_facing_direction()
player.set_casting_visual()
assert(
int(player.get("_fishing_visual_phase"))
== Player.FishingVisualPhase.CASTING
)
# Keep the transient pull-back pose visible long enough for the separate host
# process to observe it even when a cold validation run is CPU-bound.
for _frame: int in 120:
await physics_frame
player.set_fishing_visual(false)
player.call("_set_sitting", true, true)
assert(player.is_sitting())
for _frame: int in 15:
await physics_frame
assert(fishing_spot.state == FishingSpotType.FishingState.READY)
assert(fishing_spot.get("_active_player") == null)
fishing_spot.call("_begin_aiming", player)
assert(fishing_spot.state == FishingSpotType.FishingState.AIMING_CAST)
assert(player.is_movement_enabled())
assert(
int(player.get("_fishing_visual_phase"))
== Player.FishingVisualPhase.CASTING
)
var cast_charge: float = 0.0
var target: Vector3 = Vector3.ZERO
var cast_origin: Vector3 = fishing_spot.get("_cast_origin_position")
var target_diagnostics: Array[String] = []
for charge_step: int in range(1, 21):
var candidate_charge := float(charge_step) / 20.0
fishing_spot.set("_cast_charge", candidate_charge)
fishing_spot.call("_update_cast_charge", 0.0)
var candidate_target: Vector3 = fishing_spot.get("_cast_target")
var candidate_surface := fishing_spot.get(
"_aim_surface_sample"
) as FishingSurfaceSample
target_diagnostics.append(
"charge=%.2f surface=%s path=%s target=%s" % [
candidate_charge,
candidate_surface.is_fishable(),
fishing_spot.get("_cast_path_is_clear"),
candidate_target,
]
)
if (
candidate_surface.is_fishable()
and bool(fishing_spot.get("_cast_path_is_clear"))
):
cast_charge = candidate_charge
target = candidate_target
break
assert(
cast_charge > 0.0,
(
"no clear fishable test target was found; position=%s facing=%s "
+ "expected=%s diagnostics=%s"
) % [
player.global_position,
player.get_facing_direction(),
expected_direction,
target_diagnostics,
],
)
var evidence: Dictionary = fishing_spot.call("_build_network_evidence")
fishing_spot.set("state", FishingSpotType.FishingState.CASTING)
player.set_movement_enabled(false)
player.set_release_visual()
assert(
int(player.get("_fishing_visual_phase"))
== Player.FishingVisualPhase.RELEASE
)
assert(
not service.request_local_cast(
cast_origin,
target,
cast_charge,
evidence,
).is_empty()
)
var accepted_deadline: int = Time.get_ticks_msec() + 6000
while (
Time.get_ticks_msec() < accepted_deadline
and fishing_spot.state != FishingSpotType.FishingState.WAITING_FOR_BITE
):
await process_frame
assert(
fishing_spot.state == FishingSpotType.FishingState.WAITING_FOR_BITE,
(
"cast acceptance timed out: state=%s local_attempt=%s target=%s "
+ "rejections=%s"
) % [
fishing_spot.state,
service.has_local_attempt(),
target,
cast_rejections,
]
)
assert(service.has_local_attempt())
# Bait remains available while the bobber is waiting. It is consumed only
# once the authoritative bite begins.
assert(player.bag.get_quantity(&"worms") == worms_before_cast)
var fishing_deadline: int = Time.get_ticks_msec() + 5000
while (
Time.get_ticks_msec() < fishing_deadline
and int(player.get("_fishing_visual_phase"))
!= Player.FishingVisualPhase.FISHING
):
await process_frame
assert(
int(player.get("_fishing_visual_phase"))
== Player.FishingVisualPhase.FISHING
)
for _frame: int in 30:
await physics_frame
fishing_spot.set("_withdrawal_input_held", true)
fishing_spot.set("_network_primary_input_held", true)
service.submit_local_input(true, true)
var return_deadline: int = Time.get_ticks_msec() + 5000
while (
Time.get_ticks_msec() < return_deadline
and fishing_spot.state != FishingSpotType.FishingState.RETURNING
):
await process_frame
assert(fishing_spot.state == FishingSpotType.FishingState.RETURNING)
assert(
int(player.get("_fishing_visual_phase"))
== Player.FishingVisualPhase.RETRACT
)
var retract_animation_deadline: int = Time.get_ticks_msec() + 3000
while (
Time.get_ticks_msec() < retract_animation_deadline
and character_animation_player.current_animation != &"retract_sit"
):
await process_frame
assert(character_animation_player.current_animation == &"retract_sit")
var bobber := fishing_spot.get_node(
"FishingPresentation/Bobber"
) as MeshInstance3D
assert(bobber.visible)
while not player.is_retract_visual_complete():
assert(fishing_spot.state == FishingSpotType.FishingState.RETURNING)
await process_frame
var ready_deadline: int = Time.get_ticks_msec() + 3000
while (
Time.get_ticks_msec() < ready_deadline
and fishing_spot.state != FishingSpotType.FishingState.READY
):
await process_frame
assert(fishing_spot.state == FishingSpotType.FishingState.READY)
assert(player.is_movement_enabled())
assert(not bobber.visible)
var host_observation_deadline: int = Time.get_ticks_msec() + 2000
while Time.get_ticks_msec() < host_observation_deadline:
await process_frame
print("Fishing multiplayer client validation: PASS")
var host_completion_deadline: int = Time.get_ticks_msec() + 10000
while (
Time.get_ticks_msec() < host_completion_deadline
and session.is_joined_client()
):
await process_frame
if session.is_joined_client():
session.disconnect_session("")
main.queue_free()
for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit()
func _create_initialized_main() -> Node:
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
assert(bool(main.get("_application_initialized")))
return main
func _wait_for_home(
network_home: NetworkHomeService,
fingerprint: String,
) -> bool:
var deadline: int = Time.get_ticks_msec() + 10000
while Time.get_ticks_msec() < deadline:
await process_frame
var manifests: Dictionary = network_home.get(
"_manifests_by_fingerprint"
)
if manifests.has(fingerprint):
return true
return false
func _find_clear_fishing_setup(main: Node, player: Player) -> Dictionary:
var world := main.get_node("TestWorld") as TestWorld
var fishing_spot := main.get_node("%FishingSpot") as FishingSpotType
var regions: Array[FishableWaterRegion] = world.get_fishable_water_regions()
for region: FishableWaterRegion in regions:
var body := region.get_parent() as WaterBodyAuthoring
if body == null or body.surface_size.x <= 0.0 or body.surface_size.y <= 0.0:
continue
var center: Vector3 = region.global_position
var half_size: Vector2 = body.surface_size * 0.5
var candidate_offsets: Array[Vector3] = [
Vector3(half_size.x + 0.8, 0.0, 0.0),
Vector3(-half_size.x - 0.8, 0.0, 0.0),
Vector3(0.0, 0.0, half_size.y + 0.8),
Vector3(0.0, 0.0, -half_size.y - 0.8),
]
for offset: Vector3 in candidate_offsets:
var position := Vector3(
center.x + offset.x,
region.get_surface_height() + 1.44,
center.z + offset.z,
)
var direction: Vector3 = center - position
direction.y = 0.0
direction = direction.normalized()
player.global_position = position
var visuals := player.get_node("Visuals") as Node3D
visuals.global_rotation.y = atan2(-direction.x, -direction.z)
for _frame: int in 2:
await physics_frame
var cast_origin: Vector3 = player.get_cast_origin_position()
var launch_position: Vector3 = player.get_fishing_rod_tip().global_position
for charge_step: int in range(1, 21):
var cast_charge := float(charge_step) / 20.0
var cast_distance := lerpf(
fishing_spot.minimum_cast_distance,
fishing_spot.maximum_cast_distance,
cast_charge,
)
var query_position := cast_origin + direction * cast_distance
var sample: FishingSurfaceSample = (
fishing_spot.resolve_fishing_surface(
query_position, cast_origin.y
)
)
var target: Vector3 = sample.get_bobber_position(
fishing_spot.solid_bobber_clearance
)
if (
sample.is_fishable()
and fishing_spot.is_cast_path_clear(
launch_position, target
)
):
return {
"position": player.global_position,
"direction": direction,
}
return {}