class_name NetworkHomeService extends Node const NetworkHomeProtocolType = preload( "res://network/network_home_protocol.gd" ) const PlayerHomeStateType = preload("res://homes/player_home_state.gd") const DecorCatalogType = preload("res://homes/decor_catalog.gd") const OwnedItemType = preload("res://items/owned_item.gd") signal local_transition_finished(accepted: bool, message: String) signal local_space_changed(space_id: StringName, owner_fingerprint: String) signal local_manifest_rejected(message: String) const MAX_MANIFESTS: int = 128 const MAX_PENDING_LOCAL_DECOR_TRANSACTIONS: int = 16 var _session: NetworkSession var _spawn_service: PlayerSpawnService var _local_home: PlayerHomeStateType var _save_manager: PlayerSaveManager var _world_service: HomeWorldService var _bag: PlayerBag var _inventory_layout: PlayerInventoryLayout var _hotbar: PlayerHotbar var _manifests_by_fingerprint: Dictionary[String, Dictionary] = {} var _fingerprint_by_peer: Dictionary[int, String] = {} var _authorized_owned_counts: Dictionary[int, Dictionary] = {} var _authorization_result_owner: Dictionary[String, int] = {} var _interior_slots_by_space: Dictionary[StringName, Dictionary] = {} var _next_interior_index: int = 0 var _suppress_local_submission: bool = false var _publish_queued: bool = false var _last_submitted_local_home_revision: int = -1 var _pending_local_decor_transactions: Dictionary[int, Dictionary] = {} func setup( session: NetworkSession, spawn_service: PlayerSpawnService, local_home: PlayerHomeStateType, save_manager: PlayerSaveManager, world_service: HomeWorldService, bag: PlayerBag, inventory_layout: PlayerInventoryLayout, hotbar: PlayerHotbar, ) -> void: _session = session _spawn_service = spawn_service _local_home = local_home _save_manager = save_manager _world_service = world_service _bag = bag _inventory_layout = inventory_layout _hotbar = hotbar if not _session.state_changed.is_connected(_on_session_state_changed): _session.state_changed.connect(_on_session_state_changed) if not _session.join_authenticated.is_connected(_on_join_authenticated): _session.join_authenticated.connect(_on_join_authenticated) if not _session.peer_authenticated.is_connected(_on_peer_authenticated): _session.peer_authenticated.connect(_on_peer_authenticated) if not _session.peer_removed.is_connected(_on_peer_removed): _session.peer_removed.connect(_on_peer_removed) if not _local_home.changed.is_connected(_on_local_home_changed): _local_home.changed.connect(_on_local_home_changed) func get_local_space() -> StringName: if _session == null or not _session.is_gameplay_session_active(): return NetworkHomeProtocolType.WORLD_SPACE return _session.get_peer_space(_session.get_local_peer_id()) func get_local_home_owner_fingerprint() -> String: var space_id: String = String(get_local_space()) return space_id.trim_prefix("rv:") if space_id.begins_with("rv:") else "" func can_local_decorate() -> bool: return ( get_local_home_owner_fingerprint() == _session.get_local_identity_fingerprint() ) func has_pending_local_decor_mutation() -> bool: return not _pending_local_decor_transactions.is_empty() func get_authoritative_owned_count( peer_id: int, decor_id: StringName, ) -> int: var authorized: Dictionary = _authorized_owned_counts.get(peer_id, {}) if authorized.has(String(decor_id)): return int(authorized[String(decor_id)]) var fingerprint: String = _fingerprint_for_peer(peer_id) var manifest: Dictionary = _manifests_by_fingerprint.get(fingerprint, {}) if manifest.is_empty(): return -1 var home: Dictionary = manifest.get("home", {}) var owned: Dictionary = home.get("owned_decor", {}) return int(owned.get(String(decor_id), 0)) func place_local_decor( decor_id: StringName, position: Vector3, yaw_radians: float, mode: PlayerHomeStateType.PlacementMode, ) -> String: if ( not can_local_decorate() or has_pending_local_decor_mutation() or _bag == null or _bag.get_quantity(decor_id) <= 0 or _world_service == null ): return "" var owner_fingerprint: String = ( _session.get_local_identity_fingerprint() ) var normalized: Dictionary = ( _local_home.get_nearest_valid_placement_transform( decor_id, position, yaw_radians, mode, "", Callable( self, "_candidate_clear_of_players" ).bind(owner_fingerprint, decor_id), ) ) if normalized.is_empty(): return "" var transaction: Dictionary = _capture_local_decor_transaction() if transaction.is_empty(): return "" _suppress_local_submission = true var instance_id: String = _local_home.place_decor( decor_id, normalized["position"], float(normalized["yaw"]), mode, ) if instance_id.is_empty(): _suppress_local_submission = false return "" if not _bag.remove_item(decor_id, 1): _local_home.remove_placement(instance_id) _suppress_local_submission = false return "" _suppress_local_submission = false if not _commit_local_decor_transaction(transaction): return "" return instance_id func remove_local_decor(instance_id: String) -> bool: if ( not can_local_decorate() or has_pending_local_decor_mutation() or _bag == null or _inventory_layout == null or _hotbar == null ): return false var placement: Dictionary = _local_home.get_placement(instance_id) if placement.is_empty(): return false var transaction: Dictionary = _capture_local_decor_transaction() if transaction.is_empty(): return false var decor_id := StringName(str(placement["decor_id"])) var target_hotbar_slot: int = _hotbar.find_item_slot(decor_id) if target_hotbar_slot < 0: target_hotbar_slot = _hotbar.find_first_empty_slot() var prepared_hotbar_placement: bool = false if ( target_hotbar_slot >= 0 and _inventory_layout.get_container( PlayerInventoryLayout.EntryKind.ITEM, decor_id, ) < 0 ): prepared_hotbar_placement = ( _inventory_layout.prepare_new_item_placement( decor_id, PlayerInventoryLayout.InventoryContainer.HOTBAR, target_hotbar_slot, ) ) var returned_to_inventory: bool = _bag.add_item(decor_id, 1) if not returned_to_inventory: if prepared_hotbar_placement: _inventory_layout.cancel_prepared_item_placement(decor_id) returned_to_inventory = _bag.add_item_to_storage(decor_id, 1) if not returned_to_inventory: if prepared_hotbar_placement: _inventory_layout.cancel_prepared_item_placement(decor_id) return false if target_hotbar_slot >= 0: _hotbar.assign_item(target_hotbar_slot, decor_id) _hotbar.select_slot(target_hotbar_slot) _suppress_local_submission = true if not _local_home.remove_placement(instance_id): _suppress_local_submission = false _restore_local_decor_transaction(transaction) return false _suppress_local_submission = false if not _commit_local_decor_transaction(transaction): return false return true func update_local_decor( instance_id: String, position: Vector3, yaw_radians: float, mode: PlayerHomeStateType.PlacementMode, ) -> bool: if has_pending_local_decor_mutation(): return false var normalized: Dictionary = get_valid_local_decor_transform( instance_id, position, yaw_radians, mode ) if normalized.is_empty(): return false var transaction: Dictionary = _capture_local_decor_transaction() if transaction.is_empty(): return false _suppress_local_submission = true if not _local_home.update_placement( instance_id, normalized["position"], float(normalized["yaw"]), mode, ): _suppress_local_submission = false return false _suppress_local_submission = false if not _commit_local_decor_transaction(transaction): return false return true func get_valid_local_decor_transform( instance_id: String, position: Vector3, yaw_radians: float, mode: PlayerHomeStateType.PlacementMode, ) -> Dictionary: if not can_local_decorate() or _world_service == null: return {} var placement: Dictionary = _local_home.get_placement(instance_id) if placement.is_empty(): return {} var decor_id := StringName(str(placement.get("decor_id", ""))) return _local_home.get_valid_update_transform( instance_id, position, yaw_radians, mode, Callable( self, "_candidate_clear_of_players" ).bind( _session.get_local_identity_fingerprint(), decor_id ), ) func _candidate_clear_of_players( candidate: Dictionary, owner_fingerprint: String, decor_id: StringName, ) -> bool: return ( _world_service != null and _world_service.is_decor_placement_clear_of_players( owner_fingerprint, decor_id, candidate["position"], float(candidate["yaw"]), ) ) func request_enter(owner_fingerprint: String) -> bool: if ( _session == null or not _session.is_gameplay_session_active() or not _manifests_by_fingerprint.has(owner_fingerprint) or get_local_space() != NetworkHomeProtocolType.WORLD_SPACE ): return false return _submit_transition( NetworkHomeProtocolType.TransitionAction.ENTER, owner_fingerprint, ) func request_exit() -> bool: var owner_fingerprint: String = get_local_home_owner_fingerprint() if owner_fingerprint.is_empty(): return false return _submit_transition( NetworkHomeProtocolType.TransitionAction.EXIT, owner_fingerprint, ) func authorize_decor_purchase( peer_id: int, product_id: StringName, resulting_count: int, result_id: String, ) -> void: if not _session.is_host() or peer_id <= 0 or result_id.is_empty(): return var counts: Dictionary = _authorized_owned_counts.get(peer_id, {}) counts[String(product_id)] = resulting_count _authorized_owned_counts[peer_id] = counts _authorization_result_owner[result_id] = peer_id func finalize_decor_purchase( peer_id: int, result_id: String, applied: bool, ) -> void: if _authorization_result_owner.get(result_id, 0) != peer_id: return _authorization_result_owner.erase(result_id) if applied: return _authorized_owned_counts.erase(peer_id) func refresh_world_presentations() -> void: if _world_service == null: return _world_service.clear_presentations() if not _world_service.is_world_ready(): return if not _session.is_host(): for manifest: Dictionary in _manifests_by_fingerprint.values(): _world_service.apply_manifest(manifest) return var local_fingerprint: String = _session.get_local_identity_fingerprint() if ( not _session.is_dedicated_host() and not _manifests_by_fingerprint.has(local_fingerprint) ): publish_local_home() var fingerprints: Array[String] = [] fingerprints.assign(_manifests_by_fingerprint.keys()) fingerprints.sort() var reserved: Array[Transform3D] = [] for fingerprint: String in fingerprints: var manifest: Dictionary = _manifests_by_fingerprint[fingerprint] var transform: Transform3D = _world_service.choose_exterior_transform( int(manifest["owner_peer_id"]), reserved ) reserved.append(transform) manifest["exterior_transform"] = ( NetworkHomeProtocolType.transform_to_array(transform) ) _manifests_by_fingerprint[fingerprint] = manifest _broadcast_manifest(manifest) for fingerprint: String in fingerprints: var manifest: Dictionary = _manifests_by_fingerprint[fingerprint] _spawn_peer_at_exterior( int(manifest["owner_peer_id"]), fingerprint ) func publish_local_home( allow_pending_decor_transaction: bool = false, ) -> bool: if ( _session == null or not _session.is_gameplay_session_active() or _session.is_dedicated_host() or _local_home == null ): return false # A purchase can update the owned decor count while an optimistic placement # is waiting on the host. Hold that follow-up manifest until the placement is # accepted or rolled back; otherwise it can carry the still-unverified # placement and turn one rejection into a second stale rejection. if ( not allow_pending_decor_transaction and has_pending_local_decor_mutation() ): _publish_queued = true return true if _session.is_host() and ( _world_service == null or not _world_service.is_world_ready() ): return false var home_data: Dictionary = _local_home.to_save_data() var revision: int = int(home_data.get("revision", -1)) if revision >= 0 and revision == _last_submitted_local_home_revision: return true var request: Dictionary = { "request_id": _new_id("home"), "session_id": _session.get_session_id(), "owner_fingerprint": _session.get_local_identity_fingerprint(), "home": home_data, "digest": NetworkHomeProtocolType.manifest_digest(home_data), } request["sender_signature"] = _session.sign_local_action( "portable_home_manifest", NetworkHomeProtocolType.manifest_signature_fields(request), ) _last_submitted_local_home_revision = revision if _session.is_host(): _process_manifest_request(_session.get_local_peer_id(), request) else: submit_home_manifest.rpc_id(1, request) return true @rpc( "any_peer", "call_remote", "reliable", NetworkHomeProtocolType.RELIABLE_CHANNEL, ) func submit_home_manifest(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 _process_manifest_request(sender_id, data) func _process_manifest_request(peer_id: int, data: Dictionary) -> void: if ( not NetworkHomeProtocolType.validate_manifest_request(data) or str(data["session_id"]) != _session.get_session_id() ): _reject_manifest(peer_id, "The RV update was invalid.") return var record := _session.get_peer_record(peer_id) var fingerprint: String = str(data["owner_fingerprint"]) if ( record == null or record.identity_fingerprint != fingerprint or not _session.verify_peer_action( peer_id, "portable_home_manifest", NetworkHomeProtocolType.manifest_signature_fields(data), data["sender_signature"], ) ): _reject_manifest(peer_id, "The RV owner could not be verified.") return var home_data: Dictionary = PlayerHomeStateType.sanitize_save_data( data["home"] ) var prior: Dictionary = _manifests_by_fingerprint.get(fingerprint, {}) var is_first_manifest: bool = prior.is_empty() if not _valid_manifest_revision(peer_id, prior, home_data): _reject_manifest(peer_id, "The RV update was rejected by the host.") return if not _moved_decor_clear_of_players(fingerprint, prior, home_data): _reject_manifest(peer_id, "A player is in the way of that decor.") return var manifest: Dictionary if prior.is_empty(): if _manifests_by_fingerprint.size() >= MAX_MANIFESTS: _reject_manifest(peer_id, "This room cannot place another RV.") return var reserved: Array[Transform3D] = [] for existing: Dictionary in _manifests_by_fingerprint.values(): reserved.append(NetworkHomeProtocolType.array_to_transform( existing["exterior_transform"] )) var exterior_transform: Transform3D = ( _world_service.choose_exterior_transform(peer_id, reserved) ) manifest = { "owner_peer_id": peer_id, "owner_fingerprint": fingerprint, "home": home_data, "exterior_transform": ( NetworkHomeProtocolType.transform_to_array(exterior_transform) ), "interior_index": _next_interior_index, } _next_interior_index += 1 else: manifest = prior.duplicate(true) manifest["home"] = home_data manifest["owner_peer_id"] = peer_id _manifests_by_fingerprint[fingerprint] = manifest _fingerprint_by_peer[peer_id] = fingerprint _consume_owned_authorizations(peer_id, home_data) if peer_id == _session.get_local_peer_id(): _discard_local_decor_transactions_through( int(home_data.get("revision", -1)) ) if _last_submitted_local_home_revision == int( home_data.get("revision", -1) ): _last_submitted_local_home_revision = -1 _flush_queued_local_home_publish() _broadcast_manifest(manifest) if is_first_manifest: _spawn_peer_at_exterior(peer_id, fingerprint) func _valid_manifest_revision( peer_id: int, prior_manifest: Dictionary, home_data: Dictionary, ) -> bool: if prior_manifest.is_empty(): # A portable RV is owned by the player's signed local save. A new host has # no prior room ledger with which to compare its first manifest. return true var prior_home: Dictionary = prior_manifest["home"] if ( int(home_data["revision"]) <= int(prior_home["revision"]) or int(home_data["tier"]) != int(prior_home["tier"]) or str(home_data["exterior_id"]) != str(prior_home["exterior_id"]) ): return false var prior_owned: Dictionary = prior_home["owned_decor"] var next_owned: Dictionary = home_data["owned_decor"] var all_ids: Dictionary[String, bool] = {} for value: Variant in prior_owned.keys(): all_ids[str(value)] = true for value: Variant in next_owned.keys(): all_ids[str(value)] = true var authorized: Dictionary = _authorized_owned_counts.get(peer_id, {}) for product_id: String in all_ids: var prior_count: int = int(prior_owned.get(product_id, 0)) var next_count: int = int(next_owned.get(product_id, 0)) if prior_count == next_count: continue if next_count < prior_count or int(authorized.get(product_id, -1)) != next_count: return false return true func _moved_decor_clear_of_players( owner_fingerprint: String, prior_manifest: Dictionary, home_data: Dictionary, ) -> bool: if _world_service == null: return false var prior_by_instance: Dictionary[String, Dictionary] = {} if not prior_manifest.is_empty(): var prior_home: Dictionary = prior_manifest.get("home", {}) for value: Variant in prior_home.get("placements", []): if typeof(value) != TYPE_DICTIONARY: continue var prior_placement: Dictionary = value prior_by_instance[str( prior_placement.get("instance_id", "") )] = prior_placement for value: Variant in home_data.get("placements", []): if typeof(value) != TYPE_DICTIONARY: return false var placement: Dictionary = value var instance_id: String = str(placement.get("instance_id", "")) var prior_placement: Dictionary = prior_by_instance.get( instance_id, {} ) if ( not prior_placement.is_empty() and _same_decor_transform(prior_placement, placement) ): continue var decor_id := StringName(str(placement.get("decor_id", ""))) var position: Vector3 = _placement_position(placement) if ( not position.is_finite() or not _world_service.is_decor_placement_clear_of_players( owner_fingerprint, decor_id, position, float(placement.get("yaw", 0.0)), ) ): return false return true static func _same_decor_transform( first: Dictionary, second: Dictionary, ) -> bool: if str(first.get("decor_id", "")) != str(second.get("decor_id", "")): return false var first_position: Vector3 = _placement_position(first) var second_position: Vector3 = _placement_position(second) return ( first_position.is_equal_approx(second_position) and is_equal_approx( float(first.get("yaw", 0.0)), float(second.get("yaw", 0.0)), ) ) static func _placement_position(placement: Dictionary) -> Vector3: var values: Array = placement.get("position", []) if values.size() != 3: return Vector3.INF return Vector3( float(values[0]), float(values[1]), float(values[2]) ) func _consume_owned_authorizations(peer_id: int, home_data: Dictionary) -> void: var authorized: Dictionary = _authorized_owned_counts.get(peer_id, {}) if authorized.is_empty(): return var owned: Dictionary = home_data["owned_decor"] for product_id: String in authorized.keys(): if int(owned.get(product_id, -1)) == int(authorized[product_id]): authorized.erase(product_id) if authorized.is_empty(): _authorized_owned_counts.erase(peer_id) else: _authorized_owned_counts[peer_id] = authorized func _broadcast_manifest(manifest: Dictionary) -> void: if _world_service != null and _world_service.is_world_ready(): _world_service.apply_manifest(manifest) for peer_id: int in _session.get_authenticated_peer_ids(): if peer_id == 1: continue receive_home_manifest.rpc_id(peer_id, manifest) @rpc( "authority", "call_remote", "reliable", NetworkHomeProtocolType.RELIABLE_CHANNEL, ) func receive_home_manifest(data: Dictionary) -> void: if not NetworkHomeProtocolType.validate_manifest_broadcast(data): return var fingerprint: String = str(data["owner_fingerprint"]) _manifests_by_fingerprint[fingerprint] = data.duplicate(true) _fingerprint_by_peer[int(data["owner_peer_id"])] = fingerprint if fingerprint == _session.get_local_identity_fingerprint(): var home: Dictionary = data.get("home", {}) _discard_local_decor_transactions_through( int(home.get("revision", -1)) ) if _last_submitted_local_home_revision == int( home.get("revision", -1) ): _last_submitted_local_home_revision = -1 _flush_queued_local_home_publish() if _world_service != null and _world_service.is_world_ready(): _world_service.apply_manifest(data) func _reject_manifest(peer_id: int, message: String) -> void: var fingerprint: String = _fingerprint_for_peer(peer_id) var canonical: Dictionary = _manifests_by_fingerprint.get(fingerprint, {}) if peer_id == _session.get_local_peer_id(): _apply_manifest_rejection(message, canonical) else: receive_home_manifest_rejection.rpc_id(peer_id, message, canonical) @rpc( "authority", "call_remote", "reliable", NetworkHomeProtocolType.RELIABLE_CHANNEL, ) func receive_home_manifest_rejection( message: String, canonical: Dictionary, ) -> void: _apply_manifest_rejection(message, canonical) func _apply_manifest_rejection(message: String, canonical: Dictionary) -> void: _last_submitted_local_home_revision = -1 var canonical_home: Dictionary = canonical.get("home", {}) var canonical_revision: int = int(canonical_home.get("revision", -1)) var transaction: Dictionary = _get_local_decor_rejection_transaction( canonical_revision ) if canonical.is_empty() or not NetworkHomeProtocolType.validate_manifest_broadcast(canonical): if not transaction.is_empty(): _restore_local_decor_transaction(transaction) _pending_local_decor_transactions.clear() if _save_manager != null and not _save_manager.save_if_dirty(): push_warning("Unable to save the restored RV decor state.") _flush_queued_local_home_publish() local_manifest_rejected.emit(message) return # A shop result can be accepted locally while its own home manifest is still # in flight. A delayed rejection for an earlier placement must not erase that # accepted purchase just because the host's canonical manifest predates it. var locally_owned_decor: Dictionary[StringName, int] = ( _capture_local_owned_decor_counts() ) # Only carry ownership forward across an actual pending decor-action # rejection. A standalone rejected manifest must remain authoritative rather # than repeatedly re-submitting an unverified owned-count increase. var has_owned_decor_increase: bool = ( not transaction.is_empty() and _has_local_owned_decor_increase( locally_owned_decor, canonical_home, ) ) _suppress_local_submission = true var restored: bool = _local_home.restore_from_save_data(canonical_home) var owned_decor_restored: bool = ( restored and ( not has_owned_decor_increase or _restore_local_owned_decor_increases(locally_owned_decor) ) ) var reconciled: bool = ( restored and owned_decor_restored and _reconcile_local_decor_inventory(transaction) ) _suppress_local_submission = false _discard_local_decor_transactions_after(canonical_revision) if not reconciled: push_error("Unable to reconcile RV decor after a host rejection.") elif _save_manager != null and not _save_manager.save_if_dirty(): push_warning("Unable to save the restored RV decor state.") receive_home_manifest(canonical) if reconciled and has_owned_decor_increase: # Submit the preserved purchase as a fresh revision after applying the # canonical placement rollback. This is deliberately separate from the # rejected action, so the host can validate the already-authorized count. if not publish_local_home(): push_warning("Unable to publish the restored RV decor ownership.") local_manifest_rejected.emit(message) func _capture_local_decor_transaction() -> Dictionary: if ( _local_home == null or _bag == null or _inventory_layout == null or _hotbar == null ): return {} var saved_items: Array[OwnedItemType] = [] for item: OwnedItemType in _bag.get_all_items(): saved_items.append(item.duplicate_record()) return { "home": _local_home.to_save_data(), "bag_items": saved_items, "layout": _inventory_layout.to_save_data(), "hotbar_slots": _hotbar.get_slots(), "hotbar_fish_slots": _hotbar.get_fish_slots(), "selected_hotbar_slot": _hotbar.get_selected_slot(), } func _capture_local_owned_decor_counts() -> Dictionary[StringName, int]: var counts: Dictionary[StringName, int] = {} if _local_home == null: return counts for decor_id: StringName in DecorCatalogType.get_product_ids(): counts[decor_id] = _local_home.get_owned_count(decor_id) return counts func _has_local_owned_decor_increase( local_counts: Dictionary[StringName, int], canonical_home: Dictionary, ) -> bool: var canonical_owned: Dictionary = canonical_home.get("owned_decor", {}) for decor_id: StringName in DecorCatalogType.get_product_ids(): if int(local_counts.get(decor_id, 0)) > int( canonical_owned.get(String(decor_id), 0) ): return true return false func _restore_local_owned_decor_increases( local_counts: Dictionary[StringName, int], ) -> bool: if _local_home == null: return false for decor_id: StringName in DecorCatalogType.get_product_ids(): var local_count: int = int(local_counts.get(decor_id, 0)) var canonical_count: int = _local_home.get_owned_count(decor_id) while canonical_count < local_count: if not _local_home.add_owned_decor(decor_id, 1): return false canonical_count += 1 return true func _commit_local_decor_transaction(transaction: Dictionary) -> bool: if _save_manager == null or not _save_manager.save_if_dirty(): _restore_local_decor_transaction(transaction) return false var revision: int = _local_home.get_revision() _pending_local_decor_transactions[revision] = transaction.duplicate(true) _trim_pending_local_decor_transactions() if publish_local_home(true): return true _pending_local_decor_transactions.erase(revision) _restore_local_decor_transaction(transaction) _flush_queued_local_home_publish() # The optimistic mutation was already written before we attempted to publish # it. If the session ended or could not accept the manifest, immediately # persist the restored state so a restart cannot resurrect the rejected # placement or consume its decor item. if _save_manager != null and not _save_manager.save_if_dirty(): push_warning("Unable to save the restored RV decor state.") return false func _restore_local_decor_transaction(transaction: Dictionary) -> bool: if ( transaction.is_empty() or _local_home == null or _bag == null or _inventory_layout == null or _hotbar == null ): return false var home_data: Variant = transaction.get("home", {}) var layout_data: Variant = transaction.get("layout", {}) var stored_items: Variant = transaction.get("bag_items", []) var stored_slots: Variant = transaction.get("hotbar_slots", []) var stored_fish_slots: Variant = transaction.get("hotbar_fish_slots", []) if ( typeof(home_data) != TYPE_DICTIONARY or typeof(layout_data) != TYPE_DICTIONARY or typeof(stored_items) != TYPE_ARRAY or typeof(stored_slots) != TYPE_ARRAY or typeof(stored_fish_slots) != TYPE_ARRAY ): return false var items: Array[OwnedItemType] = [] for value: Variant in stored_items as Array: if not value is OwnedItemType: return false items.append((value as OwnedItemType).duplicate_record()) var slots: Array[StringName] = [] for value: Variant in stored_slots as Array: slots.append(StringName(str(value))) var fish_slots: Array[StringName] = [] for value: Variant in stored_fish_slots as Array: fish_slots.append(StringName(str(value))) var was_suppressed: bool = _suppress_local_submission _suppress_local_submission = true var home_restored: bool = _local_home.restore_from_save_data( home_data as Dictionary ) var bag_restored: bool = _bag.replace_all_items(items) var layout_restored: bool = _inventory_layout.restore_from_save_data( layout_data as Dictionary ) var hotbar_restored: bool = _hotbar.replace_state( slots, int(transaction.get("selected_hotbar_slot", 0)), fish_slots, false, ) _suppress_local_submission = was_suppressed return home_restored and bag_restored and layout_restored and hotbar_restored func _reconcile_local_decor_inventory(transaction: Dictionary) -> bool: if _bag == null or _local_home == null: return false var preferred_slots: Dictionary[StringName, int] = ( _decor_hotbar_slots_from_transaction(transaction) ) for decor_id: StringName in DecorCatalogType.get_product_ids(): var target_quantity: int = _local_home.get_available_count(decor_id) var current_quantity: int = _bag.get_quantity(decor_id) if current_quantity > target_quantity: if not _bag.remove_item( decor_id, current_quantity - target_quantity ): return false continue while current_quantity < target_quantity: var preferred_slot: int = int( preferred_slots.get(decor_id, -1) ) if not _restore_decor_item_to_bag(decor_id, preferred_slot): return false current_quantity += 1 return true func _decor_hotbar_slots_from_transaction( transaction: Dictionary, ) -> Dictionary[StringName, int]: var result: Dictionary[StringName, int] = {} var stored_slots: Variant = transaction.get("hotbar_slots", []) if typeof(stored_slots) != TYPE_ARRAY: return result for slot_index: int in range((stored_slots as Array).size()): var decor_id := StringName(str((stored_slots as Array)[slot_index])) if DecorCatalogType.has_product(decor_id): result[decor_id] = slot_index return result func _restore_decor_item_to_bag( decor_id: StringName, preferred_hotbar_slot: int, ) -> bool: if _bag == null: return false var prepared_slot: int = -1 if ( _inventory_layout != null and _hotbar != null and _inventory_layout.get_container( PlayerInventoryLayout.EntryKind.ITEM, decor_id ) < 0 ): var candidate_slot: int = preferred_hotbar_slot if candidate_slot < 0: candidate_slot = _hotbar.find_first_empty_slot() if candidate_slot >= 0 and _inventory_layout.prepare_new_item_placement( decor_id, PlayerInventoryLayout.InventoryContainer.HOTBAR, candidate_slot, ): prepared_slot = candidate_slot var restored: bool = _bag.add_item(decor_id, 1) if not restored: if prepared_slot >= 0: _inventory_layout.cancel_prepared_item_placement(decor_id) if _bag.get_owned_item(decor_id) == null: restored = _bag.add_item_to_storage(decor_id, 1) if restored and prepared_slot >= 0: _hotbar.assign_item(prepared_slot, decor_id) _hotbar.select_slot(prepared_slot) return restored func _get_local_decor_rejection_transaction( canonical_revision: int, ) -> Dictionary: var revisions: Array[int] = [] revisions.assign(_pending_local_decor_transactions.keys()) revisions.sort() for revision: int in revisions: if canonical_revision < 0 or revision > canonical_revision: return _pending_local_decor_transactions[revision].duplicate(true) return {} func _discard_local_decor_transactions_through(revision: int) -> void: for pending_revision: int in _pending_local_decor_transactions.keys(): if pending_revision <= revision: _pending_local_decor_transactions.erase(pending_revision) func _discard_local_decor_transactions_after(revision: int) -> void: for pending_revision: int in _pending_local_decor_transactions.keys(): if pending_revision > revision: _pending_local_decor_transactions.erase(pending_revision) func _trim_pending_local_decor_transactions() -> void: var revisions: Array[int] = [] revisions.assign(_pending_local_decor_transactions.keys()) revisions.sort() while revisions.size() > MAX_PENDING_LOCAL_DECOR_TRANSACTIONS: _pending_local_decor_transactions.erase(revisions.pop_front()) func _submit_transition(action: int, owner_fingerprint: String) -> bool: var request: Dictionary = { "request_id": _new_id("home_transition"), "session_id": _session.get_session_id(), "action": action, "owner_fingerprint": owner_fingerprint, } request["sender_signature"] = _session.sign_local_action( "portable_home_transition", NetworkHomeProtocolType.transition_signature_fields(request), ) if _session.is_host(): _process_transition_request(_session.get_local_peer_id(), request) else: submit_home_transition.rpc_id(1, request) return true @rpc( "any_peer", "call_remote", "reliable", NetworkHomeProtocolType.RELIABLE_CHANNEL, ) func submit_home_transition(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 _process_transition_request(sender_id, data) func _process_transition_request(peer_id: int, data: Dictionary) -> void: if ( not NetworkHomeProtocolType.validate_transition_request(data) or str(data["session_id"]) != _session.get_session_id() or not _session.verify_peer_action( peer_id, "portable_home_transition", NetworkHomeProtocolType.transition_signature_fields(data), data["sender_signature"], ) ): _send_transition_rejection(peer_id, "The RV transition was invalid.") return var owner_fingerprint: String = str(data["owner_fingerprint"]) var action: int = int(data["action"]) var avatar: Player = _spawn_service.get_avatar(peer_id) var manifest: Dictionary = _manifests_by_fingerprint.get( owner_fingerprint, {} ) if avatar == null or manifest.is_empty(): _send_transition_rejection(peer_id, "That RV is unavailable.") return if action == NetworkHomeProtocolType.TransitionAction.ENTER: var record := _session.get_peer_record(peer_id) var owner_is_local: bool = ( record != null and record.identity_fingerprint == owner_fingerprint ) var privacy: int = int((manifest["home"] as Dictionary)["privacy"]) if ( (not owner_is_local and privacy != PlayerHomeStateType.Privacy.OPEN) or _session.get_peer_space(peer_id) != NetworkHomeProtocolType.WORLD_SPACE or not _world_service.is_avatar_near_exterior( avatar, owner_fingerprint ) ): _send_transition_rejection(peer_id, "You cannot enter that RV.") return var interior_space: StringName = ( NetworkHomeProtocolType.make_space_id(owner_fingerprint) ) var visitor_slot: int = _reserve_interior_slot( interior_space, peer_id ) _apply_host_transition( peer_id, interior_space, owner_fingerprint, _world_service.get_interior_spawn_transform( owner_fingerprint, visitor_slot ), ) return if ( _session.get_peer_space(peer_id) != NetworkHomeProtocolType.make_space_id(owner_fingerprint) or not _world_service.is_avatar_near_interior_exit( avatar, owner_fingerprint ) ): _send_transition_rejection(peer_id, "Move closer to the RV door.") return _apply_host_transition( peer_id, NetworkHomeProtocolType.WORLD_SPACE, owner_fingerprint, _world_service.get_exterior_spawn_transform(owner_fingerprint), ) func _spawn_peer_at_exterior( peer_id: int, owner_fingerprint: String, ) -> void: if ( not _session.is_host() or _session.get_peer_space(peer_id) != NetworkHomeProtocolType.WORLD_SPACE or _spawn_service.get_avatar(peer_id) == null ): return _apply_host_transition( peer_id, NetworkHomeProtocolType.WORLD_SPACE, owner_fingerprint, _world_service.get_exterior_spawn_transform(owner_fingerprint), ) func _apply_host_transition( peer_id: int, space_id: StringName, owner_fingerprint: String, transform: Transform3D, ) -> void: var avatar: Player = _spawn_service.get_avatar(peer_id) var movement_sequence: int = 0 if avatar != null: movement_sequence = int( avatar.make_network_snapshot(peer_id).get( "acknowledged_input", 0 ) ) var transform_data: Array[float] = ( NetworkHomeProtocolType.transform_to_array(transform) ) if not NetworkHomeProtocolType.validate_transition_transform( transform_data ): push_error("Refused an out-of-bounds RV transition transform.") return var previous_space: StringName = _session.get_peer_space(peer_id) if ( String(previous_space).begins_with("rv:") and previous_space != space_id ): _release_interior_slot(previous_space, peer_id) var result: Dictionary = { "peer_id": peer_id, "space_id": String(space_id), "owner_fingerprint": owner_fingerprint, "transform": transform_data, "movement_sequence": movement_sequence, } _apply_transition_result(result) for recipient_id: int in _session.get_authenticated_peer_ids(): if recipient_id == 1: continue receive_home_transition.rpc_id(recipient_id, result) @rpc( "authority", "call_remote", "reliable", NetworkHomeProtocolType.RELIABLE_CHANNEL, ) func receive_home_transition(data: Dictionary) -> void: if ( typeof(data.get("peer_id")) != TYPE_INT or typeof(data.get("space_id")) not in [TYPE_STRING, TYPE_STRING_NAME] or typeof(data.get("movement_sequence", 0)) != TYPE_INT or int(data.get("movement_sequence", 0)) < 0 or not NetworkIdentityCrypto.valid_fingerprint( data.get("owner_fingerprint") ) or not NetworkHomeProtocolType.validate_transition_transform( data.get("transform") ) ): return _apply_transition_result(data) func _apply_transition_result(data: Dictionary) -> void: var peer_id: int = int(data["peer_id"]) var space_id := StringName(str(data["space_id"])) if not NetworkHomeProtocolType.validate_transition_transform( data.get("transform") ): return var target_transform: Transform3D = ( NetworkHomeProtocolType.array_to_transform(data["transform"]) ) if not _session.set_peer_space(peer_id, space_id): return if not _session.apply_authoritative_space_transition( peer_id, target_transform, int(data.get("movement_sequence", 0)), ): return if peer_id == _session.get_local_peer_id(): _world_service.set_local_space(space_id) local_space_changed.emit(space_id, str(data["owner_fingerprint"])) local_transition_finished.emit(true, "") _refresh_peer_visibility() func _send_transition_rejection(peer_id: int, message: String) -> void: if peer_id == _session.get_local_peer_id(): local_transition_finished.emit(false, message) else: receive_home_transition_rejection.rpc_id(peer_id, message) @rpc( "authority", "call_remote", "reliable", NetworkHomeProtocolType.RELIABLE_CHANNEL, ) func receive_home_transition_rejection(message: String) -> void: local_transition_finished.emit( false, message.left(NetworkHomeProtocolType.MAX_MESSAGE_LENGTH) ) func _refresh_peer_visibility() -> void: var local_peer_id: int = _session.get_local_peer_id() if local_peer_id <= 0: return for peer_id: int in _spawn_service.get_peer_ids(): if peer_id == local_peer_id: continue _spawn_service.set_peer_presentation_visible( peer_id, _session.peers_share_space(local_peer_id, peer_id) ) func _on_local_home_changed() -> void: if _suppress_local_submission or _publish_queued: return _publish_queued = true call_deferred("_publish_queued_home") func _publish_queued_home() -> void: if has_pending_local_decor_mutation(): return _publish_queued = false if _save_manager != null: _save_manager.save_if_dirty() publish_local_home() func _flush_queued_local_home_publish() -> void: if not _publish_queued or has_pending_local_decor_mutation(): return call_deferred("_publish_queued_home") func _on_join_authenticated() -> void: publish_local_home() func _on_peer_authenticated(peer_id: int, _display_name: String) -> void: if not _session.is_host(): return for manifest: Dictionary in _manifests_by_fingerprint.values(): receive_home_manifest.rpc_id(peer_id, manifest) for existing_peer_id: int in _session.get_authenticated_peer_ids(): if existing_peer_id == peer_id: continue var avatar: Player = _spawn_service.get_avatar(existing_peer_id) if avatar == null: continue var space_id: StringName = _session.get_peer_space(existing_peer_id) var owner_fingerprint: String = ( String(space_id).trim_prefix("rv:") if String(space_id).begins_with("rv:") else _fingerprint_for_peer(existing_peer_id) ) if owner_fingerprint.is_empty(): continue receive_home_transition.rpc_id(peer_id, { "peer_id": existing_peer_id, "space_id": String(space_id), "owner_fingerprint": owner_fingerprint, "transform": NetworkHomeProtocolType.transform_to_array( avatar.global_transform ), }) func _on_peer_removed(peer_id: int) -> void: _release_peer_interior_slots(peer_id) var fingerprint: String = _fingerprint_by_peer.get(peer_id, "") _fingerprint_by_peer.erase(peer_id) _authorized_owned_counts.erase(peer_id) if fingerprint.is_empty(): return if _session.is_host(): for occupant_id: int in _session.get_authenticated_peer_ids(): if ( occupant_id == peer_id or _session.get_peer_space(occupant_id) != NetworkHomeProtocolType.make_space_id(fingerprint) ): continue _apply_host_transition( occupant_id, NetworkHomeProtocolType.WORLD_SPACE, fingerprint, _world_service.get_exterior_spawn_transform(fingerprint), ) _manifests_by_fingerprint.erase(fingerprint) _world_service.remove_presentation(fingerprint) if _session.is_host(): for recipient_id: int in _session.get_authenticated_peer_ids(): if recipient_id != 1: receive_home_removed.rpc_id(recipient_id, fingerprint) @rpc( "authority", "call_remote", "reliable", NetworkHomeProtocolType.RELIABLE_CHANNEL, ) func receive_home_removed(owner_fingerprint: String) -> void: if not NetworkIdentityCrypto.valid_fingerprint(owner_fingerprint): return _manifests_by_fingerprint.erase(owner_fingerprint) _world_service.remove_presentation(owner_fingerprint) func _on_session_state_changed(state: NetworkSession.State) -> void: if state in [NetworkSession.State.PRIVATE_HOST, NetworkSession.State.OPEN_HOST]: if ( not _session.is_dedicated_host() and _world_service != null and _world_service.is_world_ready() ): publish_local_home() return if state in [ NetworkSession.State.INACTIVE, NetworkSession.State.DISCONNECTING, NetworkSession.State.CONNECTION_FAILED, NetworkSession.State.SERVER_LOST, ]: _manifests_by_fingerprint.clear() _fingerprint_by_peer.clear() _authorized_owned_counts.clear() _authorization_result_owner.clear() _publish_queued = false _last_submitted_local_home_revision = -1 _pending_local_decor_transactions.clear() _interior_slots_by_space.clear() _next_interior_index = 0 _world_service.clear_presentations() _world_service.set_local_space(NetworkHomeProtocolType.WORLD_SPACE) local_space_changed.emit(NetworkHomeProtocolType.WORLD_SPACE, "") func _fingerprint_for_peer(peer_id: int) -> String: if _fingerprint_by_peer.has(peer_id): return _fingerprint_by_peer[peer_id] var record := _session.get_peer_record(peer_id) return record.identity_fingerprint if record != null else "" func _reserve_interior_slot(space_id: StringName, peer_id: int) -> int: var slots: Dictionary = _interior_slots_by_space.get(space_id, {}) if slots.has(peer_id): return int(slots[peer_id]) var used_slots: Dictionary[int, bool] = {} for value: Variant in slots.values(): used_slots[int(value)] = true var slot: int = 0 while used_slots.has(slot): slot += 1 slots[peer_id] = slot _interior_slots_by_space[space_id] = slots return slot func _release_interior_slot(space_id: StringName, peer_id: int) -> void: var slots: Dictionary = _interior_slots_by_space.get(space_id, {}) if slots.is_empty(): return slots.erase(peer_id) if slots.is_empty(): _interior_slots_by_space.erase(space_id) else: _interior_slots_by_space[space_id] = slots func _release_peer_interior_slots(peer_id: int) -> void: for space_id: StringName in _interior_slots_by_space.keys(): _release_interior_slot(space_id, peer_id) func _new_id(prefix: String) -> String: return "%s:%s" % [ prefix, Crypto.new().generate_random_bytes(16).hex_encode(), ]