diff --git a/main/main.gd b/main/main.gd index af27fed..e028509 100644 --- a/main/main.gd +++ b/main/main.gd @@ -334,6 +334,7 @@ func _start_dedicated_server() -> void: if not _network_session.set_host_open(true): _fail_dedicated_server("Could not open the dedicated server.") return + _player_jobs.begin_progression_session() if config.public_listing and not _discovery.set_discoverable(true): _network_session.disconnect_session("Discovery setup failed.") _fail_dedicated_server(_discovery.get_host_status_message()) @@ -560,7 +561,7 @@ func _initialize_application(dedicated: bool) -> void: _network_player_list.set_surface_drawing_service( _network_surface_drawing ) - _network_chat.setup(_network_session) + _network_chat.setup(_network_session, _world_time, _world_weather) _network_fishing.setup( _network_session, _player_spawn_service, @@ -1646,14 +1647,16 @@ func _handle_failed_session_switch(message: String) -> void: _show_title_music(true) -func _on_network_server_lost() -> void: +func _on_network_server_lost(message: String) -> void: if not _gameplay_started: return + _join_requested_from_title = false + _join_requested_from_pause = false + _pending_join_endpoint = "" _set_gameplay_active(false) var title_screen: TitleScreenType = _game_ui.get_title_screen() - title_screen.reopen() - title_screen.open_join_game_page(_pending_join_endpoint) - title_screen.report_network_error("The server connection was lost.") + title_screen.reopen_to_menu() + title_screen.report_network_error(message) _show_title_music(true) diff --git a/network/network_chat_service.gd b/network/network_chat_service.gd index 368c529..50c47d3 100644 --- a/network/network_chat_service.gd +++ b/network/network_chat_service.gd @@ -5,6 +5,7 @@ const BURST_COUNT: int = 3 const WINDOW_COUNT: int = 5 const WINDOW_SECONDS: float = 10.0 const CALL_COOLDOWN_MILLISECONDS: int = 180 +const WORLD_COMMAND_COOLDOWN_MILLISECONDS: int = 500 const CALL_PITCH_VARIANTS: Array[float] = [ 0.96, 1.03, @@ -22,6 +23,7 @@ signal message_received(message: Dictionary) signal local_message_confirmed(message: Dictionary) signal send_rejected(message: String) signal history_replaced(messages: Array[Dictionary]) +signal world_command_finished(success: bool, message: String) signal character_call_received( peer_id: int, call_id: String, @@ -35,13 +37,22 @@ var _request_ledgers: Dictionary[int, Dictionary] = {} var _rate_times: Dictionary[int, Array] = {} var _last_call_msec: Dictionary[int, int] = {} var _call_variant_indices: Dictionary[int, int] = {} +var _last_world_command_msec: Dictionary[int, int] = {} var _sequence: int = 0 var _peer_names: Dictionary[int, String] = {} var _relationships: PlayerRelationshipStore +var _world_time: WorldTimeService +var _world_weather: WorldWeatherService -func setup(session: NetworkSession) -> void: +func setup( + session: NetworkSession, + world_time: WorldTimeService, + world_weather: WorldWeatherService, +) -> void: _session = session + _world_time = world_time + _world_weather = world_weather _session.peer_authenticated.connect(_on_peer_authenticated) _session.peer_removed.connect(_on_peer_removed) _session.state_changed.connect(_on_session_state_changed) @@ -242,6 +253,188 @@ func broadcast_system_message(body: String) -> bool: return true +func request_world_time_change(phase_name: String) -> bool: + var normalized: String = phase_name.strip_edges().to_lower() + if not _valid_time_phase(normalized) or _session == null: + return false + if _session.is_host(): + return _apply_world_time_change(normalized) + if ( + not _session.is_joined_client() + or not _session.is_local_operator() + or not _session.supports_server_capability( + NetworkProtocol.WORLD_TIME_CAPABILITY + ) + ): + return false + submit_world_time_command.rpc_id(1, normalized) + return true + + +func request_world_weather_change(weather_name: String) -> bool: + var normalized: String = _normalized_weather_name(weather_name) + if normalized.is_empty() or _session == null: + return false + if _session.is_host(): + return _apply_world_weather_change(normalized) + if ( + not _session.is_joined_client() + or not _session.is_local_operator() + or not _session.supports_server_capability( + NetworkProtocol.WORLD_WEATHER_CAPABILITY + ) + ): + return false + submit_world_weather_command.rpc_id(1, normalized) + return true + + +@rpc("any_peer", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL) +func submit_world_time_command(phase_name: String) -> void: + var sender_id: int = multiplayer.get_remote_sender_id() + if not _valid_world_command_sender(sender_id): + _send_world_command_result( + sender_id, false, "Only the host or an operator can change world time." + ) + return + if not _consume_world_command_rate(sender_id): + _send_world_command_result(sender_id, false, "Slow down.") + return + var success: bool = _apply_world_time_change( + phase_name.strip_edges().to_lower() + ) + _send_world_command_result( + sender_id, + success, + "" if success else "World time could not be changed.", + ) + + +@rpc("any_peer", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL) +func submit_world_weather_command(weather_name: String) -> void: + var sender_id: int = multiplayer.get_remote_sender_id() + if not _valid_world_command_sender(sender_id): + _send_world_command_result( + sender_id, + false, + "Only the host or an operator can change world weather.", + ) + return + if not _consume_world_command_rate(sender_id): + _send_world_command_result(sender_id, false, "Slow down.") + return + var success: bool = _apply_world_weather_change( + _normalized_weather_name(weather_name) + ) + _send_world_command_result( + sender_id, + success, + "" if success else "World weather could not be changed.", + ) + + +@rpc("authority", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL) +func receive_world_command_result(success: bool, message: String) -> void: + if _session == null or not _session.is_joined_client(): + return + world_command_finished.emit(success, message.left(120)) + + +func _valid_world_command_sender(peer_id: int) -> bool: + return ( + _session != null + and _session.is_host() + and peer_id > 1 + and _session.is_authenticated_peer(peer_id) + and _session.is_peer_operator(peer_id) + ) + + +func _apply_world_time_change(phase_name: String) -> bool: + if _world_time == null or not _valid_time_phase(phase_name): + return false + var target_hour: float = _time_phase_hour(phase_name) + if not _world_time.set_authoritative_time(target_hour): + return false + broadcast_system_message( + "World time set to %s (%s)." + % [phase_name, _world_time.get_clock_text()] + ) + return true + + +func _apply_world_weather_change(weather_name: String) -> bool: + if _world_weather == null: + return false + var normalized: String = _normalized_weather_name(weather_name) + if normalized.is_empty(): + return false + var target_weather: WorldWeatherService.Weather = ( + _weather_for_name(normalized) + ) + if not _world_weather.set_authoritative_weather(target_weather): + return false + broadcast_system_message("World weather set to %s." % normalized) + return true + + +func _send_world_command_result( + peer_id: int, + success: bool, + message: String, +) -> void: + if peer_id > 1 and _session.is_authenticated_peer(peer_id): + receive_world_command_result.rpc_id(peer_id, success, message.left(120)) + + +func _consume_world_command_rate(peer_id: int) -> bool: + var now_msec: int = Time.get_ticks_msec() + var last_msec: int = _last_world_command_msec.get( + peer_id, now_msec - WORLD_COMMAND_COOLDOWN_MILLISECONDS + ) + if now_msec - last_msec < WORLD_COMMAND_COOLDOWN_MILLISECONDS: + return false + _last_world_command_msec[peer_id] = now_msec + return true + + +static func _valid_time_phase(phase_name: String) -> bool: + return phase_name in ["dawn", "day", "dusk", "night"] + + +static func _time_phase_hour(phase_name: String) -> float: + match phase_name: + "dawn": + return WorldTimeService.DAWN_START_HOUR + "day": + return WorldTimeService.DAWN_END_HOUR + "dusk": + return WorldTimeService.DUSK_START_HOUR + "night": + return WorldTimeService.DUSK_END_HOUR + return -1.0 + + +static func _normalized_weather_name(weather_name: String) -> String: + var normalized: String = weather_name.strip_edges().to_lower() + return "clear" if normalized == "sunny" else normalized if normalized in [ + "clear", "cloudy", "rainy", "foggy" + ] else "" + + +static func _weather_for_name( + weather_name: String, +) -> WorldWeatherService.Weather: + match weather_name: + "cloudy": + return WorldWeatherService.Weather.CLOUDY + "rainy": + return WorldWeatherService.Weather.RAINY + "foggy": + return WorldWeatherService.Weather.FOGGY + return WorldWeatherService.Weather.SUNNY + + func get_history() -> Array[Dictionary]: var result: Array[Dictionary] = [] for message: Dictionary in _history: @@ -422,6 +615,7 @@ func _on_peer_removed(peer_id: int) -> void: _rate_times.erase(peer_id) _last_call_msec.erase(peer_id) _call_variant_indices.erase(peer_id) + _last_world_command_msec.erase(peer_id) if not _session.is_host(): return var display_name: String = _peer_names.get(peer_id, "Player") @@ -481,6 +675,7 @@ func _on_session_state_changed(state: NetworkSession.State) -> void: _rate_times.clear() _last_call_msec.clear() _call_variant_indices.clear() + _last_world_command_msec.clear() _peer_names.clear() _sequence = 0 history_replaced.emit([]) diff --git a/network/network_session.gd b/network/network_session.gd index d12e113..076acc5 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -34,7 +34,7 @@ signal server_trust_required( ) signal peer_identity_observed(peer_id: int, status: String) signal operator_status_changed(peer_id: int, is_operator: bool) -signal server_lost +signal server_lost(message: String) signal remote_recovery_requested(peer_id: int, entry_position: Vector3) signal remote_recovery_presentation_changed( peer_id: int, @@ -875,7 +875,7 @@ func _on_server_disconnected() -> void: ) _moderation_disconnect_message = "" _set_state(State.SERVER_LOST, message) - server_lost.emit() + server_lost.emit(message) connection_error.emit(message) @@ -1108,7 +1108,6 @@ func submit_client_identity_proof(data: Dictionary) -> void: @rpc("authority", "call_remote", "reliable", 0) func receive_moderation_disconnect(message: String) -> void: _moderation_disconnect_message = message.left(120) - connection_error.emit(_moderation_disconnect_message) @rpc("authority", "call_remote", "reliable", 0) diff --git a/tests/job_multiplayer_validation.gd b/tests/job_multiplayer_validation.gd index eaaf7b6..c31d392 100644 --- a/tests/job_multiplayer_validation.gd +++ b/tests/job_multiplayer_validation.gd @@ -23,13 +23,12 @@ func _run() -> void: func _run_host() -> void: var main: Node = await _create_initialized_main() var session := main.get_node("%NetworkSession") as NetworkSession - var save_manager := main.get("_save_manager") as PlayerSaveManager var jobs := main.get_node("%PlayerJobService") as PlayerJobService - assert(session.start_private_host(TEST_PORT)) - assert(save_manager.initialize_new_game()) - main.call("_enter_gameplay") + assert(session.start_dedicated_host(TEST_PORT, 8, "127.0.0.1")) + jobs.begin_progression_session() await process_frame assert(session.set_host_open(true)) + assert(session.is_dedicated_host()) var host_board: Dictionary = jobs.get_host_board_network_data() assert(PlayerJobService.validate_board(host_board)) diff --git a/tests/operator_multiplayer_validation.gd b/tests/operator_multiplayer_validation.gd index 5847aa3..3712fd4 100644 --- a/tests/operator_multiplayer_validation.gd +++ b/tests/operator_multiplayer_validation.gd @@ -88,6 +88,11 @@ func _run_host() -> void: entry.revision, )) assert(not session.is_peer_operator(remote_peer_id)) + await create_timer(2.0).timeout + assert(session.kick_authenticated_peer( + remote_peer_id, + remote_record.identity_fingerprint, + )) var disconnect_deadline: int = Time.get_ticks_msec() + 8000 while ( @@ -173,9 +178,25 @@ func _run_client() -> void: assert(not (tabs.get_child(2) as Button).visible) assert(int(players_page.get("_current_tab")) == 0) service.request_unban.rpc_id(1, SECOND_BANNED_FINGERPRINT) - await create_timer(1.0).timeout + var disconnect_deadline: int = Time.get_ticks_msec() + 10000 + while ( + Time.get_ticks_msec() < disconnect_deadline + and session.state != NetworkSession.State.SERVER_LOST + ): + await process_frame + assert(session.state == NetworkSession.State.SERVER_LOST) + assert(not bool(main.get("_gameplay_started"))) + var game_ui := main.get_node("%GameUI") as GameUI + var title_screen := game_ui.get_title_screen() + assert(title_screen.visible) + assert(not title_screen.is_awaiting_start_input()) + assert((title_screen.get_node("%Center") as Control).visible) + assert((title_screen.get_node("%ButtonCenter") as Control).visible) + assert(not (title_screen.get_node("%JoinGamePage") as Control).visible) + var feedback := title_screen.get_node("%FeedbackLabel") as RichTextLabel + assert(feedback.visible) + assert("removed by the host" in feedback.text.to_lower()) print("Operator multiplayer client validation: PASS") - session.disconnect_session("") main.queue_free() for _frame: int in 4: await process_frame diff --git a/tests/world_time_multiplayer_validation.gd b/tests/world_time_multiplayer_validation.gd index 3d1c025..cebb0eb 100644 --- a/tests/world_time_multiplayer_validation.gd +++ b/tests/world_time_multiplayer_validation.gd @@ -3,7 +3,7 @@ extends SceneTree const MainScene = preload("res://main/main.tscn") const TEST_PORT: int = 17983 const INITIAL_HOST_TIME: float = 19.75 -const UPDATED_HOST_TIME: float = 20.75 +const UPDATED_HOST_TIME: float = WorldTimeService.DUSK_END_HOUR const TIME_TOLERANCE_HOURS: float = 0.05 @@ -61,14 +61,31 @@ func _run_host() -> void: assert(session.peer_supports_capability( remote_peer_id, NetworkProtocol.WORLD_WEATHER_CAPABILITY )) + var remote_record: PeerRegistry.PeerRecord = session.get_peer_record( + remote_peer_id + ) + assert(remote_record != null and remote_record.identity_authenticated) + assert(session.set_peer_operator( + remote_peer_id, + remote_record.identity_fingerprint, + true, + )) assert(world_time.get_phase() == WorldTimeService.Phase.DUSK) - await create_timer(1.0).timeout - world_time.synchronize_time(UPDATED_HOST_TIME) + var command_deadline: int = Time.get_ticks_msec() + 10000 + while ( + Time.get_ticks_msec() < command_deadline + and _wrapped_time_difference( + world_time.get_time_hours(), UPDATED_HOST_TIME + ) > TIME_TOLERANCE_HOURS + ): + await process_frame assert(is_equal_approx( world_time.get_persistent_time_hours(), UPDATED_HOST_TIME )) - assert(chat_ui.call("_handle_chat_command", "/weather foggy")) + var fog_deadline: int = Time.get_ticks_msec() + 10000 + while Time.get_ticks_msec() < fog_deadline and not world_weather.is_foggy(): + await process_frame assert( world_weather.get_persistent_weather() == WorldWeatherService.Weather.FOGGY @@ -153,6 +170,11 @@ func _run_client() -> void: assert(is_equal_approx(clock_panel.position.y, 10.0)) assert(is_equal_approx(weather_icon.position.y, 10.0)) assert(clock_panel.position.y + clock_panel.size.y < chat_panel.position.y) + var operator_deadline: int = Time.get_ticks_msec() + 10000 + while Time.get_ticks_msec() < operator_deadline and not session.is_local_operator(): + await process_frame + assert(session.is_local_operator()) + assert(chat_ui.call("_handle_chat_command", "/time night")) var update_deadline: int = Time.get_ticks_msec() + 10000 while ( @@ -168,6 +190,8 @@ func _run_client() -> void: assert(world_time.get_phase() == WorldTimeService.Phase.NIGHT) assert(is_equal_approx(world_time.get_persistent_time_hours(), 15.25)) assert(clock_label.text == world_time.get_clock_text()) + await create_timer(0.6).timeout + assert(chat_ui.call("_handle_chat_command", "/weather foggy")) var fog_deadline: int = Time.get_ticks_msec() + 8000 while ( Time.get_ticks_msec() < fog_deadline diff --git a/ui/chat_ui.gd b/ui/chat_ui.gd index bf38a75..c080a04 100644 --- a/ui/chat_ui.gd +++ b/ui/chat_ui.gd @@ -181,6 +181,7 @@ func setup( _service.local_message_confirmed.connect(_on_local_message_confirmed) _service.history_replaced.connect(_on_history) _service.send_rejected.connect(_on_rejected) + _service.world_command_finished.connect(_on_world_command_finished) _session.peer_removed.connect(_on_peer_removed) _entry.text = _settings.current_settings.chat_draft _entry.caret_column = _entry.text.length() @@ -789,34 +790,16 @@ func _handle_time_command(parts: PackedStringArray) -> void: if parts.size() != 2: _set_status("Usage: /time [dawn, day, dusk, night]") return - if _session == null or not _session.is_host(): - _set_status("Only the host can change world time.") - return - if _world_time == null: + if _service == null or _world_time == null: _set_status("World time is unavailable.") return var phase_name := String(parts[1]).to_lower() - var target_hour: float - match phase_name: - "dawn": - target_hour = WorldTimeServiceType.DAWN_START_HOUR - "day": - target_hour = WorldTimeServiceType.DAWN_END_HOUR - "dusk": - target_hour = WorldTimeServiceType.DUSK_START_HOUR - "night": - target_hour = WorldTimeServiceType.DUSK_END_HOUR - _: - _set_status("Usage: /time [dawn, day, dusk, night]") - return - if not _world_time.set_authoritative_time(target_hour): - _set_status("World time could not be changed.") + if phase_name not in ["dawn", "day", "dusk", "night"]: + _set_status("Usage: /time [dawn, day, dusk, night]") + return + if not _service.request_world_time_change(phase_name): + _set_status("Only the host or an operator can change world time.") return - _set_status("") - _service.broadcast_system_message( - "World time set to %s (%s)." - % [phase_name, _world_time.get_clock_text()] - ) close_chat() @@ -824,37 +807,31 @@ func _handle_weather_command(parts: PackedStringArray) -> void: if parts.size() != 2: _set_status("Usage: /weather [clear, cloudy, rainy, foggy]") return - if _session == null or not _session.is_host(): - _set_status("Only the host can change world weather.") - return - if _world_weather == null: + if _service == null or _world_weather == null: _set_status("World weather is unavailable.") return var weather_name := String(parts[1]).to_lower() - var target_weather: WorldWeatherServiceType.Weather match weather_name: "clear", "sunny": - target_weather = WorldWeatherServiceType.Weather.SUNNY weather_name = "clear" - "cloudy": - target_weather = WorldWeatherServiceType.Weather.CLOUDY - "rainy": - target_weather = WorldWeatherServiceType.Weather.RAINY - "foggy": - target_weather = WorldWeatherServiceType.Weather.FOGGY + "cloudy", "rainy", "foggy": + pass _: _set_status("Usage: /weather [clear, cloudy, rainy, foggy]") return - if not _world_weather.set_authoritative_weather(target_weather): - _set_status("World weather could not be changed.") + if not _service.request_world_weather_change(weather_name): + _set_status("Only the host or an operator can change world weather.") return - _set_status("") - _service.broadcast_system_message( - "World weather set to %s." % weather_name - ) close_chat() +func _on_world_command_finished(success: bool, message: String) -> void: + if success: + _set_status("") + elif not message.is_empty(): + _set_status(message) + + func _on_local_message_confirmed(message: Dictionary) -> void: if ( not _send_pending diff --git a/ui/title_screen.gd b/ui/title_screen.gd index 54cfcf7..9e17d3e 100644 --- a/ui/title_screen.gd +++ b/ui/title_screen.gd @@ -276,6 +276,19 @@ func reopen() -> void: _start_entry_prompt_animation() +func reopen_to_menu() -> void: + reopen() + _awaiting_start_input = false + _stop_entry_prompt_animation() + _start_prompt_center.hide() + _presentation_center.show() + _button_center.show() + var bubble_field := get_node_or_null("%BubbleField") as Control + if bubble_field != null: + bubble_field.show() + _set_title_bubbles_interactive(true) + + func open_join_game_page(endpoint: String = "") -> void: _cancel_title_entry_transition() _awaiting_start_input = false