From 2045d8e2886d2138f1f76504c89128d5759fa39c Mon Sep 17 00:00:00 2001 From: Voyager Date: Thu, 13 Aug 2026 21:58:43 -0400 Subject: [PATCH] Fix multiplayer presentation and Fishnet synchronization --- jobs/player_job_service.gd | 4 + network/network_job_service.gd | 55 +++- network/network_player_animation_protocol.gd | 94 ++++++ .../network_player_animation_protocol.gd.uid | 1 + network/network_protocol.gd | 2 +- network/network_session.gd | 17 +- player/player.gd | 290 ++++++++++++++++-- scripts/run_validations.sh | 2 + tests/fish_hotbar_showcase_validation.gd | 2 +- tests/fish_quality_validation.gd | 2 +- tests/fishing_multiplayer_validation.gd | 15 +- tests/job_multiplayer_validation.gd | 14 + tests/job_system_validation.gd | 46 +++ tests/movement_multiplayer_validation.gd | 163 ++++++++++ tests/movement_multiplayer_validation.gd.uid | 1 + ...rk_player_animation_protocol_validation.gd | 49 +++ ...layer_animation_protocol_validation.gd.uid | 1 + ui/network/join_game_page.gd | 24 +- ui/the_net_page.gd | 7 +- 19 files changed, 748 insertions(+), 41 deletions(-) create mode 100644 network/network_player_animation_protocol.gd create mode 100644 network/network_player_animation_protocol.gd.uid create mode 100644 tests/movement_multiplayer_validation.gd create mode 100644 tests/movement_multiplayer_validation.gd.uid create mode 100644 tests/network_player_animation_protocol_validation.gd create mode 100644 tests/network_player_animation_protocol_validation.gd.uid 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/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..d12e113 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) @@ -1741,9 +1742,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 +1753,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/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_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..eaaf7b6 100644 --- a/tests/job_multiplayer_validation.gd +++ b/tests/job_multiplayer_validation.gd @@ -87,6 +87,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..32f6c05 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 @@ -191,10 +210,37 @@ func _run() -> void: 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_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/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 )