class_name NetworkSaleService extends Node const FishCatchType = preload("res://fish/fish_catch.gd") const FishQualityType = preload("res://fish/fish_quality.gd") const FishDataType = preload("res://fish/fish_data.gd") const FishPoolType = preload("res://fish/fish_pool.gd") const FishBuyerProfileType = preload("res://economy/fish_buyer_profile.gd") const FishSaleServiceType = preload("res://economy/fish_sale_service.gd") const FishSaleResultType = preload("res://economy/fish_sale_result.gd") const MAX_LEDGER_ENTRIES_PER_PEER: int = 64 const PELICAN_BUYER_ID: StringName = &"pelicans" const MAIN_SHOP_BUYER_ID: StringName = &"main_fishing_shop" signal local_sale_pending(request_id: String) signal local_sale_finished( request_id: String, accepted: bool, message: String, catch_ids: Array[StringName], payout: int, ) var _session: NetworkSession var _spawn_service: PlayerSpawnService var _network_fishing: NetworkFishingService var _shop_interaction: FishingShopInteraction var _inventory: FishInventory var _wallet: PlayerWallet var _sale_service: FishSaleServiceType var _save_manager: PlayerSaveManager var _fish_catalog: FishPoolType var _buyers: Dictionary[StringName, FishBuyerProfileType] = {} var _request_ledgers: Dictionary[int, Dictionary] = {} var _pending_by_peer: Dictionary[int, String] = {} var _result_owners: Dictionary[String, int] = {} var _acknowledged_results: Dictionary[String, bool] = {} var _applied_results: Dictionary[String, bool] = {} var _received_results: Dictionary[String, bool] = {} var _pending_local_request_id: String = "" var _pending_local_catch_ids: Array[StringName] = [] var _pending_local_buyer_id: StringName var _reservations: PlayerAssetReservationService func setup( session: NetworkSession, spawn_service: PlayerSpawnService, network_fishing: NetworkFishingService, shop_interaction: FishingShopInteraction, inventory: FishInventory, wallet: PlayerWallet, sale_service: FishSaleServiceType, save_manager: PlayerSaveManager, fish_catalog: FishPoolType, buyers: Array[FishBuyerProfileType], reservations: PlayerAssetReservationService, ) -> void: _session = session _spawn_service = spawn_service _network_fishing = network_fishing _shop_interaction = shop_interaction _inventory = inventory _wallet = wallet _sale_service = sale_service _save_manager = save_manager _fish_catalog = fish_catalog _buyers.clear() for buyer: FishBuyerProfileType in buyers: if buyer != null and buyer.is_valid(): _buyers[buyer.id] = buyer _reservations = reservations 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) func is_local_sale_pending() -> bool: return not _pending_local_request_id.is_empty() func can_request_sale( buyer_id: StringName = PELICAN_BUYER_ID, ) -> bool: var buyer: FishBuyerProfileType = _buyers.get(buyer_id) return ( buyer != null and buyer.is_valid() and _session != null and _session.is_gameplay_session_active() and ( _session.is_host() or _session.supports_server_capability( NetworkSaleProtocol.CAPABILITY ) ) ) func request_local_sale( catch_ids: Array[StringName], buyer_id: StringName = PELICAN_BUYER_ID, ) -> String: for catch_id: StringName in catch_ids: if _reservations != null and _reservations.is_fish_reserved(catch_id): local_sale_finished.emit( "", false, "Reserved in a letter.", _empty_catch_ids(), 0 ) return "" if is_local_sale_pending(): local_sale_finished.emit( "", false, "Selling…", _empty_catch_ids(), 0 ) return "" if not can_request_sale(buyer_id): local_sale_finished.emit( "", false, "Selling is not supported by this server.", _empty_catch_ids(), 0 ) return "" if catch_ids.is_empty() or _inventory == null: local_sale_finished.emit( "", false, "Sale could not be completed.", _empty_catch_ids(), 0 ) return "" var buyer: FishBuyerProfileType = _buyers.get(buyer_id) if buyer == null or not buyer.is_valid(): local_sale_finished.emit( "", false, "The buyer is unavailable.", _empty_catch_ids(), 0 ) return "" var evidence: Array[Dictionary] = [] for catch_id: StringName in catch_ids: var fish_catch: FishCatch = _inventory.get_catch_by_id(catch_id) if fish_catch == null: local_sale_finished.emit( "", false, "That catch is no longer available.", _empty_catch_ids(), 0 ) return "" evidence.append(fish_catch.to_network_dict()) var request_id: String = _new_id("sale") var request: Dictionary = { "request_id": request_id, "session_id": _session.get_session_id(), "buyer_id": str(buyer.id), "catches": evidence, } _pending_local_request_id = request_id _pending_local_catch_ids = catch_ids.duplicate() _pending_local_buyer_id = buyer.id local_sale_pending.emit(request_id) if _session.is_host(): _handle_sale_request(_session.get_local_peer_id(), request) else: submit_sale_request.rpc_id(1, request) return request_id @rpc( "any_peer", "call_remote", "reliable", NetworkSaleProtocol.RELIABLE_CHANNEL ) func submit_sale_request(data: Dictionary) -> void: var sender_id: int = multiplayer.get_remote_sender_id() if ( _session == null or not _session.is_host() or not _session.is_authenticated_peer(sender_id) ): return _handle_sale_request(sender_id, data) func _handle_sale_request(peer_id: int, data: Dictionary) -> void: var validation_error: String = NetworkSaleProtocol.validate_request(data) var request_id: String = str(data.get("request_id", "")) var ledger: Dictionary = _request_ledgers.get(peer_id, {}) if not request_id.is_empty() and ledger.has(request_id): _send_result(peer_id, ledger[request_id]) return if not validation_error.is_empty(): _record_and_send(peer_id, _rejected_result( request_id, validation_error )) return if str(data["session_id"]) != _session.get_session_id(): _record_and_send(peer_id, _rejected_result( request_id, "Sale could not be completed." )) return if ( _pending_by_peer.has(peer_id) and _pending_by_peer[peer_id] != request_id ): _record_and_send(peer_id, _rejected_result( request_id, "A sale is already pending." )) return var buyer_id := StringName(str(data["buyer_id"])) var buyer: FishBuyerProfileType = _buyers.get(buyer_id) if buyer == null or not buyer.is_valid(): _record_and_send(peer_id, _rejected_result( request_id, "The buyer is unavailable." )) return if ( buyer_id == MAIN_SHOP_BUYER_ID and not _is_shop_available_for_peer(peer_id) ): _record_and_send(peer_id, _rejected_result( request_id, "Move closer to the fishing shop." )) return var result: Dictionary = _build_authoritative_result( peer_id, request_id, data["catches"], buyer ) _pending_by_peer[peer_id] = request_id _record_and_send(peer_id, result) func _build_authoritative_result( peer_id: int, request_id: String, evidence_values: Array, buyer: FishBuyerProfileType, ) -> Dictionary: if _fish_catalog == null or buyer == null: return _rejected_result( request_id, "The buyer is unavailable." ) var catch_ids: Array[String] = [] var base_value: int = 0 var payout: int = 0 for value: Variant in evidence_values: var evidence: Dictionary = value var fish_id := StringName(str(evidence.get("fish_id", ""))) var fish: FishDataType = _fish_catalog.get_fish_by_id(fish_id) var decoded: FishCatch = FishCatchType.from_network_dict( evidence, fish ) if decoded == null: return _rejected_result( request_id, "Sale could not be completed." ) if ( not str(decoded.catch_id).begins_with("%s:" % fish_id) or decoded.weight_lb < fish.get_minimum_weight() or decoded.weight_lb > fish.get_maximum_weight() or decoded.sale_value != FishQualityType.apply_sale_value( fish.get_sale_value_for_weight(decoded.weight_lb), decoded.quality, ) ): return _rejected_result( request_id, "Sale could not be completed." ) if bool(evidence.get("is_favorited", false)): return _rejected_result( request_id, "Favorite catches cannot be sold." ) var ordinary_value: int = fish.get_sale_value_for_weight( decoded.weight_lb ) var offer: int = buyer.get_quality_offer( ordinary_value, decoded.quality, ) if ( offer < 0 or base_value > 9223372036854775807 - decoded.sale_value or payout > 9223372036854775807 - offer ): return _rejected_result( request_id, "Sale could not be completed." ) base_value += decoded.sale_value payout += offer catch_ids.append(str(decoded.catch_id)) return { "result_id": _new_id("sale_result"), "request_id": request_id, "session_id": _session.get_session_id(), "target_peer_id": peer_id, "buyer_id": str(buyer.id), "accepted": true, "catch_ids": catch_ids, "payout": payout, "base_value": base_value, "message": "Sale complete.", } func _rejected_result(request_id: String, message: String) -> Dictionary: var safe_request_id: String = ( request_id if ( not request_id.is_empty() and request_id.length() <= NetworkSaleProtocol.MAX_ID_LENGTH ) else "invalid" ) return { "result_id": _new_id("sale_result"), "request_id": safe_request_id, "session_id": _session.get_session_id() if _session != null else "", "target_peer_id": 0, "accepted": false, "catch_ids": [], "payout": 0, "base_value": 0, "message": message.left(NetworkSaleProtocol.MAX_MESSAGE_LENGTH), } func _record_and_send(peer_id: int, result: Dictionary) -> void: result["target_peer_id"] = peer_id var request_id: String = str(result["request_id"]) var ledger: Dictionary = _request_ledgers.get(peer_id, {}) ledger[request_id] = result.duplicate(true) while ledger.size() > MAX_LEDGER_ENTRIES_PER_PEER: ledger.erase(ledger.keys().front()) _request_ledgers[peer_id] = ledger _result_owners[str(result["result_id"])] = peer_id _bound_dictionary(_result_owners) _send_result(peer_id, result) func _send_result(peer_id: int, result: Dictionary) -> void: if peer_id == _session.get_local_peer_id(): _apply_sale_result(result) else: receive_sale_result.rpc_id(peer_id, result) @rpc( "authority", "call_remote", "reliable", NetworkSaleProtocol.RELIABLE_CHANNEL ) func receive_sale_result(data: Dictionary) -> void: _apply_sale_result(data) func _apply_sale_result(data: Dictionary) -> void: if ( not NetworkSaleProtocol.validate_result(data) or _session == null or str(data["session_id"]) != _session.get_session_id() or int(data["target_peer_id"]) != _session.get_local_peer_id() ): return var result_id: String = data["result_id"] if _received_results.has(result_id): _acknowledge_result( data, _applied_results.has(result_id), "" ) return if str(data["request_id"]) != _pending_local_request_id: return var result_buyer_id := StringName(str( data.get("buyer_id", _pending_local_buyer_id) )) if result_buyer_id != _pending_local_buyer_id: _fail_local_apply(data, "Sale could not be completed.") return if not bool(data["accepted"]): _received_results[result_id] = true _bound_dictionary(_received_results) _finish_local_sale( data["request_id"], false, str(data["message"]), [], 0 ) _acknowledge_result(data, false, str(data["message"])) return var catch_ids: Array[StringName] = [] for value: Variant in data["catch_ids"]: if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]: _fail_local_apply(data, "Sale could not be completed.") return catch_ids.append(StringName(str(value))) if catch_ids != _pending_local_catch_ids: _fail_local_apply(data, "Sale could not be completed.") return for catch_id: StringName in catch_ids: if _reservations != null and _reservations.is_fish_reserved(catch_id): _fail_local_apply(data, "Reserved in a letter.") return var buyer: FishBuyerProfileType = _buyers.get(_pending_local_buyer_id) if buyer == null or not buyer.is_valid(): _fail_local_apply(data, "The buyer is unavailable.") return var preview: FishSaleResultType = _sale_service.preview_batch( catch_ids, buyer ) if not preview.is_success(): var message: String = ( "Favorite catches cannot be sold." if preview.status == FishSaleResultType.Status.FAVORITED else "That catch is no longer available." ) _fail_local_apply(data, message) return if ( preview.payout != int(data["payout"]) or preview.base_value != int(data["base_value"]) ): _fail_local_apply(data, "Sale could not be completed.") return var inventory_snapshot: Array[FishCatch] = _inventory.get_all_catches() var sequence_snapshot: int = _inventory.get_next_catch_sequence() var wallet_snapshot: int = _wallet.get_balance() var local_result: FishSaleResultType = _sale_service.sell_batch( catch_ids, buyer ) if not local_result.is_success() or not _save_manager.save_if_dirty(): _inventory.replace_all_catches( inventory_snapshot, sequence_snapshot ) _wallet.restore_balance(wallet_snapshot) _save_manager.save_if_dirty() _fail_local_apply(data, "Sale could not be completed.") return _applied_results[result_id] = true _bound_dictionary(_applied_results) _received_results[result_id] = true _bound_dictionary(_received_results) _finish_local_sale( data["request_id"], true, "Sale complete.", catch_ids, int(data["payout"]) ) _acknowledge_result(data, true, "") func _fail_local_apply(data: Dictionary, message: String) -> void: _finish_local_sale( data["request_id"], false, message, _empty_catch_ids(), 0 ) _acknowledge_result(data, false, message) func _finish_local_sale( request_id: String, accepted: bool, message: String, catch_ids: Array[StringName], payout: int, ) -> void: _pending_local_request_id = "" _pending_local_catch_ids.clear() _pending_local_buyer_id = StringName() local_sale_finished.emit( request_id, accepted, message, catch_ids, payout ) func _acknowledge_result( data: Dictionary, applied: bool, message: String, ) -> void: if _session.is_host(): _handle_acknowledgement( _session.get_local_peer_id(), str(data["result_id"]), str(data["request_id"]), applied, message ) else: acknowledge_sale_result.rpc_id( 1, str(data["result_id"]), str(data["request_id"]), applied, message.left(NetworkSaleProtocol.MAX_MESSAGE_LENGTH) ) @rpc( "any_peer", "call_remote", "reliable", NetworkSaleProtocol.RELIABLE_CHANNEL ) func acknowledge_sale_result( result_id: String, request_id: String, applied: bool, message: String, ) -> void: var sender_id: int = multiplayer.get_remote_sender_id() if ( _session == null or not _session.is_host() or not _session.is_authenticated_peer(sender_id) ): return _handle_acknowledgement( sender_id, result_id, request_id, applied, message ) func _handle_acknowledgement( peer_id: int, result_id: String, request_id: String, _applied: bool, _message: String, ) -> void: if ( result_id.is_empty() or result_id.length() > NetworkSaleProtocol.MAX_ID_LENGTH or request_id.is_empty() or request_id.length() > NetworkSaleProtocol.MAX_ID_LENGTH or _message.length() > NetworkSaleProtocol.MAX_MESSAGE_LENGTH or _result_owners.get(result_id, 0) != peer_id ): return _acknowledged_results[result_id] = true _bound_dictionary(_acknowledged_results) if _pending_by_peer.get(peer_id, "") == request_id: _pending_by_peer.erase(peer_id) func _on_peer_removed(peer_id: int) -> void: _request_ledgers.erase(peer_id) _pending_by_peer.erase(peer_id) for result_id: String in _result_owners.keys(): if _result_owners[result_id] == peer_id: _result_owners.erase(result_id) _acknowledged_results.erase(result_id) func _is_shop_available_for_peer(peer_id: int) -> bool: if ( _session == null or not _session.is_host() or not _session.is_gameplay_session_active() or _spawn_service == null or _shop_interaction == null or not is_instance_valid(_shop_interaction) ): return false var avatar: Player = _spawn_service.get_avatar(peer_id) return ( avatar != null and not avatar.is_water_recovery_active() and ( _network_fishing == null or not _network_fishing.has_peer_attempt(peer_id) ) and _shop_interaction.is_avatar_in_range(avatar) ) 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, ]: var had_pending: bool = is_local_sale_pending() _clear_session_state() if had_pending: local_sale_finished.emit( "", false, "Connection lost.", _empty_catch_ids(), 0 ) func _empty_catch_ids() -> Array[StringName]: return [] func _clear_session_state() -> void: _request_ledgers.clear() _pending_by_peer.clear() _result_owners.clear() _acknowledged_results.clear() _applied_results.clear() _received_results.clear() _pending_local_request_id = "" _pending_local_catch_ids.clear() _pending_local_buyer_id = StringName() func _bound_dictionary(values: Dictionary) -> void: while values.size() > MAX_LEDGER_ENTRIES_PER_PEER: values.erase(values.keys().front()) func _new_id(prefix: String) -> String: return "%s:%s" % [ prefix, Crypto.new().generate_random_bytes(16).hex_encode(), ]