From 8c2f8715b0b799815ddff22ea79170a2619c3768 Mon Sep 17 00:00:00 2001 From: Voyager Date: Fri, 14 Aug 2026 22:48:59 -0400 Subject: [PATCH] Fix in-game multiplayer session switching --- scripts/run_validations.sh | 91 ++++++++- .../session_switch_multiplayer_validation.gd | 173 ++++++++++++++++++ ...ssion_switch_multiplayer_validation.gd.uid | 1 + ui/network/join_game_page.gd | 46 ++++- ui/pause_menu.gd | 46 +++-- 5 files changed, 341 insertions(+), 16 deletions(-) create mode 100644 tests/session_switch_multiplayer_validation.gd create mode 100644 tests/session_switch_multiplayer_validation.gd.uid diff --git a/scripts/run_validations.sh b/scripts/run_validations.sh index 78f4f08..20712bf 100755 --- a/scripts/run_validations.sh +++ b/scripts/run_validations.sh @@ -60,13 +60,17 @@ readonly -a NETWORK_TESTS=( "tests/world_spawn_multiplayer_validation.gd" ) +readonly SESSION_SWITCH_NETWORK_TEST="tests/session_switch_multiplayer_validation.gd" + cleanup() { rm -rf -- "${RUN_ROOT}" } trap cleanup EXIT INT TERM usage() { - printf 'Usage: %s {quick|full|host|network|all|--list}\n' "$0" + printf \ + 'Usage: %s {quick|full|host|network|session-switch|all|--list}\n' \ + "$0" } prepare_root() { @@ -169,6 +173,85 @@ run_network_test() { fi } +run_session_switch_network_test() { + local script="${SESSION_SWITCH_NETWORK_TEST}" + local name="${script#tests/}" + name="${name%.gd}" + local first_root second_root client_root + local first_pid second_pid first_status second_status client_status + local both_hosts_ready=0 + first_root="$(prepare_root "${name}-first-host")" + second_root="$(prepare_root "${name}-second-host")" + client_root="$(prepare_root "${name}-client")" + printf '\n==> %s (two hosts + client)\n' "${script}" + XDG_DATA_HOME="${first_root}/data" \ + XDG_CONFIG_HOME="${first_root}/config" \ + XDG_CACHE_HOME="${first_root}/cache" \ + timeout "${TEST_TIMEOUT_SECONDS}s" "${GODOT_BIN}" \ + --headless --path "${PROJECT_ROOT}" --script "${script}" -- first_host \ + >"${first_root}/output.log" 2>&1 & + first_pid=$! + XDG_DATA_HOME="${second_root}/data" \ + XDG_CONFIG_HOME="${second_root}/config" \ + XDG_CACHE_HOME="${second_root}/cache" \ + timeout "${TEST_TIMEOUT_SECONDS}s" "${GODOT_BIN}" \ + --headless --path "${PROJECT_ROOT}" --script "${script}" -- second_host \ + >"${second_root}/output.log" 2>&1 & + second_pid=$! + for _attempt in {1..100}; do + if ! kill -0 "${first_pid}" 2>/dev/null \ + || ! kill -0 "${second_pid}" 2>/dev/null; then + break + fi + if ! command -v ss >/dev/null 2>&1; then + sleep 2 + both_hosts_ready=1 + break + fi + if ss -H -lun "sport = :18142" | grep -q . \ + && ss -H -lun "sport = :18143" | grep -q .; then + both_hosts_ready=1 + break + fi + sleep 0.1 + done + if ((both_hosts_ready == 0)); then + kill "${first_pid}" "${second_pid}" 2>/dev/null || true + wait "${first_pid}" 2>/dev/null || true + wait "${second_pid}" 2>/dev/null || true + printf '%s\n' '-- first host output --' + sed -n '1,240p' "${first_root}/output.log" + printf '%s\n' '-- second host output --' + sed -n '1,240p' "${second_root}/output.log" + printf '%s\n' 'error: session-switch hosts did not become ready' >&2 + return 1 + fi + set +e + XDG_DATA_HOME="${client_root}/data" \ + XDG_CONFIG_HOME="${client_root}/config" \ + XDG_CACHE_HOME="${client_root}/cache" \ + timeout "${TEST_TIMEOUT_SECONDS}s" "${GODOT_BIN}" \ + --headless --path "${PROJECT_ROOT}" --script "${script}" -- client \ + >"${client_root}/output.log" 2>&1 + client_status=$? + wait "${first_pid}" + first_status=$? + wait "${second_pid}" + second_status=$? + set -e + printf '%s\n' '-- first host output --' + sed -n '1,240p' "${first_root}/output.log" + printf '%s\n' '-- second host output --' + sed -n '1,240p' "${second_root}/output.log" + printf '%s\n' '-- client output --' + sed -n '1,240p' "${client_root}/output.log" + if ((first_status != 0 || second_status != 0 || client_status != 0)); then + printf 'error: hosts exited %d/%d; client exited %d\n' \ + "${first_status}" "${second_status}" "${client_status}" >&2 + return 1 + fi +} + run_quick() { local test_script for test_script in "${QUICK_TESTS[@]}"; do @@ -196,6 +279,7 @@ run_network() { for test_script in "${NETWORK_TESTS[@]}"; do run_network_test "${test_script}" done + run_session_switch_network_test } list_tests() { @@ -207,6 +291,8 @@ list_tests() { printf ' %s\n' "${HOST_TESTS[@]}" printf '%s\n' 'Paired network tests:' printf ' %s\n' "${NETWORK_TESTS[@]}" + printf '%s\n' 'Two-host session-switch network test:' + printf ' %s\n' "${SESSION_SWITCH_NETWORK_TEST}" } case "${1:-}" in @@ -222,6 +308,9 @@ case "${1:-}" in network) run_network ;; + session-switch) + run_session_switch_network_test + ;; all) run_full run_host diff --git a/tests/session_switch_multiplayer_validation.gd b/tests/session_switch_multiplayer_validation.gd new file mode 100644 index 0000000..68cfcc7 --- /dev/null +++ b/tests/session_switch_multiplayer_validation.gd @@ -0,0 +1,173 @@ +extends SceneTree + +const MainScene: PackedScene = preload("res://main/main.tscn") +const FIRST_HOST_PORT: int = 18142 +const SECOND_HOST_PORT: int = 18143 +const WAIT_SECONDS: int = 20 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var arguments: PackedStringArray = OS.get_cmdline_user_args() + if arguments.has("first_host"): + await _run_host(FIRST_HOST_PORT, "first") + return + if arguments.has("second_host"): + await _run_host(SECOND_HOST_PORT, "second") + return + if arguments.has("client"): + await _run_client() + return + push_error( + "Session switch multiplayer validation needs first_host, " + + "second_host, or client mode." + ) + quit(1) + + +func _run_host(port: int, label: String) -> void: + var main: Node = await _create_initialized_main() + var session := main.get_node("%NetworkSession") as NetworkSession + assert(session.start_dedicated_host(port, 8, "127.0.0.1")) + assert(session.set_host_open(true)) + var remote_peer_id: int = await _wait_for_remote_peer(session) + assert(remote_peer_id > 1) + var disconnect_deadline: int = ( + Time.get_ticks_msec() + WAIT_SECONDS * 1000 + ) + while ( + Time.get_ticks_msec() < disconnect_deadline + and session.is_authenticated_peer(remote_peer_id) + ): + await process_frame + assert(not session.is_authenticated_peer(remote_peer_id)) + print("Session switch %s host validation: PASS" % label) + 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() + var session := main.get_node("%NetworkSession") as NetworkSession + main.call( + "_on_title_join_game_requested", + "127.0.0.1:%d" % FIRST_HOST_PORT, + ) + await _wait_for_join(main, session, FIRST_HOST_PORT) + var game_ui := main.get("_game_ui") as GameUI + var pause_menu := game_ui.get_pause_menu() + var join_page := pause_menu.get_node("%JoinGamePage") as JoinGamePage + var discovery := main.get_node("%DiscoveryClient") as DiscoveryClient + pause_menu.show() + (pause_menu.get_node("%RootPage") as Control).hide() + join_page.open_page() + join_page.set("_mode", JoinGamePage.Mode.DISCOVER) + var rooms: Array[Dictionary] = [{ + "room_id": "deferred-public-room", + "room_name": "Deferred public room", + "address": "127.0.0.1", + "port": SECOND_HOST_PORT, + "current_players": 0, + "max_players": 8, + }] + join_page.set("_discovery_rooms", rooms) + join_page.set("_selected_discovery_index", 0) + assert(bool(join_page.get("_gameplay_context"))) + assert( + str( + (join_page.call("_selected_discovery_room") as Dictionary).get( + "room_id", "" + ) + ) == "deferred-public-room" + ) + join_page.call("_request_join") + assert(not bool(discovery.get("_join_request_in_flight"))) + assert( + str( + (join_page.get("_pending_confirmation_room") as Dictionary).get( + "room_id", "" + ) + ) == "deferred-public-room" + ) + assert( + int(pause_menu.get("_confirmation_action")) + == PauseMenu.ConfirmationAction.JOIN_ANOTHER + ) + join_page.cancel_pending_join_confirmation() + (pause_menu.get_node("%ConfirmationPage") as Control).hide() + pause_menu.set("_confirmation_action", PauseMenu.ConfirmationAction.NONE) + pause_menu.set("_confirmation_returns_to_join_game", false) + join_page.open_page("127.0.0.1:%d" % SECOND_HOST_PORT) + join_page.set("_mode", JoinGamePage.Mode.DIRECT) + join_page.call("_request_join") + assert( + int(pause_menu.get("_confirmation_action")) + == PauseMenu.ConfirmationAction.JOIN_ANOTHER + ) + assert(not join_page.visible) + assert((pause_menu.get_node("%ConfirmationPage") as Control).visible) + pause_menu.call( + "_finish_confirmation_accept", + PauseMenu.ConfirmationAction.JOIN_ANOTHER, + ) + await _wait_for_join(main, session, SECOND_HOST_PORT) + assert(bool(main.get("_gameplay_started"))) + print("Session switch multiplayer client validation: PASS") + session.disconnect_session("") + main.queue_free() + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout + quit() + + +func _wait_for_join( + main: Node, + session: NetworkSession, + expected_port: int, +) -> void: + var deadline: int = Time.get_ticks_msec() + WAIT_SECONDS * 1000 + while Time.get_ticks_msec() < deadline: + await process_frame + if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY: + main.call("_confirm_server_trust") + var endpoint: ConnectionEndpoint = session.get_current_endpoint() + if ( + session.is_joined_client() + and bool(main.get("_gameplay_started")) + and endpoint != null + and endpoint.port == expected_port + ): + return + assert(false, "Timed out joining UDP %d." % expected_port) + + +func _wait_for_remote_peer(session: NetworkSession) -> int: + var deadline: int = Time.get_ticks_msec() + WAIT_SECONDS * 1000 + while Time.get_ticks_msec() < deadline: + await process_frame + for peer_id: int in session.get_authenticated_peer_ids(): + if peer_id > 1: + return peer_id + return 0 + + +func _create_initialized_main() -> Node: + root.size = Vector2i(1280, 720) + var main: Node = 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 diff --git a/tests/session_switch_multiplayer_validation.gd.uid b/tests/session_switch_multiplayer_validation.gd.uid new file mode 100644 index 0000000..6e6f24c --- /dev/null +++ b/tests/session_switch_multiplayer_validation.gd.uid @@ -0,0 +1 @@ +uid://bkuih0ht2vny3 diff --git a/ui/network/join_game_page.gd b/ui/network/join_game_page.gd index ca3fbe5..d985ebd 100644 --- a/ui/network/join_game_page.gd +++ b/ui/network/join_game_page.gd @@ -2,6 +2,7 @@ class_name JoinGamePage extends Control signal join_requested(endpoint: String) +signal join_confirmation_requested(endpoint: String) signal back_requested enum Mode { @@ -63,6 +64,8 @@ var _editing_entry_id: String = "" var _name_entry_active: bool = false var _delete_armed: bool = false var _connection_error_latched: bool = false +var _pending_confirmation_endpoint: String = "" +var _pending_confirmation_room: Dictionary = {} func _ready() -> void: @@ -189,6 +192,11 @@ func open_page(preserved_endpoint: String = "") -> void: func close_page() -> void: + cancel_pending_join_confirmation() + hide_for_join_confirmation() + + +func hide_for_join_confirmation() -> void: _clear_edit_state() _discovery_refresh_timer.stop() hide() @@ -206,6 +214,28 @@ func set_status(message: String) -> void: _set_status(_friendly_connection_message(message), true) +func cancel_pending_join_confirmation() -> void: + _pending_confirmation_endpoint = "" + _pending_confirmation_room.clear() + + +func confirm_pending_join() -> bool: + if _pending_confirmation_endpoint.is_empty(): + _set_status("No server is waiting to be joined.", true) + return false + var endpoint_text: String = _pending_confirmation_endpoint + var room: Dictionary = _pending_confirmation_room.duplicate(true) + cancel_pending_join_confirmation() + if not room.is_empty(): + if _discovery == null or not _discovery.prepare_public_join(room): + _set_status("Could not prepare the public connection.", true) + return false + return true + _set_status("Connecting…") + join_requested.emit(endpoint_text) + return true + + func _set_mode(mode: Mode, clear_connection_error: bool = true) -> void: if clear_connection_error: _connection_error_latched = false @@ -236,6 +266,7 @@ func _request_join() -> void: ]: _network_session.reset_failure() var endpoint_text: String = _address.text + var discovery_room: Dictionary = {} if _mode == Mode.DISCOVER: var room: Dictionary = _selected_discovery_room() if _discovery != null and _discovery.is_own_room(room): @@ -244,15 +275,26 @@ func _request_join() -> void: if _discovery_room_is_full(room): _set_status("That room is full.", true) return - if _discovery == null or not _discovery.prepare_public_join(room): + if _discovery == null: _set_status("Could not prepare the public connection.", true) - return + return + endpoint_text = _discovery.room_endpoint(room) + discovery_room = room.duplicate(true) elif _mode != Mode.DIRECT and _selected_entry != null: endpoint_text = _selected_entry.normalized_endpoint var endpoint: ConnectionEndpoint = EndpointParser.parse(endpoint_text) if not endpoint.is_valid(): _set_status(endpoint.error_message, true) return + if _gameplay_context: + _pending_confirmation_endpoint = endpoint.normalized_display + _pending_confirmation_room = discovery_room + join_confirmation_requested.emit(endpoint.normalized_display) + return + if not discovery_room.is_empty(): + if not _discovery.prepare_public_join(discovery_room): + _set_status("Could not prepare the public connection.", true) + return _set_status("Connecting…") _address.text = endpoint.normalized_display join_requested.emit(endpoint.normalized_display) diff --git a/ui/pause_menu.gd b/ui/pause_menu.gd index dccac73..f98b971 100644 --- a/ui/pause_menu.gd +++ b/ui/pause_menu.gd @@ -73,7 +73,7 @@ var _action_in_progress: bool = false var _root_transition_active: bool = false var _root_transition_generation: int = 0 var _closing_menu: bool = false -var _pending_join_endpoint: String = "" +var _confirmation_returns_to_join_game: bool = false func _ready() -> void: @@ -92,7 +92,10 @@ func _ready() -> void: _settings_panel.navigation_transition_started.connect( _emit_transition_flurry ) - _join_game_page.join_requested.connect(_on_join_game_requested) + _join_game_page.join_requested.connect(_on_confirmed_join_requested) + _join_game_page.join_confirmation_requested.connect( + _on_join_confirmation_requested + ) _join_game_page.back_requested.connect(_close_join_game) _confirmation_page.hide_page() resized.connect(_update_responsive_pause_stage) @@ -139,6 +142,7 @@ func open_menu() -> void: _join_game_page.close_page() _confirmation_page.hide_page() _confirmation_action = ConfirmationAction.NONE + _confirmation_returns_to_join_game = false _action_in_progress = false _root_page.hide_page() show() @@ -238,20 +242,29 @@ func _close_join_game() -> void: _begin_root_entry(true) -func _on_join_game_requested(endpoint: String) -> void: +func _on_join_confirmation_requested(_endpoint: String) -> void: if _action_in_progress or _root_transition_active: + _join_game_page.cancel_pending_join_confirmation() return - _pending_join_endpoint = endpoint - _open_confirmation( - ConfirmationAction.JOIN_ANOTHER, - "join another game?", + _confirmation_action = ConfirmationAction.JOIN_ANOTHER + _confirmation_returns_to_join_game = true + _confirmation_page.configure( ( - "your progression will be saved first. " - + "current players will be disconnected if you are hosting." + "join another game?\n\n" + + "your progression will be saved first. current players will be " + + "disconnected if you are hosting." ), "save and join", - false + "cancel", + BubbleConfirmationPageType.InitialFocus.CONFIRM, ) + _join_game_page.hide_for_join_confirmation() + _emit_transition_flurry() + _show_confirmation() + + +func _on_confirmed_join_requested(endpoint: String) -> void: + join_game_requested.emit(endpoint) func report_network_error(message: String) -> void: @@ -340,6 +353,7 @@ func _open_confirmation( ) -> void: if _action_in_progress or _root_transition_active: return + _confirmation_returns_to_join_game = false _confirmation_action = action _confirmation_page.configure( "%s\n\n%s" % [title, message], @@ -380,6 +394,11 @@ func _close_confirmation() -> void: func _finish_confirmation_cancel() -> void: + if _confirmation_returns_to_join_game: + _confirmation_returns_to_join_game = false + _join_game_page.cancel_pending_join_confirmation() + _join_game_page.open_page() + return _begin_root_entry(true) @@ -414,9 +433,9 @@ func _finish_confirmation_accept(action: ConfirmationAction) -> void: ConfirmationAction.QUIT_ANYWAY: quit_requested.emit() ConfirmationAction.JOIN_ANOTHER: - _join_game_page.close_page() - join_game_requested.emit(_pending_join_endpoint) - _pending_join_endpoint = "" + _confirmation_returns_to_join_game = false + _join_game_page.open_page() + _join_game_page.confirm_pending_join() _: _action_in_progress = false _begin_root_entry(false) @@ -507,6 +526,7 @@ func _finish_close(reason: CloseReason, restore_controls: bool) -> void: _root_page.hide_page() _confirmation_page.hide_page() _confirmation_action = ConfirmationAction.NONE + _confirmation_returns_to_join_game = false _action_in_progress = false _transition_flurry.clear_flurries() hide()