From 6578d3e8701fa38d0c91bdacf8543ca4e9cd1ec6 Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 31 Aug 2026 05:48:36 -0400 Subject: [PATCH] Add transactional RV decor synchronization --- homes/home_decor_controller.gd | 58 +++- homes/home_world_service.gd | 15 +- main/main.gd | 31 +- network/network_home_protocol.gd | 14 +- network/network_home_service.gd | 492 +++++++++++++++++++++++++++++-- network/network_session.gd | 70 +++++ player/player.gd | 31 ++ 7 files changed, 663 insertions(+), 48 deletions(-) diff --git a/homes/home_decor_controller.gd b/homes/home_decor_controller.gd index 1a97a5d..8f9aa5f 100644 --- a/homes/home_decor_controller.gd +++ b/homes/home_decor_controller.gd @@ -47,6 +47,12 @@ func setup( _on_local_space_changed ): _network_home.local_space_changed.connect(_on_local_space_changed) + if not _network_home.local_manifest_rejected.is_connected( + _on_local_manifest_rejected + ): + _network_home.local_manifest_rejected.connect( + _on_local_manifest_rejected + ) if not _home_state.changed.is_connected(_on_home_changed): _home_state.changed.connect(_on_home_changed) if not _toolbar.placement_mode_requested.is_connected( @@ -84,7 +90,13 @@ func _input(event: InputEvent) -> void: and pointer_button.button_index == MOUSE_BUTTON_LEFT and not pointer_button.pressed ): - _finish_drag() + # A gameplay menu can take ownership between the press and release. Do + # not let that release finalize decor behind the menu; preserve the + # original transform instead. + if _can_decorate(): + _finish_drag() + else: + _cancel_drag() get_viewport().set_input_as_handled() @@ -120,14 +132,17 @@ func _unhandled_input(event: InputEvent) -> void: func _process(delta: float) -> void: if _drag_instance_id.is_empty(): return + # Menus and other gameplay input handoffs must cancel an active drag before + # evaluating a held/released pointer. This prevents a same-frame TAB press + # followed by left-button release from committing a move behind the UI. + if not _can_decorate(): + _cancel_drag() + return if not Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT): _finish_drag() return if not _drag_target_valid: return - if not _can_decorate(): - _cancel_drag() - return var current: Dictionary = _home_world.get_decor_preview_transform( _drag_owner_fingerprint, _drag_instance_id ) @@ -146,6 +161,9 @@ func _process(delta: float) -> void: func _begin_drag(screen_position: Vector2) -> bool: + if _network_home.has_pending_local_decor_mutation(): + _toolbar.report_status("waiting for the previous decor update") + return false var owner_fingerprint: String = ( _network_home.get_local_home_owner_fingerprint() ) @@ -331,22 +349,34 @@ static func _placement_position(placement: Dictionary) -> Vector3: func _place_carried_decor(decor_id: StringName) -> void: + if _network_home.has_pending_local_decor_mutation(): + _toolbar.report_status("waiting for the previous decor update") + return var owner_fingerprint: String = ( _network_home.get_local_home_owner_fingerprint() ) - var forward: Vector3 = -_player.global_basis.z - forward.y = 0.0 - forward = forward.normalized() + # RV transitions keep the gameplay root axis-aligned so its transform can be + # synchronized independently from the presentation. Use the visual facing + # direction instead; otherwise carried decor is always placed along world -Z + # instead of in front of the character the player sees. + var forward: Vector3 = _player.get_facing_direction() var global_position: Vector3 = _player.global_position + forward * 1.45 var local_position: Vector3 = _home_world.interior_global_to_local( owner_fingerprint, global_position ) + var placement_yaw: float = atan2(-forward.x, -forward.z) var instance_id: String = _network_home.place_local_decor( - decor_id, local_position, _player.global_rotation.y, _mode + decor_id, local_position, placement_yaw, _mode ) if instance_id.is_empty(): _toolbar.report_status("make room in front of you to place this decor") return + # A local host can reject its own optimistic manifest synchronously. Do not + # claim success after that rollback or leave the player looking for decor that + # was never accepted into the room. + if _home_state.get_placement(instance_id).is_empty(): + _toolbar.report_status("decor placement was not accepted") + return _select_instance(instance_id) _toolbar.report_status("decor placed") @@ -366,6 +396,9 @@ func _select_instance(instance_id: String) -> void: func _rotate_selected() -> void: if not _can_decorate() or _selected_instance_id.is_empty(): return + if _network_home.has_pending_local_decor_mutation(): + _toolbar.report_status("waiting for the previous decor update") + return var placement: Dictionary = _home_state.get_placement( _selected_instance_id ) @@ -392,6 +425,9 @@ func _rotate_selected() -> void: func _store_selected() -> void: if _selected_instance_id.is_empty(): return + if _network_home.has_pending_local_decor_mutation(): + _toolbar.report_status("waiting for the previous decor update") + return if _network_home.remove_local_decor(_selected_instance_id): _clear_selection() _toolbar.report_status("decor packed into storage") @@ -418,6 +454,12 @@ func _on_local_space_changed( _refresh_toolbar() +func _on_local_manifest_rejected(message: String) -> void: + _clear_selection() + if _toolbar != null and _toolbar.visible: + _toolbar.report_status(message) + + func _on_home_changed() -> void: if ( not _selected_instance_id.is_empty() diff --git a/homes/home_world_service.gd b/homes/home_world_service.gd index baec913..fd02d93 100644 --- a/homes/home_world_service.gd +++ b/homes/home_world_service.gd @@ -17,6 +17,7 @@ const EXTERIOR_INTERACTION_DISTANCE: float = 2.7 const INTERIOR_EXIT_DISTANCE: float = 2.1 const INTERIOR_ORIGIN := Vector3(1000.0, 300.0, 1000.0) const INTERIOR_SPACING: float = 14.0 +const INTERIOR_MAX_SPAWN_SLOTS: int = 64 const INTERIOR_FLOOR_SIZE := Vector3(8.0, 0.18, 4.5) const INTERIOR_WALL_HEIGHT: float = 2.7 const MIN_EXTERIOR_TRIANGLE_AREA: float = 1.0 @@ -461,13 +462,17 @@ func get_interior_spawn_transform( visitor_offset: int = 0, ) -> Transform3D: var anchor: Vector3 = get_interior_anchor(owner_fingerprint) - var result := Transform3D(Basis.IDENTITY, anchor) - result.origin += Vector3( - -1.0 + float(posmod(visitor_offset, 4)) * 0.65, + # `visitor_offset` is allocated by the host as a compact room-local slot. + # Keep it bounded as a last line of defense if a future caller accidentally + # supplies an engine peer ID instead of a slot. + var visitor_slot: int = posmod(visitor_offset, INTERIOR_MAX_SPAWN_SLOTS) + # Build the origin as a value rather than mutating Transform3D.origin. + var spawn_origin: Vector3 = anchor + Vector3( + -1.0 + float(posmod(visitor_slot, 4)) * 0.65, 0.25, - 0.4 + float(floori(float(visitor_offset) / 4.0)) * 0.55, + 0.4 + float(floori(float(visitor_slot) / 4.0)) * 0.55, ) - return result + return Transform3D(Basis.IDENTITY, spawn_origin) func get_interior_anchor(owner_fingerprint: String) -> Vector3: diff --git a/main/main.gd b/main/main.gd index 8335c69..00d2b40 100644 --- a/main/main.gd +++ b/main/main.gd @@ -1731,12 +1731,14 @@ func _process(delta: float) -> void: func _apply_runtime_settings(settings: PlayerSettingsType) -> void: if settings == null: return - var requested_window_mode: DisplayServer.WindowMode = ( - DisplayServer.WINDOW_MODE_FULLSCREEN - if settings.fullscreen_enabled - else DisplayServer.WINDOW_MODE_WINDOWED + var current_window_mode: DisplayServer.WindowMode = ( + DisplayServer.window_get_mode() ) - if DisplayServer.window_get_mode() != requested_window_mode: + var requested_window_mode: DisplayServer.WindowMode = resolve_window_mode( + settings.fullscreen_enabled, + current_window_mode, + ) + if current_window_mode != requested_window_mode: DisplayServer.window_set_mode(requested_window_mode) _apply_world_pixelation(settings.world_pixel_size) _ui_pixelation.set_pixel_size(settings.ui_pixel_size) @@ -1763,6 +1765,25 @@ func _apply_runtime_settings(settings: PlayerSettingsType) -> void: ) +static func resolve_window_mode( + fullscreen_enabled: bool, + current_mode: DisplayServer.WindowMode, +) -> DisplayServer.WindowMode: + var is_fullscreen: bool = current_mode in [ + DisplayServer.WINDOW_MODE_FULLSCREEN, + DisplayServer.WINDOW_MODE_EXCLUSIVE_FULLSCREEN, + ] + if fullscreen_enabled: + return ( + current_mode + if is_fullscreen + else DisplayServer.WINDOW_MODE_FULLSCREEN + ) + if is_fullscreen: + return DisplayServer.WINDOW_MODE_WINDOWED + return current_mode + + func _apply_world_pixelation(pixel_size: int) -> void: var root_viewport: Viewport = get_viewport() root_viewport.scaling_3d_mode = Viewport.SCALING_3D_MODE_NEAREST diff --git a/network/network_home_protocol.gd b/network/network_home_protocol.gd index 62b8488..96756e9 100644 --- a/network/network_home_protocol.gd +++ b/network/network_home_protocol.gd @@ -7,6 +7,7 @@ const CAPABILITY: StringName = &"portable_home_v1" const RELIABLE_CHANNEL: int = NetworkProtocol.HOME_RELIABLE_CHANNEL const MAX_ID_LENGTH: int = 96 const MAX_MESSAGE_LENGTH: int = 160 +const MAX_TRANSITION_COORDINATE: float = 10000.0 const WORLD_SPACE: StringName = &"world" enum TransitionAction { @@ -143,7 +144,7 @@ static func transform_to_array(value: Transform3D) -> Array[float]: static func array_to_transform(value: Variant) -> Transform3D: - if not _valid_transform(value): + if not validate_transition_transform(value): return Transform3D() var fields: Array = value return Transform3D( @@ -156,6 +157,17 @@ static func array_to_transform(value: Variant) -> Transform3D: ) +static func validate_transition_transform(value: Variant) -> bool: + if not _valid_transform(value): + return false + var fields: Array = value + return ( + absf(float(fields[9])) <= MAX_TRANSITION_COORDINATE + and absf(float(fields[10])) <= MAX_TRANSITION_COORDINATE + and absf(float(fields[11])) <= MAX_TRANSITION_COORDINATE + ) + + static func _valid_transform(value: Variant) -> bool: if typeof(value) != TYPE_ARRAY or (value as Array).size() != 12: return false diff --git a/network/network_home_service.gd b/network/network_home_service.gd index df4fcde..33fa6ee 100644 --- a/network/network_home_service.gd +++ b/network/network_home_service.gd @@ -5,12 +5,15 @@ 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 @@ -24,9 +27,12 @@ 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( @@ -77,6 +83,10 @@ func can_local_decorate() -> bool: ) +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, @@ -101,6 +111,7 @@ func place_local_decor( ) -> 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 @@ -123,6 +134,10 @@ func place_local_decor( ) 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"], @@ -130,17 +145,22 @@ func place_local_decor( 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 "" - _save_manager.save_if_dirty() 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 @@ -149,6 +169,9 @@ func remove_local_decor(instance_id: String) -> bool: 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: @@ -174,14 +197,20 @@ func remove_local_decor(instance_id: String) -> bool: _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): - _bag.remove_item(decor_id, 1) + _suppress_local_submission = false + _restore_local_decor_transaction(transaction) + return false + _suppress_local_submission = false + if not _commit_local_decor_transaction(transaction): return false - _save_manager.save_if_dirty() return true @@ -191,19 +220,28 @@ func update_local_decor( 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 - _save_manager.save_if_dirty() return true @@ -337,7 +375,9 @@ func refresh_world_presentations() -> void: ) -func publish_local_home() -> bool: +func publish_local_home( + allow_pending_decor_transaction: bool = false, +) -> bool: if ( _session == null or not _session.is_gameplay_session_active() @@ -345,11 +385,24 @@ func publish_local_home() -> bool: 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(), @@ -361,6 +414,7 @@ func publish_local_home() -> bool: "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: @@ -445,6 +499,15 @@ func _process_manifest_request(peer_id: int, data: Dictionary) -> void: _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) @@ -588,6 +651,16 @@ func receive_home_manifest(data: Dictionary) -> void: 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) @@ -613,20 +686,304 @@ func receive_home_manifest_rejection( func _apply_manifest_rejection(message: String, canonical: Dictionary) -> void: - if ( - canonical.is_empty() - or not NetworkHomeProtocolType.validate_manifest_broadcast(canonical) - ): + _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 - _local_home.restore_from_save_data(canonical["home"]) + 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 - _save_manager.save_if_dirty() + _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"), @@ -697,12 +1054,18 @@ func _process_transition_request(peer_id: int, data: Dictionary) -> void: ): _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, - NetworkHomeProtocolType.make_space_id(owner_fingerprint), + interior_space, owner_fingerprint, _world_service.get_interior_spawn_transform( - owner_fingerprint, peer_id + owner_fingerprint, visitor_slot ), ) return @@ -748,18 +1111,40 @@ func _apply_host_transition( 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": NetworkHomeProtocolType.transform_to_array(transform), + "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) - _session.publish_authoritative_teleport(peer_id) @rpc( @@ -770,35 +1155,40 @@ 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 - var transform: Transform3D = NetworkHomeProtocolType.array_to_transform( - data.get("transform") - ) - if transform == Transform3D(): - 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 - var avatar: Player = _spawn_service.get_avatar(peer_id) - if avatar != null: - avatar.global_transform = NetworkHomeProtocolType.array_to_transform( - data["transform"] - ) - avatar.velocity = Vector3.ZERO + 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_space_changed.emit(space_id, str(data["owner_fingerprint"])) local_transition_finished.emit(true, "") _refresh_peer_visibility() @@ -840,12 +1230,20 @@ func _on_local_home_changed() -> void: 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() @@ -880,6 +1278,7 @@ func _on_peer_authenticated(peer_id: int, _display_name: String) -> void: 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) @@ -937,6 +1336,10 @@ func _on_session_state_changed(state: NetworkSession.State) -> void: _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) @@ -950,6 +1353,37 @@ func _fingerprint_for_peer(peer_id: int) -> String: 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(), diff --git a/network/network_session.gd b/network/network_session.gd index 8add90f..66d5101 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -28,6 +28,7 @@ const MAX_PENDING_MOVEMENT_INPUTS: int = 96 const ANIMATION_REFRESH_INTERVAL: float = 1.0 const MAX_MOVEMENT_INPUT_SEQUENCE: int = 2147483647 const MAX_MOVEMENT_ONE_WAY_TRANSIT_SECONDS: float = 0.25 +const SPACE_TRANSITION_SNAPSHOT_GUARD_DISTANCE: float = 12.0 const MOVEMENT_FLAG_JUMP: int = 1 << 0 const MOVEMENT_FLAG_SPRINT: int = 1 << 1 @@ -124,6 +125,9 @@ var _pending_movement_inputs: Array[Dictionary] = [] var _snapshot_accumulator: float = 0.0 var _movement_snapshot_tick: int = 0 var _last_local_snapshot_received_msec: int = 0 +var _local_space_transition_guard_active: bool = false +var _local_space_transition_guard_position: Vector3 = Vector3.ZERO +var _local_space_transition_minimum_ack: int = 0 var _animation_refresh_accumulator: float = 0.0 var _last_animation_state_by_peer: Dictionary[int, Dictionary] = {} var _pending_animation_state_by_peer: Dictionary[int, Dictionary] = {} @@ -703,6 +707,40 @@ func peers_share_space(first_peer_id: int, second_peer_id: int) -> bool: return get_peer_space(first_peer_id) == get_peer_space(second_peer_id) +func apply_authoritative_space_transition( + peer_id: int, + target_transform: Transform3D, + acknowledged_input: int, +) -> bool: + if not target_transform.origin.is_finite(): + return false + var local_peer_id: int = get_local_peer_id() + var local_transition: bool = peer_id == local_peer_id + var avatar: Player = _spawn_service.get_avatar(peer_id) + # Remote home transitions can arrive before their player-spawn message. Keep + # the new space assignment so the later spawn inherits correct visibility; + # only a missing local avatar is a transition failure. + if avatar == null: + return not local_transition + avatar.apply_network_space_transition(target_transform, local_transition) + if ( + local_transition + and state == State.JOINED_CLIENT + ): + # Movement snapshots use another ENet channel and a pre-transition audit + # can therefore arrive after the reliable home transition. Clear local + # prediction and reject those old-world snapshots until the host publishes + # its first audit from the new space. + _pending_movement_inputs.clear() + _last_local_snapshot_received_msec = 0 + _last_input_state_hash = 0 + _idle_input_accumulator = IDLE_INPUT_INTERVAL + _local_space_transition_guard_active = true + _local_space_transition_guard_position = target_transform.origin + _local_space_transition_minimum_ack = maxi(acknowledged_input, 0) + return true + + func get_peer_rtt_ms(peer_id: int) -> int: if peer_id == 1: return 0 @@ -2569,6 +2607,8 @@ func receive_movement_snapshots(encoded_snapshots: Array) -> void: if avatar == null: continue if peer_id == local_peer_id: + if _reject_stale_space_transition_snapshot(snapshot): + continue _discard_acknowledged_movement_inputs( int(snapshot.get("acknowledged_input", 0)) ) @@ -2586,6 +2626,33 @@ func receive_movement_snapshots(encoded_snapshots: Array) -> void: ) +func _reject_stale_space_transition_snapshot(snapshot: Dictionary) -> bool: + if not _local_space_transition_guard_active: + return false + var position_values: Array = snapshot.get("position", []) + if position_values.size() != 3: + return true + var position := Vector3( + float(position_values[0]), + float(position_values[1]), + float(position_values[2]), + ) + var acknowledged_input: int = int( + snapshot.get("acknowledged_input", 0) + ) + if acknowledged_input < _local_space_transition_minimum_ack: + return true + if acknowledged_input > _local_space_transition_minimum_ack: + _local_space_transition_guard_active = false + return false + if position.distance_to(_local_space_transition_guard_position) > ( + SPACE_TRANSITION_SNAPSHOT_GUARD_DISTANCE + ): + return true + _local_space_transition_guard_active = false + return false + + func _discard_acknowledged_movement_inputs(acknowledged_sequence: int) -> void: while ( not _pending_movement_inputs.is_empty() @@ -2914,6 +2981,9 @@ func _teardown_peer() -> void: _snapshot_accumulator = 0.0 _movement_snapshot_tick = 0 _last_local_snapshot_received_msec = 0 + _local_space_transition_guard_active = false + _local_space_transition_guard_position = Vector3.ZERO + _local_space_transition_minimum_ack = 0 _animation_refresh_accumulator = 0.0 _last_animation_state_by_peer.clear() _pending_animation_state_by_peer.clear() diff --git a/player/player.gd b/player/player.gd index 30807be..e36f92b 100644 --- a/player/player.gd +++ b/player/player.gd @@ -2093,6 +2093,37 @@ func reset_network_movement_state() -> void: _local_prediction_largest_error = 0.0 +func apply_network_space_transition( + target_transform: Transform3D, + align_local_camera: bool, +) -> void: + if not target_transform.origin.is_finite(): + return + # A room transition is a discontinuity, not ordinary movement. Preserve the + # host's input acknowledgement while clearing every motion, animation, and + # reconciliation state that could pull the avatar toward the previous space. + var acknowledged_input: int = _last_network_input_sequence + reset_network_movement_state() + _last_network_input_sequence = acknowledged_input + _apply_network_casting(false) + velocity = Vector3.ZERO + global_transform = Transform3D(Basis.IDENTITY, target_transform.origin) + var target_yaw: float = target_transform.basis.get_euler().y + _visuals.rotation.y = target_yaw + _network_camera_yaw = target_yaw + _network_target_position = global_position + _network_target_velocity = Vector3.ZERO + _network_target_visual_yaw = target_yaw + _network_snapshot_age = 0.0 + _network_snapshot_ready = true + _authoritative_movement_history.clear() + if align_local_camera: + _set_camera_dragging(false) + if _free_camera_active: + _set_free_camera_active(false) + _camera_yaw.rotation.y = target_yaw + + func capture_network_input(sequence: int) -> Dictionary: if _sitting_intent_pending and _sitting_intent_sequence < 0: _sitting_intent_sequence = sequence