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);
}