diff --git a/Multiplayer/Components/IdMonoBehaviour.cs b/Multiplayer/Components/IdMonoBehaviour.cs index 8e26582f..57579e2c 100644 --- a/Multiplayer/Components/IdMonoBehaviour.cs +++ b/Multiplayer/Components/IdMonoBehaviour.cs @@ -59,6 +59,16 @@ public void Register(T id) indexToObject[id] = this; } + /// + /// Takes a fresh id from the pool. Used when an existing object becomes network + /// relevant after its Awake has already run (e.g. an inventory item dropped back + /// into the world by the host). + /// + public void AssignNewId() + { + Register(idPool.NextId); + } + protected virtual void OnDestroy() { idPool.ReleaseId(NetId); diff --git a/Multiplayer/Components/Networking/World/NetworkedCashRegisterWithModules.cs b/Multiplayer/Components/Networking/World/NetworkedCashRegisterWithModules.cs index 7ffa8226..ea2f1a2c 100644 --- a/Multiplayer/Components/Networking/World/NetworkedCashRegisterWithModules.cs +++ b/Multiplayer/Components/Networking/World/NetworkedCashRegisterWithModules.cs @@ -2,6 +2,7 @@ using DV.Interaction; using DV.InventorySystem; using DV.Shops; +using DV.Utils; using Multiplayer.Networking.Data; using Multiplayer.Networking.Managers.Server; using Multiplayer.Networking.Packets.Common; @@ -138,12 +139,23 @@ private void CullingManager_PlayerEnteredActivationRegion(ServerPlayer serverPla } } + //Scan modules can be metres from the register itself (shelves spread around the shop), + //so scans are validated against the module position rather than the register position. + private const float MAX_SCAN_DISTANCE_SQR = 10f * 10f; + public void Server_ProcessCashRegisterAction(ServerPlayer player, CommonCashRegisterWithModulesActionPacket packet) { bool success = false; CashRegisterAction response = CashRegisterAction.RejectGeneric; NetworkLifecycle.Instance.Server?.LogDebug(() => $"NetworkedCashRegisterWithModules.Server_ProcessAction({player.Username}, {packet.Action}, {packet.Amount})"); + + if (packet.Action == CashRegisterAction.ScanItem) + { + Server_ProcessScanItem(player, packet); + return; + } + if (transform.PlayerCanReach(player, 1)) { processingAction = true; @@ -169,6 +181,8 @@ public void Server_ProcessCashRegisterAction(ServerPlayer player, CommonCashRegi } else { + // Record the buyer so items spawned by the purchase can be stamped with their owner + Patches.World.GlobalShopControllerPatch.PurchasingPlayerId = player.PlayerId; success = CashRegister?.Buy() ?? false; } @@ -235,15 +249,92 @@ public void Server_ProcessCashRegisterAction(ServerPlayer player, CommonCashRegi processingAction = false; } + private void Server_ProcessScanItem(ServerPlayer player, CommonCashRegisterWithModulesActionPacket packet) + { + if (!TryGetModule(packet.ModuleIndex, out ScanItemCashRegisterModule module)) + { + NetworkLifecycle.Instance.Server?.LogWarning($"NetworkedCashRegisterWithModules.Server_ProcessScanItem() Module index {packet.ModuleIndex} not found on {CashRegister.GetObjectPath()}"); + SendRejection(player, CashRegisterAction.RejectGeneric); + return; + } + + if ((player.WorldPosition - module.transform.position).sqrMagnitude > MAX_SCAN_DISTANCE_SQR) + { + NetworkLifecycle.Instance.Server?.LogDebug(() => $"Player \"{player.Username}\" tried to scan an item, but they are too far away"); + SendRejection(player, CashRegisterAction.RejectGeneric); + return; + } + + processingAction = true; + + //AddItemsToBuy validates stock and increments the basket; on success the + //ScanItemCashRegisterModulePatch postfix broadcasts the new basket state to all clients + if (!module.AddItemsToBuy()) + SendRejection(player, CashRegisterAction.RejectGeneric); + + processingAction = false; + } + + private void SendRejection(ServerPlayer player, CashRegisterAction response) + { + NetworkLifecycle.Instance.Server.SendCashRegisterAction + ( + new CommonCashRegisterWithModulesActionPacket + { + NetId = NetId, + Action = response, + Amount = CashRegister.DepositedCash + }, + [player] + ); + } + + public bool TryGetModule(byte moduleIndex, out ScanItemCashRegisterModule module) + { + module = null; + + if (CashRegister == null || CashRegister.registerModules == null || moduleIndex >= CashRegister.registerModules.Length) + return false; + + module = CashRegister.registerModules[moduleIndex] as ScanItemCashRegisterModule; + return module != null; + } + + public bool TryGetModuleIndex(CashRegisterModule module, out byte moduleIndex) + { + moduleIndex = 0; + + if (CashRegister == null || CashRegister.registerModules == null) + return false; + + int index = Array.IndexOf(CashRegister.registerModules, module); + if (index < 0 || index > byte.MaxValue) + return false; + + moduleIndex = (byte)index; + return true; + } + #endregion #region Client - public void Client_ProcessCashRegisterAction(CashRegisterAction action, double amount) + public void Client_ProcessCashRegisterAction(CommonCashRegisterWithModulesActionPacket packet) { + CashRegisterAction action = packet.Action; + double amount = packet.Amount; + NetworkLifecycle.Instance.Client?.LogDebug(() => $"NetworkedCashRegisterWithModules.Client_ProcessCashRegisterAction({action}, {amount}) isBuying: {isBuying}, isCancelling: {isCancelling}"); switch (action) { + case CashRegisterAction.SetBasket: + if (TryGetModule(packet.ModuleIndex, out ScanItemCashRegisterModule scanModule)) + scanModule.SetUnitsToBuy((float)amount); + else + Multiplayer.LogWarning($"NetworkedCashRegisterWithModules.Client_ProcessCashRegisterAction({action}) Module index {packet.ModuleIndex} not found on {CashRegister.GetObjectPath()}"); + + break; + case CashRegisterAction.Cancel: isCancelling = false; diff --git a/Multiplayer/Components/Networking/World/NetworkedItem.cs b/Multiplayer/Components/Networking/World/NetworkedItem.cs index 140dcd68..bc8aa10d 100644 --- a/Multiplayer/Components/Networking/World/NetworkedItem.cs +++ b/Multiplayer/Components/Networking/World/NetworkedItem.cs @@ -98,6 +98,15 @@ public static bool TryGetNetId(ItemBase item, out ushort netID) //Handle ownership public sbyte OwnerId { get; private set; } = -1; // 0 means no owner + // Last player to hold/pocket this item; not cleared on drop so systems like + // Lost & Found can tell whose item this is. 0 means no player has owned it. + public byte LastOwnerId { get; private set; } + + public void SetLastOwner(byte playerId) + { + LastOwnerId = playerId; + } + //public void SetOwner(ushort playerId) //{ // if (OwnerId != playerId) @@ -130,9 +139,10 @@ protected void Start() if (!initialised) Register(); - // Mark registration as complete for items that don't need tracked values + // Items without tracked values complete registration here; FinaliseTrackedValues + // also applies any snapshots that were received before Start ran if (!registrationComplete && !UsefulItem) - registrationComplete = true; + FinaliseTrackedValues(); } public T GetTrackedItem() where T : Component @@ -549,9 +559,15 @@ private void HandleDroppedOrThrownState(ItemUpdateData snapshot) //resolve ownership if (NetworkLifecycle.Instance.IsHost()) + { if (NetworkLifecycle.Instance.Server.TryGetServerPlayer(snapshot.Player, out ServerPlayer player) && player.OwnsItem(NetId)) player.RemoveOwnedItem(NetId); + //the item is back in the shared world; track it in world storage so it saves with the world + if (Item.InventorySpecs != null && Item.InventorySpecs.BelongsToPlayer) + StorageController.Instance.AddItemToWorldStorage(Item); + } + //activate and relocate item gameObject.SetActive(true); transform.position = snapshot.ItemPosition + WorldMover.currentMove; @@ -615,8 +631,19 @@ private void HandleInventoryOrHandState(ItemUpdateData snapshot) } if (NetworkLifecycle.Instance.IsHost()) - if (NetworkLifecycle.Instance.Server.TryGetServerPlayer(snapshot.Player, out ServerPlayer player) && !player.OwnsItem(NetId)) - player.AddOwnedItem(NetId); + { + if (NetworkLifecycle.Instance.Server.TryGetServerPlayer(snapshot.Player, out ServerPlayer player)) + { + if (!player.OwnsItem(NetId)) + player.AddOwnedItem(NetId); + + SetLastOwner(player.PlayerId); + } + + //the item is in a player's possession; take it out of the world storages so + //it is not saved (and not summonable) as a world item + StorageController.Instance.RemoveItemFromStorageItemList(Item); + } //todo add to player model's hand this.gameObject.SetActive(false); @@ -635,7 +662,9 @@ protected override void OnDestroy() return; } - if (NetworkLifecycle.Instance.IsHost()) + // NetId 0 means this was a per-player inventory copy, never a world item. Announcing + // its destruction would make other clients discard whatever they have under id 0. + if (NetworkLifecycle.Instance.IsHost() && NetId != 0) { var updateData = CreateUpdateData(ItemUpdateData.ItemUpdateType.Destroy); if (updateData != null) diff --git a/Multiplayer/Components/Networking/World/NetworkedItemManager.cs b/Multiplayer/Components/Networking/World/NetworkedItemManager.cs index 8ac84738..f9d48bb1 100644 --- a/Multiplayer/Components/Networking/World/NetworkedItemManager.cs +++ b/Multiplayer/Components/Networking/World/NetworkedItemManager.cs @@ -8,8 +8,11 @@ using System; using Multiplayer.Utils; using DV; +using DV.CabControls; using DV.Interaction; +using DV.InventorySystem; using Multiplayer.Networking.Data.Items; +using Newtonsoft.Json.Linq; namespace Multiplayer.Components.Networking.World; @@ -130,6 +133,10 @@ private void Common_OnTick(uint tick) { ProcessClientChanges(tick); } + + // The host is a player with an inventory too, so both sides poll for local + // inventory changes (pickups, drops, items used up) + SharedInventoryManager.Instance.Client_PollLocalChanges(); } private void ProcessReceived() @@ -148,6 +155,14 @@ private void ProcessReceived() continue; } + // Id 0 is not a valid world item; acting on it would hit an arbitrary + // per-player inventory copy + if (snapshot.ItemNetId == 0) + { + Multiplayer.LogWarning($"NetworkedItemManager.ProcessReceived() Ignoring snapshot with no item id. Update Type: {snapshot.UpdateType}, prefabName: {snapshot.PrefabName}"); + continue; + } + if (NetworkLifecycle.Instance.IsHost()) { ProcessReceivedAsHost(snapshot, snapshotInfo.Item2); @@ -159,7 +174,7 @@ private void ProcessReceived() } catch (Exception ex) { - Multiplayer.LogError($"NetworkedItemManager.ProcessReceived() Error! {ex.Message}\r\n{ex.StackTrace}"); + Multiplayer.LogError($"NetworkedItemManager.ProcessReceived() Error! {ex.GetType().Name}: {ex.Message}\r\n{ex.StackTrace}\r\nUpdate Type: {snapshot?.UpdateType}, ItemNetId: {snapshot?.ItemNetId}, prefabName: {snapshot?.PrefabName}, ItemState: {snapshot?.ItemState}"); } } } @@ -187,6 +202,10 @@ private void UpdatePlayerItemLists() continue; } + // The host's own shared-inventory copies are not world items + if (item.NetId == 0) + continue; + float sqrDistance = (player.WorldPosition - item.transform.position).sqrMagnitude; if (sqrDistance <= MAX_DISTANCE_TO_ITEM_SQR) @@ -217,6 +236,11 @@ private void ProcessChanged(uint tick) foreach (var item in NetworkedItem.GetAll()) { + // NetId 0 means this is one of the host's own shared-inventory copies, not a + // world item; those are per-player and must not be replicated + if (item.NetId == 0) + continue; + ItemUpdateData snapshot = item.GetSnapshot(); if (snapshot != null) dirtyItems.Add(snapshot); @@ -287,6 +311,12 @@ private void ProcessChanged(uint tick) private void ProcessReceivedAsHost(ItemUpdateData snapshot, ServerPlayer player) { + if (snapshot.ItemNetId == 0) + { + NetworkLifecycle.Instance.Server.LogDebug(() => $"NetworkedItemManager.ProcessReceivedAsHost() Ignoring snapshot for un-networked item. Update Type: {snapshot.UpdateType}, prefabName: {snapshot.PrefabName}, player: {player?.Username}"); + return; + } + if (snapshot.UpdateType == ItemUpdateData.ItemUpdateType.Create) { NetworkLifecycle.Instance.Server.LogError($"NetworkedItemManager.ProcessReceivedAsHost() Host received Create snapshot! ItemNetId: {snapshot.ItemNetId}, prefabName: {snapshot.PrefabName}"); @@ -298,6 +328,9 @@ private void ProcessReceivedAsHost(ItemUpdateData snapshot, ServerPlayer player) if (ValidatePlayerAction(snapshot, player)) //Ensure the player can do this { NetworkLifecycle.Instance.Server.LogWarning($"NetworkedItemManager.ProcessReceivedAsHost() ItemNetId: {snapshot.ItemNetId}, snapshot type: {snapshot.UpdateType}"); + + // Stamp the sender so ownership can be tracked; clients never fill this in + snapshot.Player = player.PlayerId; netItem.ReceiveSnapshot(snapshot); } else @@ -356,10 +389,69 @@ private bool GetItemOwner(ushort itemNetId, out ServerPlayer owner) owner = NetworkLifecycle.Instance.Server.ServerPlayers.FirstOrDefault(p => p.OwnsItem(itemNetId)); return owner != null; } + + /// + /// Turns an object the host just dropped from their inventory into the authoritative + /// world item, instead of destroying it and spawning a fresh copy in its place. + /// + public void Server_AdoptDroppedItem(GameObject itemGO) + { + if (itemGO == null || !itemGO.TryGetComponent(out ItemBase itemBase)) + return; + + // It belongs to the world now, so release the slot it came from; leaving the + // association behind shows up as a ghost in the inventory or in the player's hand + int equipSlot = Inventory.Instance.GetEquipSlotForItem(itemGO); + if (equipSlot >= 0) + Inventory.Instance.UnequipItem(false, equipSlot); + + Inventory.Instance.PurgeFromInventory(itemGO); + + NetworkedItem netItem = itemGO.GetOrAddComponent(); + if (netItem.NetId == 0) + netItem.AssignNewId(); + + StorageController.Instance.AddItemToWorldStorage(itemBase); + + Multiplayer.Log($"Adopted host-dropped item \"{itemBase.InventorySpecs?.ItemPrefabName}\" as world item {netItem.NetId}"); + } + + public void Server_SpawnRequestedItem(string prefabName, Vector3 position, Quaternion rotation, ServerPlayer requester, string state = null) + { + // Uses the same lookup as item creation, which also covers prefabs that are only + // reachable through Resources (Banknotes and similar) + GameObject prefab = GetItemPrefab(prefabName); + if (prefab == null) + { + NetworkLifecycle.Instance.Server.LogWarning($"Server_SpawnRequestedItem() Unknown prefab \"{prefabName}\" requested by {requester?.Username}"); + return; + } + + GameObject gameObject = Instantiate(prefab, position + WorldMover.currentMove, rotation); + SharedInventoryManager.ApplyItemState(gameObject, state); + + InventoryItemSpec itemSpec = gameObject.GetComponent(); + if (itemSpec != null) + itemSpec.BelongsToPlayer = true; + + ItemBase itemBase = gameObject.GetComponent(); + if (itemBase != null) + { + if (NetworkedItem.TryGetNetworkedItem(itemBase, out NetworkedItem netItem)) + netItem.SetLastOwner(requester.PlayerId); + + StorageController.Instance.AddItemToWorldStorage(itemBase); + } + + NetworkLifecycle.Instance.Server.LogDebug(() => $"Server_SpawnRequestedItem() Spawned \"{prefabName}\" for {requester?.Username} at {position}"); + } #endregion #region Client + private const float INVENTORY_REPORT_INTERVAL = 15f; + private float lastInventoryReport; + private void ProcessClientChanges(uint tick) { List changedItems = new List(); @@ -369,6 +461,11 @@ private void ProcessClientChanges(uint tick) foreach (var item in NetworkedItem.GetAll()) { + // Items without a NetId are this player's local inventory copies; the shared + // inventory owns them and handles drops (see SharedInventoryManager) + if (item.NetId == 0) + continue; + ItemUpdateData snapshot = item.GetSnapshot(); if (snapshot != null) { @@ -380,6 +477,57 @@ private void ProcessClientChanges(uint tick) { NetworkLifecycle.Instance.Client.SendItemsChangePacket(changedItems); } + + if (Time.time - lastInventoryReport >= INVENTORY_REPORT_INTERVAL) + { + lastInventoryReport = Time.time; + Client_SendInventoryReport(); + } + } + + private void Client_SendInventoryReport() + { + List items = new List(); + + GameObject[] inventoryItems = Inventory.Instance.GetItemsArray(includingDropped: false); + for (int slot = 0; slot < inventoryItems.Length; slot++) + { + GameObject itemGO = inventoryItems[slot]; + if (itemGO == null) + continue; + + ItemBase itemBase = itemGO.GetComponent(); + if (itemBase == null || itemBase.InventorySpecs == null) + continue; + + JObject state = null; + ItemSaveData saveData = itemGO.GetComponent(); + if (saveData != null) + { + try + { + state = saveData.SaveItemData(); + } + catch (Exception ex) + { + Multiplayer.LogWarning($"Client_SendInventoryReport() Failed to save item state for {itemBase.InventorySpecs.ItemPrefabName}: {ex.Message}"); + } + } + + items.Add(new PlayerItemSaveData + { + ItemPrefabName = itemBase.InventorySpecs.ItemPrefabName, + BelongsToPlayer = true, + IsGrabbed = itemBase.IsGrabbed(), + InventorySlotIndex = slot, + ContainerSlotIndex = -1, + InLockedSlot = Inventory.Instance.GetSlotLockState(slot), + IsDropped = Inventory.Instance.GetSlotDroppedState(slot), + State = state, + }); + } + + NetworkLifecycle.Instance.Client.SendPlayerInventory(items.ToArray()); } private void ProcessReceivedAsClient(ItemUpdateData snapshot) @@ -421,18 +569,32 @@ private void CreateItem(ItemUpdateData snapshot) NetworkedItem newItem = GetFromCache(snapshot.PrefabName); + if (newItem == null) + newItem = FindLocalInstance(snapshot); + if(newItem == null) { - //GameObject prefabObj = Resources.Load(snapshot.PrefabName) as GameObject; - - if (!ItemPrefabs.TryGetValue(snapshot.PrefabName, out InventoryItemSpec spec)) + GameObject prefab = null; + + if (ItemPrefabs.TryGetValue(snapshot.PrefabName, out InventoryItemSpec spec)) + { + prefab = spec.gameObject; + } + else + { + //Some items (e.g. Banknotes) are not in the shop item catalogue and are only + //reachable through Resources, which is how the game itself spawns them + prefab = Resources.Load(snapshot.PrefabName) as GameObject; + } + + if (prefab == null) { Multiplayer.LogError($"NetworkedItemManager.CreateItem() Unable to load prefab for ItemNetId: {snapshot.ItemNetId}, prefabName: {snapshot.PrefabName}"); return; } //create a new item - GameObject gameObject = Instantiate(spec.gameObject, snapshot.ItemPosition + WorldMover.currentMove, snapshot.ItemRotation); + GameObject gameObject = Instantiate(prefab, snapshot.ItemPosition + WorldMover.currentMove, snapshot.ItemRotation); //Make sure we have a NetworkedItem newItem = gameObject.GetOrAddComponent(); @@ -441,9 +603,29 @@ private void CreateItem(ItemUpdateData snapshot) newItem.gameObject.SetActive(true); newItem.NetId = snapshot.ItemNetId; + // Restore normal respawn behaviour for items coming back out of the cache + RespawnOnDrop respawn = newItem.Item != null ? newItem.Item.GetComponent() : null; + if (respawn != null) + respawn.StartChecking(); + newItem.ReceiveSnapshot(snapshot); } + /// + /// Resolves an item prefab by name. Some items (e.g. Banknotes) are not in the item + /// catalogue and are only reachable through Resources, which is how the game spawns them. + /// + public GameObject GetItemPrefab(string prefabName) + { + if (string.IsNullOrEmpty(prefabName)) + return null; + + if (ItemPrefabs.TryGetValue(prefabName, out InventoryItemSpec spec)) + return spec.gameObject; + + return Resources.Load(prefabName) as GameObject; + } + private void BuildPrefabLookup() { NetworkLifecycle.Instance.Client.LogDebug(() => $"BuildPrefabLookup()"); @@ -484,6 +666,37 @@ public void CacheWorldItems() ClientInitialised = true; } + private NetworkedItem FindLocalInstance(ItemUpdateData snapshot) + { + // Scene-placed items that were not cached at join (e.g. essential items like shop + // scanners) still exist locally; adopt the local instance instead of spawning a duplicate. + // Only match world-resting states so a player's own personal items can never be adopted. + if (snapshot.ItemState != ItemState.Dropped && snapshot.ItemState != ItemState.Attached) + return null; + + Vector3 targetPosition = snapshot.ItemPosition + WorldMover.currentMove; + + foreach (var item in NetworkedItem.GetAll()) + { + if (item == null || item.NetId != 0 || item.Item == null) + continue; + + if (!item.gameObject.activeInHierarchy || item.Item.IsGrabbed()) + continue; + + if (item.Item.InventorySpecs?.ItemPrefabName != snapshot.PrefabName) + continue; + + if ((item.transform.position - targetPosition).sqrMagnitude <= 9f) + { + NetworkLifecycle.Instance.Client.LogDebug(() => $"FindLocalInstance() Adopting local {snapshot.PrefabName} for ItemNetId: {snapshot.ItemNetId}"); + return item; + } + } + + return null; + } + private NetworkedItem GetFromCache(string prefabName) { if (CachedItems.TryGetValue(prefabName, out var items) && items.Count > 0) @@ -504,15 +717,14 @@ private void SendToCache(NetworkedItem netItem) //NetworkLifecycle.Instance.Client.LogDebug(() => $"Caching Spawned Item: {prefabName ?? ""}"); netItem.gameObject.SetActive(false); - RespawnOnDrop respawn = netItem.Item.GetComponent(); - Destroy(respawn); - - //NetworkLifecycle.Instance.Client.LogDebug(() => $"Caching Spawned Item: {prefabName ?? ""}: checkWhileDisabled {respawn.checkWhileDisabled}, ignoreDistanceFromSpawnPosition {respawn.ignoreDistanceFromSpawnPosition}, respawnOnDropThroughFloor {respawn.respawnOnDropThroughFloor}"); - - //respawn.checkWhileDisabled = false; - //respawn.ignoreDistanceFromSpawnPosition = true; - //respawn.respawnOnDropThroughFloor = false; + // Neutralise the respawn/destroy checker while the item sits in the cache. + // It must NOT be destroyed: StorageController.AddItemToStorageItemList calls + // RespawnOnDrop.UpdateSpawnParams() unguarded, so a missing component throws and + // aborts the game's inventory handling mid-way (leaving unusable "ghost" items). + RespawnOnDrop respawn = netItem.Item.GetComponent(); + if (respawn != null) + respawn.SetMaxDistance(float.MaxValue); if (SingletonBehaviour.Instance.StorageWorld.ContainsItem(netItem.Item)) { diff --git a/Multiplayer/Components/Networking/World/SharedInventoryManager.cs b/Multiplayer/Components/Networking/World/SharedInventoryManager.cs new file mode 100644 index 00000000..1ad95d57 --- /dev/null +++ b/Multiplayer/Components/Networking/World/SharedInventoryManager.cs @@ -0,0 +1,713 @@ +using DV.CabControls; +using DV.InventorySystem; +using DV.Utils; +using JetBrains.Annotations; +using Multiplayer.Networking.Data; +using Multiplayer.Networking.Data.Items; +using Multiplayer.Utils; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +namespace Multiplayer.Components.Networking.World; + +/// +/// Multiplayer is co-op, so there is a single shared inventory: every player carries the +/// same set of items. Each player holds their own physical copy of each item, so slot +/// placement, which item is in hand, and per-item state (battery charge, coal load, ...) +/// are local. Only the presence of an item is shared: if anyone drops or uses up an item, +/// it leaves everyone's inventory. +/// +/// Items lying in the world stay under ; this manager owns the +/// handover in both directions (world item -> shared entry on pickup, shared entry -> world +/// item on drop). +/// +public class SharedInventoryManager : SingletonBehaviour +{ + #region Server + private readonly IdPool entryIdPool = new(); + private readonly Dictionary entries = new(64); + + // Serialized ItemSaveData per entry, so an item keeps its state (battery charge, coal + // load, wick, ...) as it moves between the world and players' inventories + private readonly Dictionary entryStates = new(64); + + public IReadOnlyDictionary Entries => entries; + + /// Reads an item's own save state, if it has any. + public static string CaptureItemState(GameObject itemGO) + { + if (itemGO == null || !itemGO.TryGetComponent(out ItemSaveData saveData)) + return null; + + try + { + return saveData.SaveItemData()?.ToString(Newtonsoft.Json.Formatting.None); + } + catch (System.Exception ex) + { + Multiplayer.LogWarning($"Failed to capture item state: {ex.Message}"); + return null; + } + } + + /// Restores an item's own save state onto a freshly created copy. + public static void ApplyItemState(GameObject itemGO, string state) + { + if (string.IsNullOrEmpty(state) || itemGO == null || !itemGO.TryGetComponent(out ItemSaveData saveData)) + return; + + try + { + saveData.LoadItemData(Newtonsoft.Json.Linq.JObject.Parse(state)); + } + catch (System.Exception ex) + { + Multiplayer.LogWarning($"Failed to apply item state: {ex.Message}"); + } + } + + /// + /// Adds an item to the shared inventory and gives every player a copy. The host is a + /// player too, so it applies the change to its own inventory directly rather than + /// through the network (it is excluded from its own broadcasts). + /// + public ushort Server_AddEntry(string prefabName, string state = null, ushort originItemNetId = 0) + { + ushort entryId = entryIdPool.NextId; + entries[entryId] = prefabName; + entryStates[entryId] = state; + + NetworkLifecycle.Instance.Server.SendSharedInventoryChange(entryId, prefabName, true, state, originItemNetId); + Multiplayer.Log($"Shared inventory + [{entryId}] {prefabName}"); + + Client_AddEntry(entryId, prefabName, state, originItemNetId); + + return entryId; + } + + /// Removes an item from the shared inventory and from every player. + public bool Server_RemoveEntry(ushort entryId) + { + if (!entries.TryGetValue(entryId, out string prefabName)) + return false; + + entries.Remove(entryId); + entryStates.Remove(entryId); + entryIdPool.ReleaseId(entryId); + + NetworkLifecycle.Instance.Server.SendSharedInventoryChange(entryId, prefabName, false, null); + Multiplayer.Log($"Shared inventory - [{entryId}] {prefabName}"); + + // The host carries a copy as well + Client_RemoveEntry(entryId); + + return true; + } + + /// Converts a world item into a shared inventory entry (someone picked it up). + public void Server_StoreWorldItem(ushort itemNetId, ServerPlayer player) + { + if (!NetworkedItem.TryGet(itemNetId, out NetworkedItem netItem) || netItem.Item?.InventorySpecs == null) + { + NetworkLifecycle.Instance.Server.LogWarning($"{player?.Username} stored item {itemNetId}, but it does not exist"); + return; + } + + if (IsExcludedFromSharedInventory(netItem.Item)) + { + NetworkLifecycle.Instance.Server.LogWarning($"Refusing to share \"{netItem.Item.InventorySpecs.ItemPrefabName}\" ({player?.Username}): it is synchronised by its own system"); + return; + } + + string prefabName = netItem.Item.InventorySpecs.ItemPrefabName; + string state = CaptureItemState(netItem.gameObject); + GameObject worldObject = netItem.gameObject; + + // Announce the world item's removal first, while it still has a valid id, so the + // message cannot be mistaken for anything else. It is delivered after the addition + // below, by which point whoever adopted the object has cleared its id and will + // correctly ignore it. + ItemUpdateData removal = netItem.CreateUpdateData(ItemUpdateData.ItemUpdateType.Destroy); + if (removal != null) + NetworkedItemManager.Instance.AddDirtyItemSnapshot(netItem, removal); + + // Give every player a copy. The one already holding this object keeps it, so the + // item does not get pulled out of their hands the moment they pick it up. + Server_AddEntry(prefabName, state, itemNetId); + + // Nobody here adopted it, so the world copy is ours to remove + if (netItem != null && netItem.NetId != 0) + Destroy(worldObject); + } + + /// Removes a shared entry; if it was dropped, spawn it back into the world. + public void Server_RemoveFromInventory(ushort entryId, bool dropped, Vector3 position, Quaternion rotation, ServerPlayer player, string state = null) + { + if (!entries.TryGetValue(entryId, out string prefabName)) + { + Multiplayer.LogWarning($"{player?.Username} removed shared item [{entryId}], but no such entry exists (dropped: {dropped})"); + return; + } + + Multiplayer.Log($"{player?.Username} removed shared item [{entryId}] {prefabName} (dropped: {dropped})"); + + // Prefer the state the dropping player reported; fall back to the last known one + if (string.IsNullOrEmpty(state)) + entryStates.TryGetValue(entryId, out state); + + Server_RemoveEntry(entryId); + + if (dropped) + NetworkedItemManager.Instance.Server_SpawnRequestedItem(prefabName, position, rotation, player, state); + } + + public void Server_SendFullInventory(ServerPlayer player) + { + ushort[] ids = entries.Keys.ToArray(); + NetworkLifecycle.Instance.Server.SendSharedInventory( + ids, + ids.Select(id => entries[id]).ToArray(), + ids.Select(id => entryStates.TryGetValue(id, out string s) ? s ?? string.Empty : string.Empty).ToArray(), + player); + } + + /// + /// Seeds the shared inventory from the host's own inventory when a session starts, so + /// joiners receive what the host is already carrying. + /// + public void Server_SeedFromHostInventory() + { + if (entries.Count > 0 || Inventory.Instance == null) + return; + + foreach (GameObject itemGO in Inventory.Instance.GetItemsArray(false)) + { + if (itemGO == null) + continue; + + ItemBase item = itemGO.GetComponent(); + if (item?.InventorySpecs == null || IsExcludedFromSharedInventory(item)) + continue; + + ushort entryId = entryIdPool.NextId; + entries[entryId] = item.InventorySpecs.ItemPrefabName; + entryStates[entryId] = CaptureItemState(itemGO); + + // Registering also strips the network id: inventory copies are per-player + Client_RegisterLocalCopy(entryId, itemGO); + } + + Multiplayer.Log($"Seeded shared inventory with {entries.Count} items from the host"); + } + #endregion + + #region Client + // entryId -> this player's physical copy of that item + private readonly Dictionary localCopies = new(64); + + // Same copies as a set, so the per-tick scan can test membership without walking the + // dictionary once per inventory slot + private readonly HashSet localCopyObjects = new(64); + + // Reused by the scan so it does not allocate every tick + private readonly List staleEntries = []; + + // Entries whose copy exists but has not been stowed yet. A new copy needs a couple of + // frames to initialise before it can go into a slot, and during that window it has no + // slot - which the scan below would otherwise read as the player having dropped it. + private readonly HashSet awaitingStow = []; + + // Upper bound on hands; GetEquippedItemAtSlot returns null beyond the real count + private const int MAX_EQUIP_SLOTS = 4; + + // Inventory changes are a human-speed event; polling every network tick is wasted work + private const float POLL_INTERVAL = 0.25f; + private float nextPoll; + + // Set while applying a server-driven change, so our own inventory polling does not + // report it straight back to the server as a local action + private bool applyingRemoteChange; + + // World items we have already asked the server to share, so a refusal (or a slow + // answer) does not make us re-send the request on every tick + private readonly HashSet pendingStoreRequests = new(); + + + public void Client_ApplyFullInventory(ushort[] entryIds, string[] prefabNames, string[] states) + { + if (entryIds == null || prefabNames == null || entryIds.Length != prefabNames.Length) + return; + + for (int i = 0; i < entryIds.Length; i++) + Client_AddEntry(entryIds[i], prefabNames[i], states != null && i < states.Length ? states[i] : null, 0); + } + + public void Client_AddEntry(ushort entryId, string prefabName, string state, ushort originItemNetId) + { + if (localCopies.ContainsKey(entryId)) + return; + + applyingRemoteChange = true; + try + { + // If this player is the one carrying the item this entry came from, keep that + // exact object. Clearing its network id also makes it ignore the world item's + // removal that follows. + GameObject itemGO = null; + if (originItemNetId != 0 + && NetworkedItem.TryGet(originItemNetId, out NetworkedItem origin) + && origin.Item != null + && IsCarriedByLocalPlayer(origin.gameObject)) + { + itemGO = origin.gameObject; + origin.NetId = 0; + pendingStoreRequests.Remove(originItemNetId); + } + + // The joining player already loaded this inventory through the normal save path, + // so adopt a matching item that is not yet claimed rather than duplicating it + itemGO ??= FindUnclaimedInventoryItem(prefabName); + + bool freshCopy = false; + + if (itemGO == null) + { + GameObject prefab = NetworkedItemManager.Instance.GetItemPrefab(prefabName); + if (prefab == null) + { + Multiplayer.LogError($"SharedInventoryManager.Client_AddEntry() Unknown prefab \"{prefabName}\" for entry {entryId}"); + return; + } + + // Spawn next to the player, never at the world origin: that is a real place + // kilometres away, where the game's distance culling deactivates the object. + // An inactive object never runs Start(), so the item's own scripts never wire + // themselves up and it ends up present but unusable (a lighter that won't light). + Vector3 spawnPosition = PlayerManager.PlayerTransform != null + ? PlayerManager.PlayerTransform.position + : WorldMover.currentMove; + + itemGO = Instantiate(prefab, spawnPosition, Quaternion.identity); + itemGO.SetActive(true); + + InventoryItemSpec spec = itemGO.GetComponent(); + if (spec != null) + spec.BelongsToPlayer = true; + + freshCopy = true; + } + + Client_RegisterLocalCopy(entryId, itemGO); + + if (freshCopy) + { + // A just-instantiated item has run Awake but not Start, so its scripts have + // not finished wiring up: applying save data or stowing it now leaves it + // half-initialised and unusable. Let it initialise first. + awaitingStow.Add(entryId); + StartCoroutine(FinishAddingCopy(entryId, itemGO, state)); + } + else + { + ApplyItemState(itemGO, state); + StoreInInventory(itemGO); + } + } + finally + { + applyingRemoteChange = false; + } + } + + public void Client_RemoveEntry(ushort entryId) + { + if (!localCopies.TryGetValue(entryId, out GameObject itemGO)) + return; + + localCopies.Remove(entryId); + if (itemGO != null) + localCopyObjects.Remove(itemGO); + + applyingRemoteChange = true; + try + { + DiscardLocalCopy(itemGO); + } + finally + { + applyingRemoteChange = false; + } + } + + /// + /// Removes a local copy from the inventory and every storage list before destroying + /// it. Skipping the storage cleanup leaves the game holding a reference to a + /// destroyed object, which shows up as an item that cannot be interacted with. + /// + private static void DiscardLocalCopy(GameObject itemGO) + { + if (itemGO == null) + return; + + if (itemGO.TryGetComponent(out ItemBase item)) + { + if (item.IsGrabbed()) + item.ForceEndInteraction(); + + StorageController.Instance.RemoveItemFromStorageItemList(item); + } + + // Destroying an item that is still equipped leaves the hand holding a dead + // reference, which shows up as a ghost item until something else replaces it + int equipSlot = Inventory.Instance.GetEquipSlotForItem(itemGO); + if (equipSlot >= 0) + Inventory.Instance.UnequipItem(false, equipSlot); + + Inventory.Instance.PurgeFromInventory(itemGO); + Destroy(itemGO); + } + + /// + /// Compares this player's inventory against the shared entries and reports differences. + /// Polling is used rather than the inventory events because it also catches items that + /// were consumed (their object is simply gone). + /// + public void Client_PollLocalChanges() + { + if (applyingRemoteChange || Inventory.Instance == null || Time.time < nextPoll) + return; + + nextPoll = Time.time + POLL_INTERVAL; + + bool isHost = NetworkLifecycle.Instance.IsHost(); + + // Find items that are no longer stowed. Collected first, because acting on them + // modifies localCopies. + staleEntries.Clear(); + foreach (var kvp in localCopies) + { + GameObject itemGO = kvp.Value; + + // Still being set up: it has no slot yet, which is not a drop + if (awaitingStow.Contains(kvp.Key)) + continue; + + // Destroyed means used up + if (itemGO == null) + { + staleEntries.Add(kvp.Key); + continue; + } + + // Still in a real slot. A dropped item keeps its slot *reserved* so it can + // return there, so the reservation has to be told apart from real storage. + int slot = Inventory.Instance.IndexOf(itemGO); + if (slot >= 0 && !Inventory.Instance.GetSlotDroppedState(slot)) + continue; + + // Held items leave their slot but are still owned. Which item is in hand is + // per-player, so it must never leave the shared inventory. + if (IsHeldByLocalPlayer(itemGO)) + continue; + + staleEntries.Add(kvp.Key); + } + + foreach (ushort entryId in staleEntries) + { + if (!localCopies.TryGetValue(entryId, out GameObject itemGO)) + continue; + + // Used up: it just leaves the shared inventory + if (itemGO == null) + { + ForgetLocalCopy(entryId); + + if (isHost) + Server_RemoveEntry(entryId); + else + NetworkLifecycle.Instance.Client.SendInventoryRemove(entryId, false, Vector3.zero, Quaternion.identity, null); + + continue; + } + + // Dropped into the world + Vector3 position = itemGO.transform.position - WorldMover.currentMove; + Quaternion rotation = itemGO.transform.rotation; + + ForgetLocalCopy(entryId); + + if (isHost) + { + // The host's own object becomes the authoritative world item, rather than + // being destroyed and respawned + Server_RemoveEntry(entryId); + NetworkedItemManager.Instance.Server_AdoptDroppedItem(itemGO); + } + else + { + Multiplayer.Log($"Dropped shared item [{entryId}] \"{itemGO.name}\"; asking the server to place it"); + NetworkLifecycle.Instance.Client.SendInventoryRemove(entryId, true, position, rotation, CaptureItemState(itemGO)); + + // The server places the authoritative world item, so this copy goes. It must + // be unregistered from the inventory and storages first, or the game keeps + // referencing a destroyed object (a "ghost"). + DiscardLocalCopy(itemGO); + } + } + + // Items that entered this player's possession from the world. + // Excluding dropped items is essential: an item lying in the world keeps its slot + // reserved and is still listed by GetItemsArray(true), so counting it as carried + // would read every drop as an instant pickup. + foreach (GameObject itemGO in Inventory.Instance.GetItemsArray(false)) + TryShareCarriedItem(itemGO, isHost); + + // Picking an item off the ground puts it straight into the player's hands rather + // than into a slot, and held items are not part of the slot array. Without this the + // item would stay unshared until the player happened to stow it. + for (int equipSlot = 0; equipSlot < MAX_EQUIP_SLOTS; equipSlot++) + TryShareCarriedItem(Inventory.Instance.GetEquippedItemAtSlot(equipSlot), isHost); + } + + /// + /// Adds a world item the player is now carrying to the shared inventory. + /// + private void TryShareCarriedItem(GameObject itemGO, bool isHost) + { + if (itemGO == null || localCopyObjects.Contains(itemGO)) + return; + + ItemBase carriedItem = itemGO.GetComponent(); + + if (!NetworkedItem.TryGetNetworkedItem(carriedItem, out NetworkedItem netItem) || netItem.NetId == 0) + return; + + // Job papers, money and licences are synchronised by their own systems + if (IsExcludedFromSharedInventory(carriedItem)) + return; + + // Only ask once per item; the server may take a few ticks to answer, and some items + // are never shareable and would otherwise be requested forever + if (!pendingStoreRequests.Add(netItem.NetId)) + return; + + if (isHost) + { + NetworkLifecycle.Instance.Server.TryGetServerPlayer(NetworkLifecycle.Instance.Client.PlayerId, out ServerPlayer hostPlayer); + Server_StoreWorldItem(netItem.NetId, hostPlayer); + } + else + { + NetworkLifecycle.Instance.Client.SendInventoryStore(netItem.NetId); + } + } + + private IEnumerator FinishAddingCopy(ushort entryId, GameObject itemGO, string state) + { + // Awake has run, but Start has not, and Start only runs while the object is active. + // Wait for it to actually initialise before loading state into it or stowing it away. + yield return null; + yield return null; + + if (itemGO == null) + { + awaitingStow.Remove(entryId); + yield break; + } + + applyingRemoteChange = true; + try + { + ApplyItemState(itemGO, state); + StoreInInventory(itemGO); + } + finally + { + applyingRemoteChange = false; + awaitingStow.Remove(entryId); + } + + // A copy is created next to the player so it can initialise. If it did not make it + // into the inventory it would simply lie there, visible and grabbable by anyone + // walking past, so make that loud rather than silent. + if (itemGO != null && !Inventory.Instance.Contains(itemGO, true)) + Multiplayer.LogWarning($"Shared item \"{itemGO.name}\" could not be stowed and is lying at the player's feet"); + } + + /// + /// Stows an item, preferring the backpack so shared items do not clutter the hotbar. + /// + private static void StoreInInventory(GameObject itemGO) + { + // Already carried, or in the player's hands - leave it where it is rather than + // yanking a just-picked-up item into the backpack + if (Inventory.Instance.Contains(itemGO, true) || IsHeldByLocalPlayer(itemGO)) + return; + + int slot = -1; + + // If this player keeps a locked slot for this kind of item, put it back there - the + // whole point of locking a slot is that the item always returns to the same place, + // even when it took a detour through another player's hands. + int reservedSlot = Inventory.Instance.FindReservedSlotForDroppedItem(itemGO); + if (reservedSlot >= 0 && Inventory.Instance.IsSlotEmpty(reservedSlot)) + slot = Inventory.Instance.AddItemToInventory(itemGO, reservedSlot); + + // Otherwise prefer the backpack so shared items do not clutter the hotbar + if (slot < 0) + { + int backpackSlot = Inventory.Instance.GetFirstFreeBackpackSlot(); + slot = backpackSlot >= 0 + ? Inventory.Instance.AddItemToInventory(itemGO, backpackSlot) + : Inventory.Instance.AddItemToInventory(itemGO); + } + + if (slot < 0) + { + // Should not happen while inventories stay in lockstep; leave it at the + // player's feet rather than losing the item outright + Multiplayer.LogWarning($"No free slot for shared item \"{itemGO.name}\"; leaving it at the player's feet"); + itemGO.transform.position = PlayerManager.PlayerTransform.position; + itemGO.SetActive(true); + } + } + + /// + /// Items that must never be pulled into the shared inventory. Each of these already + /// has its own authoritative synchronisation, and each carries state that cannot be + /// rebuilt from a prefab - sharing them destroys the original and hands everyone a + /// blank copy. + /// + public static bool IsExcludedFromSharedInventory(ItemBase item) + { + if (item == null) + return true; + + // Job papers belong to the job system: the job they refer to lives on the original + // object, so a fresh copy is a blank sheet that cannot start or complete anything + if (item.GetComponent() != null + || item.GetComponent() != null + || item.GetComponent() != null + || item.GetComponent() != null + || item.GetComponent() != null) + return true; + + // Physical money is backed by the shared wallet. Copying a banknote to every player + // would let the same payment be banked more than once. The wallet itself is a normal + // carried item, so only spend-and-destroy money is excluded. + if (item.TryGetComponent(out IMoney money) && money.ShouldDestroyOnUse) + return true; + + // Licences are consumed to unlock something that is already synchronised separately + string prefabName = item.InventorySpecs?.ItemPrefabName; + if (prefabName != null && prefabName.StartsWith("License", System.StringComparison.OrdinalIgnoreCase)) + return true; + + return false; + } + + /// Stops tracking an entry's copy, without touching the object itself. + private void ForgetLocalCopy(ushort entryId) + { + if (localCopies.TryGetValue(entryId, out GameObject itemGO) && itemGO != null) + localCopyObjects.Remove(itemGO); + + localCopies.Remove(entryId); + } + + /// Whether this player has the item, in a slot or in their hands. + private static bool IsCarriedByLocalPlayer(GameObject itemGO) + { + return Inventory.Instance.Contains(itemGO, true) || IsHeldByLocalPlayer(itemGO); + } + + private static bool IsHeldByLocalPlayer(GameObject itemGO) + { + if (Inventory.Instance.GetEquipSlotForItem(itemGO) >= 0) + return true; + + return itemGO.TryGetComponent(out ItemBase item) && item.IsGrabbed(); + } + + private GameObject FindUnclaimedInventoryItem(string prefabName) + { + if (Inventory.Instance == null) + return null; + + foreach (GameObject itemGO in Inventory.Instance.GetItemsArray(true)) + { + if (itemGO == null || localCopyObjects.Contains(itemGO)) + continue; + + ItemBase item = itemGO.GetComponent(); + if (item?.InventorySpecs?.ItemPrefabName != prefabName) + continue; + + // Never adopt something that is still a world item: it is about to be destroyed + // as part of the pickup, which would take the inventory copy with it + if (NetworkedItem.TryGetNetworkedItem(item, out NetworkedItem netItem) && netItem.NetId != 0) + continue; + + return itemGO; + } + + return null; + } + + /// + /// Registers a local copy for an entry. Inventory copies are per-player, so they must + /// never carry a network id: on the host every new object is given one automatically, + /// which would turn the copy back into a replicated world item and, once it was picked + /// up again, spawn an endless add/remove loop between the two entries. + /// + public void Client_RegisterLocalCopy(ushort entryId, GameObject itemGO) + { + if (localCopies.TryGetValue(entryId, out GameObject previous) && previous != null) + localCopyObjects.Remove(previous); + + localCopies[entryId] = itemGO; + + if (itemGO != null) + localCopyObjects.Add(itemGO); + + if (itemGO != null + && itemGO.TryGetComponent(out ItemBase item) + && NetworkedItem.TryGetNetworkedItem(item, out NetworkedItem netItem) + && netItem.NetId != 0) + { + netItem.NetId = 0; + } + } + #endregion + + /// + /// Drops all session state. Without this a reconnect inherits the previous session's + /// entries and copies, which no longer refer to anything valid. + /// + public void Reset() + { + entries.Clear(); + entryStates.Clear(); + entryIdPool.Reset(); + + localCopies.Clear(); + localCopyObjects.Clear(); + awaitingStow.Clear(); + pendingStoreRequests.Clear(); + staleEntries.Clear(); + + applyingRemoteChange = false; + nextPoll = 0f; + + StopAllCoroutines(); + } + + [UsedImplicitly] + public new static string AllowAutoCreate() + { + return $"[{nameof(SharedInventoryManager)}]"; + } +} diff --git a/Multiplayer/Components/SaveGame/NetworkedSaveGameManager.cs b/Multiplayer/Components/SaveGame/NetworkedSaveGameManager.cs index 24b0cee7..dd675c4c 100644 --- a/Multiplayer/Components/SaveGame/NetworkedSaveGameManager.cs +++ b/Multiplayer/Components/SaveGame/NetworkedSaveGameManager.cs @@ -4,9 +4,15 @@ using DV.Utils; using JetBrains.Annotations; using Multiplayer.Components.Networking; +using Multiplayer.Components.Networking.World; using Multiplayer.Networking.Data; +using Multiplayer.Networking.Data.Items; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; namespace Multiplayer.Components.SaveGame; @@ -14,6 +20,8 @@ public class NetworkedSaveGameManager : SingletonBehaviour lastKnownPlacement = []; + + public void Server_RememberPlacement(ServerPlayer player) + { + if (player == null || player.LoadingState != PlayerLoadingState.Complete) + return; + + lastKnownPlacement[player.Guid] = (player.AbsoluteWorldPosition, player.WorldRotationY); + Multiplayer.Log($"Remembered where {player.Username} left off: {player.AbsoluteWorldPosition}"); + } + + public bool Server_TryGetPlacement(SaveGameData data, Guid guid, out Vector3 position, out float rotation) + { + if (lastKnownPlacement.TryGetValue(guid, out var placement)) + { + position = placement.Position; + rotation = placement.Rotation; + return true; + } + + JObject playerData = Server_GetPlayerData(data, guid); + Vector3? saved = playerData?.GetVector3(SaveGameKeys.Player_position); + if (saved.HasValue) + { + position = saved.Value; + rotation = playerData.GetFloat(SaveGameKeys.Player_rotation) ?? 0f; + return true; + } + + position = default; + rotation = 0f; + return false; + } + + /// + /// The inventory every player receives. Multiplayer is co-op only, so all players + /// carry the same items; the server keeps a single shared list. + /// + public PlayerItemSaveData[] SharedInventory { get; private set; } + + public void Server_SetSharedInventory(PlayerItemSaveData[] items) + { + SharedInventory = items; + } + + public PlayerItemSaveData[] Server_GetPlayerInventory(ServerPlayer player, SaveGameData data) + { + // Co-op: everyone carries the same items. The shared entries are the single source of + // truth; sending them here lets the joining client load them through the game's own + // save path (correct slots, item state), and SharedInventoryManager then adopts them. + SharedInventoryManager.Instance.Server_SeedFromHostInventory(); + + var entries = SharedInventoryManager.Instance.Entries; + if (entries.Count == 0) + return []; + + // Slot layout is per-player: reuse the slots this player last had an item of that + // kind in, so a reconnecting player finds their belongings where they left them + // instead of in a heap in the backpack. + List previous = []; + if (Server_GetPlayerData(data, player.Guid)?[INVENTORY_KEY] is JArray savedInventory) + previous = [.. DeserializeInventory(savedInventory)]; + + HashSet usedSlots = []; + List items = []; + + foreach (var entry in entries) + { + int slot = -1; + + int match = previous.FindIndex(p => p.ItemPrefabName == entry.Value && p.InventorySlotIndex >= 0 && !usedSlots.Contains(p.InventorySlotIndex)); + if (match >= 0) + { + slot = previous[match].InventorySlotIndex; + usedSlots.Add(slot); + previous.RemoveAt(match); + } + + items.Add(new PlayerItemSaveData + { + NetId = entry.Key, + ItemPrefabName = entry.Value, + BelongsToPlayer = true, + InventorySlotIndex = slot, + ContainerSlotIndex = -1, + }); + } + + return items.ToArray(); + + /* Per-player inventories (non-co-op mode). Restore this in place of the shared + inventory above once players are meant to keep separate belongings: + + if (player.SavedInventory != null) + return player.SavedInventory; + + if (Server_GetPlayerData(data, player.Guid)?[INVENTORY_KEY] is JArray inventory) + return DeserializeInventory(inventory); + + return []; + */ + } + + private static JArray SerializeInventory(PlayerItemSaveData[] items) + { + JArray array = []; + foreach (PlayerItemSaveData item in items) + { + JObject entry = new() + { + ["prefab"] = item.ItemPrefabName, + ["slot"] = item.InventorySlotIndex, + ["locked"] = item.InLockedSlot, + ["dropped"] = item.IsDropped, + ["grabbed"] = item.IsGrabbed, + }; + + if (item.State != null) + entry["state"] = item.State; + + array.Add(entry); + } + return array; + } + + private static PlayerItemSaveData[] DeserializeInventory(JArray array) + { + List items = []; + foreach (JToken token in array) + { + if (token is not JObject entry) + continue; + + items.Add(new PlayerItemSaveData + { + ItemPrefabName = entry["prefab"]?.Value(), + BelongsToPlayer = true, + InventorySlotIndex = entry["slot"]?.Value() ?? -1, + ContainerSlotIndex = -1, + InLockedSlot = entry["locked"]?.Value() ?? false, + IsDropped = entry["dropped"]?.Value() ?? false, + IsGrabbed = entry["grabbed"]?.Value() ?? false, + State = entry["state"] as JObject, + }); + } + return items.ToArray(); + } + #endregion [UsedImplicitly] diff --git a/Multiplayer/Networking/Data/ServerPlayer.cs b/Multiplayer/Networking/Data/ServerPlayer.cs index f8f9eb57..b8c6d93c 100644 --- a/Multiplayer/Networking/Data/ServerPlayer.cs +++ b/Multiplayer/Networking/Data/ServerPlayer.cs @@ -94,6 +94,8 @@ public string DisplayName } } + public Networking.Data.Items.PlayerItemSaveData[] SavedInventory { get; set; } //last inventory report from this player's client + public Dictionary KnownItems { get; private set; } = new Dictionary(); //NetworkedItem, last updated tick public Dictionary NearbyItems { get; private set; } = new Dictionary(); //NetworkedItem, time since near the item public HashSet OwnedItems { get; private set; } = new HashSet(); diff --git a/Multiplayer/Networking/Managers/Client/NetworkClient.cs b/Multiplayer/Networking/Managers/Client/NetworkClient.cs index c1f6c058..12f57ae9 100644 --- a/Multiplayer/Networking/Managers/Client/NetworkClient.cs +++ b/Multiplayer/Networking/Managers/Client/NetworkClient.cs @@ -5,6 +5,7 @@ using DV.InventorySystem; using DV.LocoRestoration; using DV.Logic.Job; +using DV.Shops; using DV.MultipleUnit; using DV.ServicePenalty.UI; using DV.ThingTypes; @@ -148,6 +149,11 @@ public override void Stop() Settings.OnSettingsUpdated -= OnSettingsUpdated; + // Clear session state so a reconnect starts clean instead of inheriting entries + // and item copies that no longer refer to anything + if (SharedInventoryManager.Instance != null) + SharedInventoryManager.Instance.Reset(); + base.Stop(); } @@ -248,6 +254,9 @@ protected override void Subscribe() netPacketProcessor.SubscribeNetSerializable(OnCommonPitStopPlugInteractionPacket); netPacketProcessor.SubscribeReusable(OnClientboundPitStopBulkUpdatePacket); netPacketProcessor.SubscribeReusable(OnCommonCashRegisterWithModulesActionPacket); + netPacketProcessor.SubscribeReusable(OnClientboundShopStockPacket); + netPacketProcessor.SubscribeReusable(OnClientboundSharedInventoryPacket); + netPacketProcessor.SubscribeReusable(OnClientboundSharedInventoryChangePacket); netPacketProcessor.SubscribeReusable(OnCommonGenericSwitchStatePacket); netPacketProcessor.SubscribeReusable(OnCommonChatPacket); @@ -1333,7 +1342,7 @@ private void OnCommonItemChangePacket(CommonItemChangePacket packet) // return debug; //}); - //NetworkedItemManager.Instance.ReceiveSnapshots(packet.Items, null); + NetworkedItemManager.Instance.ReceiveSnapshots(packet.Items, null); } private void OnCommonPaintThemePacket(CommonPaintThemePacket packet) @@ -1438,7 +1447,63 @@ private void OnCommonCashRegisterWithModulesActionPacket(CommonCashRegisterWithM Log($"Cash Register With Modules Action received for {netCashRegister.GetObjectPath()}, Action: {packet.Action}, Amount: {packet.Amount}"); - netCashRegister.Client_ProcessCashRegisterAction(packet.Action, packet.Amount); + netCashRegister.Client_ProcessCashRegisterAction(packet); + } + + private void OnClientboundSharedInventoryPacket(ClientboundSharedInventoryPacket packet) + { + if (NetworkLifecycle.Instance.IsHost()) + return; + + LogDebug(() => $"Received shared inventory with {packet.EntryIds?.Length ?? 0} items"); + SharedInventoryManager.Instance.Client_ApplyFullInventory(packet.EntryIds, packet.PrefabNames, packet.States); + } + + private void OnClientboundSharedInventoryChangePacket(ClientboundSharedInventoryChangePacket packet) + { + if (NetworkLifecycle.Instance.IsHost()) + return; + + if (packet.Added) + SharedInventoryManager.Instance.Client_AddEntry(packet.EntryId, packet.PrefabName, packet.State, packet.OriginItemNetId); + else + SharedInventoryManager.Instance.Client_RemoveEntry(packet.EntryId); + } + + private void OnClientboundShopStockPacket(ClientboundShopStockPacket packet) + { + if (NetworkLifecycle.Instance.IsHost()) + return; + + GlobalShopController shopController = GlobalShopController.Instance; + if (shopController == null) + { + LogWarning("Shop stock packet received, but GlobalShopController does not exist!"); + return; + } + + if (packet.PrefabNames == null || packet.PrefabNames.Length != packet.PurchasedCounts?.Length || packet.PrefabNames.Length != packet.AllowedCounts?.Length) + { + LogWarning("Shop stock packet received with mismatched data lengths!"); + return; + } + + LogDebug(() => $"OnClientboundShopStockPacket() Updating stock for {packet.PrefabNames.Length} items"); + + for (int i = 0; i < packet.PrefabNames.Length; i++) + { + ShopItemData shopItemData = shopController.GetShopItemData(packet.PrefabNames[i]); + if (shopItemData == null) + { + LogWarning($"OnClientboundShopStockPacket() Shop item not found for prefab: {packet.PrefabNames[i]}"); + continue; + } + + shopItemData.purchasedItems = packet.PurchasedCounts[i]; + shopItemData.allowedToHaveAmount = packet.AllowedCounts[i]; + } + + shopController.Fire_GlobalShopDataChanged(); } private void OnCommonGenericSwitchStatePacket(CommonGenericSwitchStatePacket packet) @@ -1902,14 +1967,42 @@ public void SendPaintThemeChange(NetworkedTrainCar netTraincar, TrainCarPaint.Ta SendPacketToServer(new CommonPaintThemePacket { NetId = netTraincar.NetId, TargetArea = targetArea, PaintThemeId = themeId }, DeliveryMethod.ReliableUnordered); } - public void SendCashRegisterAction(ushort netId, CashRegisterAction action, double amount = 0.0f) + public void SendInventoryStore(ushort itemNetId) + { + SendPacketToServer(new ServerboundInventoryStorePacket { ItemNetId = itemNetId }, DeliveryMethod.ReliableOrdered); + } + + public void SendInventoryRemove(ushort entryId, bool dropped, Vector3 position, Quaternion rotation, string state) + { + SendPacketToServer(new ServerboundInventoryRemovePacket + { + EntryId = entryId, + Dropped = dropped, + Position = position, + Rotation = rotation, + State = state ?? string.Empty + }, DeliveryMethod.ReliableOrdered); + } + + public void SendMoneyStash(ushort itemNetId) + { + SendPacketToServer(new ServerboundMoneyStashPacket { ItemNetId = itemNetId }, DeliveryMethod.ReliableOrdered); + } + + public void SendPlayerInventory(PlayerItemSaveData[] items) + { + SendPacketToServer(new ServerboundPlayerInventoryPacket { Items = items }, DeliveryMethod.ReliableOrdered); + } + + public void SendCashRegisterAction(ushort netId, CashRegisterAction action, double amount = 0.0f, byte moduleIndex = 0) { SendPacketToServer( new CommonCashRegisterWithModulesActionPacket { NetId = netId, Action = action, - Amount = amount + Amount = amount, + ModuleIndex = moduleIndex }, DeliveryMethod.ReliableOrdered ); diff --git a/Multiplayer/Networking/Managers/NetworkManager.cs b/Multiplayer/Networking/Managers/NetworkManager.cs index 66a7f5f9..9f30fd15 100644 --- a/Multiplayer/Networking/Managers/NetworkManager.cs +++ b/Multiplayer/Networking/Managers/NetworkManager.cs @@ -71,6 +71,7 @@ private void RegisterNestedTypes() netPacketProcessor.RegisterNestedType(PlayerTrackingData.Serialize, PlayerTrackingData.Deserialize); netPacketProcessor.RegisterNestedType(Vector2Serializer.Serialize, Vector2Serializer.Deserialize); netPacketProcessor.RegisterNestedType(Vector3Serializer.Serialize, Vector3Serializer.Deserialize); + netPacketProcessor.RegisterNestedType(QuaternionSerializer.Serialize, QuaternionSerializer.Deserialize); netPacketProcessor.RegisterNestedType(ColorSerializer.Serialize, ColorSerializer.Deserialize); } diff --git a/Multiplayer/Networking/Managers/Server/NetworkServer.cs b/Multiplayer/Networking/Managers/Server/NetworkServer.cs index 6bd33d92..26fdc468 100644 --- a/Multiplayer/Networking/Managers/Server/NetworkServer.cs +++ b/Multiplayer/Networking/Managers/Server/NetworkServer.cs @@ -5,6 +5,7 @@ using DV.InventorySystem; using DV.LocoRestoration; using DV.Logic.Job; +using DV.Shops; using DV.Scenarios.Common; using DV.ServicePenalty; using DV.ThingTypes; @@ -148,6 +149,9 @@ public override void Stop() Log($"Stopping server..."); WorldStreamingInit.LoadingFinished -= OnLoaded; + if (GlobalShopController.Instance != null) + GlobalShopController.Instance.GlobalShopDataChanged -= OnGlobalShopDataChanged; + if (lobbyServerManager != null) { lobbyServerManager.RemoveFromLobbyServer(); @@ -190,6 +194,10 @@ protected override void Subscribe() netPacketProcessor.SubscribeNetSerializable(OnCommonPitStopPlugInteractionPacket); netPacketProcessor.SubscribeReusable(OnCommonCashRegisterWithModulesActionPacket); + netPacketProcessor.SubscribeReusable(OnServerboundPlayerInventoryPacket); + netPacketProcessor.SubscribeReusable(OnServerboundMoneyStashPacket); + netPacketProcessor.SubscribeReusable(OnServerboundInventoryStorePacket); + netPacketProcessor.SubscribeReusable(OnServerboundInventoryRemovePacket); netPacketProcessor.SubscribeReusable(OnCommonGenericSwitchStatePacket); @@ -270,6 +278,9 @@ private void OnLoaded() NetworkedPitStopStation.InitialisePitStops(); NetworkedCashRegisterWithModules.InitialiseCashRegisters(); + if (GlobalShopController.Instance != null) + GlobalShopController.Instance.GlobalShopDataChanged += OnGlobalShopDataChanged; + while (joinQueue.Count > 0) { ITransportPeer peer = joinQueue.Dequeue(); @@ -300,6 +311,20 @@ private void OnTick(uint tick) SendWeatherState(); lastTick = NetworkLifecycle.Instance.Tick; } + + if (shopStockDirty) + { + shopStockDirty = false; + SendShopStock(); + } + } + + private bool shopStockDirty; + + private void OnGlobalShopDataChanged() + { + //Coalesce multi-item purchases into a single stock broadcast on the next tick + shopStockDirty = true; } public bool TryGetServerPlayer(ITransportPeer peer, out ServerPlayer player) @@ -337,6 +362,9 @@ public override void OnPeerDisconnected(ITransportPeer peer, DisconnectReason di else Log($"Player {player?.Username} disconnected: {disconnectReason}"); + // Record where they left off before they stop being a known player + Components.SaveGame.NetworkedSaveGameManager.Instance.Server_RememberPlacement(player); + if (WorldStreamingInit.isLoaded) SaveGameManager.Instance.UpdateInternalData(); @@ -1009,6 +1037,28 @@ public void SendPitStopPlugInteractionPacket(ServerPlayer player, CommonPitStopP SendNetSerializablePacket(player.Peer, packet, DeliveryMethod.ReliableOrdered); } + public void SendSharedInventory(ushort[] entryIds, string[] prefabNames, string[] states, ServerPlayer player) + { + SendPacket(player.Peer, new ClientboundSharedInventoryPacket + { + EntryIds = entryIds, + PrefabNames = prefabNames, + States = states + }, DeliveryMethod.ReliableOrdered); + } + + public void SendSharedInventoryChange(ushort entryId, string prefabName, bool added, string state, ushort originItemNetId = 0) + { + SendPacketToAll(new ClientboundSharedInventoryChangePacket + { + EntryId = entryId, + PrefabName = prefabName, + Added = added, + State = state ?? string.Empty, + OriginItemNetId = originItemNetId + }, DeliveryMethod.ReliableOrdered, PlayerLoadingState.ReadyForItems, true); + } + public void SendCashRegisterAction(CommonCashRegisterWithModulesActionPacket packet, ServerPlayer[] players = null) { if (players == null) @@ -1018,6 +1068,25 @@ public void SendCashRegisterAction(CommonCashRegisterWithModulesActionPacket pac SendPacket(player.Peer, packet, DeliveryMethod.ReliableOrdered); } + public void SendShopStock(ITransportPeer peer = null) + { + if (GlobalShopController.Instance == null) + return; + + var shopItems = GlobalShopController.Instance.shopItemsData; + var packet = new ClientboundShopStockPacket + { + PrefabNames = shopItems.Select(item => item.item.ItemPrefabName).ToArray(), + PurchasedCounts = shopItems.Select(item => item.purchasedItems).ToArray(), + AllowedCounts = shopItems.Select(item => item.allowedToHaveAmount).ToArray() + }; + + if (peer == null) + SendPacketToAll(packet, DeliveryMethod.ReliableOrdered, PlayerLoadingState.ReadyForItems, true); + else + SendPacket(peer, packet, DeliveryMethod.ReliableOrdered); + } + public void SendGenericSwitchState(uint netId, bool isOn, ServerPlayer player = null) { var packet = new CommonGenericSwitchStatePacket @@ -1311,6 +1380,12 @@ private void OnServerboundLoadStateUpdatePacket(ServerboundLoadStateUpdatePacket case PlayerLoadingState.ReadyForItems: // Send Inventory and world items + SendShopStock(peer); + + // Co-op: hand the joining player the shared inventory + SharedInventoryManager.Instance.Server_SeedFromHostInventory(); + if (TryGetServerPlayer(peer, out var itemsPlayer)) + SharedInventoryManager.Instance.Server_SendFullInventory(itemsPlayer); break; @@ -2098,10 +2173,10 @@ private void OnCommonPitStopPlugInteractionPacket(CommonPitStopPlugInteractionPa private void OnCommonItemChangePacket(CommonItemChangePacket packet, ITransportPeer peer) { - //if(!TryGetServerPlayer(peer, out var player)) - // return; + if (!TryGetServerPlayer(peer, out var player)) + return; - //LogDebug(()=>$"OnCommonItemChangePacket({packet?.Items?.Count}, {peer.Id} (\"{player.Username}\"))"); + LogDebug(() => $"OnCommonItemChangePacket({packet?.Items?.Count}, {peer.Id} (\"{player.Username}\"))"); //LogDebug(() => //{ @@ -2132,7 +2207,70 @@ private void OnCommonItemChangePacket(CommonItemChangePacket packet, ITransportP //); - //NetworkedItemManager.Instance.ReceiveSnapshots(packet.Items, player); + NetworkedItemManager.Instance.ReceiveSnapshots(packet.Items, player); + } + + private void OnServerboundPlayerInventoryPacket(ServerboundPlayerInventoryPacket packet, ITransportPeer peer) + { + if (!TryGetServerPlayer(peer, out var player)) + return; + + player.SavedInventory = packet.Items; + + // Co-op: the most recent report becomes the shared inventory that every player + // receives on join. (Per-player inventories are still recorded on ServerPlayer + // for the future non-co-op mode.) + Components.SaveGame.NetworkedSaveGameManager.Instance.Server_SetSharedInventory(packet.Items); + + LogDebug(() => $"Received inventory report from {player.Username} with {packet.Items?.Length ?? 0} items"); + } + + private void OnServerboundInventoryStorePacket(ServerboundInventoryStorePacket packet, ITransportPeer peer) + { + if (!TryGetServerPlayer(peer, out var player)) + return; + + SharedInventoryManager.Instance.Server_StoreWorldItem(packet.ItemNetId, player); + } + + private void OnServerboundInventoryRemovePacket(ServerboundInventoryRemovePacket packet, ITransportPeer peer) + { + if (!TryGetServerPlayer(peer, out var player)) + return; + + SharedInventoryManager.Instance.Server_RemoveFromInventory(packet.EntryId, packet.Dropped, packet.Position, packet.Rotation, player, packet.State); + } + + private void OnServerboundMoneyStashPacket(ServerboundMoneyStashPacket packet, ITransportPeer peer) + { + if (!TryGetServerPlayer(peer, out var player)) + return; + + if (!NetworkedItem.TryGet(packet.ItemNetId, out NetworkedItem netItem) || netItem.Item == null) + { + LogWarning($"Money stash requested by {player.Username} for netId {packet.ItemNetId}, but the item does not exist"); + return; + } + + if (!netItem.TryGetComponent(out IMoney money) || !money.ShouldDestroyOnUse) + { + LogWarning($"Money stash requested by {player.Username} for netId {packet.ItemNetId}, but it is not a money item"); + return; + } + + if (!netItem.transform.PlayerCanReach(player, 1)) + { + LogWarning($"Money stash requested by {player.Username} for netId {packet.ItemNetId}, but they are too far away"); + return; + } + + double amount = money.TrySpend(money.Amount); + Inventory.Instance.AddMoney(amount); + + Log($"{player.Username} stashed ${amount} (item {packet.ItemNetId}); crediting shared wallet"); + + // Destroying the item propagates to all clients through the normal item destroy path + UnityEngine.Object.Destroy(netItem.gameObject); } private void OnCommonCashRegisterWithModulesActionPacket(CommonCashRegisterWithModulesActionPacket packet, ITransportPeer peer) diff --git a/Multiplayer/Networking/Packets/Clientbound/ClientboundSaveGameDataPacket.cs b/Multiplayer/Networking/Packets/Clientbound/ClientboundSaveGameDataPacket.cs index 66234715..26fb11be 100644 --- a/Multiplayer/Networking/Packets/Clientbound/ClientboundSaveGameDataPacket.cs +++ b/Multiplayer/Networking/Packets/Clientbound/ClientboundSaveGameDataPacket.cs @@ -51,6 +51,10 @@ public static ClientboundSaveGameDataPacket CreatePacket(ServerPlayer player) JObject playerData = NetworkedSaveGameManager.Instance.Server_GetPlayerData(data, player.Guid); + // Put a returning player back where they left off, falling back to the default spawn + bool hasPlacement = NetworkedSaveGameManager.Instance.Server_TryGetPlacement(data, player.Guid, out Vector3 placement, out float placementRotation); + Multiplayer.Log($"Spawning {player.Username} at {(hasPlacement ? placement.ToString() : "the default spawn")}"); + Multiplayer.LogDebug(() => { string unlockedGen = string.Join(", ", UnlockablesManager.Instance.UnlockedGeneralLicenses); @@ -62,28 +66,10 @@ public static ClientboundSaveGameDataPacket CreatePacket(ServerPlayer player) return $"ClientboundSaveGameDataPacket.CreatePacket() UnlockedGen: {{{unlockedGen}}}, PacketGen: {{{packetGen}}}, UnlockedJob: {{{unlockedJob}}}, PacketJob: {{{packetJob}}}"; }); - List playerItems = []; - string[] items = ["shovel", "lighter", "Oiler", "Lantern", "Flashlight", "Hanger", "DuctTape"]; - string[] states = ["", "", "", "", "{\"Restock\": true,\"Battery_power\": 100}", "", ""]; - - for (int i = 0; i < items.Length; i++) - { - JObject state; - - if (!string.IsNullOrEmpty(states[i])) - state = JObject.Parse(states[i]); - else - state = []; - - var testItem = new PlayerItemSaveData() - { - ItemPrefabName = items[i], - BelongsToPlayer = true, - InventorySlotIndex = 14 + i, - State = state - }; - playerItems.Add(testItem); - } + // The player's inventory: the latest report from their client this session, + // or the one persisted in the save from a previous session. New players get an + // empty list; StartingItemsController then provides the default starting items. + PlayerItemSaveData[] playerItems = NetworkedSaveGameManager.Instance.Server_GetPlayerInventory(player, data); return new ClientboundSaveGameDataPacket { @@ -93,8 +79,8 @@ public static ClientboundSaveGameDataPacket CreatePacket(ServerPlayer player) AcquiredGeneralLicenses = data.GetStringArray(SaveGameKeys.Licenses_General), AcquiredJobLicenses = data.GetStringArray(SaveGameKeys.Licenses_Jobs), UnlockedGarages = data.GetStringArray(SaveGameKeys.Garages), - Position = playerData?.GetVector3(SaveGameKeys.Player_position) ?? LevelInfo.DefaultSpawnPosition, - Rotation = playerData?.GetFloat(SaveGameKeys.Player_rotation) ?? LevelInfo.DefaultSpawnRotation.y, + Position = hasPlacement ? placement : LevelInfo.DefaultSpawnPosition, + Rotation = hasPlacement ? placementRotation : LevelInfo.DefaultSpawnRotation.y, HasDebt = data.GetFloat(SaveGameKeys.Debt_total).GetValueOrDefault(CareerManagerDebtController.Instance != null ? CareerManagerDebtController.Instance.NumberOfNonZeroPricedDebts : 0) > 0, // Debt_existing_locos = data.GetJObjectArray(SaveGameKeys.Debt_existing_locos)?.NotNull().Select(j => j.ToString()).ToArray(), // Debt_deleted_locos = data.GetJObjectArray(SaveGameKeys.Debt_deleted_locos)?.NotNull().Select(j => j.ToString()).ToArray(), @@ -106,7 +92,7 @@ public static ClientboundSaveGameDataPacket CreatePacket(ServerPlayer player) JobManagerTime = JobsManager.Instance.Time, - PlayerItems = playerItems.ToArray() + PlayerItems = playerItems }; } diff --git a/Multiplayer/Networking/Packets/Clientbound/ClientboundSharedInventoryChangePacket.cs b/Multiplayer/Networking/Packets/Clientbound/ClientboundSharedInventoryChangePacket.cs new file mode 100644 index 00000000..302f2139 --- /dev/null +++ b/Multiplayer/Networking/Packets/Clientbound/ClientboundSharedInventoryChangePacket.cs @@ -0,0 +1,24 @@ +namespace Multiplayer.Networking.Packets.Clientbound; + +/// +/// A single item entering or leaving the shared (co-op) inventory. +/// +public class ClientboundSharedInventoryChangePacket +{ + public ushort EntryId { get; set; } + public string PrefabName { get; set; } + public bool Added { get; set; } + + /// + /// For additions: the item's own save state (battery charge, coal load, ...) so the + /// copy each player receives carries it over. Empty when the item has no state. + /// + public string State { get; set; } + + /// + /// For additions: the world item this entry came from. The player who picked it up is + /// already holding that exact object and keeps it, rather than having it swapped for a + /// fresh copy; everyone else builds their own. + /// + public ushort OriginItemNetId { get; set; } +} diff --git a/Multiplayer/Networking/Packets/Clientbound/ClientboundSharedInventoryPacket.cs b/Multiplayer/Networking/Packets/Clientbound/ClientboundSharedInventoryPacket.cs new file mode 100644 index 00000000..ebc84036 --- /dev/null +++ b/Multiplayer/Networking/Packets/Clientbound/ClientboundSharedInventoryPacket.cs @@ -0,0 +1,13 @@ +namespace Multiplayer.Networking.Packets.Clientbound; + +/// +/// Full contents of the shared (co-op) inventory. Sent when a player joins. +/// +public class ClientboundSharedInventoryPacket +{ + public ushort[] EntryIds { get; set; } + public string[] PrefabNames { get; set; } + + /// Serialized per-item save state, empty string when the item has none. + public string[] States { get; set; } +} diff --git a/Multiplayer/Networking/Packets/Clientbound/World/ClientboundShopStockPacket.cs b/Multiplayer/Networking/Packets/Clientbound/World/ClientboundShopStockPacket.cs new file mode 100644 index 00000000..2b4367b3 --- /dev/null +++ b/Multiplayer/Networking/Packets/Clientbound/World/ClientboundShopStockPacket.cs @@ -0,0 +1,8 @@ +namespace Multiplayer.Networking.Packets.Clientbound.World; + +public class ClientboundShopStockPacket +{ + public string[] PrefabNames { get; set; } + public int[] PurchasedCounts { get; set; } + public int[] AllowedCounts { get; set; } +} diff --git a/Multiplayer/Networking/Packets/Common/CommonCashRegisterWithModulesActionPacket.cs b/Multiplayer/Networking/Packets/Common/CommonCashRegisterWithModulesActionPacket.cs index 780a0811..ee382f1c 100644 --- a/Multiplayer/Networking/Packets/Common/CommonCashRegisterWithModulesActionPacket.cs +++ b/Multiplayer/Networking/Packets/Common/CommonCashRegisterWithModulesActionPacket.cs @@ -10,11 +10,14 @@ public enum CashRegisterAction : byte RejectGeneric, RejectFunds, RejectedNoItems, - Approve + Approve, + ScanItem, + SetBasket } public class CommonCashRegisterWithModulesActionPacket { public ushort NetId { get; set; } public CashRegisterAction Action { get; set; } public double Amount { get; set; } + public byte ModuleIndex { get; set; } } diff --git a/Multiplayer/Networking/Packets/Serverbound/ServerboundInventoryRemovePacket.cs b/Multiplayer/Networking/Packets/Serverbound/ServerboundInventoryRemovePacket.cs new file mode 100644 index 00000000..055c4e8d --- /dev/null +++ b/Multiplayer/Networking/Packets/Serverbound/ServerboundInventoryRemovePacket.cs @@ -0,0 +1,21 @@ +using UnityEngine; + +namespace Multiplayer.Networking.Packets.Serverbound; + +/// +/// A player removed a shared item from their inventory, either by dropping it into the +/// world or by using it up. Either way it leaves every player's inventory. +/// +public class ServerboundInventoryRemovePacket +{ + public ushort EntryId { get; set; } + + /// True if the item was dropped into the world (spawn it), false if consumed. + public bool Dropped { get; set; } + + public Vector3 Position { get; set; } + public Quaternion Rotation { get; set; } + + /// The item's own save state at the moment it left the inventory. + public string State { get; set; } +} diff --git a/Multiplayer/Networking/Packets/Serverbound/ServerboundInventoryStorePacket.cs b/Multiplayer/Networking/Packets/Serverbound/ServerboundInventoryStorePacket.cs new file mode 100644 index 00000000..56ad36de --- /dev/null +++ b/Multiplayer/Networking/Packets/Serverbound/ServerboundInventoryStorePacket.cs @@ -0,0 +1,9 @@ +namespace Multiplayer.Networking.Packets.Serverbound; + +/// +/// A player took a world item into their inventory; it should become a shared entry. +/// +public class ServerboundInventoryStorePacket +{ + public ushort ItemNetId { get; set; } +} diff --git a/Multiplayer/Networking/Packets/Serverbound/ServerboundMoneyStashPacket.cs b/Multiplayer/Networking/Packets/Serverbound/ServerboundMoneyStashPacket.cs new file mode 100644 index 00000000..6c00ce47 --- /dev/null +++ b/Multiplayer/Networking/Packets/Serverbound/ServerboundMoneyStashPacket.cs @@ -0,0 +1,10 @@ +namespace Multiplayer.Networking.Packets.Serverbound; + +/// +/// Sent when a client puts a physical money item into their wallet. The wallet is shared +/// and server-authoritative, so the host credits the amount and destroys the item. +/// +public class ServerboundMoneyStashPacket +{ + public ushort ItemNetId { get; set; } +} diff --git a/Multiplayer/Networking/Packets/Serverbound/ServerboundPlayerInventoryPacket.cs b/Multiplayer/Networking/Packets/Serverbound/ServerboundPlayerInventoryPacket.cs new file mode 100644 index 00000000..005c1bbf --- /dev/null +++ b/Multiplayer/Networking/Packets/Serverbound/ServerboundPlayerInventoryPacket.cs @@ -0,0 +1,8 @@ +using Multiplayer.Networking.Data.Items; + +namespace Multiplayer.Networking.Packets.Serverbound; + +public class ServerboundPlayerInventoryPacket +{ + public PlayerItemSaveData[] Items { get; set; } +} diff --git a/Multiplayer/Patches/World/CashRegisterBasePatch.cs b/Multiplayer/Patches/World/CashRegisterBasePatch.cs index 8ffead33..629645e5 100644 --- a/Multiplayer/Patches/World/CashRegisterBasePatch.cs +++ b/Multiplayer/Patches/World/CashRegisterBasePatch.cs @@ -31,9 +31,6 @@ private static bool AddCash(CashRegisterBase __instance, double amount) return true; } - if (netCashRegister.IsShopRegister) - return true; - Inventory.Instance.AddMoney(amount); CoroutineManager.Instance.StartCoroutine(netCashRegister.AddCash(amount)); diff --git a/Multiplayer/Patches/World/CashRegisterWithModulesPatch.cs b/Multiplayer/Patches/World/CashRegisterWithModulesPatch.cs index 0031ca4c..72f1b5c7 100644 --- a/Multiplayer/Patches/World/CashRegisterWithModulesPatch.cs +++ b/Multiplayer/Patches/World/CashRegisterWithModulesPatch.cs @@ -28,14 +28,21 @@ private static bool OnDisable(CashRegisterWithModules __instance) [HarmonyPrefix] [HarmonyPatch(nameof(CashRegisterWithModules.OnBuyPressed))] - private static bool OnBuyPressed(CashRegisterWithModules __instance) + private static bool OnBuyPressed(CashRegisterWithModules __instance, out bool __state) { + // Record whether there was anything in the basket so the postfix can tell if the buy succeeded + __state = NetworkLifecycle.Instance.IsHost() && __instance.TotalUnitsInBasket() > 0f; + var player = PlayerManager.PlayerTransform.position; var reg = __instance.transform.position; var sqrMag = (player - reg).sqrMagnitude; Multiplayer.LogDebug(() => $"CashRegisterWithModules.OnBuyPressed() player pos: {player} register pos: {reg}, sqrMag: {sqrMag}"); if (NetworkLifecycle.Instance.IsHost()) + { + // Record the host player as buyer so spawned items can be stamped with their owner + GlobalShopControllerPatch.PurchasingPlayerId = NetworkLifecycle.Instance.Client?.PlayerId ?? 0; return true; + } if (!NetworkedCashRegisterWithModules.TryGet(__instance, out var netCashRegister)) { @@ -43,9 +50,6 @@ private static bool OnBuyPressed(CashRegisterWithModules __instance) return false; } - if (netCashRegister.IsShopRegister) - return true; - CoroutineManager.Instance.StartCoroutine(netCashRegister.Buy()); return false; @@ -53,11 +57,16 @@ private static bool OnBuyPressed(CashRegisterWithModules __instance) [HarmonyPostfix] [HarmonyPatch(nameof(CashRegisterWithModules.OnBuyPressed))] - private static void OnBuyPressed_Postfix(CashRegisterWithModules __instance) + private static void OnBuyPressed_Postfix(CashRegisterWithModules __instance, bool __state) { if (!NetworkLifecycle.Instance.IsHost()) return; + // Only broadcast if the buy succeeded (a successful Buy resets the basket to zero); + // broadcasting a failed buy would wrongly reset the clients' baskets + if (!__state || __instance.TotalUnitsInBasket() > 0f) + return; + if (!NetworkedCashRegisterWithModules.TryGet(__instance, out var netCashRegister)) { Multiplayer.LogWarning($"CashRegisterWithModules.OnBuyPressed_Postfix({__instance.GetObjectPath()}) NetworkedCashRegisterWithModules not found!"); @@ -89,9 +98,6 @@ private static bool Cancel(CashRegisterWithModules __instance) return false; } - if (netCashRegister.IsShopRegister) - return true; - CoroutineManager.Instance.StartCoroutine(netCashRegister.Cancel()); return false; @@ -111,9 +117,6 @@ private static void Cancel_Postfix(CashRegisterWithModules __instance) return; } - if (netCashRegister.IsShopRegister) - return; - // Send cancel action to all clients NetworkLifecycle.Instance.Server.SendCashRegisterAction(new CommonCashRegisterWithModulesActionPacket { diff --git a/Multiplayer/Patches/World/GlobalShopControllerPatch.cs b/Multiplayer/Patches/World/GlobalShopControllerPatch.cs new file mode 100644 index 00000000..8eb7b30e --- /dev/null +++ b/Multiplayer/Patches/World/GlobalShopControllerPatch.cs @@ -0,0 +1,140 @@ +using DV.CabControls; +using DV.Shops; +using DV.Utils; +using HarmonyLib; +using Multiplayer.Components.Networking; +using Multiplayer.Components.Networking.World; +using Multiplayer.Utils; +using System.Collections; +using UnityEngine; + +namespace Multiplayer.Patches.World; + +[HarmonyPatch(typeof(GlobalShopController))] +public static class GlobalShopControllerPatch +{ + // Player executing the current purchase, so spawned items can be stamped with their owner. + // Set around Buy() by the cash register code; 0 means unknown. + public static byte PurchasingPlayerId; + + [HarmonyPrefix] + [HarmonyPatch(nameof(GlobalShopController.AddItemToInstantiationQueue))] + private static bool AddItemToInstantiationQueue(GlobalShopController __instance) + { + // Purchases are executed on the host only; clients receive the spawned + // items through the item sync and the stock levels through ClientboundShopStockPacket + return NetworkLifecycle.Instance.IsHost(); + } + + [HarmonyPostfix] + [HarmonyPatch(nameof(GlobalShopController.AddItemToInstantiationQueue))] + private static void AddItemToInstantiationQueue_Postfix(GlobalShopController __instance) + { + // Vanilla does not raise GlobalShopDataChanged on purchase; raising it here keeps + // host-side shop displays current and lets the server broadcast the new stock levels + if (NetworkLifecycle.Instance.IsHost()) + __instance.Fire_GlobalShopDataChanged(); + } + + [HarmonyPrefix] + [HarmonyPatch(nameof(GlobalShopController.Restock))] + private static bool Restock(GlobalShopController __instance) + { + return NetworkLifecycle.Instance.IsHost(); + } + + [HarmonyPrefix] + [HarmonyPatch(nameof(GlobalShopController.UpdateItemStocksOnGameLoad))] + private static bool UpdateItemStocksOnGameLoad() + { + // Clients receive authoritative stock levels from the server on join + return NetworkLifecycle.Instance.IsHost(); + } + + [HarmonyPrefix] + [HarmonyPatch(nameof(GlobalShopController.InstantiatePurchasedItems))] + private static bool InstantiatePurchasedItems(GlobalShopController __instance, ref IEnumerator __result) + { + // Reimplementation of the vanilla coroutine with one change: items are placed at the + // shop when ANY player is near it, not just the host's player - the buyer may be a remote client + __result = InstantiatePurchasedItems_AnyPlayer(__instance); + return false; + } + + private static IEnumerator InstantiatePurchasedItems_AnyPlayer(GlobalShopController gsc) + { + gsc.isInstantiatingItems = true; + gsc.SetShopProcessingTransactionState(true); + gsc.staggeredItemActivationCollection.Clear(); + + Vector3 zero = Vector3.zero; + Vector3 right = Vector3.right; + int offset = 0; + + foreach (ShoppingCartEntry entry in gsc.itemInstantiationQueue) + { + for (int i = 0; i < entry.desiredAmount; i++) + { + Vector3 position = zero + right * offset; + string itemPrefabName = entry.specs.ItemPrefabName; + GameObject obj = Object.Instantiate(Resources.Load(itemPrefabName) as GameObject, position, Quaternion.identity); + Transform itemTransform = obj.transform; + obj.GetComponent().BelongsToPlayer = true; + + ShopRestocker restocker = obj.GetComponent(); + if (restocker == null) + Multiplayer.LogError($"Missing ShopRestocker component on item prefab {itemPrefabName}. This should not happen."); + else + restocker.restockOnItemDestroyed = true; + + ItemBase itemBase = itemTransform.GetComponent(); + + if (PurchasingPlayerId != 0 && NetworkedItem.TryGetNetworkedItem(itemBase, out var netItem)) + netItem.SetLastOwner(PurchasingPlayerId); + + gsc.staggeredItemActivationCollection.Add((itemTransform, itemBase, entry.shop)); + offset++; + } + } + + PurchasingPlayerId = 0; + + yield return null; + + for (int i = gsc.staggeredItemActivationCollection.Count - 1; i >= 0; i--) + { + ItemBase item = gsc.staggeredItemActivationCollection[i].itemBase; + SingletonBehaviour.Instance.AddItemToLostAndFound(item, updateTransformData: false); + item.gameObject.SetActive(false); + } + + foreach (var (itemTransform, itemBase, shop) in gsc.staggeredItemActivationCollection) + { + Vector3 spawnPosition = shop.itemSpawnTransform.position; + + RespawnOnDrop respawnOnDrop = itemBase.GetComponent(); + if (respawnOnDrop != null && spawnPosition.AnyPlayerSqrMag() >= respawnOnDrop.maxDistance * respawnOnDrop.maxDistance) + { + itemBase.ItemRigidbody.velocity = Vector3.zero; + itemBase.ItemRigidbody.angularVelocity = Vector3.zero; + continue; + } + + SingletonBehaviour.Instance.AddItemToWorldStorage(itemBase); + itemTransform.position = spawnPosition; + itemBase.ItemRigidbody.velocity = Vector3.zero; + itemBase.ItemRigidbody.angularVelocity = Vector3.zero; + itemBase.gameObject.SetActive(true); + + APurchaseTrigger purchaseTrigger = itemBase.GetComponent(); + if (purchaseTrigger != null) + purchaseTrigger.OnPurchased(itemBase.gameObject); + + yield return WaitFor.Seconds(0.5f); + } + + gsc.itemInstantiationQueue.Clear(); + gsc.isInstantiatingItems = false; + gsc.SetShopProcessingTransactionState(false); + } +} diff --git a/Multiplayer/Patches/World/Items/MoneyUsePatch.cs b/Multiplayer/Patches/World/Items/MoneyUsePatch.cs new file mode 100644 index 00000000..8eb83cb0 --- /dev/null +++ b/Multiplayer/Patches/World/Items/MoneyUsePatch.cs @@ -0,0 +1,46 @@ +using DV.CabControls; +using DV.CashRegister; +using DV.Interaction; +using HarmonyLib; +using Multiplayer.Components.Networking; +using Multiplayer.Components.Networking.World; + +namespace Multiplayer.Patches.World.Items; + +[HarmonyPatch(typeof(MoneyUse))] +public static class MoneyUsePatch +{ + // The wallet is shared and server-authoritative, so a client stashing a banknote must ask + // the host to credit it. The host destroys the item, which replicates back as a normal + // item update. Cash register payments already route through the register packets. + [HarmonyPrefix] + [HarmonyPatch(nameof(MoneyUse.HandleUse))] + private static bool HandleUse(MoneyUse __instance, ItemUseTarget target, ref bool __result) + { + if (NetworkLifecycle.Instance.IsHost()) + return true; + + // Paying a cash register is handled by the cash register sync + if (target.GetComponent() != null) + return true; + + // Only intercept stashing a destroy-on-use money item (banknotes/coins) into the wallet + if (!target.TryGetComponent(out var targetMoney) || targetMoney.ShouldDestroyOnUse) + return true; + + if (!__instance.TryGetComponent(out var heldMoney) || !heldMoney.ShouldDestroyOnUse) + return true; + + if (!__instance.TryGetComponent(out var item) || !NetworkedItem.TryGetNetworkedItem(item, out NetworkedItem netItem) || netItem.NetId == 0) + { + Multiplayer.LogWarning($"MoneyUse.HandleUse() Money item is not networked; ignoring stash request"); + __result = false; + return false; + } + + NetworkLifecycle.Instance.Client.SendMoneyStash(netItem.NetId); + + __result = true; + return false; + } +} diff --git a/Multiplayer/Patches/World/ScanItemCashRegisterModulePatch.cs b/Multiplayer/Patches/World/ScanItemCashRegisterModulePatch.cs new file mode 100644 index 00000000..e94275f2 --- /dev/null +++ b/Multiplayer/Patches/World/ScanItemCashRegisterModulePatch.cs @@ -0,0 +1,75 @@ +using DV.Shops; +using DV.Utils; +using HarmonyLib; +using Multiplayer.Components.Networking; +using Multiplayer.Components.Networking.World; +using Multiplayer.Networking.Packets.Common; + +namespace Multiplayer.Patches.World; + +[HarmonyPatch(typeof(ScanItemCashRegisterModule))] +public static class ScanItemCashRegisterModulePatch +{ + [HarmonyPrefix] + [HarmonyPatch(nameof(ScanItemCashRegisterModule.AddItemsToBuy))] + private static bool AddItemsToBuy_Prefix(ScanItemCashRegisterModule __instance, ref bool __result) + { + if (NetworkLifecycle.Instance.IsHost()) + return true; + + if (!TryGetRegisterAndIndex(__instance, out var netCashRegister, out byte moduleIndex)) + { + Multiplayer.LogWarning($"ScanItemCashRegisterModule.AddItemsToBuy() NetworkedCashRegisterWithModules not found for module {__instance.name}"); + __result = false; + return false; + } + + // Validate against the local (server-synced) stock so the scanner gives immediate feedback; + // the server is authoritative and echoes the accepted basket state back via SetBasket + ShopItemData shopItemData = SingletonBehaviour.Instance.GetShopItemData(__instance.sellingItemSpec); + int itemsInStock = shopItemData?.ItemsInStock ?? 0; + __result = __instance.sellingItemSpec != null && itemsInStock > 0 && __instance.Data.unitsToBuy < (float)itemsInStock; + + if (__result) + NetworkLifecycle.Instance.Client.SendCashRegisterAction(netCashRegister.NetId, CashRegisterAction.ScanItem, 0, moduleIndex); + + return false; + } + + [HarmonyPostfix] + [HarmonyPatch(nameof(ScanItemCashRegisterModule.AddItemsToBuy))] + private static void AddItemsToBuy_Postfix(ScanItemCashRegisterModule __instance, bool __result) + { + // Covers both the host player scanning locally and the server processing a client's + // ScanItem request - either way the new basket state is broadcast to all clients + if (!NetworkLifecycle.Instance.IsHost() || !__result) + return; + + if (!TryGetRegisterAndIndex(__instance, out var netCashRegister, out byte moduleIndex)) + { + Multiplayer.LogWarning($"ScanItemCashRegisterModule.AddItemsToBuy() NetworkedCashRegisterWithModules not found for module {__instance.name}"); + return; + } + + NetworkLifecycle.Instance.Server.SendCashRegisterAction(new CommonCashRegisterWithModulesActionPacket + { + NetId = netCashRegister.NetId, + Action = CashRegisterAction.SetBasket, + Amount = __instance.Data.unitsToBuy, + ModuleIndex = moduleIndex + }); + } + + private static bool TryGetRegisterAndIndex(ScanItemCashRegisterModule module, out NetworkedCashRegisterWithModules netCashRegister, out byte moduleIndex) + { + netCashRegister = null; + moduleIndex = 0; + + Shop shop = module.GetComponentInParent(); + if (shop == null || shop.cashRegister == null) + return false; + + return NetworkedCashRegisterWithModules.TryGet(shop.cashRegister, out netCashRegister) + && netCashRegister.TryGetModuleIndex(module, out moduleIndex); + } +} diff --git a/Multiplayer/Patches/World/StorageControllerPatch.cs b/Multiplayer/Patches/World/StorageControllerPatch.cs index df1e9813..af8da853 100644 --- a/Multiplayer/Patches/World/StorageControllerPatch.cs +++ b/Multiplayer/Patches/World/StorageControllerPatch.cs @@ -1,82 +1,83 @@ using DV.CabControls; using HarmonyLib; +using Multiplayer.Components.Networking; using Multiplayer.Components.Networking.World; -using Multiplayer.Utils; -using System; -using UnityEngine; +using Multiplayer.Networking.Data; +using System.Collections.Generic; + +namespace Multiplayer.Patches.World; -namespace Multiplayer.Patches.World.Items; -/* [HarmonyPatch(typeof(StorageController))] public static class StorageControllerPatch { - [HarmonyPatch(nameof(StorageController.AddItemToLostAndFound))] + // Reimplementation of the vanilla method with one addition: items belonging to other + // players are skipped, so a player's Lost & Found (summon button, fast travel) can no + // longer capture someone else's belongings. (#104) [HarmonyPrefix] - static void AddItemToLostAndFound(StorageController __instance, ItemBase item) + [HarmonyPatch(nameof(StorageController.MoveItemsFromWorldToLostAndFound))] + private static bool MoveItemsFromWorldToLostAndFound(StorageController __instance, bool includeNonRespawnParents, bool includeRespawnParents, bool includeSnappedItems) { - - Multiplayer.LogDebug(() => + foreach (ItemBase item in new List(__instance.StorageWorld.GetStorageItemList())) { - NetworkedItem.TryGetNetworkedItem(item, out NetworkedItem netItem); - return $"StorageController.AddItemToLostAndFound({item.name}) netId: {netItem?.NetId}\r\n{new System.Diagnostics.StackTrace()}"; - }); - } + if (item == null || item.IsGrabbed() || !item.GetComponent().BelongsToPlayer) + continue; - [HarmonyPatch(nameof(StorageController.RemoveItemFromLostAndFound))] - [HarmonyPrefix] - static void RemoveItemFromLostAndFound(StorageController __instance, ItemBase item) - { + if (BelongsToAnotherPlayer(item)) + continue; - Multiplayer.LogDebug(() => - { - NetworkedItem.TryGetNetworkedItem(item, out NetworkedItem netItem); - return $"StorageController.RemoveItemFromLostAndFound({item.name}) netId: {netItem?.NetId}\r\n{new System.Diagnostics.StackTrace()}"; - }); - } - - [HarmonyPatch(nameof(StorageController.RequestLostAndFoundItemActivation))] - [HarmonyPrefix] - static void RequestLostAndFoundItemActivation(StorageController __instance) - { + bool isSnapped = item.IsSnapped; + if (isSnapped && !includeSnappedItems) + continue; - Multiplayer.LogDebug(() => - { - return $"StorageController.RequestLostAndFoundItemActivation()\r\n{new System.Diagnostics.StackTrace()}"; - }); - } + if (item.GetComponent().OnValidRespawnParent) + { + if (!includeRespawnParents || !__instance.PrepareItemForLostAndFound(item)) + continue; + } + else if (!includeNonRespawnParents) + { + if (!includeSnappedItems || !isSnapped || !__instance.PrepareItemForLostAndFound(item)) + continue; + } + else if (isSnapped && !__instance.PrepareItemForLostAndFound(item)) + { + continue; + } - [HarmonyPatch(nameof(StorageController.MoveItemsFromWorldToLostAndFound))] - [HarmonyPrefix] - static void MoveItemsFromWorldToLostAndFound(StorageController __instance, bool ignoreItemsWithRespawnParents) - { + __instance.StorageWorld.RemoveItem(item); + __instance.StorageLostAndFound.AddItem(item); + } - Multiplayer.LogDebug(() => - { - return $"StorageController.MoveItemsFromWorldToLostAndFound({ignoreItemsWithRespawnParents})\r\n{new System.Diagnostics.StackTrace()}"; - }); + return false; } - [HarmonyPatch(nameof(StorageController.ForceSummonAllWorldItemsToLostAndFound))] + // AddItemToStorageItemList ends with an unguarded RespawnOnDrop.UpdateSpawnParams(). + // If anything removed that component the call throws, aborting the game's inventory + // handling part-way and leaving an unusable item in the slot. Restore it defensively. [HarmonyPrefix] - static void ForceSummonAllWorldItemsToLostAndFound(StorageController __instance) + [HarmonyPatch(nameof(StorageController.AddItemToStorageItemList), typeof(StorageBase), typeof(ItemBase))] + private static void AddItemToStorageItemList(ItemBase item) { - - Multiplayer.LogDebug(() => + if (item != null && item.GetComponent() == null) { - return $"StorageController.ForceSummonAllWorldItemsToLostAndFound()\r\n{new System.Diagnostics.StackTrace()}"; - }); + Multiplayer.LogWarning($"Restoring missing RespawnOnDrop on {item.name} before adding it to storage"); + item.gameObject.AddComponent(); + } } - [HarmonyPatch(nameof(StorageController.RequestItemActivation))] - [HarmonyPrefix] - static void RequestItemActivation(StorageController __instance) + private static bool BelongsToAnotherPlayer(ItemBase item) { + if (!NetworkedItem.TryGetNetworkedItem(item, out NetworkedItem netItem)) + return false; // purely local item -> personal, summon allowed - Multiplayer.LogDebug(() => - { - return $"StorageController.RequestItemActivation()\r\n{new System.Diagnostics.StackTrace()}"; - }); - } + // Clients may only summon their own local (un-networked) items; + // networked items are managed by the server + if (!NetworkLifecycle.Instance.IsHost()) + return netItem.NetId != 0; + // Host: skip items last owned by a connected remote player + return netItem.LastOwnerId != 0 + && NetworkLifecycle.Instance.Server.TryGetServerPlayer(netItem.LastOwnerId, out ServerPlayer owner) + && !NetworkLifecycle.Instance.IsHost(owner); + } } -*/ diff --git a/Multiplayer/Utils/IdPool.cs b/Multiplayer/Utils/IdPool.cs index eb0280fc..a0481b21 100644 --- a/Multiplayer/Utils/IdPool.cs +++ b/Multiplayer/Utils/IdPool.cs @@ -32,6 +32,13 @@ public T NextId { public void ReleaseId(T id) { + // NextId increments before returning, so the default value is never handed out and + // must never be taken back: objects that opt out of networking keep the default id, + // and releasing it would later hand id 0 to a real object, which then cannot be + // referenced over the network at all. + if (EqualityComparer.Default.Equals(id, default)) + return; + releasedIds.Enqueue(id); }