From d3e3bf40538d0a1fbaffad97dc363311845f7ad8 Mon Sep 17 00:00:00 2001 From: Voyager Date: Wed, 29 Jul 2026 16:29:25 -0400 Subject: [PATCH] Add host-authoritative multiplayer fishing --- fish/fish_catch.gd | 47 + fishing/catch_controller.gd | 15 + fishing/fishing_spot.gd | 244 ++++- fishing/remote_fishing_presentation.gd | 91 ++ fishing/remote_fishing_presentation.gd.uid | 1 + main/main.gd | 24 +- main/main.tscn | 7 +- network/direct_enet_transport.gd | 4 +- network/network_fishing_attempt.gd | 38 + network/network_fishing_attempt.gd.uid | 1 + network/network_fishing_protocol.gd | 142 +++ network/network_fishing_protocol.gd.uid | 1 + network/network_fishing_service.gd | 1048 ++++++++++++++++++++ network/network_fishing_service.gd.uid | 1 + network/network_protocol.gd | 7 +- network/network_session.gd | 20 + player/player.gd | 4 + 17 files changed, 1672 insertions(+), 23 deletions(-) create mode 100644 fishing/remote_fishing_presentation.gd create mode 100644 fishing/remote_fishing_presentation.gd.uid create mode 100644 network/network_fishing_attempt.gd create mode 100644 network/network_fishing_attempt.gd.uid create mode 100644 network/network_fishing_protocol.gd create mode 100644 network/network_fishing_protocol.gd.uid create mode 100644 network/network_fishing_service.gd create mode 100644 network/network_fishing_service.gd.uid diff --git a/fish/fish_catch.gd b/fish/fish_catch.gd index 318e2bb..b7278c4 100644 --- a/fish/fish_catch.gd +++ b/fish/fish_catch.gd @@ -135,6 +135,53 @@ static func from_save_dict( return fish_catch if fish_catch.is_valid() else null +static func from_network_dict( + data: Dictionary, + resolved_fish: FishDataType, +) -> FishCatch: + if resolved_fish == null: + return null + for required_key: String in [ + "catch_id", + "fish_id", + "weight_lb", + "display_scale", + "sale_value", + ]: + if not data.has(required_key): + return null + var loaded_id := StringName(str(data.get("catch_id", ""))) + var loaded_fish_id := StringName(str(data.get("fish_id", ""))) + var loaded_weight: float = _read_safe_float( + data.get("weight_lb"), 0.0, MAX_SAFE_WEIGHT_LB + ) + var loaded_scale: float = _read_safe_float( + data.get("display_scale"), 0.0, MAX_SAFE_DISPLAY_SCALE + ) + var loaded_value: int = _read_safe_integer( + data.get("sale_value"), -1, MAX_SAFE_SALE_VALUE + ) + if ( + loaded_id.is_empty() + or loaded_id.length() > 160 + or loaded_fish_id != resolved_fish.id + or loaded_weight <= 0.0 + or loaded_scale <= 0.0 + or loaded_value < 0 + ): + return null + var fish_catch := FishCatch.new() + fish_catch.fish = resolved_fish + fish_catch.fish_id = loaded_fish_id + fish_catch.catch_id = loaded_id + fish_catch.catch_sequence = 0 + fish_catch.weight_lb = loaded_weight + fish_catch.display_scale = loaded_scale + fish_catch.sale_value = loaded_value + fish_catch.is_favorited = false + return fish_catch if fish_catch.is_valid() else null + + static func _read_safe_integer( value: Variant, invalid_value: int, diff --git a/fishing/catch_controller.gd b/fishing/catch_controller.gd index 350bafa..53617df 100644 --- a/fishing/catch_controller.gd +++ b/fishing/catch_controller.gd @@ -122,6 +122,21 @@ func start_encounter( _emit_encounter_update() +func start_authoritative_encounter( + profile: CatchDifficultyProfileType, + reel_speed: float, + click_power: int, + seed: int, +) -> void: + var previous_test_mode: bool = use_deterministic_test_seed + var previous_seed: int = deterministic_test_seed + use_deterministic_test_seed = true + deterministic_test_seed = seed + start_encounter(profile, reel_speed, click_power) + use_deterministic_test_seed = previous_test_mode + deterministic_test_seed = previous_seed + + func set_reel_input(held: bool) -> void: _reel_input_held = held if not held: diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index 18be08a..32fef8a 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -27,6 +27,9 @@ const FishableWaterRegionType = preload( "res://world/fishable_water_region.gd" ) const NetworkSessionType = preload("res://network/network_session.gd") +const NetworkFishingServiceType = preload( + "res://network/network_fishing_service.gd" +) signal status_changed(status: String) signal catch_display_changed( @@ -106,6 +109,7 @@ var _fishing_upgrades: PlayerFishingUpgradesType var _item_effects: PlayerItemEffectsType var _cooler_capacity: PlayerCoolerCapacityType var _network_session: NetworkSessionType +var _network_fishing: NetworkFishingServiceType var _active_player: PlayerType var _state_time_remaining: float = 0.0 var _cast_charge: float = 0.0 @@ -129,6 +133,8 @@ var _pending_catch: FishCatchType var _showcase_ready: bool = false var _put_away_press_armed: bool = false var _showcase_restore_generation: int = 0 +var _network_auto_click_accumulator: float = 0.0 +var _network_active_barrier_index: int = -1 func _ready() -> void: @@ -150,6 +156,7 @@ func setup( item_effects: PlayerItemEffectsType, cooler_capacity: PlayerCoolerCapacityType, network_session: NetworkSessionType = null, + network_fishing: NetworkFishingServiceType = null, ) -> void: _local_player = local_player _local_inventory = local_inventory @@ -161,6 +168,26 @@ func setup( _item_effects = item_effects _cooler_capacity = cooler_capacity _network_session = network_session + _network_fishing = network_fishing + if _network_fishing != null: + _network_fishing.local_cast_accepted.connect( + _on_network_cast_accepted + ) + _network_fishing.local_cast_rejected.connect( + _on_network_cast_rejected + ) + _network_fishing.local_bite_started.connect( + _on_network_bite_started + ) + _network_fishing.local_snapshot_received.connect( + _on_network_fishing_snapshot + ) + _network_fishing.local_catch_received.connect( + _on_network_catch_received + ) + _network_fishing.local_attempt_ended.connect( + _on_network_attempt_ended + ) if not _local_inventory.catches_changed.is_connected( _on_cooler_availability_changed ): @@ -350,14 +377,18 @@ func _process(delta: float) -> void: if not Input.is_action_pressed("fish_primary"): _confirm_cast() FishingState.WAITING_FOR_BITE: - _update_waiting_for_bite(delta) + if _network_fishing == null: + _update_waiting_for_bite(delta) FishingState.FIGHTING: - _catch_controller.set_effective_stats( - _get_effective_reel_speed(), - _get_effective_barrier_damage() - ) - if not Input.is_action_pressed("fish_primary"): - _catch_controller.set_reel_input(false) + if _network_fishing == null: + _catch_controller.set_effective_stats( + _get_effective_reel_speed(), + _get_effective_barrier_damage() + ) + if not Input.is_action_pressed("fish_primary"): + _catch_controller.set_reel_input(false) + else: + _update_network_auto_click(delta) FishingState.COOLDOWN: _state_time_remaining -= delta if _state_time_remaining <= 0.0: @@ -375,14 +406,6 @@ func _unhandled_input(event: InputEvent) -> void: return if not event.is_action("fish_primary"): return - if not _can_use_shared_gameplay(): - if event.is_pressed(): - status_changed.emit( - "Fishing in joined games is coming in the next multiplayer phase." - ) - get_viewport().set_input_as_handled() - return - if event.is_pressed(): match state: FishingState.READY: @@ -414,11 +437,16 @@ func _unhandled_input(event: InputEvent) -> void: _begin_aiming(_local_player) FishingState.WAITING_FOR_BITE: _withdrawal_input_held = true + if _network_fishing != null: + _network_fishing.submit_local_input(true, true) _presentation.set_line_mode( FishingPresentationType.LineMode.TAUT ) FishingState.FIGHTING: - _catch_controller.handle_primary_pressed() + if _network_fishing != null: + _network_fishing.submit_local_input(true, true) + else: + _catch_controller.handle_primary_pressed() _presentation.set_line_mode( FishingPresentationType.LineMode.TAUT ) @@ -435,10 +463,15 @@ func _unhandled_input(event: InputEvent) -> void: get_viewport().set_input_as_handled() elif state == FishingState.WAITING_FOR_BITE: _withdrawal_input_held = false + if _network_fishing != null: + _network_fishing.submit_local_input(false, false) _presentation.set_line_mode(FishingPresentationType.LineMode.SLACK) get_viewport().set_input_as_handled() elif state == FishingState.FIGHTING: - _catch_controller.set_reel_input(false) + if _network_fishing != null: + _network_fishing.submit_local_input(false, false) + else: + _catch_controller.set_reel_input(false) get_viewport().set_input_as_handled() @@ -558,6 +591,15 @@ func _confirm_cast() -> void: func _on_cast_completed() -> void: if state != FishingState.CASTING: return + if _network_fishing != null: + _network_fishing.request_local_cast( + _cast_origin_position, + _cast_target, + _cast_charge, + _build_network_evidence() + ) + status_changed.emit("checking the water...") + return _selected_water_region = get_fishable_water_region(_cast_target) _cast_landing_is_fishable = _selected_water_region != null @@ -593,6 +635,8 @@ func _on_cast_completed() -> void: ) _withdrawal_progress = 0.0 _withdrawal_input_held = false + _network_auto_click_accumulator = 0.0 + _network_active_barrier_index = -1 _bobber_water_position = _cast_target _withdrawal_endpoint = Vector3( _cast_origin_position.x + _cast_direction.x * withdrawal_cancel_distance, @@ -948,6 +992,9 @@ func _return_to_ready() -> void: func _cancel_attempt() -> void: + if _network_fishing != null and _network_fishing.has_local_attempt(): + _network_fishing.cancel_local_attempt("Fishing cancelled.") + return _cleanup_attempt("fishing cancelled.", &"cancel") @@ -1045,6 +1092,169 @@ func _build_fishing_context( return context +func build_network_context( + region: FishableWaterRegionType, +) -> FishingContextType: + return _build_fishing_context(region) + + +func _build_network_evidence() -> Dictionary: + var rarity_multipliers: Array[float] = [] + for rarity: int in range(FishDataType.Rarity.size()): + rarity_multipliers.append( + _item_effects.get_rarity_weight_multiplier(rarity) + if _item_effects != null + else 1.0 + ) + var discovered_ids: Array[String] = [] + if _local_collection_log != null: + for fish_id: StringName in _local_collection_log.get_discovered_ids(): + discovered_ids.append(str(fish_id)) + var item: ItemDataType = _get_active_item() + return { + "rod_id": str(item.item_id) if item != null else "", + "reel_speed": _get_effective_reel_speed(), + "barrier_damage": _get_effective_barrier_damage(), + "bite_multiplier": ( + _item_effects.get_bite_time_multiplier() + if _item_effects != null + else 1.0 + ), + "rarity_multipliers": rarity_multipliers, + "discovered_fish_ids": discovered_ids, + "capacity_available": not _is_cooler_full(), + } + + +func _on_network_cast_accepted( + _attempt_id: String, + target: Vector3, +) -> void: + if state != FishingState.CASTING: + return + _cast_target = target + _bobber_water_position = target + state = FishingState.WAITING_FOR_BITE + _presentation.set_line_mode(FishingPresentationType.LineMode.SLACK) + status_changed.emit("waiting for a bite...") + + +func _on_network_cast_rejected(message: String) -> void: + if state not in [FishingState.CASTING, FishingState.AIMING_CAST]: + return + _cleanup_attempt(message, &"invalid") + + +func _on_network_bite_started(_attempt_id: String) -> void: + if state != FishingState.WAITING_FOR_BITE: + return + state = FishingState.FIGHTING + _withdrawal_input_held = false + _network_auto_click_accumulator = 0.0 + _network_active_barrier_index = -1 + _fight_start_position = _bobber_water_position + bite_activated.emit() + status_changed.emit("fish on!") + _presentation.set_line_mode(FishingPresentationType.LineMode.TAUT) + _presentation.begin_reeling() + _presentation.show_bite() + _network_fishing.submit_local_input( + Input.is_action_pressed("fish_primary"), + false + ) + + +func _on_network_fishing_snapshot(snapshot: Dictionary) -> void: + if state not in [ + FishingState.WAITING_FOR_BITE, + FishingState.FIGHTING, + ]: + return + var positions := PackedFloat32Array(snapshot.get("barrier_positions", [])) + var health := PackedInt32Array(snapshot.get("barrier_health", [])) + var maximum_health := PackedInt32Array( + snapshot.get("barrier_max_health", []) + ) + var progress: float = float(snapshot.get("progress", 0.0)) + var chase_progress: float = float(snapshot.get("chase_progress", 0.0)) + if state == FishingState.FIGHTING: + _network_active_barrier_index = int( + snapshot.get("active_barrier_index", -1) + ) + catch_display_changed.emit( + progress, + chase_progress, + positions, + health, + maximum_health, + int(snapshot.get("active_barrier_index", -1)), + bool(snapshot.get("visible", false)) + ) + var bobber_data: Array = snapshot.get("bobber_position", []) + if bobber_data.size() == 3: + _bobber_water_position = Vector3( + float(bobber_data[0]), + float(bobber_data[1]), + float(bobber_data[2]) + ) + if state == FishingState.WAITING_FOR_BITE: + _presentation.show_withdrawal_position(_bobber_water_position) + else: + _presentation.show_reel_position( + _bobber_water_position, + Input.is_action_pressed("fish_primary") + ) + + +func _update_network_auto_click(delta: float) -> void: + if ( + not _catch_controller.auto_click_enabled + or not Input.is_action_pressed("fish_primary") + or _network_active_barrier_index < 0 + ): + _network_auto_click_accumulator = 0.0 + return + _network_auto_click_accumulator += delta + var interval: float = maxf( + _catch_controller.auto_click_interval, + 0.05 + ) + while _network_auto_click_accumulator >= interval: + _network_auto_click_accumulator -= interval + _network_fishing.submit_local_input(true, true) + + +func _on_network_catch_received(fish_catch: FishCatchType) -> void: + if state != FishingState.FIGHTING or fish_catch == null: + return + _pending_catch = fish_catch + state = FishingState.SHOWING_CATCH + _showcase_ready = false + _put_away_press_armed = false + catch_display_changed.emit( + 0.0, 0.0, PackedFloat32Array(), PackedInt32Array(), + PackedInt32Array(), -1, false + ) + _presentation.set_line_mode(FishingPresentationType.LineMode.TAUT) + _presentation.play_outcome(&"catch") + + +func _on_network_attempt_ended( + outcome: StringName, + message: String, +) -> void: + if state not in [ + FishingState.CASTING, + FishingState.WAITING_FOR_BITE, + FishingState.FIGHTING, + ]: + return + _cleanup_attempt( + message if not message.is_empty() else "Fishing attempt ended.", + &"escape" if outcome == &"escape" else &"cancel" + ) + + func find_last_fishable_position(from: Vector3, to: Vector3) -> Vector3: if from.is_equal_approx(to): return from diff --git a/fishing/remote_fishing_presentation.gd b/fishing/remote_fishing_presentation.gd new file mode 100644 index 0000000..3cbb1f2 --- /dev/null +++ b/fishing/remote_fishing_presentation.gd @@ -0,0 +1,91 @@ +class_name RemoteFishingPresentation +extends Node3D + +var _owner: Player +var _bobber: MeshInstance3D +var _line: MeshInstance3D +var _line_mesh := ImmediateMesh.new() +var _target: Vector3 +var _active: bool = false + + +func setup(owner: Player) -> void: + _owner = owner + _bobber = MeshInstance3D.new() + var bobber_mesh := SphereMesh.new() + bobber_mesh.radius = 0.12 + bobber_mesh.height = 0.24 + _bobber.mesh = bobber_mesh + var bobber_material := StandardMaterial3D.new() + bobber_material.albedo_color = Color(0.92, 0.12, 0.08, 1.0) + bobber_material.roughness = 0.45 + _bobber.material_override = bobber_material + add_child(_bobber) + _line = MeshInstance3D.new() + _line.mesh = _line_mesh + var line_material := StandardMaterial3D.new() + line_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + line_material.albedo_color = Color(0.9, 0.9, 0.82, 1.0) + _line.material_override = line_material + add_child(_line) + cleanup() + + +func show_cast(target: Vector3) -> void: + if _owner == null or not target.is_finite(): + return + _active = true + _target = target + _bobber.global_position = target + _bobber.visible = true + _line.visible = true + _owner.set_active_item_is_rod(true) + _redraw_line() + + +func update_bobber(position: Vector3) -> void: + if not _active or not position.is_finite(): + return + _target = position + _bobber.global_position = position + _redraw_line() + + +func show_bite() -> void: + if not _active: + return + var tween: Tween = create_tween() + tween.tween_property(_bobber, "scale", Vector3.ONE * 0.7, 0.08) + tween.tween_property(_bobber, "scale", Vector3.ONE, 0.12) + + +func cleanup() -> void: + _active = false + if _bobber != null: + _bobber.visible = false + _bobber.scale = Vector3.ONE + if _line != null: + _line.visible = false + _line_mesh.clear_surfaces() + + +func _process(_delta: float) -> void: + if _active: + _redraw_line() + + +func _redraw_line() -> void: + if _owner == null or not is_instance_valid(_owner): + cleanup() + return + var tip: Marker3D = _owner.get_fishing_rod_tip() + if tip == null: + return + _line_mesh.clear_surfaces() + _line_mesh.surface_begin(Mesh.PRIMITIVE_LINE_STRIP) + _line_mesh.surface_add_vertex(to_local(tip.global_position)) + var midpoint: Vector3 = tip.global_position.lerp(_target, 0.5) + midpoint.y -= 0.12 + _line_mesh.surface_add_vertex(to_local(midpoint)) + _line_mesh.surface_add_vertex(to_local(_target)) + _line_mesh.surface_end() diff --git a/fishing/remote_fishing_presentation.gd.uid b/fishing/remote_fishing_presentation.gd.uid new file mode 100644 index 0000000..62f590d --- /dev/null +++ b/fishing/remote_fishing_presentation.gd.uid @@ -0,0 +1 @@ +uid://db3t8mrcaw062 diff --git a/main/main.gd b/main/main.gd index bd18c78..50a481c 100644 --- a/main/main.gd +++ b/main/main.gd @@ -43,6 +43,9 @@ const SavedServerStoreType = preload( const PlayerSpawnServiceType = preload( "res://network/player_spawn_service.gd" ) +const NetworkFishingServiceType = preload( + "res://network/network_fishing_service.gd" +) const TITLE_MUSIC_SILENCE_DB: float = -80.0 @@ -77,6 +80,9 @@ const TITLE_MUSIC_SILENCE_DB: float = -80.0 @onready var _player_spawn_service: PlayerSpawnServiceType = ( %PlayerSpawnService ) +@onready var _network_fishing: NetworkFishingServiceType = ( + %NetworkFishingService +) @onready var _players_root: Node3D = $Players var _gameplay_started: bool = false @@ -136,6 +142,17 @@ func _ready() -> void: _player.cooler_capacity ) _save_manager.set_autosave_enabled(false) + _network_fishing.setup( + _network_session, + _player_spawn_service, + _fishing_spot, + _player.inventory, + _player.collection_log, + _player.cooler_capacity, + _save_manager, + item_catalog, + fish_catalog + ) _fishing_spot.setup( _player, _player.inventory, @@ -146,7 +163,8 @@ func _ready() -> void: _player.fishing_upgrades, _player.item_effects, _player.cooler_capacity, - _network_session + _network_session, + _network_fishing ) _game_ui.setup( _player, @@ -562,6 +580,10 @@ func _on_remote_recovery_requested( var avatar: PlayerType = _player_spawn_service.get_avatar(peer_id) if avatar == null: return + _network_fishing.cancel_peer_attempt( + peer_id, + "Fishing attempt ended." + ) var target_position: Vector3 = _test_world.get_player_spawn_transform().origin var nearest_distance: float = INF for point: SafeRespawnPoint in _test_world.get_safe_respawn_points(): diff --git a/main/main.tscn b/main/main.tscn index bcb80fb..88fe2dc 100644 --- a/main/main.tscn +++ b/main/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=21 format=3] +[gd_scene load_steps=22 format=3] [ext_resource type="PackedScene" path="res://world/test_world.tscn" id="1_world"] [ext_resource type="PackedScene" path="res://player/player.tscn" id="2_player"] @@ -20,6 +20,7 @@ [ext_resource type="Script" path="res://network/network_profile_preferences.gd" id="18_network_profile"] [ext_resource type="Script" path="res://network/saved_server_store.gd" id="19_saved_servers"] [ext_resource type="Script" path="res://network/player_spawn_service.gd" id="20_spawn_service"] +[ext_resource type="Script" path="res://network/network_fishing_service.gd" id="21_network_fishing"] [node name="Main" type="Node3D"] script = ExtResource("3_main") @@ -44,6 +45,10 @@ script = ExtResource("19_saved_servers") unique_name_in_owner = true script = ExtResource("20_spawn_service") +[node name="NetworkFishingService" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("21_network_fishing") + [node name="TestWorld" parent="." instance=ExtResource("1_world")] [node name="Players" type="Node3D" parent="."] diff --git a/network/direct_enet_transport.gd b/network/direct_enet_transport.gd index 3d6201d..ece4863 100644 --- a/network/direct_enet_transport.gd +++ b/network/direct_enet_transport.gd @@ -11,7 +11,7 @@ func start_host( var enet_peer := ENetMultiplayerPeer.new() if not bind_address.is_empty() and bind_address != "*": enet_peer.set_bind_ip(bind_address) - var error: Error = enet_peer.create_server(port, max_clients, 3) + var error: Error = enet_peer.create_server(port, max_clients, 5) if error != OK: transport_error.emit("Unable to host UDP port %d." % port) return error @@ -27,7 +27,7 @@ func connect_to_route(route: ConnectionRoute) -> Error: return ERR_INVALID_PARAMETER var endpoint: ConnectionEndpoint = route.direct_endpoint var enet_peer := ENetMultiplayerPeer.new() - var error: Error = enet_peer.create_client(endpoint.host, endpoint.port, 3) + var error: Error = enet_peer.create_client(endpoint.host, endpoint.port, 5) if error != OK: transport_error.emit( "Unable to connect to %s." % endpoint.normalized_display diff --git a/network/network_fishing_attempt.gd b/network/network_fishing_attempt.gd new file mode 100644 index 0000000..c48bab5 --- /dev/null +++ b/network/network_fishing_attempt.gd @@ -0,0 +1,38 @@ +class_name NetworkFishingAttempt +extends RefCounted + +enum Phase { + CASTING, + WAITING_FOR_BITE, + FIGHTING, + PENDING_CAPACITY, + CAUGHT, + ESCAPED, + CANCELLED, +} + +var owner_peer_id: int = 0 +var request_id: String = "" +var attempt_id: String = "" +var result_id: String = "" +var session_id: String = "" +var phase: Phase = Phase.CASTING +var origin: Vector3 +var target: Vector3 +var bobber_position: Vector3 +var fish_id: StringName +var encounter_seed: int = 0 +var bite_time_remaining: float = 0.0 +var withdrawal_progress: float = 0.0 +var reel_speed: float = 0.0 +var barrier_damage: int = 1 +var input_held: bool = false +var last_input_sequence: int = 0 +var catch_payload: Dictionary = {} +var capacity_nonce: String = "" +var capacity_deadline: float = 0.0 +var controller: CatchController + + +func is_terminal() -> bool: + return phase in [Phase.CAUGHT, Phase.ESCAPED, Phase.CANCELLED] diff --git a/network/network_fishing_attempt.gd.uid b/network/network_fishing_attempt.gd.uid new file mode 100644 index 0000000..549ffcf --- /dev/null +++ b/network/network_fishing_attempt.gd.uid @@ -0,0 +1 @@ +uid://c4c00yp1546af diff --git a/network/network_fishing_protocol.gd b/network/network_fishing_protocol.gd new file mode 100644 index 0000000..21071f4 --- /dev/null +++ b/network/network_fishing_protocol.gd @@ -0,0 +1,142 @@ +class_name NetworkFishingProtocol +extends RefCounted + +const MAX_ID_LENGTH: int = 96 +const MAX_EVIDENCE_FISH_IDS: int = 256 +const MAX_CAST_DISTANCE: float = 30.0 +const MAX_REEL_SPEED: float = 10.0 +const MAX_BARRIER_DAMAGE: int = 100 +const INPUT_CHANNEL: int = 3 +const SNAPSHOT_CHANNEL: int = 4 +const INPUT_RATE: float = 1.0 / 25.0 +const SNAPSHOT_RATE: float = 1.0 / 15.0 + +enum Outcome { + CATCH, + ESCAPE, + CANCELLED, +} + + +static func validate_cast_request(data: Variant) -> String: + if typeof(data) != TYPE_DICTIONARY: + return "Malformed fishing request." + var payload: Dictionary = data + for key: String in [ + "request_id", + "session_id", + "origin", + "target", + "charge", + "rod_id", + "reel_speed", + "barrier_damage", + "bite_multiplier", + "rarity_multipliers", + "discovered_fish_ids", + "capacity_available", + ]: + if not payload.has(key): + return "Malformed fishing request." + if ( + typeof(payload["request_id"]) != TYPE_STRING + or typeof(payload["session_id"]) != TYPE_STRING + or typeof(payload["origin"]) != TYPE_ARRAY + or typeof(payload["target"]) != TYPE_ARRAY + or typeof(payload["charge"]) not in [TYPE_FLOAT, TYPE_INT] + or typeof(payload["rod_id"]) not in [TYPE_STRING, TYPE_STRING_NAME] + or typeof(payload["reel_speed"]) not in [TYPE_FLOAT, TYPE_INT] + or typeof(payload["barrier_damage"]) != TYPE_INT + or typeof(payload["bite_multiplier"]) not in [TYPE_FLOAT, TYPE_INT] + or typeof(payload["rarity_multipliers"]) != TYPE_ARRAY + or typeof(payload["discovered_fish_ids"]) != TYPE_ARRAY + or typeof(payload["capacity_available"]) != TYPE_BOOL + ): + return "Malformed fishing request." + var request_id: String = payload["request_id"] + var session_id: String = payload["session_id"] + var origin: Array = payload["origin"] + var target: Array = payload["target"] + var charge: float = float(payload["charge"]) + var reel_speed: float = float(payload["reel_speed"]) + var barrier_damage: int = payload["barrier_damage"] + var bite_multiplier: float = float(payload["bite_multiplier"]) + var rarity: Array = payload["rarity_multipliers"] + var discovered: Array = payload["discovered_fish_ids"] + if ( + request_id.is_empty() + or request_id.length() > MAX_ID_LENGTH + or session_id.is_empty() + or session_id.length() > MAX_ID_LENGTH + or origin.size() != 3 + or target.size() != 3 + or not _array_is_finite_vector3(origin) + or not _array_is_finite_vector3(target) + or not is_finite(charge) + or charge < 0.0 + or charge > 1.01 + or not is_finite(reel_speed) + or reel_speed <= 0.0 + or reel_speed > MAX_REEL_SPEED + or barrier_damage < 1 + or barrier_damage > MAX_BARRIER_DAMAGE + or not is_finite(bite_multiplier) + or bite_multiplier < 0.1 + or bite_multiplier > 10.0 + or rarity.size() > 16 + or discovered.size() > MAX_EVIDENCE_FISH_IDS + or str(payload["rod_id"]).is_empty() + or str(payload["rod_id"]).length() > 96 + ): + return "Fishing request values are outside allowed limits." + for value: Variant in rarity: + if ( + typeof(value) not in [TYPE_FLOAT, TYPE_INT] + or not is_finite(float(value)) + or float(value) < 0.0 + or float(value) > 10.0 + ): + return "Fishing rarity evidence is invalid." + for value: Variant in discovered: + if ( + typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME] + or str(value).is_empty() + or str(value).length() > 96 + ): + return "Fishing discovery evidence is invalid." + return "" + + +static func validate_input(data: Variant) -> bool: + if typeof(data) != TYPE_DICTIONARY: + return false + var payload: Dictionary = data + return ( + typeof(payload.get("attempt_id")) == TYPE_STRING + and not str(payload["attempt_id"]).is_empty() + and str(payload["attempt_id"]).length() <= MAX_ID_LENGTH + and typeof(payload.get("sequence")) == TYPE_INT + and int(payload["sequence"]) > 0 + and typeof(payload.get("held")) == TYPE_BOOL + and typeof(payload.get("pressed")) == TYPE_BOOL + ) + + +static func vector3_to_array(value: Vector3) -> Array[float]: + return [value.x, value.y, value.z] + + +static func array_to_vector3(value: Array) -> Vector3: + if value.size() != 3: + return Vector3.INF + return Vector3(float(value[0]), float(value[1]), float(value[2])) + + +static func _array_is_finite_vector3(value: Array) -> bool: + for component: Variant in value: + if ( + typeof(component) not in [TYPE_FLOAT, TYPE_INT] + or not is_finite(float(component)) + ): + return false + return true diff --git a/network/network_fishing_protocol.gd.uid b/network/network_fishing_protocol.gd.uid new file mode 100644 index 0000000..2826314 --- /dev/null +++ b/network/network_fishing_protocol.gd.uid @@ -0,0 +1 @@ +uid://cbihol2i6e2iw diff --git a/network/network_fishing_service.gd b/network/network_fishing_service.gd new file mode 100644 index 0000000..0292ba8 --- /dev/null +++ b/network/network_fishing_service.gd @@ -0,0 +1,1048 @@ +class_name NetworkFishingService +extends Node + +const FishCatchType = preload("res://fish/fish_catch.gd") +const FishDataType = preload("res://fish/fish_data.gd") +const FishPoolType = preload("res://fish/fish_pool.gd") +const FishSelectorType = preload("res://fish/fish_selector.gd") +const FishingContextType = preload("res://fishing/fishing_context.gd") +const CollectionLogType = preload("res://collection/collection_log.gd") +const RemotePresentationType = preload( + "res://fishing/remote_fishing_presentation.gd" +) + +const MAX_LEDGER_ENTRIES_PER_PEER: int = 64 +const CAST_ORIGIN_TOLERANCE: float = 2.5 +const CAPACITY_RESPONSE_TIMEOUT: float = 5.0 +const MIN_CAST_INTERVAL: float = 0.25 + +signal local_cast_accepted(attempt_id: String, target: Vector3) +signal local_cast_rejected(message: String) +signal local_bite_started(attempt_id: String) +signal local_snapshot_received(snapshot: Dictionary) +signal local_catch_received(fish_catch: FishCatch) +signal local_attempt_ended(outcome: StringName, message: String) + +var _session: NetworkSession +var _spawn_service: PlayerSpawnService +var _fishing_spot: FishingSpot +var _local_inventory: FishInventory +var _local_collection: CollectionLog +var _local_capacity: PlayerCoolerCapacity +var _save_manager: PlayerSaveManager +var _item_catalog: ItemCatalog +var _fish_catalog: FishPoolType +var _attempts: Dictionary[int, NetworkFishingAttempt] = {} +var _request_ledgers: Dictionary[int, Dictionary] = {} +var _result_ledgers: Dictionary[String, bool] = {} +var _result_acknowledgements: Dictionary[String, String] = {} +var _last_cast_time: Dictionary[int, float] = {} +var _last_input_time: Dictionary[int, float] = {} +var _remote_presentations: Dictionary[int, RemoteFishingPresentation] = {} +var _snapshot_accumulator: float = 0.0 +var _local_input_sequence: int = 0 + + +func setup( + session: NetworkSession, + spawn_service: PlayerSpawnService, + fishing_spot: FishingSpot, + local_inventory: FishInventory, + local_collection: CollectionLog, + local_capacity: PlayerCoolerCapacity, + save_manager: PlayerSaveManager, + item_catalog: ItemCatalog, + fish_catalog: FishPoolType, +) -> void: + _session = session + _spawn_service = spawn_service + _fishing_spot = fishing_spot + _local_inventory = local_inventory + _local_collection = local_collection + _local_capacity = local_capacity + _save_manager = save_manager + _item_catalog = item_catalog + _fish_catalog = fish_catalog + if not _session.peer_removed.is_connected(_on_peer_removed): + _session.peer_removed.connect(_on_peer_removed) + if not _session.state_changed.is_connected(_on_session_state_changed): + _session.state_changed.connect(_on_session_state_changed) + if not _spawn_service.avatar_removed.is_connected(_on_avatar_removed): + _spawn_service.avatar_removed.connect(_on_avatar_removed) + + +func request_local_cast( + origin: Vector3, + target: Vector3, + charge: float, + evidence: Dictionary, +) -> String: + if ( + _session == null + or not _session.is_gameplay_session_active() + or not origin.is_finite() + or not target.is_finite() + ): + local_cast_rejected.emit("Fishing attempt ended.") + return "" + var request_id: String = _new_id("cast") + var data: Dictionary = { + "request_id": request_id, + "session_id": _session.get_session_id(), + "origin": NetworkFishingProtocol.vector3_to_array(origin), + "target": NetworkFishingProtocol.vector3_to_array(target), + "charge": charge, + "rod_id": str(evidence.get("rod_id", "")), + "reel_speed": float(evidence.get("reel_speed", 0.0)), + "barrier_damage": int(evidence.get("barrier_damage", 0)), + "bite_multiplier": float(evidence.get("bite_multiplier", 1.0)), + "rarity_multipliers": evidence.get("rarity_multipliers", []), + "discovered_fish_ids": evidence.get("discovered_fish_ids", []), + "capacity_available": bool(evidence.get("capacity_available", false)), + } + if _session.is_host(): + _handle_cast_request(_session.get_local_peer_id(), data) + else: + submit_cast_request.rpc_id(1, data) + return request_id + + +func submit_local_input(held: bool, pressed: bool) -> void: + var peer_id: int = _session.get_local_peer_id() + var attempt: NetworkFishingAttempt = _attempts.get(peer_id) + if attempt == null: + return + _local_input_sequence += 1 + var data: Dictionary = { + "attempt_id": attempt.attempt_id, + "sequence": _local_input_sequence, + "held": held, + "pressed": pressed, + } + if _session.is_host(): + _handle_fishing_input(peer_id, data) + else: + submit_fishing_input.rpc_id(1, data) + + +func cancel_local_attempt(reason: String = "Fishing attempt ended.") -> void: + if _session == null or not _session.is_gameplay_session_active(): + return + var peer_id: int = _session.get_local_peer_id() + var attempt: NetworkFishingAttempt = _attempts.get(peer_id) + if attempt == null: + return + if _session.is_host(): + _cancel_attempt(peer_id, reason) + else: + submit_cancel_request.rpc_id(1, attempt.attempt_id) + + +func cancel_peer_attempt(peer_id: int, reason: String) -> void: + if _session != null and _session.is_host() and _attempts.has(peer_id): + _cancel_attempt(peer_id, reason) + + +func has_local_attempt() -> bool: + return ( + _session != null + and _attempts.has(_session.get_local_peer_id()) + ) + + +func _process(delta: float) -> void: + if _session == null or not _session.is_host(): + return + var now: float = Time.get_ticks_msec() / 1000.0 + for peer_id: int in _attempts.keys(): + var attempt: NetworkFishingAttempt = _attempts.get(peer_id) + if attempt == null: + continue + match attempt.phase: + NetworkFishingAttempt.Phase.WAITING_FOR_BITE: + _update_waiting_attempt(attempt, delta) + if not _attempts.has(peer_id): + continue + attempt.bite_time_remaining -= delta + if attempt.bite_time_remaining <= 0.0: + _start_bite(attempt) + NetworkFishingAttempt.Phase.PENDING_CAPACITY: + if now >= attempt.capacity_deadline: + _cancel_attempt(peer_id, "Fishing attempt ended.") + _snapshot_accumulator += delta + if _snapshot_accumulator >= NetworkFishingProtocol.SNAPSHOT_RATE: + _snapshot_accumulator = fmod( + _snapshot_accumulator, + NetworkFishingProtocol.SNAPSHOT_RATE + ) + _broadcast_snapshots() + + +@rpc("any_peer", "call_remote", "reliable", 0) +func submit_cast_request(data: Dictionary) -> void: + var sender_id: int = multiplayer.get_remote_sender_id() + if not _session.is_host() or not _session.is_authenticated_peer(sender_id): + return + _handle_cast_request(sender_id, data) + + +func _handle_cast_request(peer_id: int, data: Dictionary) -> void: + var validation_error: String = NetworkFishingProtocol.validate_cast_request( + data + ) + if not validation_error.is_empty(): + _send_cast_rejected(peer_id, str(data.get("request_id", "")), + validation_error) + return + var request_id: String = data["request_id"] + var ledger: Dictionary = _request_ledgers.get(peer_id, {}) + if ledger.has(request_id): + _resend_request_response(peer_id, ledger[request_id]) + return + if str(data["session_id"]) != _session.get_session_id(): + _record_and_reject(peer_id, request_id, "Fishing attempt ended.") + return + var now: float = Time.get_ticks_msec() / 1000.0 + if now - float(_last_cast_time.get(peer_id, -INF)) < MIN_CAST_INTERVAL: + _record_and_reject(peer_id, request_id, "Already fishing.") + return + _last_cast_time[peer_id] = now + if _attempts.has(peer_id): + _record_and_reject(peer_id, request_id, "Already fishing.") + return + if not bool(data["capacity_available"]): + _record_and_reject(peer_id, request_id, "Cooler is full.") + return + var rod: ItemData = _item_catalog.get_item_by_id( + StringName(str(data["rod_id"])) + ) + if rod == null or rod.category != ItemData.Category.ROD: + _record_and_reject(peer_id, request_id, "Select a fishing rod to cast.") + return + var avatar: Player = _spawn_service.get_avatar(peer_id) + if avatar == null or avatar.is_water_recovery_active(): + _record_and_reject(peer_id, request_id, "Fishing attempt ended.") + return + var origin: Vector3 = NetworkFishingProtocol.array_to_vector3(data["origin"]) + var target: Vector3 = NetworkFishingProtocol.array_to_vector3(data["target"]) + var authoritative_origin: Vector3 = avatar.get_cast_origin_position() + if origin.distance_to(authoritative_origin) > CAST_ORIGIN_TOLERANCE: + _record_and_reject(peer_id, request_id, "Cannot fish here.") + return + var cast_offset: Vector3 = target - authoritative_origin + cast_offset.y = 0.0 + var expected_distance: float = lerpf( + _fishing_spot.minimum_cast_distance, + _fishing_spot.maximum_cast_distance, + float(data["charge"]) + ) + var facing: Vector3 = avatar.get_facing_direction() + facing.y = 0.0 + if ( + cast_offset.length() < _fishing_spot.minimum_cast_distance - 0.25 + or cast_offset.length() > _fishing_spot.maximum_cast_distance + 0.5 + or absf(cast_offset.length() - expected_distance) > 1.25 + or ( + not facing.is_zero_approx() + and facing.normalized().dot(cast_offset.normalized()) < 0.2 + ) + ): + _record_and_reject(peer_id, request_id, "Cannot fish here.") + return + var region: FishableWaterRegion = ( + _fishing_spot.get_fishable_water_region(target) + ) + if region == null or region.fish_pool == null: + _record_and_reject(peer_id, request_id, "Cannot fish here.") + return + var selected_fish: FishDataType = _select_authoritative_fish( + region, data + ) + if selected_fish == null: + _record_and_reject(peer_id, request_id, "Nothing is biting here.") + return + var attempt := NetworkFishingAttempt.new() + attempt.owner_peer_id = peer_id + attempt.request_id = request_id + attempt.attempt_id = _new_id("attempt") + attempt.session_id = _session.get_session_id() + attempt.phase = NetworkFishingAttempt.Phase.WAITING_FOR_BITE + attempt.origin = authoritative_origin + attempt.target = target + attempt.bobber_position = target + attempt.fish_id = selected_fish.id + attempt.reel_speed = float(data["reel_speed"]) + attempt.barrier_damage = int(data["barrier_damage"]) + attempt.bite_time_remaining = _fishing_spot.wait_time * float( + data["bite_multiplier"] + ) + attempt.controller = CatchController.new() + add_child(attempt.controller) + attempt.controller.encounter_updated.connect( + _on_encounter_updated.bind(peer_id) + ) + attempt.controller.caught.connect(_on_attempt_caught.bind(peer_id)) + attempt.controller.escaped.connect(_on_attempt_escaped.bind(peer_id)) + attempt.set_meta("snapshot", _make_waiting_snapshot(attempt)) + _attempts[peer_id] = attempt + avatar.set_movement_enabled(false) + var response: Dictionary = _make_cast_accepted(attempt) + _record_request_response(peer_id, request_id, response) + _broadcast_cast_accepted(response) + + +func _update_waiting_attempt( + attempt: NetworkFishingAttempt, + delta: float, +) -> void: + if not attempt.input_held: + return + var flat_offset: Vector3 = attempt.target - attempt.origin + flat_offset.y = 0.0 + var withdrawable_distance: float = ( + flat_offset.length() - _fishing_spot.withdrawal_cancel_distance + ) + if withdrawable_distance <= 0.0: + _cancel_attempt(attempt.owner_peer_id, "Fishing cancelled.") + return + attempt.withdrawal_progress = minf( + attempt.withdrawal_progress + + _fishing_spot.withdrawal_rate * delta / withdrawable_distance, + 1.0 + ) + var endpoint: Vector3 = attempt.origin + ( + flat_offset.normalized() + * _fishing_spot.withdrawal_cancel_distance + ) + endpoint.y = attempt.target.y + var desired: Vector3 = attempt.target.lerp( + endpoint, + attempt.withdrawal_progress + ) + attempt.bobber_position = _fishing_spot.find_last_fishable_position( + attempt.bobber_position, + desired + ) + attempt.set_meta("snapshot", _make_waiting_snapshot(attempt)) + if ( + not attempt.bobber_position.is_equal_approx(desired) + or is_equal_approx(attempt.withdrawal_progress, 1.0) + ): + _cancel_attempt(attempt.owner_peer_id, "") + + +func _make_waiting_snapshot( + attempt: NetworkFishingAttempt, +) -> Dictionary: + return { + "attempt_id": attempt.attempt_id, + "owner_peer_id": attempt.owner_peer_id, + "phase": int(attempt.phase), + "progress": 0.0, + "chase_progress": 0.0, + "barrier_positions": [], + "barrier_health": [], + "barrier_max_health": [], + "active_barrier_index": -1, + "visible": false, + "acknowledged_input_sequence": attempt.last_input_sequence, + "bobber_position": NetworkFishingProtocol.vector3_to_array( + attempt.bobber_position + ), + } + + +func _select_authoritative_fish( + region: FishableWaterRegion, + data: Dictionary, +) -> FishDataType: + var evidence_log := CollectionLogType.new() + for value: Variant in data["discovered_fish_ids"]: + var fish_id := StringName(str(value)) + if _fish_catalog.get_fish_by_id(fish_id) == null: + return null + evidence_log.mark_discovered(fish_id) + var selector := FishSelectorType.new() + selector.undiscovered_weight_multiplier = ( + _fishing_spot.undiscovered_weight_multiplier + ) + for value: Variant in data["rarity_multipliers"]: + selector.rarity_weight_multipliers.append(float(value)) + selector.begin_roll() + var context: FishingContextType = _fishing_spot.build_network_context(region) + return selector.select_fish(region.fish_pool, context, evidence_log) + + +func _start_bite(attempt: NetworkFishingAttempt) -> void: + if attempt.phase != NetworkFishingAttempt.Phase.WAITING_FOR_BITE: + return + var fish: FishDataType = _fish_catalog.get_fish_by_id(attempt.fish_id) + if fish == null or fish.catch_profile == null: + _cancel_attempt(attempt.owner_peer_id, "Fishing attempt ended.") + return + attempt.phase = NetworkFishingAttempt.Phase.FIGHTING + attempt.encounter_seed = _new_seed() + attempt.controller.start_authoritative_encounter( + fish.catch_profile, + attempt.reel_speed, + attempt.barrier_damage, + attempt.encounter_seed + ) + var data: Dictionary = { + "attempt_id": attempt.attempt_id, + "owner_peer_id": attempt.owner_peer_id, + } + _apply_bite_started(data) + receive_bite_started.rpc(data) + + +@rpc("any_peer", "call_remote", "unreliable_ordered", 3) +func submit_fishing_input(data: Dictionary) -> void: + var sender_id: int = multiplayer.get_remote_sender_id() + if not _session.is_host() or not _session.is_authenticated_peer(sender_id): + return + _handle_fishing_input(sender_id, data) + + +func _handle_fishing_input(peer_id: int, data: Dictionary) -> void: + if not NetworkFishingProtocol.validate_input(data): + return + var now: float = Time.get_ticks_msec() / 1000.0 + if now - float(_last_input_time.get(peer_id, -INF)) < 1.0 / 120.0: + return + _last_input_time[peer_id] = now + var attempt: NetworkFishingAttempt = _attempts.get(peer_id) + if ( + attempt == null + or attempt.attempt_id != str(data["attempt_id"]) + or int(data["sequence"]) <= attempt.last_input_sequence + ): + return + attempt.last_input_sequence = int(data["sequence"]) + attempt.input_held = bool(data["held"]) + if attempt.phase != NetworkFishingAttempt.Phase.FIGHTING: + return + attempt.controller.set_reel_input(attempt.input_held) + if bool(data["pressed"]): + attempt.controller.handle_primary_pressed() + + +@rpc("any_peer", "call_remote", "reliable", 0) +func submit_cancel_request(attempt_id: String) -> void: + var sender_id: int = multiplayer.get_remote_sender_id() + var attempt: NetworkFishingAttempt = _attempts.get(sender_id) + if ( + not _session.is_host() + or not _session.is_authenticated_peer(sender_id) + or attempt == null + or attempt.attempt_id != attempt_id + ): + return + _cancel_attempt(sender_id, "Fishing cancelled.") + + +func _on_encounter_updated( + progress: float, + chase_progress: float, + barrier_positions: PackedFloat32Array, + barrier_health: PackedInt32Array, + barrier_max_health: PackedInt32Array, + active_barrier_index: int, + visible: bool, + peer_id: int, +) -> void: + var attempt: NetworkFishingAttempt = _attempts.get(peer_id) + if attempt == null or attempt.phase != NetworkFishingAttempt.Phase.FIGHTING: + return + attempt.bobber_position = attempt.target.lerp(attempt.origin, progress) + attempt.set_meta("snapshot", { + "attempt_id": attempt.attempt_id, + "owner_peer_id": peer_id, + "phase": int(attempt.phase), + "progress": progress, + "chase_progress": chase_progress, + "barrier_positions": Array(barrier_positions), + "barrier_health": Array(barrier_health), + "barrier_max_health": Array(barrier_max_health), + "active_barrier_index": active_barrier_index, + "visible": visible, + "acknowledged_input_sequence": attempt.last_input_sequence, + "bobber_position": NetworkFishingProtocol.vector3_to_array( + attempt.bobber_position + ), + }) + + +func _broadcast_snapshots() -> void: + var snapshots: Array[Dictionary] = [] + for attempt: NetworkFishingAttempt in _attempts.values(): + var snapshot: Dictionary = attempt.get_meta("snapshot", {}) + if not snapshot.is_empty(): + snapshots.append(snapshot) + if snapshots.is_empty(): + return + _apply_snapshots(snapshots) + receive_fishing_snapshots.rpc(snapshots) + + +@rpc("authority", "call_remote", "unreliable_ordered", 4) +func receive_fishing_snapshots(snapshots: Array) -> void: + _apply_snapshots(snapshots) + + +func _apply_snapshots(snapshots: Array) -> void: + var local_peer_id: int = _session.get_local_peer_id() + for value: Variant in snapshots: + if typeof(value) != TYPE_DICTIONARY: + continue + var snapshot: Dictionary = value + if not _valid_snapshot(snapshot): + continue + var owner_peer_id: int = int(snapshot["owner_peer_id"]) + if owner_peer_id == local_peer_id: + local_snapshot_received.emit(snapshot) + else: + var presentation := _get_remote_presentation(owner_peer_id) + if presentation != null: + presentation.update_bobber( + NetworkFishingProtocol.array_to_vector3( + snapshot["bobber_position"] + ) + ) + + +func _valid_snapshot(data: Dictionary) -> bool: + if not ( + typeof(data.get("attempt_id")) == TYPE_STRING + and typeof(data.get("owner_peer_id")) == TYPE_INT + and typeof(data.get("phase")) == TYPE_INT + and typeof(data.get("progress")) in [TYPE_FLOAT, TYPE_INT] + and typeof(data.get("chase_progress")) in [TYPE_FLOAT, TYPE_INT] + and typeof(data.get("barrier_positions")) == TYPE_ARRAY + and typeof(data.get("barrier_health")) == TYPE_ARRAY + and typeof(data.get("barrier_max_health")) == TYPE_ARRAY + and typeof(data.get("active_barrier_index")) == TYPE_INT + and typeof(data.get("visible")) == TYPE_BOOL + and typeof(data.get("bobber_position")) == TYPE_ARRAY + and NetworkFishingProtocol.array_to_vector3( + data["bobber_position"] + ).is_finite() + ): + return false + var positions: Array = data["barrier_positions"] + var health: Array = data["barrier_health"] + var maximum_health: Array = data["barrier_max_health"] + if ( + positions.size() > 32 + or health.size() != positions.size() + or maximum_health.size() != positions.size() + ): + return false + var progress: float = float(data["progress"]) + var chase: float = float(data["chase_progress"]) + if ( + not is_finite(progress) + or not is_finite(chase) + or progress < -0.01 + or progress > 1.01 + or chase < -10.0 + or chase > 1.01 + ): + return false + for index: int in range(positions.size()): + if ( + typeof(positions[index]) not in [TYPE_FLOAT, TYPE_INT] + or not is_finite(float(positions[index])) + or typeof(health[index]) != TYPE_INT + or typeof(maximum_health[index]) != TYPE_INT + or int(health[index]) < 0 + or int(maximum_health[index]) < 0 + or int(health[index]) > int(maximum_health[index]) + ): + return false + return true + + +func _on_attempt_caught(peer_id: int) -> void: + var attempt: NetworkFishingAttempt = _attempts.get(peer_id) + if attempt == null or attempt.phase != NetworkFishingAttempt.Phase.FIGHTING: + return + var fish: FishDataType = _fish_catalog.get_fish_by_id(attempt.fish_id) + if fish == null: + _cancel_attempt(peer_id, "Fishing attempt ended.") + return + var selector := FishSelectorType.new() + selector.use_deterministic_test_seed = true + selector.deterministic_test_seed = attempt.encounter_seed ^ 0x5F3759DF + selector.begin_roll() + var fish_catch: FishCatch = selector.create_catch(fish) + if fish_catch == null or not fish_catch.is_valid(): + _cancel_attempt(peer_id, "Fishing attempt ended.") + return + attempt.phase = NetworkFishingAttempt.Phase.PENDING_CAPACITY + attempt.result_id = _new_id("result") + attempt.catch_payload = fish_catch.to_save_dict() + attempt.capacity_nonce = _new_id("capacity") + attempt.capacity_deadline = ( + Time.get_ticks_msec() / 1000.0 + CAPACITY_RESPONSE_TIMEOUT + ) + var probe: Dictionary = { + "attempt_id": attempt.attempt_id, + "capacity_nonce": attempt.capacity_nonce, + "catch_id": str(fish_catch.catch_id), + } + if peer_id == _session.get_local_peer_id(): + _handle_local_capacity_probe(probe) + else: + receive_capacity_probe.rpc_id(peer_id, probe) + + +@rpc("authority", "call_remote", "reliable", 0) +func receive_capacity_probe(data: Dictionary) -> void: + _handle_local_capacity_probe(data) + + +func _handle_local_capacity_probe(data: Dictionary) -> void: + if ( + typeof(data.get("attempt_id")) != TYPE_STRING + or typeof(data.get("capacity_nonce")) != TYPE_STRING + or typeof(data.get("catch_id")) != TYPE_STRING + ): + return + var can_accept: bool = ( + _local_inventory != null + and _local_capacity != null + and ( + _local_inventory.contains_catch_id(StringName(data["catch_id"])) + or _local_inventory.get_all_catches().size() + < _local_capacity.get_capacity() + ) + ) + if _session.is_host(): + _handle_capacity_response( + _session.get_local_peer_id(), + str(data["attempt_id"]), + str(data["capacity_nonce"]), + can_accept + ) + else: + submit_capacity_response.rpc_id( + 1, + str(data["attempt_id"]), + str(data["capacity_nonce"]), + can_accept + ) + + +@rpc("any_peer", "call_remote", "reliable", 0) +func submit_capacity_response( + attempt_id: String, + capacity_nonce: String, + can_accept: bool, +) -> void: + var sender_id: int = multiplayer.get_remote_sender_id() + if not _session.is_host() or not _session.is_authenticated_peer(sender_id): + return + _handle_capacity_response( + sender_id, attempt_id, capacity_nonce, can_accept + ) + + +func _handle_capacity_response( + peer_id: int, + attempt_id: String, + capacity_nonce: String, + can_accept: bool, +) -> void: + var attempt: NetworkFishingAttempt = _attempts.get(peer_id) + if ( + attempt == null + or attempt.phase != NetworkFishingAttempt.Phase.PENDING_CAPACITY + or attempt.attempt_id != attempt_id + or attempt.capacity_nonce != capacity_nonce + ): + return + if not can_accept: + _cancel_attempt(peer_id, "Cooler is full.") + return + _finalize_catch(attempt) + + +func _finalize_catch(attempt: NetworkFishingAttempt) -> void: + attempt.phase = NetworkFishingAttempt.Phase.CAUGHT + var outcome: Dictionary = { + "result_id": attempt.result_id, + "request_id": attempt.request_id, + "attempt_id": attempt.attempt_id, + "owner_peer_id": attempt.owner_peer_id, + "session_id": attempt.session_id, + "outcome": int(NetworkFishingProtocol.Outcome.CATCH), + "catch": attempt.catch_payload.duplicate(true), + } + if attempt.owner_peer_id == _session.get_local_peer_id(): + _apply_target_outcome(outcome) + else: + receive_target_outcome.rpc_id(attempt.owner_peer_id, outcome) + _broadcast_public_outcome(attempt, &"catch", "") + _dispose_attempt(attempt.owner_peer_id) + + +@rpc("authority", "call_remote", "reliable", 0) +func receive_target_outcome(data: Dictionary) -> void: + _apply_target_outcome(data) + + +func _apply_target_outcome(data: Dictionary) -> void: + if not _validate_target_outcome(data): + return + var result_id: String = data["result_id"] + var catch_data: Dictionary = data["catch"] + var catch_id := StringName(str(catch_data.get("catch_id", ""))) + if _result_ledgers.has(result_id): + _acknowledge_result(result_id, catch_id) + return + var fish_id := StringName(str(catch_data.get("fish_id", ""))) + var fish: FishDataType = _fish_catalog.get_fish_by_id(fish_id) + var fish_catch: FishCatch = FishCatchType.from_network_dict( + catch_data, fish + ) + if fish_catch == null: + return + var already_owned: bool = _local_inventory.contains_catch_id(catch_id) + if ( + not already_owned + and _local_inventory.get_all_catches().size() + >= _local_capacity.get_capacity() + ): + return + if not already_owned: + _local_inventory.add_catch(fish_catch) + _local_collection.mark_discovered(fish_id) + if not _save_manager.save_if_dirty(): + return + _result_ledgers[result_id] = true + _bound_result_ledger() + if not _session.is_host(): + _attempts.erase(_session.get_local_peer_id()) + local_catch_received.emit(fish_catch) + _acknowledge_result(result_id, catch_id) + + +func _validate_target_outcome(data: Dictionary) -> bool: + return ( + typeof(data.get("result_id")) == TYPE_STRING + and typeof(data.get("request_id")) == TYPE_STRING + and typeof(data.get("attempt_id")) == TYPE_STRING + and typeof(data.get("owner_peer_id")) == TYPE_INT + and int(data["owner_peer_id"]) == _session.get_local_peer_id() + and typeof(data.get("session_id")) == TYPE_STRING + and str(data["session_id"]) == _session.get_session_id() + and int(data.get("outcome", -1)) + == NetworkFishingProtocol.Outcome.CATCH + and typeof(data.get("catch")) == TYPE_DICTIONARY + ) + + +func _acknowledge_result(result_id: String, catch_id: StringName) -> void: + if _session.is_host(): + return + acknowledge_fishing_result.rpc_id(1, result_id, str(catch_id)) + + +@rpc("any_peer", "call_remote", "reliable", 0) +func acknowledge_fishing_result(result_id: String, catch_id: String) -> void: + var sender_id: int = multiplayer.get_remote_sender_id() + if ( + not _session.is_host() + or not _session.is_authenticated_peer(sender_id) + or result_id.is_empty() + or catch_id.is_empty() + ): + return + _result_acknowledgements[result_id] = catch_id + while _result_acknowledgements.size() > MAX_LEDGER_ENTRIES_PER_PEER: + _result_acknowledgements.erase( + _result_acknowledgements.keys().front() + ) + + +func _on_attempt_escaped(peer_id: int) -> void: + var attempt: NetworkFishingAttempt = _attempts.get(peer_id) + if attempt == null: + return + attempt.phase = NetworkFishingAttempt.Phase.ESCAPED + _broadcast_public_outcome(attempt, &"escape", "The fish got away!") + _dispose_attempt(peer_id) + + +func _cancel_attempt(peer_id: int, message: String) -> void: + var attempt: NetworkFishingAttempt = _attempts.get(peer_id) + if attempt == null: + return + attempt.phase = NetworkFishingAttempt.Phase.CANCELLED + _broadcast_public_outcome(attempt, &"cancelled", message) + _dispose_attempt(peer_id) + + +func _broadcast_public_outcome( + attempt: NetworkFishingAttempt, + outcome: StringName, + message: String, +) -> void: + var data: Dictionary = { + "attempt_id": attempt.attempt_id, + "owner_peer_id": attempt.owner_peer_id, + "outcome": str(outcome), + "message": message.left(128), + } + _apply_public_outcome(data) + receive_public_outcome.rpc(data) + + +@rpc("authority", "call_remote", "reliable", 0) +func receive_public_outcome(data: Dictionary) -> void: + _apply_public_outcome(data) + + +func _apply_public_outcome(data: Dictionary) -> void: + if ( + typeof(data.get("owner_peer_id")) != TYPE_INT + or typeof(data.get("outcome")) != TYPE_STRING + or typeof(data.get("message")) != TYPE_STRING + ): + return + var peer_id: int = data["owner_peer_id"] + if peer_id == _session.get_local_peer_id(): + if str(data["outcome"]) != "catch": + if not _session.is_host(): + _attempts.erase(peer_id) + local_attempt_ended.emit( + StringName(str(data["outcome"])), + str(data["message"]) + ) + else: + _cleanup_remote_presentation(peer_id) + + +func _make_cast_accepted(attempt: NetworkFishingAttempt) -> Dictionary: + return { + "accepted": true, + "request_id": attempt.request_id, + "attempt_id": attempt.attempt_id, + "owner_peer_id": attempt.owner_peer_id, + "origin": NetworkFishingProtocol.vector3_to_array(attempt.origin), + "target": NetworkFishingProtocol.vector3_to_array(attempt.target), + } + + +func _broadcast_cast_accepted(data: Dictionary) -> void: + _apply_cast_accepted(data) + receive_cast_accepted.rpc(data) + + +@rpc("authority", "call_remote", "reliable", 0) +func receive_cast_accepted(data: Dictionary) -> void: + _apply_cast_accepted(data) + + +func _apply_cast_accepted(data: Dictionary) -> void: + if ( + typeof(data.get("attempt_id")) != TYPE_STRING + or typeof(data.get("owner_peer_id")) != TYPE_INT + or typeof(data.get("target")) != TYPE_ARRAY + ): + return + var peer_id: int = data["owner_peer_id"] + var target: Vector3 = NetworkFishingProtocol.array_to_vector3(data["target"]) + if not target.is_finite(): + return + if peer_id == _session.get_local_peer_id(): + var attempt := NetworkFishingAttempt.new() + attempt.owner_peer_id = peer_id + attempt.request_id = str(data.get("request_id", "")) + attempt.attempt_id = str(data["attempt_id"]) + attempt.session_id = _session.get_session_id() + attempt.phase = NetworkFishingAttempt.Phase.WAITING_FOR_BITE + attempt.target = target + # On the host this replaces the same authoritative value with itself. + if not _session.is_host(): + _attempts[peer_id] = attempt + local_cast_accepted.emit(attempt.attempt_id, target) + else: + var presentation := _get_remote_presentation(peer_id) + if presentation != null: + presentation.show_cast(target) + + +@rpc("authority", "call_remote", "reliable", 0) +func receive_bite_started(data: Dictionary) -> void: + _apply_bite_started(data) + + +func _apply_bite_started(data: Dictionary) -> void: + if ( + typeof(data.get("attempt_id")) != TYPE_STRING + or typeof(data.get("owner_peer_id")) != TYPE_INT + ): + return + var peer_id: int = data["owner_peer_id"] + if peer_id == _session.get_local_peer_id(): + local_bite_started.emit(str(data["attempt_id"])) + else: + var presentation := _get_remote_presentation(peer_id) + if presentation != null: + presentation.show_bite() + + +func _record_and_reject( + peer_id: int, + request_id: String, + message: String, +) -> void: + var response: Dictionary = { + "accepted": false, + "request_id": request_id, + "message": message.left(128), + } + _record_request_response(peer_id, request_id, response) + _send_cast_rejected(peer_id, request_id, message) + + +func _send_cast_rejected( + peer_id: int, + request_id: String, + message: String, +) -> void: + if peer_id == _session.get_local_peer_id(): + local_cast_rejected.emit(message) + else: + receive_cast_rejected.rpc_id(peer_id, request_id, message.left(128)) + + +@rpc("authority", "call_remote", "reliable", 0) +func receive_cast_rejected(_request_id: String, message: String) -> void: + local_cast_rejected.emit(message) + + +func _record_request_response( + peer_id: int, + request_id: String, + response: Dictionary, +) -> void: + var ledger: Dictionary = _request_ledgers.get(peer_id, {}) + ledger[request_id] = response.duplicate(true) + while ledger.size() > MAX_LEDGER_ENTRIES_PER_PEER: + ledger.erase(ledger.keys().front()) + _request_ledgers[peer_id] = ledger + + +func _resend_request_response(peer_id: int, response: Dictionary) -> void: + if bool(response.get("accepted", false)): + if peer_id == _session.get_local_peer_id(): + _apply_cast_accepted(response) + else: + receive_cast_accepted.rpc_id(peer_id, response) + else: + _send_cast_rejected( + peer_id, + str(response.get("request_id", "")), + str(response.get("message", "Fishing attempt ended.")) + ) + + +func _get_remote_presentation( + peer_id: int, +) -> RemoteFishingPresentation: + var existing: RemoteFishingPresentation = _remote_presentations.get(peer_id) + if existing != null and is_instance_valid(existing): + return existing + var avatar: Player = _spawn_service.get_avatar(peer_id) + if avatar == null: + return null + var presentation := RemotePresentationType.new() + presentation.name = "RemoteFishing_%d" % peer_id + add_child(presentation) + presentation.setup(avatar) + _remote_presentations[peer_id] = presentation + return presentation + + +func _cleanup_remote_presentation(peer_id: int) -> void: + var presentation: RemoteFishingPresentation = _remote_presentations.get(peer_id) + _remote_presentations.erase(peer_id) + if presentation != null and is_instance_valid(presentation): + presentation.cleanup() + presentation.queue_free() + + +func _dispose_attempt(peer_id: int) -> void: + var attempt: NetworkFishingAttempt = _attempts.get(peer_id) + _attempts.erase(peer_id) + if attempt != null and attempt.controller != null: + attempt.controller.reset() + attempt.controller.queue_free() + var avatar: Player = _spawn_service.get_avatar(peer_id) + if avatar != null: + avatar.set_movement_enabled(true) + + +func _on_peer_removed(peer_id: int) -> void: + if _session.is_host() and _attempts.has(peer_id): + _cancel_attempt(peer_id, "Fishing attempt ended.") + else: + _attempts.erase(peer_id) + _cleanup_remote_presentation(peer_id) + _request_ledgers.erase(peer_id) + _last_cast_time.erase(peer_id) + _last_input_time.erase(peer_id) + + +func _on_avatar_removed(peer_id: int) -> void: + _cleanup_remote_presentation(peer_id) + + +func _on_session_state_changed(state: NetworkSession.State) -> void: + if state in [ + NetworkSession.State.INACTIVE, + NetworkSession.State.DISCONNECTING, + NetworkSession.State.CONNECTION_FAILED, + NetworkSession.State.SERVER_LOST, + ]: + _clear_all() + + +func _clear_all() -> void: + for peer_id: int in _attempts.keys(): + var attempt: NetworkFishingAttempt = _attempts[peer_id] + if attempt != null and attempt.controller != null: + attempt.controller.queue_free() + _attempts.clear() + for peer_id: int in _remote_presentations.keys(): + _cleanup_remote_presentation(peer_id) + _request_ledgers.clear() + _result_ledgers.clear() + _result_acknowledgements.clear() + _last_cast_time.clear() + _last_input_time.clear() + _snapshot_accumulator = 0.0 + _local_input_sequence = 0 + + +func _bound_result_ledger() -> void: + while _result_ledgers.size() > MAX_LEDGER_ENTRIES_PER_PEER: + _result_ledgers.erase(_result_ledgers.keys().front()) + + +func _new_id(prefix: String) -> String: + return "%s:%s" % [ + prefix, + Crypto.new().generate_random_bytes(16).hex_encode(), + ] + + +func _new_seed() -> int: + var bytes: PackedByteArray = Crypto.new().generate_random_bytes(8) + var seed_value: int = 0 + for byte: int in bytes: + seed_value = (seed_value << 8) ^ byte + return seed_value diff --git a/network/network_fishing_service.gd.uid b/network/network_fishing_service.gd.uid new file mode 100644 index 0000000..921f52c --- /dev/null +++ b/network/network_fishing_service.gd.uid @@ -0,0 +1 @@ +uid://b4v56htdn1y2l diff --git a/network/network_protocol.gd b/network/network_protocol.gd index 7d093f6..6fa0d2b 100644 --- a/network/network_protocol.gd +++ b/network/network_protocol.gd @@ -1,7 +1,7 @@ class_name NetworkProtocol extends RefCounted -const PROTOCOL_VERSION: int = 1 +const PROTOCOL_VERSION: int = 2 const GAME_BUILD: String = "prealpha" const MAX_DISPLAY_NAME_LENGTH: int = 48 const MAX_PROFILE_ID_LENGTH: int = 96 @@ -99,7 +99,10 @@ static func make_server_hello( "server_display_name": "NETFISHING", "player_count": player_count, "max_players": max_players, - "capability_flags": PackedStringArray(["movement_v1"]), + "capability_flags": PackedStringArray([ + "movement_v1", + "fishing_v1", + ]), } diff --git a/network/network_session.gd b/network/network_session.gd index 5c8e1e1..26ee5d7 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -254,6 +254,26 @@ func can_use_host_gameplay() -> bool: return is_host() +func is_gameplay_session_active() -> bool: + return state in [State.PRIVATE_HOST, State.OPEN_HOST, State.JOINED_CLIENT] + + +func is_authenticated_peer(peer_id: int) -> bool: + return _registry.has_peer(peer_id) + + +func get_session_id() -> String: + return _session_id + + +func get_operation_generation() -> int: + return _operation_generation + + +func get_local_peer_id() -> int: + return multiplayer.get_unique_id() if is_gameplay_session_active() else 0 + + func _process(delta: float) -> void: var now: float = Time.get_ticks_msec() / 1000.0 if ( diff --git a/player/player.gd b/player/player.gd index d7e3452..ba9952a 100644 --- a/player/player.gd +++ b/player/player.gd @@ -506,6 +506,10 @@ func set_water_recovery_active(active: bool) -> void: velocity = Vector3.ZERO +func is_water_recovery_active() -> bool: + return _water_recovery_active + + func prepare_for_water_recovery() -> void: _restore_gameplay_presentation_for_recovery()