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 FishExperienceType = preload("res://fish/fish_experience.gd") const PlayerExperienceType = preload( "res://progression/player_experience.gd" ) const RemotePresentationType = preload( "res://fishing/remote_fishing_presentation.gd" ) const ItemDataType = preload("res://items/item_data.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_bite_pending(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_bag: PlayerBag var _local_collection: CollectionLog var _local_capacity: PlayerCoolerCapacity var _local_experience: PlayerExperienceType var _save_manager: PlayerSaveManager var _item_catalog: ItemCatalog var _fish_catalog: FishPoolType var _item_use: NetworkItemUseService 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 _pending_local_bait_by_request: Dictionary[String, StringName] = {} 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_bag: PlayerBag, local_collection: CollectionLog, local_capacity: PlayerCoolerCapacity, local_experience: PlayerExperienceType, save_manager: PlayerSaveManager, item_catalog: ItemCatalog, fish_catalog: FishPoolType, item_use: NetworkItemUseService, ) -> void: _session = session _spawn_service = spawn_service _fishing_spot = fishing_spot _local_inventory = local_inventory _local_bag = local_bag _local_collection = local_collection _local_capacity = local_capacity _local_experience = local_experience _save_manager = save_manager _item_catalog = item_catalog _fish_catalog = fish_catalog _item_use = item_use 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 bait_id := StringName(str(evidence.get("bait_id", ""))) if not bait_id.is_empty() and ( _local_bag == null or not _local_bag.owns_item(bait_id) ): local_cast_rejected.emit("No bait available.") return "" var lure_id := StringName(str(evidence.get("lure_id", ""))) if not lure_id.is_empty() and ( _local_bag == null or not _local_bag.owns_item(lure_id) ): local_cast_rejected.emit("Lure is unavailable.") 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)), "bait_id": str(bait_id), "lure_id": str(lure_id), } if _session.is_host(): _handle_cast_request(_session.get_local_peer_id(), data) else: if not bait_id.is_empty(): _pending_local_bait_by_request[request_id] = bait_id 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 has_peer_attempt(peer_id: int) -> bool: return _attempts.has(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: if attempt.bite_confirmation_pending: continue _update_waiting_attempt(attempt, delta) if not _attempts.has(peer_id): continue attempt.bite_time_remaining -= delta if attempt.bite_time_remaining <= 0.0: if &"deferred_fight" in attempt.lure_effects: _set_bite_pending(attempt) else: _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 surface: FishingSurfaceSample = _fishing_spot.resolve_fishing_surface( target, authoritative_origin.y, ) var region: FishableWaterRegion = surface.water_region var authoritative_target: Vector3 = surface.position if ( not surface.is_fishable() or region == null or region.fish_pool == null or not _fishing_spot.is_cast_path_clear( authoritative_origin, authoritative_target ) ): _record_and_reject(peer_id, request_id, "Cannot fish here.") return var effects: PlayerItemEffects = ( _item_use.get_effects_for_peer(peer_id) if _item_use != null else null ) var selected_fish: FishDataType = _select_authoritative_fish( region, data, effects ) if selected_fish == null or not selected_fish.is_allowed_in_water( region.water_type ): _record_and_reject(peer_id, request_id, "Nothing is biting here.") return var bait_id: StringName = StringName(str(data.get("bait_id", ""))) var avatar_bag: PlayerBag = avatar.bag var owns_authoritative_bag: bool = ( peer_id == _session.get_local_peer_id() ) var bait: ItemDataType = ( _item_catalog.get_item_by_id(bait_id) if not bait_id.is_empty() and _item_catalog != null else null ) var lure_id: StringName = StringName(str(data.get("lure_id", ""))) var lure: ItemDataType = ( _item_catalog.get_item_by_id(lure_id) if not lure_id.is_empty() and _item_catalog != null else null ) if ( not lure_id.is_empty() and ( lure == null or not lure.is_lure() or ( owns_authoritative_bag and ( avatar_bag == null or not avatar_bag.owns_item(lure_id) ) ) ) ): _record_and_reject(peer_id, request_id, "Lure is unavailable.") return if not bait_id.is_empty() and ( bait == null or not bait.is_bait() or ( owns_authoritative_bag and ( avatar_bag == null or not avatar_bag.remove_item(bait_id, 1) ) ) ): _record_and_reject(peer_id, request_id, "No bait available.") 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 = authoritative_target attempt.bobber_position = authoritative_target attempt.fish_id = selected_fish.id attempt.bait_tags = _bait_tags_for_request(data) attempt.lure_effects.clear() if lure != null: for effect_id: StringName in lure.lure_effects: attempt.lure_effects.append(effect_id) attempt.reel_speed = float(data["reel_speed"]) * ( effects.get_reel_multiplier() if effects != null else 1.0 ) attempt.barrier_damage = int(data["barrier_damage"]) + ( effects.get_barrier_bonus() if effects != null else 0 ) attempt.bite_time_remaining = _fishing_spot.roll_bite_wait_time() * float( effects.get_bite_time_multiplier() if effects != null else 1.0 ) 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, "", &"withdrawal") 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 ) var withdrawal_surface: FishingSurfaceSample = ( _fishing_spot.resolve_safe_withdrawal_surface( attempt.bobber_position, desired, endpoint - attempt.bobber_position, ) ) if not withdrawal_surface.is_fishable(): _cancel_attempt(attempt.owner_peer_id, "", &"withdrawal") return attempt.bobber_position = withdrawal_surface.position attempt.set_meta("snapshot", _make_waiting_snapshot(attempt)) if is_equal_approx(attempt.withdrawal_progress, 1.0): _cancel_attempt(attempt.owner_peer_id, "", &"withdrawal") 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, effects: PlayerItemEffects, ) -> 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 rarity: int in range(FishDataType.Rarity.size()): selector.rarity_weight_multipliers.append( effects.get_rarity_weight_multiplier(rarity) if effects != null else 1.0 ) selector.begin_roll() var context: FishingContextType = _fishing_spot.build_network_context(region) context.active_bait_tags = _bait_tags_for_request(data) var selected_fish := selector.select_fish( region.fish_pool, context, evidence_log ) evidence_log.free() return selected_fish func _bait_tags_for_request(data: Dictionary) -> Array[StringName]: var bait_id: StringName = StringName(str(data.get("bait_id", ""))) if bait_id.is_empty() or _item_catalog == null: return [] var bait: ItemDataType = _item_catalog.get_item_by_id(bait_id) return bait.bait_tags.duplicate() if bait != null and bait.is_bait() else [] func _set_bite_pending(attempt: NetworkFishingAttempt) -> void: if ( attempt.phase != NetworkFishingAttempt.Phase.WAITING_FOR_BITE or attempt.bite_confirmation_pending ): return attempt.bite_confirmation_pending = true attempt.bite_time_remaining = 0.0 attempt.input_held = false var data: Dictionary = { "attempt_id": attempt.attempt_id, "owner_peer_id": attempt.owner_peer_id, } _apply_bite_pending(data) receive_bite_pending.rpc(data) 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.bite_confirmation_pending = false attempt.phase = NetworkFishingAttempt.Phase.FIGHTING attempt.encounter_seed = _new_seed() 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, attempt.bait_tags) if fish_catch == null or not fish_catch.is_valid(): _cancel_attempt(attempt.owner_peer_id, "Fishing attempt ended.") return attempt.catch_payload = fish_catch.to_network_dict() attempt.controller.start_authoritative_encounter( fish.catch_profile, attempt.reel_speed, attempt.barrier_damage, attempt.encounter_seed, fish_catch.quality, int(fish.rarity), fish.get_weight_percentile(fish_catch.weight_lb), ) 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.WAITING_FOR_BITE and attempt.bite_confirmation_pending ): if bool(data["pressed"]): attempt.input_held = false _start_bite(attempt) return 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, "") 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.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 fish_catch: FishCatch = FishCatchType.from_network_dict( attempt.catch_payload, 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_network_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", "", attempt.catch_payload, ) _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: var experience_award: int = ( FishExperienceType.calculate_for_collection( fish_catch, _local_collection, ) ) _local_inventory.add_catch(fish_catch) _local_collection.mark_quality_discovered( fish_id, fish_catch.quality, ) _local_experience.award_experience(experience_award) 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, outcome: StringName = &"cancelled", ) -> void: var attempt: NetworkFishingAttempt = _attempts.get(peer_id) if attempt == null: return attempt.phase = NetworkFishingAttempt.Phase.CANCELLED _broadcast_public_outcome(attempt, outcome, message) _dispose_attempt(peer_id) func _broadcast_public_outcome( attempt: NetworkFishingAttempt, outcome: StringName, message: String, catch_payload: Dictionary = {}, ) -> void: var data: Dictionary = { "attempt_id": attempt.attempt_id, "owner_peer_id": attempt.owner_peer_id, "outcome": str(outcome), "message": message.left(128), } if outcome == &"catch" and not catch_payload.is_empty(): data["catch"] = catch_payload.duplicate(true) _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: var outcome := StringName(str(data["outcome"])) if outcome in [&"catch", &"escape", &"withdrawal"]: _return_remote_presentation(peer_id, outcome, data) 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("origin")) != TYPE_ARRAY or typeof(data.get("target")) != TYPE_ARRAY ): return var peer_id: int = data["owner_peer_id"] var origin: Vector3 = NetworkFishingProtocol.array_to_vector3( data.get("origin", []) ) var target: Vector3 = NetworkFishingProtocol.array_to_vector3(data["target"]) if not origin.is_finite() or 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 var pending_bait_id: StringName = ( _pending_local_bait_by_request.get( attempt.request_id, StringName(), ) ) _pending_local_bait_by_request.erase(attempt.request_id) if not pending_bait_id.is_empty() and ( _local_bag == null or not _local_bag.remove_item(pending_bait_id, 1) ): cancel_local_attempt("No bait available.") local_cast_rejected.emit("No bait available.") return local_cast_accepted.emit(attempt.attempt_id, target) else: var presentation := _get_remote_presentation(peer_id) if presentation != null: presentation.show_cast(origin, target) @rpc("authority", "call_remote", "reliable", 0) func receive_bite_pending(data: Dictionary) -> void: _apply_bite_pending(data) func _apply_bite_pending(data: Dictionary) -> void: if ( typeof(data.get("attempt_id")) != TYPE_STRING or typeof(data.get("owner_peer_id")) != TYPE_INT ): return if int(data["owner_peer_id"]) == _session.get_local_peer_id(): local_bite_pending.emit(str(data["attempt_id"])) @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: _pending_local_bait_by_request.erase(request_id) 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 _return_remote_presentation( peer_id: int, outcome: StringName, data: Dictionary, ) -> void: var presentation: RemoteFishingPresentation = _remote_presentations.get( peer_id ) _remote_presentations.erase(peer_id) if presentation == null or not is_instance_valid(presentation): return presentation.return_completed.connect( presentation.queue_free, CONNECT_ONE_SHOT, ) var showcase_catch: FishCatch = null if outcome == &"catch" and typeof(data.get("catch")) == TYPE_DICTIONARY: var catch_data: Dictionary = data["catch"] var fish_id := StringName(str(catch_data.get("fish_id", ""))) var fish: FishDataType = _fish_catalog.get_fish_by_id(fish_id) showcase_catch = FishCatchType.from_network_dict(catch_data, fish) presentation.play_return(showcase_catch) 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) var local_return_is_active: bool = ( peer_id == _session.get_local_peer_id() and _fishing_spot != null and _fishing_spot.is_returning() ) if avatar != null and not local_return_is_active: 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