diff --git a/docs/README-PLAYTEST.txt b/docs/README-PLAYTEST.txt index a73faf0..9d895f1 100644 --- a/docs/README-PLAYTEST.txt +++ b/docs/README-PLAYTEST.txt @@ -1,6 +1,6 @@ NETfishing -v0.6.8-alpha -Alpha 0.6.8 +v0.7.1-alpha +Alpha 0.7.1 Thank you for trying this early private playtest. diff --git a/export_presets.cfg b/export_presets.cfg index cb2bb67..bbcdec2 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -9,7 +9,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.6.8-alpha/windows-x86_64/NETfishing.exe" +export_path="builds/v0.7.1-alpha/windows-x86_64/NETfishing.exe" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -35,11 +35,11 @@ application/modify_resources=true application/icon="res://art/exported/system_icons/netfishing.ico" application/console_wrapper_icon="" application/icon_interpolation=4 -application/file_version="0.6.8.0" -application/product_version="0.6.8.0" +application/file_version="0.7.1.0" +application/product_version="0.7.1.0" application/company_name="Woofmeow" application/product_name="NETfishing" -application/file_description="NETfishing v0.6.8-alpha" +application/file_description="NETfishing v0.7.1-alpha" application/copyright="Copyright © 2026 Woofmeow" application/trademarks="NETfishing and Woofmeow branding is reserved; see TRADEMARKS.md" application/export_angle=0 @@ -62,7 +62,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.6.8-alpha/linux-arm64/NETfishing.arm64" +export_path="builds/v0.7.1-alpha/linux-arm64/NETfishing.arm64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -92,7 +92,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.6.8-alpha/macos/NETfishing.zip" +export_path="builds/v0.7.1-alpha/macos/NETfishing.zip" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -108,8 +108,8 @@ custom_template/release="" application/bundle_identifier="io.woofmeow.netfishing" application/icon="res://art/exported/system_icons/netfishing_1024.png" application/icon_interpolation=0 -application/short_version="0.6.8" -application/version="0.6.8" +application/short_version="0.7.1" +application/version="0.7.1" application/architecture="universal" codesign/enable=false notarization/enable=false @@ -125,7 +125,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*,tests/*" -export_path="builds/v0.6.8-alpha/server-linux-x86_64/NETfishingServer.x86_64" +export_path="builds/v0.7.1-alpha/server-linux-x86_64/NETfishingServer.x86_64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -155,7 +155,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.6.8-alpha/android/NETfishing.apk" +export_path="builds/v0.7.1-alpha/android/NETfishing.apk" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -176,8 +176,8 @@ architectures/armeabi-v7a=false architectures/arm64-v8a=true architectures/x86=false architectures/x86_64=false -version/code=60800 -version/name="v0.6.8-alpha" +version/code=70100 +version/name="v0.7.1-alpha" package/unique_name="io.woofmeow.netfishing" package/name="NETfishing" package/signed=true @@ -214,7 +214,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.6.8-alpha/linux-x86_64/NETfishing.x86_64" +export_path="builds/v0.7.1-alpha/linux-x86_64/NETfishing.x86_64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index 243a069..167a08b 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -868,7 +868,7 @@ func _on_cast_completed() -> void: _cast_origin_position.z + _cast_direction.z * withdrawal_cancel_distance ) _presentation.set_line_mode(FishingPresentationType.LineMode.SLACK) - status_changed.emit("waiting for a bite...") + status_changed.emit("") func _consume_active_bait() -> void: @@ -1131,10 +1131,7 @@ func _on_catch_escaped() -> void: return _stop_fight_audio() - var fish_name: String = "the fish" - if _selected_fish != null and not _selected_fish.display_name.is_empty(): - fish_name = "the %s" % _selected_fish.display_name - _cleanup_attempt("%s got away!" % fish_name, &"escape") + _cleanup_attempt("", &"escape") func _on_outcome_completed(outcome: StringName) -> void: @@ -1597,7 +1594,7 @@ func _on_network_cast_accepted( _network_input_resend_elapsed = 0.0 _presentation.show_withdrawal_position(_bobber_water_position) _presentation.set_line_mode(FishingPresentationType.LineMode.SLACK) - status_changed.emit("waiting for a bite...") + status_changed.emit("") func _on_network_cast_rejected(message: String) -> void: diff --git a/jobs/player_job_service.gd b/jobs/player_job_service.gd index 6f79e8f..18d6f89 100644 --- a/jobs/player_job_service.gd +++ b/jobs/player_job_service.gd @@ -150,6 +150,10 @@ func get_plan_id() -> String: return _active_plan_id +func has_active_board() -> bool: + return validate_board(_active_board) + + func get_time_until_refresh_text() -> String: if _active_plan_id.is_empty() or _world_time == null: return "daily jobs unavailable" diff --git a/main/main.gd b/main/main.gd index 51929a0..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, @@ -1555,8 +1556,13 @@ func _on_pause_join_game_requested(endpoint: String) -> void: _join_requested_from_title = false _pending_join_endpoint = endpoint _game_ui.get_pause_menu().close_for_title_transition() + var preserved_public_join: bool = ( + _discovery.preserve_public_join_for_session_switch() + ) _network_session.disconnect_session("Connecting to another game.") if not _network_session.join_direct(endpoint): + if preserved_public_join: + _discovery.cancel_pending_public_join() _handle_failed_session_switch( "Could not begin the direct connection." ) @@ -1641,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/discovery_client.gd b/network/discovery_client.gd index 476cff4..bf15bbf 100644 --- a/network/discovery_client.gd +++ b/network/discovery_client.gd @@ -85,6 +85,7 @@ var _join_request_in_flight: bool = false var _pending_join_endpoint: String = "" var _pending_join_token: String = "" var _pending_join_room_id: String = "" +var _preserve_pending_join_on_inactive: bool = false var _upnp_thread: Thread var _upnp_mapping_in_progress: bool = false var _upnp_operation_is_renewal: bool = false @@ -291,6 +292,17 @@ func is_public_join_preparing() -> bool: return _join_request_in_flight +func preserve_public_join_for_session_switch() -> bool: + _preserve_pending_join_on_inactive = not _pending_join_token.is_empty() + return _preserve_pending_join_on_inactive + + +func cancel_pending_public_join() -> void: + _preserve_pending_join_on_inactive = false + _set_public_join_state(PublicJoinState.IDLE) + _clear_pending_join() + + func prepare_public_join(room: Dictionary) -> bool: if _join_request_in_flight: return false @@ -913,18 +925,30 @@ func _discovery_version_mismatch_message(required_version: String) -> String: func _on_session_state_changed(state: NetworkSession.State) -> void: if state == NetworkSession.State.CONNECTING and not _pending_join_token.is_empty(): + _preserve_pending_join_on_inactive = false _set_public_join_state(PublicJoinState.CONNECTING) _join_probe_timer.start() call_deferred("_send_pending_join_probe") elif state in [ NetworkSession.State.JOINED_CLIENT, NetworkSession.State.SERVER_LOST, - NetworkSession.State.INACTIVE, ]: + _preserve_pending_join_on_inactive = false _set_public_join_state(PublicJoinState.IDLE) _join_probe_timer.stop() _clear_pending_join() + elif state == NetworkSession.State.INACTIVE: + if ( + _preserve_pending_join_on_inactive + and not _pending_join_token.is_empty() + ): + _preserve_pending_join_on_inactive = false + else: + _set_public_join_state(PublicJoinState.IDLE) + _join_probe_timer.stop() + _clear_pending_join() elif state == NetworkSession.State.CONNECTION_FAILED: + _preserve_pending_join_on_inactive = false _set_public_join_state(PublicJoinState.ERROR) _join_probe_timer.stop() _clear_pending_join() 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_fishing_service.gd b/network/network_fishing_service.gd index a5a2154..2015681 100644 --- a/network/network_fishing_service.gd +++ b/network/network_fishing_service.gd @@ -953,7 +953,7 @@ func _on_attempt_escaped(peer_id: int) -> void: if attempt == null: return attempt.phase = NetworkFishingAttempt.Phase.ESCAPED - _broadcast_public_outcome(attempt, &"escape", "The fish got away!") + _broadcast_public_outcome(attempt, &"escape", "") _dispose_attempt(peer_id) diff --git a/network/network_job_service.gd b/network/network_job_service.gd index 7745903..ebf5a6e 100644 --- a/network/network_job_service.gd +++ b/network/network_job_service.gd @@ -2,11 +2,13 @@ class_name NetworkJobService extends Node const MAX_SESSION_ID_LENGTH: int = 96 +const BOARD_REQUEST_INTERVAL_SECONDS: float = 1.5 var _session: NetworkSession var _jobs: PlayerJobService var _sequence: int = 0 var _last_received_sequence: int = -1 +var _board_request_accumulator: float = 0.0 func setup(session: NetworkSession, jobs: PlayerJobService) -> void: @@ -15,13 +17,36 @@ func setup(session: NetworkSession, jobs: PlayerJobService) -> void: _session.state_changed.connect(_on_session_state_changed) _session.peer_authenticated.connect(_on_peer_authenticated) _jobs.board_changed.connect(_on_board_changed) + set_process(true) + + +func _process(delta: float) -> void: + if ( + _session == null + or _jobs == null + or not _session.is_joined_client() + or not _session.supports_server_capability( + NetworkProtocol.JOBS_CAPABILITY + ) + or _jobs.has_active_board() + ): + _board_request_accumulator = 0.0 + return + _board_request_accumulator += delta + if _board_request_accumulator < BOARD_REQUEST_INTERVAL_SECONDS: + return + _board_request_accumulator = 0.0 + _request_remote_board() func _on_session_state_changed(state: NetworkSession.State) -> void: _sequence = 0 _last_received_sequence = -1 + _board_request_accumulator = 0.0 if state == NetworkSession.State.JOINED_CLIENT: - if not _session.supports_server_capability(NetworkProtocol.JOBS_CAPABILITY): + if _session.supports_server_capability(NetworkProtocol.JOBS_CAPABILITY): + _request_remote_board() + else: _jobs.clear_remote_board() elif state in [ NetworkSession.State.INACTIVE, @@ -69,6 +94,33 @@ func _send_board(peer_id: int) -> void: }) +func _request_remote_board() -> void: + if ( + _session != null + and _session.is_joined_client() + and _session.supports_server_capability( + NetworkProtocol.JOBS_CAPABILITY + ) + ): + request_job_board.rpc_id(1) + + +@rpc("any_peer", "call_remote", "reliable", 0) +func request_job_board() -> void: + var sender_id: int = multiplayer.get_remote_sender_id() + if ( + _session == null + or not _session.is_host() + or sender_id <= 1 + or not _session.is_authenticated_peer(sender_id) + or not _session.peer_supports_capability( + sender_id, NetworkProtocol.JOBS_CAPABILITY + ) + ): + return + _send_board(sender_id) + + @rpc("authority", "call_remote", "reliable", 0) func receive_job_board(data: Dictionary) -> void: if ( @@ -86,6 +138,7 @@ func receive_job_board(data: Dictionary) -> void: var board: Dictionary = data.get("board", {}) if _jobs.apply_remote_board(board): _last_received_sequence = sequence + _board_request_accumulator = 0.0 static func validate_snapshot(value: Variant) -> bool: diff --git a/network/network_player_animation_protocol.gd b/network/network_player_animation_protocol.gd new file mode 100644 index 0000000..b27cb9d --- /dev/null +++ b/network/network_player_animation_protocol.gd @@ -0,0 +1,94 @@ +class_name NetworkPlayerAnimationProtocol +extends RefCounted + +const FORMAT_VERSION: int = 1 +const MAX_STATE_ID_LENGTH: int = 64 +const MAX_ACTION_SEQUENCE: int = 2147483647 +const MAX_ACTION_ELAPSED_SECONDS: float = 86400.0 + +const LOCOMOTION_IDLE: StringName = &"idle" +const LOCOMOTION_WALKING: StringName = &"walking" +const LOCOMOTION_RUNNING: StringName = &"running" + + +static func make_state( + locomotion_id: StringName, + grounded: bool, + action_id: StringName = &"", + action_sequence: int = 0, + action_elapsed: float = 0.0, +) -> Dictionary: + return { + "format_version": FORMAT_VERSION, + "locomotion_id": String(locomotion_id), + "grounded": grounded, + "action": make_action_state( + action_id, action_sequence, action_elapsed + ), + } + + +static func make_action_state( + action_id: StringName = &"", + action_sequence: int = 0, + action_elapsed: float = 0.0, +) -> Dictionary: + return { + "id": String(action_id), + "sequence": action_sequence, + "elapsed": action_elapsed, + } + + +static func validate_state(value: Variant) -> bool: + if typeof(value) != TYPE_DICTIONARY: + return false + var state: Dictionary = value + return ( + typeof(state.get("format_version")) == TYPE_INT + and int(state["format_version"]) == FORMAT_VERSION + and valid_state_id(state.get("locomotion_id"), false) + and typeof(state.get("grounded")) == TYPE_BOOL + and validate_action_state(state.get("action")) + ) + + +static func validate_action_state(value: Variant) -> bool: + if typeof(value) != TYPE_DICTIONARY: + return false + var action: Dictionary = value + if ( + not valid_state_id(action.get("id"), true) + or typeof(action.get("sequence")) != TYPE_INT + or typeof(action.get("elapsed")) not in [TYPE_FLOAT, TYPE_INT] + ): + return false + var sequence: int = int(action["sequence"]) + var elapsed: float = float(action["elapsed"]) + return ( + sequence >= 0 + and sequence <= MAX_ACTION_SEQUENCE + and is_finite(elapsed) + and elapsed >= 0.0 + and elapsed <= MAX_ACTION_ELAPSED_SECONDS + ) + + +static func valid_state_id(value: Variant, allow_empty: bool) -> bool: + if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]: + return false + var state_id: String = str(value) + if state_id.is_empty(): + return allow_empty + if state_id.length() > MAX_STATE_ID_LENGTH: + return false + for index: int in state_id.length(): + var codepoint: int = state_id.unicode_at(index) + var valid_character: bool = ( + codepoint >= 97 and codepoint <= 122 + or codepoint >= 48 and codepoint <= 57 + or codepoint == 95 + ) + if not valid_character: + return false + return true diff --git a/network/network_player_animation_protocol.gd.uid b/network/network_player_animation_protocol.gd.uid new file mode 100644 index 0000000..466ad02 --- /dev/null +++ b/network/network_player_animation_protocol.gd.uid @@ -0,0 +1 @@ +uid://cmaaep4n3p2tq diff --git a/network/network_protocol.gd b/network/network_protocol.gd index 46192b6..7250fba 100644 --- a/network/network_protocol.gd +++ b/network/network_protocol.gd @@ -1,7 +1,7 @@ class_name NetworkProtocol extends RefCounted -const PROTOCOL_VERSION: int = 3 +const PROTOCOL_VERSION: int = 4 const GAME_BUILD: String = "prealpha" const MAX_GAME_VERSION_LENGTH: int = 64 const MAX_DISPLAY_NAME_LENGTH: int = 24 diff --git a/network/network_session.gd b/network/network_session.gd index 2043072..076acc5 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -9,6 +9,7 @@ const CONNECTION_TIMEOUT_SECONDS: float = 10.0 const AUTHENTICATION_TIMEOUT_SECONDS: float = 60.0 const INPUT_INTERVAL: float = 1.0 / 30.0 const SNAPSHOT_INTERVAL: float = 1.0 / 30.0 +const MAX_MOVEMENT_INPUT_SEQUENCE: int = 2147483647 signal state_changed(state: State) signal status_message_changed(message: String) @@ -33,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, @@ -874,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) @@ -1107,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) @@ -1741,9 +1741,10 @@ func _is_valid_movement_input(data: Dictionary) -> bool: or typeof(data.get("sprint")) != TYPE_BOOL or typeof(data.get("sneak")) != TYPE_BOOL or typeof(data.get("slow_walk")) != TYPE_BOOL - or ( - data.has("sitting") - and typeof(data.get("sitting")) != TYPE_BOOL + or typeof(data.get("sitting")) != TYPE_BOOL + or typeof(data.get("casting")) != TYPE_BOOL + or not NetworkPlayerAnimationProtocol.validate_action_state( + data.get("animation_action") ) or typeof(data.get("camera_yaw")) not in [TYPE_FLOAT, TYPE_INT] ): @@ -1751,11 +1752,18 @@ func _is_valid_movement_input(data: Dictionary) -> bool: var axis: Array = data["axis"] if axis.size() != 2: return false + if ( + typeof(axis[0]) not in [TYPE_FLOAT, TYPE_INT] + or typeof(axis[1]) not in [TYPE_FLOAT, TYPE_INT] + ): + return false var x: float = float(axis[0]) var y: float = float(axis[1]) var camera_yaw: float = float(data["camera_yaw"]) return ( - is_finite(x) + int(data["sequence"]) > 0 + and int(data["sequence"]) <= MAX_MOVEMENT_INPUT_SEQUENCE + and is_finite(x) and is_finite(y) and is_finite(camera_yaw) and absf(x) <= 1.01 diff --git a/player/player.gd b/player/player.gd index a381e11..6284873 100644 --- a/player/player.gd +++ b/player/player.gd @@ -68,6 +68,10 @@ const CHARACTER_FISHING_ANIMATION: StringName = &"fishing" const CHARACTER_FISHING_SIT_ANIMATION: StringName = &"fishing_sit" const CHARACTER_FIGHTING_ANIMATION: StringName = &"fighting" const CHARACTER_FIGHTING_SIT_ANIMATION: StringName = &"fighting_sit" +# Add future networked emote animation IDs here. The protocol accepts unknown +# safe IDs so newer clients can extend it, but Player only presents actions +# explicitly approved by this catalog. +const NETWORK_ANIMATION_ACTION_IDS: Array[StringName] = [] enum FishingVisualPhase { NONE, @@ -84,6 +88,12 @@ enum PocketVisualTarget { ART_KIT, CATCH_SHOWCASE, } + +enum LocomotionState { + IDLE, + WALKING, + RUNNING, +} const FIGHTING_EYES_ID: String = "alligator_eyes" const BLINK_EYES_ID: String = "closed" const BLINK_INTERVAL_SECONDS := Vector2(2.8, 7.5) @@ -393,8 +403,18 @@ var _last_network_input_sequence: int = 0 var _network_target_position: Vector3 var _network_target_velocity: Vector3 var _network_target_visual_yaw: float = 0.0 +var _network_target_grounded: bool = false +var _network_target_locomotion_state: LocomotionState = LocomotionState.IDLE +var _network_target_animation_action_id: StringName = &"" +var _network_target_animation_action_sequence: int = 0 +var _network_target_animation_action_elapsed: float = 0.0 var _network_snapshot_ready: bool = false var _network_snapshot_age: float = 0.0 +var _animation_action_id: StringName = &"" +var _animation_action_sequence: int = 0 +var _animation_action_elapsed: float = 0.0 +var _presented_animation_action_id: StringName = &"" +var _presented_animation_action_sequence: int = -1 var _character_animation_name: StringName = &"" var _sitting: bool = false var _sit_after_landing: bool = false @@ -475,6 +495,72 @@ func set_controller_mapping_manager( _controller_mapping_manager = mapping_manager +func begin_animation_action( + action_id: StringName, + elapsed_seconds: float = 0.0, +) -> bool: + if ( + action_id.is_empty() + or not supports_network_animation_action(action_id) + or _character_animation_player == null + or not _character_animation_player.has_animation(action_id) + ): + return false + var next_sequence: int = ( + 1 + if _animation_action_sequence + >= NetworkPlayerAnimationProtocol.MAX_ACTION_SEQUENCE + else _animation_action_sequence + 1 + ) + var action_state: Dictionary = ( + NetworkPlayerAnimationProtocol.make_action_state( + action_id, next_sequence, elapsed_seconds + ) + ) + if not NetworkPlayerAnimationProtocol.validate_action_state(action_state): + return false + _apply_animation_action_state(action_state) + return true + + +func end_animation_action() -> void: + if _animation_action_id.is_empty(): + return + var next_sequence: int = ( + 1 + if _animation_action_sequence + >= NetworkPlayerAnimationProtocol.MAX_ACTION_SEQUENCE + else _animation_action_sequence + 1 + ) + _apply_animation_action_state( + NetworkPlayerAnimationProtocol.make_action_state( + &"", next_sequence, 0.0 + ) + ) + + +func _apply_animation_action_state(action_state: Dictionary) -> void: + if not NetworkPlayerAnimationProtocol.validate_action_state(action_state): + return + var action_id := StringName(str(action_state["id"])) + if ( + not action_id.is_empty() + and not supports_network_animation_action(action_id) + ): + action_id = &"" + var action_sequence: int = int(action_state["sequence"]) + var action_changed: bool = ( + action_id != _animation_action_id + or action_sequence != _animation_action_sequence + ) + _animation_action_id = action_id + _animation_action_sequence = action_sequence + _animation_action_elapsed = float(action_state["elapsed"]) + if action_changed: + _character_animation_name = &"" + _update_character_animation() + + func _initialize_fishing_rod() -> void: var skeleton := get_node_or_null( "Visuals/CharacterRig/CharacterRig/Skeleton3D" @@ -613,6 +699,11 @@ func _physics_process(delta: float) -> void: func _process(delta: float) -> void: + if not _animation_action_id.is_empty(): + _animation_action_elapsed = minf( + _animation_action_elapsed + delta, + NetworkPlayerAnimationProtocol.MAX_ACTION_ELAPSED_SECONDS, + ) _update_blink(delta) _update_character_animation() _update_sprint_dust(delta) @@ -705,7 +796,7 @@ func _update_sprint_dust(delta: float) -> void: if _sprint_dust == null: return var horizontal_velocity := Vector3(velocity.x, 0.0, velocity.z) - var grounded: bool = is_on_floor() + var grounded: bool = _get_presented_grounded() if _water_recovery_active: _sprint_dust_airborne = false _sprint_dust_fall_speed = 0.0 @@ -730,18 +821,11 @@ func _update_sprint_dust(delta: float) -> void: ) _sprint_dust_airborne = false _sprint_dust_fall_speed = 0.0 - var horizontal_speed_squared: float = horizontal_velocity.length_squared() - var fastest_non_sprint_speed := maxf( - walk_speed, - maxf(sneak_speed, slow_walk_speed) - ) - var running_threshold := fastest_non_sprint_speed + 0.5 var should_emit: bool = ( grounded and not _sitting and not _water_recovery_active - and horizontal_speed_squared - > running_threshold * running_threshold + and _get_presented_locomotion_state() == LocomotionState.RUNNING ) _sprint_dust.update_trail( delta, @@ -778,18 +862,17 @@ func _get_controller_zoom_strength() -> float: func _update_character_animation() -> void: if _character_animation_player == null: return - var horizontal_speed_squared: float = ( - velocity.x * velocity.x + velocity.z * velocity.z + var locomotion_state: LocomotionState = ( + _get_presented_locomotion_state() ) - var is_walking: bool = horizontal_speed_squared > 0.0025 - var fastest_non_sprint_speed := maxf( - walk_speed, - maxf(sneak_speed, slow_walk_speed) - ) - var running_threshold := fastest_non_sprint_speed + 0.5 - var is_running := ( - is_walking - and horizontal_speed_squared > running_threshold * running_threshold + var is_walking: bool = locomotion_state != LocomotionState.IDLE + var is_running: bool = locomotion_state == LocomotionState.RUNNING + var animation_action: Dictionary = _get_presented_animation_action() + var animation_action_id := StringName(str(animation_action.get("id", ""))) + var animation_action_available: bool = ( + not animation_action_id.is_empty() + and supports_network_animation_action(animation_action_id) + and _character_animation_player.has_animation(animation_action_id) ) var held_show_item_visible: bool = ( _held_fish_visible or _held_art_kit_visible @@ -878,6 +961,8 @@ func _update_character_animation() -> void: CHARACTER_FISHING_ANIMATION, CHARACTER_IDLE_ANIMATION, ] + elif animation_action_available: + requested_animation = [animation_action_id] elif _sitting: if held_show_item_visible: requested_animation = [ @@ -934,13 +1019,45 @@ func _update_character_animation() -> void: break if next_animation.is_empty(): return - if _character_animation_name == next_animation: + var action_selected: bool = ( + animation_action_available and next_animation == animation_action_id + ) + var action_sequence: int = int(animation_action.get("sequence", 0)) + var action_changed: bool = ( + action_selected + and ( + animation_action_id != _presented_animation_action_id + or action_sequence != _presented_animation_action_sequence + ) + ) + if not action_selected: + _presented_animation_action_id = &"" + _presented_animation_action_sequence = -1 + if _character_animation_name == next_animation and not action_changed: return _character_animation_player.play(next_animation) _character_animation_name = next_animation + if action_selected: + _presented_animation_action_id = animation_action_id + _presented_animation_action_sequence = action_sequence + var elapsed: float = float(animation_action.get("elapsed", 0.0)) + var animation: Animation = _character_animation_player.get_animation( + next_animation + ) + if elapsed > 0.0 and animation != null and animation.length > 0.0: + _character_animation_player.seek( + fposmod(elapsed, animation.length), true + ) func _on_character_animation_finished(animation_name: StringName) -> void: + if ( + local_control_enabled + and not _animation_action_id.is_empty() + and animation_name == _animation_action_id + ): + end_animation_action() + return if animation_name in [ CHARACTER_POCKET_IDLE_IDLE_ANIMATION, CHARACTER_POCKET_IDLE_SHOW_ANIMATION, @@ -1278,6 +1395,11 @@ func configure_network_remote(authoritative_simulation: bool) -> void: _network_interpolation_enabled = not authoritative_simulation _network_snapshot_ready = false _network_snapshot_age = 0.0 + _network_target_grounded = false + _network_target_locomotion_state = LocomotionState.IDLE + _network_target_animation_action_id = &"" + _network_target_animation_action_sequence = 0 + _network_target_animation_action_elapsed = 0.0 _camera.current = false @@ -1301,6 +1423,7 @@ func capture_network_input(sequence: int) -> Dictionary: "casting": ( _fishing_visual_phase == FishingVisualPhase.CASTING ), + "animation_action": _make_animation_action_state(), } var axis: Vector2 = Input.get_vector( "move_left", @@ -1318,6 +1441,7 @@ func capture_network_input(sequence: int) -> Dictionary: "slow_walk": Input.is_action_pressed("slow_walk"), "sitting": _sitting, "casting": _fishing_visual_phase == FishingVisualPhase.CASTING, + "animation_action": _make_animation_action_state(), } @@ -1335,6 +1459,7 @@ func apply_authoritative_network_input(data: Dictionary) -> void: _network_sprint = bool(data.get("sprint", false)) _network_sneak = bool(data.get("sneak", false)) _network_slow_walk = bool(data.get("slow_walk", false)) + _apply_animation_action_state(data.get("animation_action", {})) _apply_network_casting(bool(data.get("casting", false))) var sitting_requested: bool = bool(data.get("sitting", false)) if sitting_requested and not is_on_floor(): @@ -1352,7 +1477,13 @@ func make_network_snapshot(peer_id: int) -> Dictionary: "position": [global_position.x, global_position.y, global_position.z], "velocity": [velocity.x, velocity.y, velocity.z], "visual_yaw": _visuals.rotation.y, - "grounded": is_on_floor(), + "animation_state": NetworkPlayerAnimationProtocol.make_state( + _get_authoritative_locomotion_id(), + is_on_floor(), + _animation_action_id, + _animation_action_sequence, + _animation_action_elapsed, + ), "sitting": _sitting, "casting": _fishing_visual_phase == FishingVisualPhase.CASTING, } @@ -1365,6 +1496,7 @@ func push_network_snapshot(snapshot: Dictionary) -> void: _network_target_position = parsed["position"] _network_target_velocity = parsed["velocity"] _network_target_visual_yaw = parsed["visual_yaw"] + _apply_network_target_animation_state(parsed["animation_state"]) _network_snapshot_age = 0.0 _apply_network_casting(bool(parsed["casting"])) _set_sitting(bool(parsed["sitting"])) @@ -1412,6 +1544,7 @@ func apply_network_teleport(snapshot: Dictionary) -> void: _network_target_position = global_position _network_target_velocity = velocity _network_target_visual_yaw = _visuals.rotation.y + _apply_network_target_animation_state(parsed["animation_state"]) _network_snapshot_age = 0.0 _network_snapshot_ready = true @@ -1437,6 +1570,12 @@ func _parse_network_snapshot(snapshot: Dictionary) -> Dictionary: float(network_velocity[2]) ) var visual_yaw: float = float(snapshot.get("visual_yaw", 0.0)) + var animation_state_value: Variant = snapshot.get("animation_state") + if not NetworkPlayerAnimationProtocol.validate_state(animation_state_value): + return {} + var animation_state: Dictionary = ( + animation_state_value as Dictionary + ).duplicate(true) if ( snapshot.has("acknowledged_input") and ( @@ -1464,12 +1603,119 @@ func _parse_network_snapshot(snapshot: Dictionary) -> Dictionary: "position": parsed_position, "velocity": parsed_velocity, "visual_yaw": visual_yaw, + "animation_state": animation_state, "acknowledged_input": acknowledged_input, "sitting": sitting, "casting": casting, } +func _make_animation_action_state() -> Dictionary: + return NetworkPlayerAnimationProtocol.make_action_state( + _animation_action_id, + _animation_action_sequence, + _animation_action_elapsed, + ) + + +func _apply_network_target_animation_state(state: Dictionary) -> void: + if not NetworkPlayerAnimationProtocol.validate_state(state): + return + _network_target_grounded = bool(state["grounded"]) + _network_target_locomotion_state = _locomotion_state_from_id( + StringName(str(state["locomotion_id"])) + ) + var action: Dictionary = state["action"] + _network_target_animation_action_id = StringName(str(action["id"])) + _network_target_animation_action_sequence = int(action["sequence"]) + _network_target_animation_action_elapsed = float(action["elapsed"]) + + +func _get_authoritative_locomotion_state() -> LocomotionState: + if ( + _network_authoritative_simulation + and _is_movement_input_enabled() + and not _water_recovery_active + and not _sitting + and _network_axis.length_squared() > 0.0025 + ): + return ( + LocomotionState.RUNNING + if _network_sprint + else LocomotionState.WALKING + ) + return _locomotion_state_from_velocity() + + +func _get_authoritative_locomotion_id() -> StringName: + return _locomotion_id_from_state(_get_authoritative_locomotion_state()) + + +func _get_presented_locomotion_state() -> LocomotionState: + if _network_interpolation_enabled and _network_snapshot_ready: + return _network_target_locomotion_state + return _locomotion_state_from_velocity() + + +func _get_presented_animation_action() -> Dictionary: + if _network_interpolation_enabled and _network_snapshot_ready: + return NetworkPlayerAnimationProtocol.make_action_state( + _network_target_animation_action_id, + _network_target_animation_action_sequence, + minf( + _network_target_animation_action_elapsed + _network_snapshot_age, + NetworkPlayerAnimationProtocol.MAX_ACTION_ELAPSED_SECONDS, + ), + ) + return _make_animation_action_state() + + +func _locomotion_id_from_state(state: LocomotionState) -> StringName: + match state: + LocomotionState.WALKING: + return NetworkPlayerAnimationProtocol.LOCOMOTION_WALKING + LocomotionState.RUNNING: + return NetworkPlayerAnimationProtocol.LOCOMOTION_RUNNING + _: + return NetworkPlayerAnimationProtocol.LOCOMOTION_IDLE + + +func _locomotion_state_from_id(state_id: StringName) -> LocomotionState: + match state_id: + NetworkPlayerAnimationProtocol.LOCOMOTION_WALKING: + return LocomotionState.WALKING + NetworkPlayerAnimationProtocol.LOCOMOTION_RUNNING: + return LocomotionState.RUNNING + _: + return LocomotionState.IDLE + + +static func supports_network_animation_action(action_id: StringName) -> bool: + return action_id in NETWORK_ANIMATION_ACTION_IDS + + +func _locomotion_state_from_velocity() -> LocomotionState: + var horizontal_speed_squared: float = ( + velocity.x * velocity.x + velocity.z * velocity.z + ) + if horizontal_speed_squared <= 0.0025: + return LocomotionState.IDLE + var fastest_non_sprint_speed := maxf( + walk_speed, + maxf(sneak_speed, slow_walk_speed) + ) + var running_threshold := fastest_non_sprint_speed + 0.5 + if horizontal_speed_squared > running_threshold * running_threshold: + return LocomotionState.RUNNING + return LocomotionState.WALKING + + +func _get_presented_grounded() -> bool: + if _network_interpolation_enabled and _network_snapshot_ready: + return _network_target_grounded + return is_on_floor() + + func _apply_network_casting(casting: bool) -> void: if casting: if _fishing_visual_phase == FishingVisualPhase.NONE: diff --git a/project.godot b/project.godot index 6975e3b..1745ecc 100644 --- a/project.godot +++ b/project.godot @@ -11,7 +11,7 @@ config_version=5 [application] config/name="NETFISHING" -config/version="0.6.8-alpha" +config/version="0.7.1-alpha" run/main_scene="res://main/main.tscn" config/features=PackedStringArray("4.7", "GL Compatibility") config/icon="res://art/exported/system_icons/netfishing_256.png" diff --git a/scripts/build_playtest.sh b/scripts/build_playtest.sh index 980d4c4..7749b3d 100755 --- a/scripts/build_playtest.sh +++ b/scripts/build_playtest.sh @@ -4,14 +4,14 @@ set -euo pipefail readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" -readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.6.8-alpha" +readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.7.1-alpha" readonly WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64" readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64" readonly README_SOURCE="${PROJECT_ROOT}/docs/README-PLAYTEST.txt" readonly SOURCE_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)" readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/netfishing" -readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.6.8-alpha-windows-x86_64.zip" -readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.6.8-alpha-linux-x86_64.zip" +readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.7.1-alpha-windows-x86_64.zip" +readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.7.1-alpha-linux-x86_64.zip" readonly GODOT_BIN="${GODOT_BIN:-godot}" if [[ ! -f "${PROJECT_ROOT}/project.godot" ]]; then diff --git a/scripts/run_validations.sh b/scripts/run_validations.sh index f7f77f3..7ae8929 100755 --- a/scripts/run_validations.sh +++ b/scripts/run_validations.sh @@ -17,6 +17,7 @@ readonly -a QUICK_TESTS=( "tests/fishing_surface_validation.gd" "tests/fur_pattern_validation.gd" "tests/logbook_validation.gd" + "tests/network_player_animation_protocol_validation.gd" "tests/player_experience_validation.gd" "tests/shoreline_ambience_validation.gd" "tests/surface_drawing_validation.gd" @@ -49,6 +50,7 @@ readonly -a NETWORK_TESTS=( "tests/fish_showcase_multiplayer_validation.gd" "tests/fishing_multiplayer_validation.gd" "tests/job_multiplayer_validation.gd" + "tests/movement_multiplayer_validation.gd" "tests/operator_multiplayer_validation.gd" "tests/surface_drawing_multiplayer_validation.gd" "tests/world_time_multiplayer_validation.gd" diff --git a/tests/fish_hotbar_showcase_validation.gd b/tests/fish_hotbar_showcase_validation.gd index 8919221..06b2f4d 100644 --- a/tests/fish_hotbar_showcase_validation.gd +++ b/tests/fish_hotbar_showcase_validation.gd @@ -181,7 +181,7 @@ func _run() -> void: var invalid_state: Dictionary = valid_state.duplicate(true) invalid_state["display_scale"] = 1000.0 assert(not NetworkFishShowcaseProtocol.validate_state(invalid_state)) - assert(NetworkProtocol.PROTOCOL_VERSION == 3) + assert(NetworkProtocol.PROTOCOL_VERSION == 4) assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10) assert( NetworkProtocol.FISH_QUALITY_CAPABILITY diff --git a/tests/fish_quality_validation.gd b/tests/fish_quality_validation.gd index 72ebecc..555fb3c 100644 --- a/tests/fish_quality_validation.gd +++ b/tests/fish_quality_validation.gd @@ -25,7 +25,7 @@ func _run() -> void: _validate_mail_round_trip() _validate_collection_mastery() _validate_version_four_migration() - assert(NetworkProtocol.PROTOCOL_VERSION == 3) + assert(NetworkProtocol.PROTOCOL_VERSION == 4) assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10) assert(NetworkProtocol.FISH_QUALITY_CAPABILITY == "fish_quality_v1") print("Fish quality validation: PASS") diff --git a/tests/fishing_authority_validation.gd b/tests/fishing_authority_validation.gd index 20f02e1..6b0429d 100644 --- a/tests/fishing_authority_validation.gd +++ b/tests/fishing_authority_validation.gd @@ -31,6 +31,9 @@ func _run() -> void: "%NetworkFishingService" ) as NetworkFishingService var fishing_spot := main.get_node("%FishingSpot") as FishingSpotType + var game_ui := main.get_node("UIPresentation/UIViewport/GameUI") + var fishing_status := game_ui.get_node("%StatusLabel") as Label + var fishing_panel := game_ui.get_node("%FishingPanel") as PanelContainer var player := main.get("_player") as Player assert(session.is_host()) assert(not session.is_open_host()) @@ -71,6 +74,9 @@ func _run() -> void: await process_frame assert(fishing_spot.state == FishingSpotType.FishingState.WAITING_FOR_BITE) assert(service.has_local_attempt()) + assert(fishing_status.text.is_empty()) + assert(not fishing_status.visible) + assert(not fishing_panel.visible) var attempts: Dictionary = service.get("_attempts") var attempt: NetworkFishingAttempt = attempts.get(session.get_local_peer_id()) assert(attempt != null) @@ -164,9 +170,12 @@ func _run() -> void: <= FishQuality.BARRIER_HEALTH_MAXIMUMS[fish_catch.quality] ) reference_controller.queue_free() - service.call("_cancel_attempt", session.get_local_peer_id(), "") + service.call("_on_attempt_escaped", session.get_local_peer_id()) await process_frame assert(not service.has_local_attempt()) + assert(fishing_status.text.is_empty()) + assert(not fishing_status.visible) + assert(not fishing_panel.visible) print("Fishing authority validation: PASS") session.disconnect_session("") diff --git a/tests/fishing_multiplayer_validation.gd b/tests/fishing_multiplayer_validation.gd index f0976f8..c40e492 100644 --- a/tests/fishing_multiplayer_validation.gd +++ b/tests/fishing_multiplayer_validation.gd @@ -147,12 +147,6 @@ func _run_host() -> void: await process_frame assert(remote_animation_player.current_animation == &"retract_sit") - var completion_deadline: int = Time.get_ticks_msec() + 12000 - while ( - Time.get_ticks_msec() < completion_deadline - and session.get_authenticated_peer_ids().size() == 2 - ): - await process_frame print("Fishing multiplayer host validation: PASS") session.disconnect_session("") main.queue_free() @@ -337,7 +331,14 @@ func _run_client() -> void: while Time.get_ticks_msec() < host_observation_deadline: await process_frame print("Fishing multiplayer client validation: PASS") - session.disconnect_session("") + 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 diff --git a/tests/job_multiplayer_validation.gd b/tests/job_multiplayer_validation.gd index b40fc2e..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)) @@ -87,6 +86,20 @@ func _run_client() -> void: assert(not jobs.get_plan_id().is_empty()) assert(jobs.get_daily_jobs().size() == JobCatalog.DAILY_JOB_COUNT) assert(jobs.get_forecast().size() == JobCatalog.WEATHER_SEGMENT_COUNT) + var preserved_plan_id: String = jobs.get_plan_id() + jobs.clear_remote_board() + assert(not jobs.has_active_board()) + assert(jobs.get_plan_id() == preserved_plan_id) + var retry_deadline: int = Time.get_ticks_msec() + 5000 + while ( + Time.get_ticks_msec() < retry_deadline + and not jobs.has_active_board() + ): + await process_frame + assert(jobs.has_active_board()) + assert(jobs.get_plan_id() == preserved_plan_id) + assert(jobs.get_daily_jobs().size() == JobCatalog.DAILY_JOB_COUNT) + assert(jobs.get_forecast().size() == JobCatalog.WEATHER_SEGMENT_COUNT) assert(weather.get_daily_plan_id().is_empty()) var game_ui := main.get_node("%GameUI") as GameUI var player_menu := game_ui.get("_player_menu") as PlayerMenu diff --git a/tests/job_system_validation.gd b/tests/job_system_validation.gd index b6036eb..32fe10e 100644 --- a/tests/job_system_validation.gd +++ b/tests/job_system_validation.gd @@ -85,6 +85,7 @@ func _run() -> void: (player_menu.get_node("%NavigationCluster") as Control).get_child_count() == 7 ) + await _validate_unavailable_fishnet_layout() var wallet_before: int = player.wallet.get_balance() var experience_before: int = player.experience.get_total_experience() @@ -162,6 +163,24 @@ func _run() -> void: var pause_menu := game_ui.get_pause_menu() var join_page := pause_menu.get_node("%JoinGamePage") as JoinGamePage assert(join_page != null) + pause_menu.show() + join_page.set_status("Connection timed out.") + join_page.show() + join_page.call( + "_on_discovery_status_changed", "2 public rooms found", false + ) + var join_status := join_page.get_node("%Status") as Label + assert(join_status.text == "Could not reach the server.") + join_page.hide() + join_page.call("_set_mode", JoinGamePage.Mode.DIRECT) + join_page.call("_set_mode", JoinGamePage.Mode.DISCOVER) + join_page.show() + join_page.call( + "_on_discovery_status_changed", "2 public rooms found", false + ) + assert(join_status.text == "2 public rooms found") + join_page.close_page() + pause_menu.hide() join_page.call("_refresh") var session_summary := ( join_page.get_node("%SessionSummary") as Label @@ -187,14 +206,63 @@ func _run() -> void: assert(int(save_data.get("save_version", -1)) == 7) assert(PlayerJobService.validate_save_data(save_data.get("jobs", {}))) - session.disconnect_session("Job system validation complete.") + _validate_pause_session_switch(main, session) main.queue_free() for _frame: int in 4: await process_frame + await create_timer(0.1).timeout print("Job system validation: PASS") quit() +func _validate_pause_session_switch( + main: Node, + session: NetworkSession, +) -> void: + var discovery := main.get_node("%DiscoveryClient") as DiscoveryClient + discovery.set("_pending_join_endpoint", "127.0.0.1:18141") + discovery.set("_pending_join_room_id", "session-switch-room") + discovery.set("_pending_join_token", "session-switch-token") + discovery.call( + "_set_public_join_state", + DiscoveryClient.PublicJoinState.CONNECTING, + ) + main.call("_on_pause_join_game_requested", "127.0.0.1:18141") + assert(session.state == NetworkSession.State.CONNECTING) + assert(discovery.get("_pending_join_token") == "session-switch-token") + var join_probe_timer := discovery.get("_join_probe_timer") as Timer + assert(join_probe_timer != null and not join_probe_timer.is_stopped()) + session.cancel_connection() + assert(session.state == NetworkSession.State.INACTIVE) + assert(str(discovery.get("_pending_join_token")).is_empty()) + + +func _validate_unavailable_fishnet_layout() -> void: + var unavailable_page := TheNetPage.new() + unavailable_page.size = Vector2(1280.0, 720.0) + root.add_child(unavailable_page) + for _frame: int in 3: + await process_frame + unavailable_page.call("_refresh_forecast", true) + await process_frame + var forecast_list := unavailable_page.get("_forecast_list") as HBoxContainer + assert(forecast_list != null) + assert(forecast_list.size.x >= 272.0) + assert(forecast_list.size.y <= 66.0) + assert(forecast_list.get_child_count() == 1) + var placeholder := forecast_list.get_child(0) as Label + assert(placeholder != null) + assert(placeholder.size.x >= 252.0) + assert(placeholder.size.y <= 66.0) + assert(placeholder.autowrap_mode == TextServer.AUTOWRAP_OFF) + var tabs := unavailable_page.get("_tabs") as HBoxContainer + assert(tabs != null) + assert(tabs.position.y + tabs.size.y < UtilityPageStyle.LAPTOP_RECT.end.y) + unavailable_page.queue_free() + for _frame: int in 2: + await process_frame + + func _validate_weather_guarantees(board: Dictionary) -> void: var jobs: Array = board.get("jobs", []) var schedule: Array = board.get("weather_schedule", []) diff --git a/tests/movement_multiplayer_validation.gd b/tests/movement_multiplayer_validation.gd new file mode 100644 index 0000000..cb1d4eb --- /dev/null +++ b/tests/movement_multiplayer_validation.gd @@ -0,0 +1,163 @@ +extends SceneTree + +const MainScene: PackedScene = preload("res://main/main.tscn") +const TEST_PORT: int = 18141 + + +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("Movement multiplayer validation needs host or client mode.") + quit(1) + + +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 + assert(session.start_private_host(TEST_PORT)) + assert(save_manager.initialize_new_game()) + main.call("_enter_gameplay") + for _frame: int in 4: + await physics_frame + assert(session.set_host_open(true)) + var remote_peer_id: int = await _wait_for_remote_peer(session) + assert(remote_peer_id > 1) + var player := main.get("_player") as Player + player.configure_network_remote(true) + player.apply_authoritative_network_input(_movement_input(1, true)) + await create_timer(2.5).timeout + player.apply_authoritative_network_input(_movement_input(2, false)) + await create_timer(2.5).timeout + player.apply_authoritative_network_input(_movement_input(3, false, false)) + var disconnect_deadline: int = Time.get_ticks_msec() + 8000 + 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("Movement 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:%d" % TEST_PORT, + ) + var session := main.get_node("%NetworkSession") as NetworkSession + 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")): + break + assert(session.is_joined_client()) + var spawn_service := main.get_node( + "%PlayerSpawnService" + ) as PlayerSpawnService + var host_avatar: Player = spawn_service.get_avatar(1) + assert(host_avatar != null) + var animation_player := host_avatar.get_node( + "Visuals/CharacterRig/AnimationPlayer" + ) as AnimationPlayer + var sprint_dust := host_avatar.get_node("%SprintDust") as SprintDustTrail + var saw_running: bool = false + var saw_walking: bool = false + var saw_animation_advance: bool = false + var saw_dust: bool = false + var previous_animation: StringName = &"" + var previous_animation_position: float = -1.0 + var observation_deadline: int = Time.get_ticks_msec() + 7000 + while Time.get_ticks_msec() < observation_deadline: + await process_frame + var current_animation: StringName = animation_player.current_animation + saw_running = saw_running or current_animation.begins_with("running") + saw_walking = saw_walking or current_animation.begins_with("walking") + var current_position: float = ( + animation_player.current_animation_position + ) + if ( + current_animation == previous_animation + and previous_animation_position >= 0.0 + and absf(current_position - previous_animation_position) > 0.001 + ): + saw_animation_advance = true + previous_animation = current_animation + previous_animation_position = current_position + saw_dust = saw_dust or sprint_dust.get_active_puff_count() > 0 + if saw_running and saw_walking and saw_animation_advance and saw_dust: + break + assert(saw_running) + assert(saw_walking) + assert(saw_animation_advance) + assert(saw_dust) + print("Movement 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 _movement_input( + sequence: int, + sprinting: bool, + moving: bool = true, +) -> Dictionary: + return { + "sequence": sequence, + "axis": [0.0, -1.0] if moving else [0.0, 0.0], + "camera_yaw": 0.0, + "jump": false, + "sprint": sprinting, + "sneak": false, + "slow_walk": false, + "sitting": false, + "casting": false, + "animation_action": ( + NetworkPlayerAnimationProtocol.make_action_state() + ), + } + + +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 + + +func _wait_for_remote_peer(session: NetworkSession) -> int: + var deadline: int = Time.get_ticks_msec() + 20000 + while Time.get_ticks_msec() < deadline: + await process_frame + for peer_id: int in session.get_authenticated_peer_ids(): + if peer_id != session.get_local_peer_id(): + return peer_id + return 0 diff --git a/tests/movement_multiplayer_validation.gd.uid b/tests/movement_multiplayer_validation.gd.uid new file mode 100644 index 0000000..4d8c726 --- /dev/null +++ b/tests/movement_multiplayer_validation.gd.uid @@ -0,0 +1 @@ +uid://dlpu5lwb2pges diff --git a/tests/network_player_animation_protocol_validation.gd b/tests/network_player_animation_protocol_validation.gd new file mode 100644 index 0000000..8c3474c --- /dev/null +++ b/tests/network_player_animation_protocol_validation.gd @@ -0,0 +1,49 @@ +extends SceneTree + + +func _initialize() -> void: + var idle := NetworkPlayerAnimationProtocol.make_state( + NetworkPlayerAnimationProtocol.LOCOMOTION_IDLE, + true, + ) + assert(NetworkPlayerAnimationProtocol.validate_state(idle)) + var emote := NetworkPlayerAnimationProtocol.make_state( + NetworkPlayerAnimationProtocol.LOCOMOTION_RUNNING, + false, + &"wave_hello", + 17, + 1.25, + ) + assert(NetworkPlayerAnimationProtocol.validate_state(emote)) + var future_locomotion := NetworkPlayerAnimationProtocol.make_state( + &"swimming_fast", + false, + ) + assert(NetworkPlayerAnimationProtocol.validate_state(future_locomotion)) + var extra_future_field: Dictionary = emote.duplicate(true) + extra_future_field["future_layer"] = {"id": "umbrella"} + assert(NetworkPlayerAnimationProtocol.validate_state(extra_future_field)) + + var invalid_action: Dictionary = emote.duplicate(true) + invalid_action["action"] = { + "id": "../unsafe", + "sequence": 17, + "elapsed": 1.25, + } + assert(not NetworkPlayerAnimationProtocol.validate_state(invalid_action)) + var invalid_sequence: Dictionary = emote.duplicate(true) + invalid_sequence["action"] = { + "id": "wave_hello", + "sequence": -1, + "elapsed": 1.25, + } + assert(not NetworkPlayerAnimationProtocol.validate_state(invalid_sequence)) + var invalid_elapsed: Dictionary = emote.duplicate(true) + invalid_elapsed["action"] = { + "id": "wave_hello", + "sequence": 17, + "elapsed": INF, + } + assert(not NetworkPlayerAnimationProtocol.validate_state(invalid_elapsed)) + print("Network player animation protocol validation: PASS") + quit() diff --git a/tests/network_player_animation_protocol_validation.gd.uid b/tests/network_player_animation_protocol_validation.gd.uid new file mode 100644 index 0000000..017146d --- /dev/null +++ b/tests/network_player_animation_protocol_validation.gd.uid @@ -0,0 +1 @@ +uid://cjgelp62dkykn 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/tests/world_weather_validation.gd b/tests/world_weather_validation.gd index 01ac8e1..a725117 100644 --- a/tests/world_weather_validation.gd +++ b/tests/world_weather_validation.gd @@ -456,8 +456,14 @@ func _validate_weather_presentation() -> void: assert(rain.emitting) assert(rain.amount_ratio > 0.99) assert(rain.amount == WorldTimeVisualControllerType.RAIN_PARTICLE_AMOUNT) + assert(rain.visibility_aabb == ( + WorldTimeVisualControllerType.RAIN_VISIBILITY_AABB + )) var rain_material := rain.process_material as ParticleProcessMaterial assert(rain_material != null) + assert(rain_material.emission_box_extents.is_equal_approx( + WorldTimeVisualControllerType.RAIN_EMISSION_EXTENTS + )) assert(is_equal_approx( rain_material.initial_velocity_min, WorldTimeVisualControllerType.RAIN_VELOCITY_MIN, 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/network/join_game_page.gd b/ui/network/join_game_page.gd index 1788ea5..ca3fbe5 100644 --- a/ui/network/join_game_page.gd +++ b/ui/network/join_game_page.gd @@ -62,6 +62,7 @@ var _discovery_refresh_timer: Timer var _editing_entry_id: String = "" var _name_entry_active: bool = false var _delete_armed: bool = false +var _connection_error_latched: bool = false func _ready() -> void: @@ -181,7 +182,7 @@ func open_page(preserved_endpoint: String = "") -> void: _address.text = "127.0.0.1:7777" show() UtilityPageStyle.animate_in(self) - _set_mode(_mode) + _set_mode(_mode, false) if _mode == Mode.DIRECT: _address.grab_focus() _address.select_all() @@ -201,10 +202,13 @@ func get_endpoint_text() -> String: func set_status(message: String) -> void: + _connection_error_latched = true _set_status(_friendly_connection_message(message), true) -func _set_mode(mode: Mode) -> void: +func _set_mode(mode: Mode, clear_connection_error: bool = true) -> void: + if clear_connection_error: + _connection_error_latched = false _mode = mode _selected_entry = null _selected_discovery_index = -1 @@ -225,6 +229,7 @@ func _set_mode(mode: Mode) -> void: func _request_join() -> void: + _connection_error_latched = false if _network_session.state in [ NetworkSession.State.CONNECTION_FAILED, NetworkSession.State.SERVER_LOST, @@ -267,6 +272,10 @@ func _on_public_join_prepared(endpoint_text: String) -> void: func _on_public_join_status_changed(message: String, is_error: bool) -> void: if _mode == Mode.DISCOVER and is_visible_in_tree(): + if is_error: + _connection_error_latched = true + elif _connection_error_latched: + return _set_status(message, is_error) _refresh() @@ -708,7 +717,11 @@ func _on_discovery_rooms_updated(rooms: Array[Dictionary]) -> void: func _on_discovery_status_changed(message: String, is_error: bool) -> void: - if _mode == Mode.DISCOVER and is_visible_in_tree(): + if ( + _mode == Mode.DISCOVER + and is_visible_in_tree() + and not _connection_error_latched + ): _set_status(message, is_error) @@ -740,6 +753,7 @@ func _friendly_connection_message(message: String) -> String: return "Connection cancelled." if ( "timeout" in normalized + or "timed out" in normalized or "unavailable" in normalized or "refused" in normalized or "reach" in normalized @@ -769,6 +783,7 @@ func _clear_edit_state() -> void: func _on_cancel_pressed() -> void: + _connection_error_latched = false if _network_session != null: _network_session.cancel_connection() _refresh() @@ -795,11 +810,14 @@ func _on_state_changed(_state: NetworkSession.State) -> void: func _on_status_message_changed(message: String) -> void: + if _connection_error_latched: + return _set_status(_friendly_connection_message(message)) _refresh() func _on_connection_error(message: String) -> void: + _connection_error_latched = true _set_status(_friendly_connection_message(message), true) _refresh() diff --git a/ui/the_net_page.gd b/ui/the_net_page.gd index a60bc26..ff83a11 100644 --- a/ui/the_net_page.gd +++ b/ui/the_net_page.gd @@ -158,6 +158,8 @@ func _build_laptop() -> void: ) forecast_stack.add_child(forecast_heading) _forecast_list = HBoxContainer.new() + _forecast_list.custom_minimum_size = Vector2(272.0, 66.0) + _forecast_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL _forecast_list.alignment = BoxContainer.ALIGNMENT_CENTER _forecast_list.add_theme_constant_override("separation", 6) forecast_stack.add_child(_forecast_list) @@ -501,7 +503,10 @@ func _daily_refresh_text() -> String: func _add_forecast_empty(message: String) -> void: var label := Label.new() label.text = message - label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + label.custom_minimum_size = Vector2(252.0, 66.0) + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + label.autowrap_mode = TextServer.AUTOWRAP_OFF label.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY ) 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 diff --git a/ui/title_screen.tscn b/ui/title_screen.tscn index c79e894..ae0c0c6 100644 --- a/ui/title_screen.tscn +++ b/ui/title_screen.tscn @@ -221,7 +221,7 @@ unique_name_in_owner = true layout_mode = 2 theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1) theme_override_font_sizes/font_size = 22 -text = "v0.6.8-alpha" +text = "v0.7.1-alpha" horizontal_alignment = 1 [node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"] diff --git a/world/world_time_visual_controller.gd b/world/world_time_visual_controller.gd index 231bbfd..99b0429 100644 --- a/world/world_time_visual_controller.gd +++ b/world/world_time_visual_controller.gd @@ -5,7 +5,12 @@ const UPDATE_INTERVAL_SECONDS: float = 0.1 const SUN_YAW_DEGREES: float = -32.0 const WEATHER_TRANSITION_SECONDS: float = 10.0 const RAIN_EMITTER_OFFSET := Vector3(0.0, 7.0, 0.0) -const RAIN_PARTICLE_AMOUNT: int = 560 +const RAIN_EMISSION_EXTENTS := Vector3(13.0, 1.0, 13.0) +const RAIN_VISIBILITY_AABB := AABB( + Vector3(-13.5, -9.0, -13.5), + Vector3(27.0, 12.0, 27.0), +) +const RAIN_PARTICLE_AMOUNT: int = 2240 const RAIN_VELOCITY_MIN: float = 16.0 const RAIN_VELOCITY_MAX: float = 20.0 const RAIN_DROP_SIZE := Vector3(0.014, 0.34, 0.014) @@ -187,14 +192,12 @@ func _prepare_rain() -> void: _rain.lifetime = 1.25 _rain.fixed_fps = 30 _rain.local_coords = false - _rain.visibility_aabb = AABB( - Vector3(-7.0, -9.0, -7.0), Vector3(14.0, 12.0, 14.0) - ) + _rain.visibility_aabb = RAIN_VISIBILITY_AABB var process_material := ParticleProcessMaterial.new() process_material.emission_shape = ( ParticleProcessMaterial.EMISSION_SHAPE_BOX ) - process_material.emission_box_extents = Vector3(6.5, 1.0, 6.5) + process_material.emission_box_extents = RAIN_EMISSION_EXTENTS process_material.direction = Vector3.DOWN process_material.spread = 5.0 process_material.initial_velocity_min = RAIN_VELOCITY_MIN