From 2e73d254a10f9fde6e7f2b2bec4bb98a75002060 Mon Sep 17 00:00:00 2001 From: Patrick van Halm Date: Sun, 16 Aug 2026 15:03:50 +0200 Subject: [PATCH 01/14] Add multiplayer customization and item synchronization --- .../Networking/World/CustomizationRef.cs | 69 ++ .../World/CustomizationStateManager.cs | 921 ++++++++++++++++++ .../World/CustomizationSyncScope.cs | 31 + .../World/GadgetTrackedValueRegistry.cs | 264 +++++ .../Networking/World/NetworkedItem.cs | 134 ++- .../Networking/World/NetworkedItemManager.cs | 227 ++++- Multiplayer/Multiplayer.csproj | 1 + .../Customization/CustomizationStateData.cs | 257 +++++ .../Networking/Data/Items/TrackedValue.cs | 2 +- .../Managers/Client/NetworkClient.cs | 66 +- .../Managers/Server/NetworkServer.cs | 53 +- .../ClientboundCustomizationStatePacket.cs | 19 + .../ClientboundItemReconciliationPacket.cs | 44 + .../Common/CommonCustomizationPacket.cs | 38 + .../ServerboundItemReconciliationPacket.cs | 54 + .../Patches/World/CustomizationPatch.cs | 101 ++ .../Patches/World/GadgetRelationshipPatch.cs | 221 +++++ .../World/Items/CustomizationToolPatch.cs | 87 ++ .../Patches/World/Items/GadgetItemPatch.cs | 20 + 19 files changed, 2567 insertions(+), 42 deletions(-) create mode 100644 Multiplayer/Components/Networking/World/CustomizationRef.cs create mode 100644 Multiplayer/Components/Networking/World/CustomizationStateManager.cs create mode 100644 Multiplayer/Components/Networking/World/CustomizationSyncScope.cs create mode 100644 Multiplayer/Components/Networking/World/GadgetTrackedValueRegistry.cs create mode 100644 Multiplayer/Networking/Data/Customization/CustomizationStateData.cs create mode 100644 Multiplayer/Networking/Packets/Clientbound/ClientboundCustomizationStatePacket.cs create mode 100644 Multiplayer/Networking/Packets/Clientbound/ClientboundItemReconciliationPacket.cs create mode 100644 Multiplayer/Networking/Packets/Common/CommonCustomizationPacket.cs create mode 100644 Multiplayer/Networking/Packets/Serverbound/ServerboundItemReconciliationPacket.cs create mode 100644 Multiplayer/Patches/World/CustomizationPatch.cs create mode 100644 Multiplayer/Patches/World/GadgetRelationshipPatch.cs create mode 100644 Multiplayer/Patches/World/Items/CustomizationToolPatch.cs create mode 100644 Multiplayer/Patches/World/Items/GadgetItemPatch.cs diff --git a/Multiplayer/Components/Networking/World/CustomizationRef.cs b/Multiplayer/Components/Networking/World/CustomizationRef.cs new file mode 100644 index 00000000..db9fa2fb --- /dev/null +++ b/Multiplayer/Components/Networking/World/CustomizationRef.cs @@ -0,0 +1,69 @@ +using DV.Customization; +using Multiplayer.Components.Networking.Train; +using Multiplayer.Networking.Data.Customization; +using System.Linq; +using UnityEngine; + +namespace Multiplayer.Components.Networking.World; + +public static class CustomizationRef +{ + public static bool TryCreate(Customization customization, out CustomizationRefData reference) + { + reference = default; + if (customization == null) + return false; + + if (customization is TrainCarCustomization trainCustomization) + { + if (!NetworkedTrainCar.TryGetNetId(trainCustomization.TrainCar, out ushort trainCarNetId)) + return false; + + reference.Kind = CustomizationTargetKind.TrainCar; + reference.TrainCarNetId = trainCarNetId; + return true; + } + + reference.Kind = customization switch + { + WorldCustomization => CustomizationTargetKind.World, + StorageShedCustomization => CustomizationTargetKind.Storage, + PlayerHouseCustomization => CustomizationTargetKind.PlayerHouse, + PaintStationCustomization => CustomizationTargetKind.PaintStation, + _ => reference.Kind, + }; + + return customization is WorldCustomization or StorageShedCustomization or + PlayerHouseCustomization or PaintStationCustomization; + } + + public static bool TryResolve(CustomizationRefData reference, out Customization customization) + { + customization = null; + + if (reference.Kind == CustomizationTargetKind.TrainCar) + { + if (!NetworkedTrainCar.TryGet(reference.TrainCarNetId, out TrainCar trainCar)) + return false; + + customization = trainCar.GetComponent(); + return customization != null; + } + + customization = Resources.FindObjectsOfTypeAll() + .FirstOrDefault(candidate => candidate != null && candidate.gameObject.scene.IsValid() && Matches(reference.Kind, candidate)); + return customization != null; + } + + private static bool Matches(CustomizationTargetKind kind, Customization customization) + { + return kind switch + { + CustomizationTargetKind.World => customization is WorldCustomization, + CustomizationTargetKind.Storage => customization is StorageShedCustomization, + CustomizationTargetKind.PlayerHouse => customization is PlayerHouseCustomization, + CustomizationTargetKind.PaintStation => customization is PaintStationCustomization, + _ => false, + }; + } +} diff --git a/Multiplayer/Components/Networking/World/CustomizationStateManager.cs b/Multiplayer/Components/Networking/World/CustomizationStateManager.cs new file mode 100644 index 00000000..9a1c38c1 --- /dev/null +++ b/Multiplayer/Components/Networking/World/CustomizationStateManager.cs @@ -0,0 +1,921 @@ +using DV.Customization; +using DV.Customization.Gadgets; +using DV.Customization.Gadgets.Implementations; +using DV.InventorySystem; +using DV.Items.Snapping; +using Multiplayer.Networking.Data.Customization; +using Multiplayer.Networking.Data.Items; +using Multiplayer.Networking.Packets.Common; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +namespace Multiplayer.Components.Networking.World; + +public static class CustomizationStateManager +{ + public static CustomizationStateData CaptureCurrentState() + { + var state = new CustomizationStateData(); + + foreach (var item in NetworkedItem.GetAll()) + CaptureItem(state, item); + + CaptureFreeHoles(state); + LogSnapshot("captured", state); + return state; + } + + public static IEnumerator ApplyCurrentStateWhenReady(CustomizationStateData state) + { + LogSnapshot("applying", state); + + using (CustomizationSyncScope.Remote()) + { + CreateRelatedItems(state.RelatedItems); + CreateGadgetItems(state.Gadgets); + } + + bool loggedWait = false; + while (!AreSnapshotItemsReady(state, out string waitingFor)) + { + if (!loggedWait) + { + Multiplayer.LogDebug(() => $"Waiting to apply customization snapshot until {waitingFor} is initialized"); + loggedWait = true; + } + yield return null; + } + + if (loggedWait) + Multiplayer.LogDebug(() => "Customization snapshot items initialized; continuing reconstruction"); + + using (CustomizationSyncScope.Remote()) + PlaceGadgets(state.Gadgets); + + // NetworkedItem registration can finish before SnappableItem.Initialize in + // the same Unity frame. Wait for the item's compatible anchor before + // restoring snap relationships; SnapItem otherwise returns false once and + // leaves items such as shovels lying at their captured world position. + const int maxSnapInitializationFrames = 120; + int snapInitializationFrames = 0; + bool loggedSnapWait = false; + bool snapRelationshipsReady = AreSnapRelationshipsReady(state.Relationships, out string snapWaitingFor); + while (!snapRelationshipsReady && snapInitializationFrames < maxSnapInitializationFrames) + { + if (!loggedSnapWait) + { + Multiplayer.LogDebug(() => $"Waiting to apply customization snapshot until {snapWaitingFor}"); + loggedSnapWait = true; + } + yield return null; + snapInitializationFrames++; + snapRelationshipsReady = AreSnapRelationshipsReady(state.Relationships, out snapWaitingFor); + } + + if (!snapRelationshipsReady) + Multiplayer.LogWarning($"Customization snapshot timed out waiting for {snapWaitingFor}; attempting relationships with diagnostics"); + else if (loggedSnapWait) + Multiplayer.LogDebug(() => $"Customization snap relationships ready after {snapInitializationFrames} frame(s)"); + + using (CustomizationSyncScope.Remote()) + { + ApplyRelationships(state.Relationships); + ApplyFreeHoles(state.Holes); + ApplyTrackedValues(state.Gadgets); + } + + Multiplayer.LogDebug(() => "Customization snapshot application completed"); + } + + public static void SendAction(CommonCustomizationPacket packet) + { + if (NetworkLifecycle.Instance.IsHost()) + NetworkLifecycle.Instance.Server.SendCustomizationAction(packet); + else + NetworkLifecycle.Instance.Client.SendCustomizationAction(packet); + } + + public static void ApplyAction(CommonCustomizationPacket packet) + { + Multiplayer.LogDebug(() => $"Applying customization action={packet.Action} item={packet.ItemNetId} " + + $"other={packet.OtherItemNetId} indices={packet.IndexA}:{packet.IndexB}"); + + using (CustomizationSyncScope.Remote()) + { + switch (packet.Action) + { + case CustomizationAction.PlaceGadget: + ApplyPlacement(packet); + break; + case CustomizationAction.RemoveGadget: + ApplyRemoval(packet); + break; + case CustomizationAction.AddHole: + case CustomizationAction.MoveHole: + case CustomizationAction.RemoveHole: + ApplyHoleAction(packet); + break; + case CustomizationAction.Mount: + case CustomizationAction.Unmount: + ApplyMountAction(packet); + break; + case CustomizationAction.Wire: + case CustomizationAction.Unwire: + ApplyWireAction(packet); + break; + case CustomizationAction.DropEmptySpool: + case CustomizationAction.LoadSpool: + case CustomizationAction.ReplaceSpool: + ApplySpoolAction(packet); + break; + case CustomizationAction.ReplaceDuctTape: + ApplyDuctTapeReplacement(packet); + break; + case CustomizationAction.SnapItem: + case CustomizationAction.UnsnapItem: + ApplySnapAction(packet); + break; + } + } + } + + public static bool TryGadget(ushort itemNetId, out GadgetBase gadget) + { + gadget = null; + if (!NetworkedItem.TryGet(itemNetId, out var item)) + return false; + + gadget = item.GetTrackedItem()?.Gadget; + return gadget != null; + } + + internal static bool TryGetSnapPointOwner(ItemSnapPointBase snapPoint, out GadgetBase owner) + { + owner = null; + if (snapPoint == null) + return false; + + if (snapPoint is SnapPointGadgetSliding sliding && sliding.drillable?.ThisGadget != null) + owner = sliding.drillable.ThisGadget; + else if (snapPoint is SnapPointGadget gadgetSnapPoint && gadgetSnapPoint.gadgetBase != null) + owner = gadgetSnapPoint.gadgetBase; + else + owner = snapPoint.GetComponentInParent(); + + return owner != null; + } + + internal static ItemSnapPointBase[] GetSnapPoints(GadgetBase owner) + { + if (owner == null) + return []; + + if (owner is GadgetWithSnapPoint gadgetWithSnapPoint && gadgetWithSnapPoint.snapPoint != null) + return [gadgetWithSnapPoint.snapPoint]; + + return owner.GetComponentsInChildren(true) + .Where(point => TryGetSnapPointOwner(point, out var pointOwner) && pointOwner == owner) + .ToArray(); + } + + internal static bool TryGetSnapPointIndex(GadgetBase owner, ItemSnapPointBase snapPoint, out int index) + { + index = System.Array.IndexOf(GetSnapPoints(owner), snapPoint); + return index >= 0; + } + + private static void CaptureItem(CustomizationStateData state, NetworkedItem item) + { + if (item == null) + return; + + CaptureLoadedSpool(state, item); + + var gadget = item.GetTrackedItem()?.Gadget; + if (gadget == null || !gadget.IsLinked || !CustomizationRef.TryCreate(gadget.Custom, out var target)) + return; + + CapturePlacement(state, item, gadget, target); + CaptureMounts(state, item, gadget); + CaptureSnappedItems(state, item, gadget); + CaptureWires(state, item, gadget); + } + + private static void CaptureLoadedSpool(CustomizationStateData state, NetworkedItem toolItem) + { + var tool = toolItem.GetTrackedItem(); + var magazineItems = tool?.magazine?.items; + if (magazineItems == null || magazineItems.Length == 0 || magazineItems[0] == null) + return; + + var spoolItemBase = magazineItems[0].GetComponent(); + if (!NetworkedItem.TryGetNetworkedItem(spoolItemBase, out var spoolItem)) + return; + + AddRelatedItem(state, toolItem, forceDropped: false); + AddRelatedItem(state, spoolItem, forceDropped: true); + AddRelationship(state, CustomizationAction.LoadSpool, toolItem.NetId, spoolItem.NetId); + } + + private static void CapturePlacement( + CustomizationStateData state, + NetworkedItem item, + GadgetBase gadget, + CustomizationRefData target) + { + var fullState = item.CreateUpdateData(ItemUpdateData.ItemUpdateType.FullSync); + state.Gadgets.Add(new GadgetPlacementData + { + ItemNetId = item.NetId, + PrefabName = item.Item.InventorySpecs.ItemPrefabName, + Target = target, + LocalPosition = gadget.transform.localPosition, + LocalRotation = gadget.transform.localRotation, + IsOnGlass = gadget.IsOnGlass, + TrackedValues = fullState?.States ?? new Dictionary(), + }); + + Multiplayer.LogDebug(() => $"Customization snapshot gadget={item.NetId} type={gadget.GetType().Name} " + + $"target={target.Kind}:{target.TrainCarNetId} pos={gadget.transform.localPosition} " + + $"solder={gadget.SolderingProgressUnits} tracked={fullState?.States?.Count ?? 0}"); + } + + private static void CaptureMounts(CustomizationStateData state, NetworkedItem ownerItem, GadgetBase owner) + { + var mounts = owner.GetComponents(); + for (int index = 0; index < mounts.Length; index++) + { + if (mounts[index].MountedGadget != null && TryItemId(mounts[index].MountedGadget, out var mountedId)) + AddRelationship(state, CustomizationAction.Mount, ownerItem.NetId, mountedId, index); + } + } + + private static void CaptureSnappedItems(CustomizationStateData state, NetworkedItem ownerItem, GadgetBase owner) + { + var snapPoints = GetSnapPoints(owner); + for (int index = 0; index < snapPoints.Length; index++) + { + var snappedItem = snapPoints[index].SnappedItem; + if (snappedItem == null || !NetworkedItem.TryGetNetId(snappedItem, out var snappedId)) + continue; + + if (NetworkedItem.TryGet(snappedId, out var networkedSnappedItem)) + AddRelatedItem(state, networkedSnappedItem, forceDropped: true); + + bool hasAnchorPosition = false; + Vector3 anchorPosition = Vector3.zero; + if (snappedItem.SnappableItem != null) + { + var anchor = snappedItem.SnappableItem.GetAnchor(snapPoints[index].SnapPointType); + if (anchor != null) + { + hasAnchorPosition = true; + anchorPosition = anchor.localPosition; + } + } + + AddRelationship(state, CustomizationAction.SnapItem, ownerItem.NetId, snappedId, index, + hasPosition: hasAnchorPosition, position: anchorPosition); + Multiplayer.LogDebug(() => $"Customization snapshot snap owner={ownerItem.NetId} item={snappedId} " + + $"point={snapPoints[index].GetType().Name}:{snapPoints[index].SnapPointType} " + + $"anchor={hasAnchorPosition} offset={anchorPosition}"); + } + } + + private static void CaptureWires(CustomizationStateData state, NetworkedItem ownerItem, GadgetBase owner) + { + for (int portIndex = 0; portIndex < owner.WireLinkPorts.Count; portIndex++) + { + var links = new List(); + owner.WireLinkPorts[portIndex].GetLinks(links); + + foreach (var link in links) + { + if (!TryItemId(link.owner, out var linkedId) || ownerItem.NetId >= linkedId) + continue; + + int linkedPortIndex = link.owner.WireLinkPorts.ToList().IndexOf(link); + if (linkedPortIndex >= 0) + AddRelationship(state, CustomizationAction.Wire, ownerItem.NetId, linkedId, portIndex, linkedPortIndex); + } + } + } + + private static void CaptureFreeHoles(CustomizationStateData state) + { + var customizations = Resources.FindObjectsOfTypeAll() + .Where(customization => customization != null && customization.gameObject.scene.IsValid()); + + foreach (var customization in customizations) + { + if (!CustomizationRef.TryCreate(customization, out var target)) + continue; + + foreach (var hole in customization.Holes) + { + state.Holes.Add(new CustomizationHoleData + { + Target = target, + LocalPosition = hole.transform.localPosition, + LocalNormal = hole.transform.localRotation * Vector3.forward, + }); + } + } + } + + private static void CreateRelatedItems(IEnumerable relatedItems) + { + foreach (var relatedItem in relatedItems) + { + if (!NetworkedItem.TryGet(relatedItem.ItemNetId, out _)) + NetworkedItemManager.Instance.CreateCustomizationItem(relatedItem); + } + } + + private static void CreateGadgetItems(IEnumerable placements) + { + foreach (var placement in placements) + GetOrCreateGadgetItem(placement); + } + + private static bool AreSnapshotItemsReady(CustomizationStateData state, out string waitingFor) + { + foreach (var relatedItem in state.RelatedItems) + { + if (!NetworkedItem.TryGet(relatedItem.ItemNetId, out var item) || !item.IsReadyForSnapshots) + { + waitingFor = $"related item {relatedItem.ItemNetId}"; + return false; + } + } + + foreach (var placement in state.Gadgets) + { + if (!NetworkedItem.TryGet(placement.ItemNetId, out var item) || !item.IsReadyForSnapshots) + { + waitingFor = $"gadget item {placement.ItemNetId}"; + return false; + } + + var gadgetItem = item.GetTrackedItem(); + if (gadgetItem?.Gadget == null || gadgetItem.Item == null) + { + waitingFor = $"GadgetItem {placement.ItemNetId} Start"; + return false; + } + } + + waitingFor = null; + return true; + } + + private static bool AreSnapRelationshipsReady(IEnumerable relationships, out string waitingFor) + { + foreach (var relationship in relationships) + { + if ((CustomizationAction)relationship.Action != CustomizationAction.SnapItem) + continue; + + if (!TryGadget(relationship.ItemA, out var owner)) + { + waitingFor = $"snap-point owner {relationship.ItemA} is initialized"; + return false; + } + + var snapPoints = GetSnapPoints(owner); + if (!IsValidIndex(relationship.IndexA, snapPoints.Length)) + { + waitingFor = $"snap point {relationship.IndexA} on gadget {relationship.ItemA} is initialized"; + return false; + } + + if (!NetworkedItem.TryGet(relationship.ItemB, out var snappedItem) || + snappedItem.Item?.SnappableItem == null) + { + waitingFor = $"snappable item {relationship.ItemB} is initialized"; + return false; + } + + var snapPoint = snapPoints[relationship.IndexA]; + var snappable = snappedItem.Item.SnappableItem; + if (((int)snappable.AllowedSnapPointTypes & (int)snapPoint.SnapPointType) == 0 || + snappable.GetAnchor(snapPoint.SnapPointType) == null) + { + waitingFor = $"snap anchor on item {relationship.ItemB} is initialized"; + return false; + } + } + + waitingFor = null; + return true; + } + + private static void PlaceGadgets(IEnumerable placements) + { + foreach (var placement in placements) + { + if (!NetworkedItem.TryGet(placement.ItemNetId, out var item)) + continue; + + var gadgetItem = item?.GetTrackedItem(); + if (gadgetItem == null || !CustomizationRef.TryResolve(placement.Target, out var target)) + continue; + + var gadget = GadgetItem.Place( + target, + placement.LocalPosition, + placement.LocalRotation, + gadgetItem, + colliderForPlacementData: null); + + if (gadget != null) + gadget.IsOnGlass = placement.IsOnGlass; + } + } + + private static NetworkedItem GetOrCreateGadgetItem(GadgetPlacementData placement) + { + if (NetworkedItem.TryGet(placement.ItemNetId, out var item)) + return item; + + NetworkedItemManager.Instance.CreateCustomizationItem(new ItemUpdateData + { + UpdateType = ItemUpdateData.ItemUpdateType.Create, + ItemNetId = placement.ItemNetId, + PrefabName = placement.PrefabName, + ItemState = ItemState.Dropped, + ItemPosition = Vector3.zero, + ItemRotation = Quaternion.identity, + }); + + NetworkedItem.TryGet(placement.ItemNetId, out item); + return item; + } + + private static void ApplyRelationships(IEnumerable relationships) + { + foreach (var relationship in relationships) + { + ApplyAction(new CommonCustomizationPacket + { + Action = (CustomizationAction)relationship.Action, + ItemNetId = relationship.ItemA, + OtherItemNetId = relationship.ItemB, + IndexA = relationship.IndexA, + IndexB = relationship.IndexB, + Flag = relationship.HasPosition, + Position = relationship.Position, + }); + } + } + + private static void ApplyFreeHoles(IEnumerable holes) + { + foreach (var hole in holes) + { + if (CustomizationRef.TryResolve(hole.Target, out var target)) + target.AddHole(hole.LocalPosition, hole.LocalNormal); + } + } + + private static void ApplyTrackedValues(IEnumerable placements) + { + foreach (var placement in placements) + { + if (!NetworkedItem.TryGet(placement.ItemNetId, out var item)) + continue; + + item.ReceiveSnapshot(new ItemUpdateData + { + UpdateType = ItemUpdateData.ItemUpdateType.ObjectState, + ItemNetId = placement.ItemNetId, + States = placement.TrackedValues, + }); + } + } + + private static void ApplyPlacement(CommonCustomizationPacket packet) + { + if (!NetworkedItem.TryGet(packet.ItemNetId, out var item) || + item.GetTrackedItem() is not GadgetItem gadgetItem || + !CustomizationRef.TryResolve(TargetOf(packet), out var target)) + return; + + var gadget = GadgetItem.Place(target, packet.Position, packet.Rotation, gadgetItem, colliderForPlacementData: null); + if (gadget != null) + gadget.IsOnGlass = packet.Flag; + } + + private static void ApplyRemoval(CommonCustomizationPacket packet) + { + if (!NetworkedItem.TryGet(packet.ItemNetId, out var item)) + return; + + var removedItem = item.GetTrackedItem()?.Gadget?.Remove(packet.Flag); + if (removedItem != null) + { + if (packet.Flag) + { + // Gadget remover: native removal reparents the item to the train car, + // where it must remain visible until somebody actually picks it up. + item.gameObject.SetActive(true); + item.MarkAsSynchronized(); + } + else + { + // Direct hand removal is relayed before its resulting ownership + // update. Conceal the remote copy until InHand/InInventory arrives. + item.gameObject.SetActive(false); + } + } + } + + private static void ApplyHoleAction(CommonCustomizationPacket packet) + { + if (!CustomizationRef.TryResolve(TargetOf(packet), out var target)) + return; + + switch (packet.Action) + { + case CustomizationAction.AddHole: + target.AddHole(packet.Position, packet.Normal); + break; + case CustomizationAction.MoveHole: + if (target.FindHole(packet.PreviousPosition, out var movedHole)) + target.MoveHole(movedHole, packet.Position, packet.Normal); + break; + case CustomizationAction.RemoveHole: + if (target.FindHole(packet.PreviousPosition, out var removedHole)) + target.RemoveHole(removedHole); + break; + } + } + + private static void ApplyMountAction(CommonCustomizationPacket packet) + { + if (!TryGadget(packet.ItemNetId, out var owner)) + return; + + var mounts = owner.GetComponents(); + if (!IsValidIndex(packet.IndexA, mounts.Length)) + return; + + if (packet.Action == CustomizationAction.Unmount) + { + mounts[packet.IndexA].UnmountGadget(); + return; + } + + if (TryGadget(packet.OtherItemNetId, out var mounted)) + mounts[packet.IndexA].MountGadget(mounted); + } + + private static void ApplyWireAction(CommonCustomizationPacket packet) + { + if (!TryGadget(packet.ItemNetId, out var first) || + !TryGadget(packet.OtherItemNetId, out var second) || + !IsValidIndex(packet.IndexA, first.WireLinkPorts.Count) || + !IsValidIndex(packet.IndexB, second.WireLinkPorts.Count)) + return; + + var firstPort = first.WireLinkPorts[packet.IndexA]; + var secondPort = second.WireLinkPorts[packet.IndexB]; + if (packet.Action == CustomizationAction.Wire) + GadgetWiringModule.WireLinkPort.Wire(firstPort, secondPort); + else + GadgetWiringModule.WireLinkPort.Unwire(firstPort, secondPort); + } + + private static void ApplySpoolAction(CommonCustomizationPacket packet) + { + if (!NetworkedItem.TryGet(packet.ItemNetId, out var toolItem)) + return; + + var tool = toolItem.GetTrackedItem(); + if (packet.Action == CustomizationAction.ReplaceSpool) + { + ApplySpoolReplacement(tool, packet); + return; + } + + if (packet.Action == CustomizationAction.DropEmptySpool) + { + DropEmptySpool(tool, packet); + return; + } + + if (tool?.magazine != null && NetworkedItem.TryGet(packet.OtherItemNetId, out var spoolItem)) + { + if (tool.magazine.AddItem(spoolItem.gameObject, 0)) + FinalizeContainedSpool(spoolItem); + else + Multiplayer.LogWarning($"Could not load reel {spoolItem.NetId} into soldering tool {toolItem.NetId}"); + } + } + + private static void ApplySpoolReplacement(GadgetSolderingTool tool, CommonCustomizationPacket packet) + { + if (tool?.magazine == null) + return; + + // The base game changes the physical full reel into its empty reel model on + // the first soldering tick, while the wire resource remains available in + // remainingUnits. Magazine.AddItem sees isSpent and temporarily sets -1, so + // preserve the actual resource count across the model replacement. + int remainingUnits = tool.remainingUnits; + + var magazineItems = tool.magazine.items; + var current = magazineItems != null && magazineItems.Length > 0 ? magazineItems[0] : null; + var currentAmmo = current?.GetComponent(); + GameObject spentObject = currentAmmo != null && currentAmmo.isSpent ? current : null; + + if (spentObject == null) + { + if (current != null) + { + if (NetworkedItem.TryGetNetworkedItem(current.GetComponent(), out var oldSpool)) + oldSpool.SuppressDestroySync(); + try + { + tool.ignoreMagazineDataChange = true; + tool.magazine.RemoveItem(0, true, true); + Inventory.Instance.DestroyItem(current); + } + finally + { + tool.ignoreMagazineDataChange = false; + } + } + + spentObject = Object.Instantiate( + tool.emptyCoilItemPrefab, + tool.reelInteractionPoint.transform.position, + tool.reelInteractionPoint.transform.rotation); + tool.UpdateEmptySpoolItemParams(spentObject); + tool.magazine.AddItem(spentObject, 0); + } + + var spentItemBase = spentObject.GetComponent(); + if (!NetworkedItem.TryGetNetworkedItem(spentItemBase, out var spentItem)) + return; + + if (packet.OtherItemNetId == 0 && NetworkLifecycle.Instance.IsHost()) + packet.OtherItemNetId = spentItem.NetId; + else if (packet.OtherItemNetId != 0) + spentItem.NetId = packet.OtherItemNetId; + + spentItem.MarkAsSynchronized(); + NetworkedItemManager.Instance.MarkKnownToCurrentPlayers(spentItem); + tool.currentSpool = spentObject.GetComponent(); + tool.remainingUnits = remainingUnits; + tool.OnUnitsChanged(); + } + + private static void DropEmptySpool(GadgetSolderingTool tool, CommonCustomizationPacket packet) + { + var magazineItems = tool?.magazine?.items; + var spoolObject = magazineItems != null && magazineItems.Length > 0 ? magazineItems[0] : null; + var spoolAmmo = spoolObject?.GetComponent(); + NetworkedItem spoolItem = null; + if (packet.OtherItemNetId != 0) + NetworkedItem.TryGet(packet.OtherItemNetId, out spoolItem); + if (spoolObject != null) + NetworkedItem.TryGetNetworkedItem(spoolObject.GetComponent(), out spoolItem); + + if (tool == null || spoolAmmo == null || !spoolAmmo.isSpent) + { + Multiplayer.LogWarning($"Cannot apply empty-spool ejection: tool={tool != null}, spool={spoolObject != null}, spent={spoolAmmo?.isSpent}"); + return; + } + + tool.DropEmptySpool(); + if (tool.HasEjectableSpool) + { + Multiplayer.LogWarning($"Empty-spool ejection did not remove reel {spoolItem?.NetId ?? 0} from the soldering tool"); + return; + } + + spoolObject.SetActive(true); + if (packet.Flag) + { + spoolObject.transform.SetPositionAndRotation( + packet.Position + WorldMover.currentMove, + packet.Rotation); + } + var storage = StorageController.Instance; + var spoolItemBase = spoolObject.GetComponent(); + if (storage != null && spoolItemBase.BelongsToPlayer() && !storage.AnyStorageContains(spoolItemBase)) + storage.AddItemToWorldStorage(spoolItemBase); + spoolItem?.MarkAsSynchronized(); + NetworkedItemManager.Instance.MarkKnownToCurrentPlayers(spoolItem); + } + + private static void FinalizeContainedSpool(NetworkedItem spoolItem) + { + spoolItem.MarkAsSynchronized(); + NetworkedItemManager.Instance.MarkKnownToCurrentPlayers(spoolItem); + } + + private static void ApplyDuctTapeReplacement(CommonCustomizationPacket packet) + { + NetworkedItem.TryGet(packet.ItemNetId, out var oldItem); + var oldTape = oldItem?.GetTrackedItem(); + Vector3 position = packet.Flag ? packet.Position + WorldMover.currentMove : oldItem?.transform.position ?? Vector3.zero; + Quaternion rotation = packet.Flag ? packet.Rotation : oldItem?.transform.rotation ?? Quaternion.identity; + + // The originating client already has the native replacement, but it still + // has NetId 0 until the server echoes the authoritative replacement ID. + var replacementItem = NetworkedItem.GetAll().FirstOrDefault(candidate => + candidate != null && candidate.NetId == 0 && + candidate.GetComponent() != null && + Vector3.SqrMagnitude(candidate.transform.position - position) < 4f); + bool existingLocalReplacement = replacementItem != null; + + if (replacementItem == null) + { + if (oldTape?.emptyTapeItemPrefab == null) + { + Multiplayer.LogWarning($"Cannot replace consumed duct tape {packet.ItemNetId}: empty-tape prefab unavailable"); + return; + } + + var replacementObject = Object.Instantiate(oldTape.emptyTapeItemPrefab, position, rotation); + var itemBase = replacementObject.GetComponent(); + if (!NetworkedItem.TryGetNetworkedItem(itemBase, out replacementItem)) + replacementItem = replacementObject.AddComponent(); + replacementObject.GetComponent().ignoreDistanceFromSpawnPosition = true; + } + + if (oldItem != null) + { + oldItem.SuppressDestroySync(); + oldItem.gameObject.SetActive(false); + Inventory.Instance.DestroyItem(oldItem.gameObject); + } + + if (packet.OtherItemNetId == 0 && NetworkLifecycle.Instance.IsHost()) + packet.OtherItemNetId = replacementItem.NetId; + else if (packet.OtherItemNetId != 0) + replacementItem.NetId = packet.OtherItemNetId; + + if (!existingLocalReplacement) + { + replacementItem.transform.SetPositionAndRotation(position, rotation); + // A newly instantiated inactive item never reaches Start(), so finish + // registration now. Otherwise every subsequent drop/hand snapshot is + // queued indefinitely on the host. + replacementItem.FinaliseTrackedValues(); + replacementItem.gameObject.SetActive(false); + } + replacementItem.MarkAsSynchronized(); + NetworkedItemManager.Instance.MarkKnownToCurrentPlayers(replacementItem); + } + + private static void ApplySnapAction(CommonCustomizationPacket packet) + { + if (!TryGadget(packet.ItemNetId, out var owner)) + { + Multiplayer.LogWarning($"Cannot apply {packet.Action}: gadget item {packet.ItemNetId} was not found"); + return; + } + + var snapPoints = GetSnapPoints(owner); + if (!IsValidIndex(packet.IndexA, snapPoints.Length)) + { + Multiplayer.LogWarning($"Cannot apply {packet.Action}: snap point {packet.IndexA} was not found on gadget {packet.ItemNetId} ({snapPoints.Length} available)"); + return; + } + + if (packet.Action == CustomizationAction.UnsnapItem) + { + if (!snapPoints[packet.IndexA].UnsnapItem(forced: true)) + Multiplayer.LogWarning($"Could not unsnap item {packet.OtherItemNetId} from gadget {packet.ItemNetId} point {packet.IndexA}"); + return; + } + + if (!NetworkedItem.TryGet(packet.OtherItemNetId, out var snappedItem)) + { + Multiplayer.LogWarning($"Cannot snap item {packet.OtherItemNetId}: networked item was not found"); + return; + } + + var snapPoint = snapPoints[packet.IndexA]; + bool snapSucceeded; + if (snapPoint is SnapPointGadgetSliding) + { + var anchor = snappedItem.Item.SnappableItem?.GetAnchor(snapPoint.SnapPointType); + if (anchor == null) + { + Multiplayer.LogWarning($"Could not restore sliding snap for item {packet.OtherItemNetId}: anchor {snapPoint.SnapPointType} was not found"); + return; + } + + // The sliding override performs local physics queries even for a forced + // snap. Prefer the authoritative anchor offset. Older/incomplete state + // can derive the same offset from the synchronized snapped item pose. + if (packet.Flag) + { + anchor.localPosition = packet.Position; + } + else + { + var target = snapPoint.snapPointTarget != null ? snapPoint.snapPointTarget : snapPoint.transform; + anchor.localPosition = snappedItem.transform.InverseTransformPoint(target.position); + Multiplayer.LogWarning($"Sliding snap {packet.ItemNetId}:{packet.IndexA} for item {packet.OtherItemNetId} " + + $"had no anchor offset; derived {anchor.localPosition} from synchronized transforms"); + } + + // Keep the rigidbody kinematic while applying an authoritative sliding + // relationship. Native FinalizeSnap also leaves snapped items kinematic. + snapSucceeded = global::Multiplayer.Patches.World.GadgetRelationshipPatch.SnapItemBaseAuthoritative( + snapPoint, snappedItem.Item, forced: true); + } + else + { + // Match the native save loader for ordinary snap points. + if (snappedItem.Item?.ItemRigidbody != null && snappedItem.Item.ItemRigidbody.isKinematic) + snappedItem.Item.ItemRigidbody.isKinematic = false; + snapSucceeded = snapPoint.SnapItem(snappedItem.Item, forced: true); + } + + if (!snapSucceeded) + { + var snappable = snappedItem.Item?.SnappableItem; + Multiplayer.LogWarning($"Could not snap item {packet.OtherItemNetId} to gadget {packet.ItemNetId} point {packet.IndexA}: " + + $"pointType={snapPoint.SnapPointType}, allowedTypes={snappable?.AllowedSnapPointTypes}, occupied={snapPoint.SnappedItem != null}"); + } + else + { + if (snappedItem.Item?.ItemRigidbody != null) + snappedItem.Item.ItemRigidbody.isKinematic = true; + snappedItem.MarkAsSynchronized(); + Multiplayer.LogDebug(() => $"Snapped item {packet.OtherItemNetId} to gadget {packet.ItemNetId} point {packet.IndexA}"); + } + } + + private static CustomizationRefData TargetOf(CommonCustomizationPacket packet) + { + return new CustomizationRefData + { + Kind = (CustomizationTargetKind)packet.TargetKind, + TrainCarNetId = packet.TargetTrainCarNetId, + }; + } + + private static bool TryItemId(GadgetBase gadget, out ushort id) + { + id = 0; + return gadget?.GadgetItem?.Item != null && NetworkedItem.TryGetNetId(gadget.GadgetItem.Item, out id); + } + + private static bool IsValidIndex(int index, int count) => index >= 0 && index < count; + + private static void AddRelationship( + CustomizationStateData state, + CustomizationAction action, + ushort firstItem, + ushort secondItem, + int firstIndex = 0, + int secondIndex = 0, + bool hasPosition = false, + Vector3 position = default) + { + state.Relationships.Add(new GadgetRelationshipData + { + Action = (byte)action, + ItemA = firstItem, + ItemB = secondItem, + IndexA = firstIndex, + IndexB = secondIndex, + HasPosition = hasPosition, + Position = position, + }); + } + + private static void AddRelatedItem(CustomizationStateData state, NetworkedItem item, bool forceDropped) + { + if (item == null || state.RelatedItems.Any(existing => existing.ItemNetId == item.NetId)) + return; + + var snapshot = item.CreateCurrentUpdateData(ItemUpdateData.ItemUpdateType.Create); + if (snapshot == null) + return; + + if (forceDropped) + { + snapshot.ItemState = ItemState.Dropped; + snapshot.ItemPosition = item.transform.position - WorldMover.currentMove; + snapshot.ItemRotation = item.transform.rotation; + } + + state.RelatedItems.Add(snapshot); + } + + private static void LogSnapshot(string operation, CustomizationStateData state) + { + Multiplayer.LogDebug(() => $"Customization snapshot {operation} related={state.RelatedItems.Count} " + + $"gadgets={state.Gadgets.Count} relationships={state.Relationships.Count} holes={state.Holes.Count}"); + } +} diff --git a/Multiplayer/Components/Networking/World/CustomizationSyncScope.cs b/Multiplayer/Components/Networking/World/CustomizationSyncScope.cs new file mode 100644 index 00000000..413f1b05 --- /dev/null +++ b/Multiplayer/Components/Networking/World/CustomizationSyncScope.cs @@ -0,0 +1,31 @@ +using System; + +namespace Multiplayer.Components.Networking.World; + +public static class CustomizationSyncScope +{ + [ThreadStatic] + private static int remoteDepth; + + public static bool IsApplyingRemote => remoteDepth > 0; + + public static IDisposable Remote() + { + remoteDepth++; + return new RemoteScope(); + } + + private sealed class RemoteScope : IDisposable + { + private bool disposed; + + public void Dispose() + { + if (disposed) + return; + + disposed = true; + remoteDepth = Math.Max(0, remoteDepth - 1); + } + } +} diff --git a/Multiplayer/Components/Networking/World/GadgetTrackedValueRegistry.cs b/Multiplayer/Components/Networking/World/GadgetTrackedValueRegistry.cs new file mode 100644 index 00000000..566c169d --- /dev/null +++ b/Multiplayer/Components/Networking/World/GadgetTrackedValueRegistry.cs @@ -0,0 +1,264 @@ +using DV.Customization; +using DV.Customization.Gadgets; +using DV.Customization.Gadgets.Implementations; +using System; +using UnityEngine; + +namespace Multiplayer.Components.Networking.World; + +public static class GadgetTrackedValueRegistry +{ + public static void Register(NetworkedItem item, GadgetBase gadget) + { + if (gadget == null) + return; + + RegisterSoldering(item, gadget); + RegisterDrillable(item, gadget); + RegisterText(item, gadget); + RegisterConcreteGadget(item, gadget); + } + + private static void RegisterSoldering(NetworkedItem item, GadgetBase gadget) + { + if (!gadget.IsSolderable) + return; + + item.RegisterTrackedValue( + "solder.units", + () => gadget.SolderingProgressUnits, + units => ApplySolderingUnits(gadget, units)); + item.RegisterTrackedValue( + "solder.complete", + () => gadget.IsSoldered, + complete => ApplySolderingCompletion(gadget, complete)); + } + + private static void ApplySolderingUnits(GadgetBase gadget, int units) + { + int target = Clamp(units, 0, 65536); + int delta = target - gadget.SolderingProgressUnits; + if (delta > 0) + gadget.MakeSoldered(delta); + else if (delta < 0) + gadget.RemoveSoldering(-delta); + } + + private static void ApplySolderingCompletion(GadgetBase gadget, bool complete) + { + if (complete && !gadget.IsSoldered) + gadget.MakeSoldered(); + else if (!complete && gadget.IsSoldered) + gadget.RemoveSoldering(); + } + + private static void RegisterDrillable(NetworkedItem item, GadgetBase gadget) + { + var drillable = gadget.GetComponent(); + if (drillable == null) + return; + + for (int pointIndex = 0; pointIndex < drillable.MountPointCount; pointIndex++) + RegisterMountPoint(item, drillable, pointIndex); + } + + private static void RegisterMountPoint( + NetworkedItem item, + Drillable drillable, + int pointIndex) + { + string keyPrefix = $"drill.{pointIndex}"; + item.RegisterTrackedValue( + $"{keyPrefix}.state", + () => (int)drillable.GetMountPointState(pointIndex), + state => drillable.SetMountPointState(pointIndex, (MountPoint.States)state)); + item.RegisterTrackedValue( + $"{keyPrefix}.glass", + () => drillable.GetMountPoint(pointIndex).IsOnGlass, + isOnGlass => drillable.GetMountPoint(pointIndex).IsOnGlass = isOnGlass); + } + + private static void RegisterText(NetworkedItem item, GadgetBase gadget) + { + var text = gadget.GetComponent(); + if (text == null) + return; + + item.RegisterTrackedValue( + "text.value", + () => text.textMesh.text, + value => SetText(text, value)); + } + + private static void SetText(TextGadget text, string value) + { + text.textMesh.text = value ?? string.Empty; + text.UpdateItemText(); + } + + private static void RegisterConcreteGadget(NetworkedItem item, GadgetBase gadget) + { + if (gadget is GadgetProximityScreen proximity) + { + item.RegisterTrackedValue("proximity.channel", () => proximity.CurrentChannel, + value => SetAndRefresh(proximity, () => proximity.CurrentChannel = value)); + item.RegisterTrackedValue("proximity.mode", () => proximity.CurrentMode, + value => SetAndRefresh(proximity, () => proximity.CurrentMode = value)); + return; + } + + if (gadget is GadgetRoadrunner roadrunner) + { + item.RegisterTrackedValue( + "roadrunner.target", + () => roadrunner.LengthMeters, + value => roadrunner.LengthMeters = Clamp(value, 0, roadrunner.MaxLength)); + item.RegisterTrackedValue("roadrunner.countup", () => roadrunner.Countup, value => roadrunner.countup = value); + return; + } + + if (gadget is AlternatingController alternating) + { + // AlternatingController inherits GadgetSwitch; its selected interval is the canonical state. + item.RegisterTrackedValue( + "alternating.interval", + () => alternating.SelectedInterval, + value => SetAndRefresh(alternating, () => alternating.SelectedInterval = value)); + return; + } + + if (gadget is GadgetSwitch gadgetSwitch) + { + item.RegisterTrackedValue("switch.output", () => gadgetSwitch.RawOutputValue, + value => SetAndRefresh(gadgetSwitch, () => gadgetSwitch.SetOutputValue(value))); + return; + } + + if (gadget is GadgetATS ats) + { + item.RegisterTrackedValue("ats.regime", () => ats.Regime, + value => SetAndRefresh(ats, () => ats.SetRegime(value))); + return; + } + + if (gadget is GadgetSwitchSetter switchSetter) + { + item.RegisterTrackedValue("switchSetter.mode", () => switchSetter.Mode, + value => SetAndRefresh(switchSetter, () => switchSetter.Mode = value)); + item.RegisterTrackedValue( + "switchSetter.sideCorrection", + () => switchSetter.SideCorrectRegime, + value => SetAndRefresh(switchSetter, () => switchSetter.SideCorrectRegime = value)); + item.RegisterTrackedValue( + "switchSetter.direction", + () => switchSetter.DirectionMode, + value => SetAndRefresh(switchSetter, () => switchSetter.DirectionMode = value)); + return; + } + + if (gadget is GadgetOverheatProtection overheat) + { + item.RegisterTrackedValue("overheat.mode", () => overheat.ModeIndex, + value => SetAndRefresh(overheat, () => overheat.ModeIndex = value)); + item.RegisterTrackedValue("overheat.cutEngine", () => overheat.cutEngine, + value => SetAndRefresh(overheat, () => overheat.cutEngine = value)); + return; + } + + if (gadget is GadgetDPU dpu) + { + item.RegisterTrackedValue("dpu.regime", () => (int)dpu.Regime, + value => SetAndRefresh(dpu, () => dpu.Regime = (GadgetDPU.WirelessMode)value)); + item.RegisterTrackedValue("dpu.reverse", () => dpu.ReverseOrientation, + value => SetAndRefresh(dpu, () => dpu.ReverseOrientation = value)); + item.RegisterTrackedValue("dpu.channel", () => dpu.Channel, + value => SetAndRefresh(dpu, () => dpu.Channel = value)); + return; + } + + if (gadget is GadgetControlPanel controlPanel) + { + item.RegisterTrackedValue("controlPanel.tilt", () => controlPanel.tilt, + value => SetAndRefresh(controlPanel, () => controlPanel.tilt = value)); + return; + } + + if (gadget is GadgetAmpLimiter amp) + { + item.RegisterTrackedValue("ampLimiter.mode", () => amp.ModeIndex, + value => SetAndRefresh(amp, () => amp.ModeIndex = value)); + return; + } + } + + private static void SetAndRefresh(GadgetBase gadget, Action setter) + { + setter(); + RefreshLoadedLods(gadget); + } + + private static void RefreshLoadedLods(GadgetBase gadget) + { + if (gadget == null || !gadget.IsLODLoaded || gadget.LODObjects == null) + return; + + foreach (CustomizerLODObject lod in gadget.LODObjects) + { + switch (lod) + { + case AlternatingControllerLOD alternatingLod: + if (alternatingLod.modeSwitchController != null) + alternatingLod.SyncControls(); + alternatingLod.UpdateLamp(); + break; + case GadgetSwitchLOD switchLod when gadget is GadgetSwitch gadgetSwitch: + if (switchLod.controlKnobControl != null) + switchLod.SyncControls(); + switchLod.UpdateIndicatorLight(gadgetSwitch); + break; + case GadgetAmpLimiterLOD ampLod: + if (ampLod.limitKnobControl != null) + ampLod.SyncControls(); + ampLod.OnStateUpdated(); + break; + case GadgetSwitchSetterLOD switchSetterLod: + if (switchSetterLod.knbRangeControl != null && switchSetterLod.knbModeControl != null && + switchSetterLod.knbSideControl != null) + switchSetterLod.SyncControls(); + break; + case GadgetOverheatProtectionLOD overheatLod: + if (overheatLod.switchLimitControl != null && overheatLod.switchMethodControl != null) + overheatLod.SyncControls(); + overheatLod.UpdateLamp(); + break; + case GadgetDPULOD dpuLod: + if (dpuLod.powerSwitchControl != null && dpuLod.orientationSwitchControl != null && + dpuLod.channelSwitchControl != null) + dpuLod.SyncControls(); + break; + case GadgetATSLOD atsLod: + if (atsLod.regimeSelectorControl != null) + atsLod.UpdateDisplay(); + break; + case GadgetProximityScreenLOD proximityLod when gadget is GadgetProximityScreen proximity: + proximityLod.channelSwitchControl?.SetValue(proximity.CurrentChannel / 8f, default); + proximityLod.settingSwitchControl?.SetValue(proximity.CurrentMode, default); + if (proximityLod.channelSwitchControl != null && proximityLod.settingSwitchControl != null) + proximityLod.FindSensor(null); + break; + case GadgetControlPanelLODObject panelLod when gadget is GadgetControlPanel panel: + if (panelLod.tiltControl != null && panelLod.panelDegreesPerKnobRound > 0f) + { + panelLod.tiltControl.SetValue( + Mathf.Repeat(panel.tilt, panelLod.panelDegreesPerKnobRound) / panelLod.panelDegreesPerKnobRound, + default); + } + panelLod.smoothedTilt = panel.tilt; + panelLod.panel.localRotation = Quaternion.AngleAxis(panel.tilt, panelLod.tiltAxis); + break; + } + } + } + + private static int Clamp(int value, int minimum, int maximum) => Math.Max(minimum, Math.Min(value, maximum)); +} diff --git a/Multiplayer/Components/Networking/World/NetworkedItem.cs b/Multiplayer/Components/Networking/World/NetworkedItem.cs index 140dcd68..912c8fa8 100644 --- a/Multiplayer/Components/Networking/World/NetworkedItem.cs +++ b/Multiplayer/Components/Networking/World/NetworkedItem.cs @@ -2,6 +2,7 @@ using DV.Interaction; using DV.InventorySystem; using DV.Items; +using DV.Customization.Gadgets; using Multiplayer.Components.Networking.Train; using Multiplayer.Networking.Data; using Multiplayer.Networking.Data.Items; @@ -21,7 +22,8 @@ public enum ItemState : byte Thrown, //was thrown by player InHand, //held by player InInventory, //in player's inventory - Attached //attached to another object (e.g. EOT Lanterns) + Attached, //attached to another object (e.g. EOT Lanterns) + InstalledGadget //backing item is hidden while its Gadget is linked to a customization target } public class NetworkedItem : IdMonoBehaviour @@ -76,11 +78,17 @@ public static bool TryGetNetId(ItemBase item, out ushort netID) public ItemBase Item { get; private set; } private GrabHandlerItem grabHandler; private SnappableItem snappableItem; + private ItemReparentingBase itemReparenting; private Component trackedItem; private List trackedValues = new List(); public bool UsefulItem { get; private set; } = false; public Type TrackedItemType { get; private set; } public uint LastDirtyTick { get; private set; } + public bool IsReadyForSnapshots => registrationComplete && Item != null; + public Vector3 WorldPosition => GetItemState() == ItemState.InstalledGadget && + trackedItem is GadgetItem gadgetItem && gadgetItem.Gadget != null + ? gadgetItem.Gadget.transform.position + : transform.position; private bool initialised; private bool registrationComplete = false; private Queue pendingSnapshots = new Queue(); @@ -88,8 +96,11 @@ public static bool TryGetNetId(ItemBase item, out ushort netID) //Track dirty states private bool createdDirty = true; //if set, we created this item dirty and have not sent an update private ItemState lastState; + private ItemState observedState; + private bool hasObservedState; private bool stateDirty; private bool wasThrown; + private bool suppressDestroySync; private Vector3 thrownPosition; private Quaternion thrownRotation; @@ -132,7 +143,7 @@ protected void Start() // Mark registration as complete for items that don't need tracked values if (!registrationComplete && !UsefulItem) - registrationComplete = true; + FinaliseTrackedValues(); } public T GetTrackedItem() where T : Component @@ -181,6 +192,7 @@ private bool Register() //Find special interaction components TryGetComponent(out grabHandler); TryGetComponent(out snappableItem); + TryGetComponent(out itemReparenting); lastState = GetItemState(); stateDirty = false; @@ -262,7 +274,8 @@ private Dictionary GetDirtyStateData() var dirtyData = new Dictionary(); foreach (var trackedValue in trackedValues) { - if (((dynamic)trackedValue).IsDirty) + if (((dynamic)trackedValue).IsDirty && + (NetworkLifecycle.Instance.IsHost() || !((dynamic)trackedValue).ServerAuthoritative)) { dirtyData[((dynamic)trackedValue).Key] = ((dynamic)trackedValue).GetValueAsObject(); } @@ -374,6 +387,11 @@ private void ApplySnapshot(ItemUpdateData snapshot) HandleAttachedState(snapshot); break; + case ItemState.InstalledGadget: + gameObject.SetActive(false); + OwnerId = 0; + break; + default: throw new Exception($"NetworkedItem.ApplySnapshot() Item state not implemented: {snapshot?.ItemState}"); @@ -461,40 +479,74 @@ public ItemUpdateData CreateUpdateData(ItemUpdateData.ItemUpdateType updateType) States = states, }; + // A Create assembled outside GetSnapshot (initial/full player sync and the + // per-player nearby path) is still the item's initial create. Consume the + // flag here so its next real state change is not sent as another Create. + if (updateType.HasFlag(ItemUpdateData.ItemUpdateType.Create)) + createdDirty = false; + return updateData; } - private ItemState GetItemState() + public void SuppressDestroySync() => suppressDestroySync = true; + + public void MarkAsSynchronized() + { + createdDirty = false; + stateDirty = false; + lastState = GetItemState(); + MarkValuesClean(); + } + + public ItemUpdateData CreateCurrentUpdateData(ItemUpdateData.ItemUpdateType updateType) { - //Multiplayer.LogDebug(() => $"GetItemState() NetId: {NetId}, {name}, Parent: {Item.transform.parent} WorldMover: {WorldMover.OriginShiftParent}, wasThrown: {wasThrown}, isGrabbed: {Item.IsGrabbed()} Inventory.Contains(): {Inventory.Instance.Contains(this.gameObject, false)} Storage.Contains: {StorageController.Instance.StorageInventory.ContainsItem(Item)}"); + lastState = GetItemState(); + return CreateUpdateData(updateType); + } + private ItemState GetItemState() + { + if (trackedItem is GadgetItem gadgetItem && gadgetItem.Gadget != null && gadgetItem.Gadget.IsLinked) + return ObserveState(ItemState.InstalledGadget); if (Item.transform.parent == WorldMover.OriginShiftParent && !wasThrown) - { - Multiplayer.LogDebug(() => $"GetItemState() NetId: {NetId}, {name}, Parent: {Item.transform.parent} WorldMover: {WorldMover.OriginShiftParent}, wasThrown: {wasThrown}"); - return ItemState.Dropped; - } + return ObserveState(ItemState.Dropped); if (wasThrown) - { - Multiplayer.LogDebug(() => $"GetItemState() NetId: {NetId}, {name}, Parent: {Item.transform.parent} WorldMover: {WorldMover.OriginShiftParent}, wasThrown: {wasThrown}"); - return ItemState.Thrown; - } + return ObserveState(ItemState.Thrown); if (Item.IsGrabbed()) - return ItemState.InHand; + return ObserveState(ItemState.InHand); if (Inventory.Instance.Contains(this.gameObject, false)) - return ItemState.InInventory; + return ObserveState(ItemState.InInventory); if (snappableItem != null && snappableItem.IsSnapped) + return ObserveState(ItemState.Attached); + + //do we need a condition to check if it's attached to something else (last attach vs current attach)? + return ObserveState(ItemState.Dropped); + + } + + private ItemState ObserveState(ItemState currentState) + { + if (!hasObservedState) { - Multiplayer.LogDebug(() => $"GetItemState() NetId: {NetId}, {name}, snapped! {this.transform.parent}"); - return ItemState.Attached; + observedState = currentState; + hasObservedState = true; + return currentState; } - //do we need a condition to check if it's attached to something else (last attach vs current attach)? - return ItemState.Dropped; + if (observedState != currentState) + { + ItemState previousState = observedState; + observedState = currentState; + Multiplayer.LogDebug(() => $"NetworkedItem state changed NetId: {NetId}, {name}, " + + $"{previousState} -> {currentState}, Parent: {Item.transform.parent}, wasThrown: {wasThrown}"); + } + + return currentState; } @@ -558,6 +610,23 @@ private void HandleDroppedOrThrownState(ItemUpdateData snapshot) transform.rotation = snapshot.ItemRotation; OwnerId = 0; + // Network-created/cached items were removed from every StorageController + // list. ItemMagazine.AddItem expects a dropped item to belong to world + // storage and otherwise can pass a null StorageBase into the base game. + var storage = StorageController.Instance; + if (storage != null && Item.BelongsToPlayer() && !storage.AnyStorageContains(Item)) + storage.AddItemToWorldStorage(Item); + + // Installed gadgets can retain stale rigidbody velocity while their backing + // item is hidden. Reset it on a remote drop so removal does not launch the + // item through the cab when physics resumes. + if (snapshot.ItemState == ItemState.Dropped && Item.ItemRigidbody != null) + { + TrainCar parentCar = Item.GetComponentInParent(); + Item.ItemRigidbody.velocity = parentCar?.rb != null ? parentCar.rb.velocity : Vector3.zero; + Item.ItemRigidbody.angularVelocity = parentCar?.rb != null ? parentCar.rb.angularVelocity : Vector3.zero; + } + //handle throwing of the item if (snapshot.ItemState == ItemState.Thrown) { @@ -618,6 +687,20 @@ private void HandleInventoryOrHandState(ItemUpdateData snapshot) if (NetworkLifecycle.Instance.Server.TryGetServerPlayer(snapshot.Player, out ServerPlayer player) && !player.OwnsItem(NetId)) player.AddOwnedItem(NetId); + // Mirror ItemReparentingBase.OnGrab for a remote pickup. This removes the + // proxy from the train's item activity/LOD container and notifies + // RespawnOnDrop that its spawn parent is no longer the train interior. + if (WorldMover.OriginShiftParent != null && transform.parent != WorldMover.OriginShiftParent) + { + if (itemReparenting != null) + itemReparenting.ParentItemExternal(WorldMover.OriginShiftParent, null); + else + { + TrainPhysicsLod.RemoveItemFromAnyCar(Item); + transform.SetParent(WorldMover.OriginShiftParent, true); + } + } + //todo add to player model's hand this.gameObject.SetActive(false); } @@ -637,9 +720,16 @@ protected override void OnDestroy() if (NetworkLifecycle.Instance.IsHost()) { - var updateData = CreateUpdateData(ItemUpdateData.ItemUpdateType.Destroy); - if (updateData != null) - NetworkedItemManager.Instance.AddDirtyItemSnapshot(this, updateData); + if (suppressDestroySync) + { + NetworkedItemManager.Instance.ForgetItem(this); + } + else + { + var updateData = CreateUpdateData(ItemUpdateData.ItemUpdateType.Destroy); + if (updateData != null) + NetworkedItemManager.Instance.AddDirtyItemSnapshot(this, updateData); + } } if (Item != null) diff --git a/Multiplayer/Components/Networking/World/NetworkedItemManager.cs b/Multiplayer/Components/Networking/World/NetworkedItemManager.cs index 8ac84738..fe88c65a 100644 --- a/Multiplayer/Components/Networking/World/NetworkedItemManager.cs +++ b/Multiplayer/Components/Networking/World/NetworkedItemManager.cs @@ -9,12 +9,16 @@ using Multiplayer.Utils; using DV; using DV.Interaction; +using DV.InventorySystem; using Multiplayer.Networking.Data.Items; +using Multiplayer.Networking.Packets.Clientbound; +using Multiplayer.Networking.Packets.Serverbound; namespace Multiplayer.Components.Networking.World; public class NetworkedItemManager : SingletonBehaviour { + private const int MAX_RECONCILED_ITEMS = 128; /* * Server */ @@ -28,6 +32,7 @@ public class NetworkedItemManager : SingletonBehaviour //caches for item snapshots private List DestroyedItems = new(64); + private Dictionary AcceptedClientUpdates = new(64); //Item ownership //private Dictionary playerInventories = new Dictionary(); @@ -42,6 +47,7 @@ public class NetworkedItemManager : SingletonBehaviour private Dictionary> CachedItems = new(1024); //Client cached items private Dictionary ItemPrefabs = new(1024); //Item prefabs private bool ClientInitialised = false; + private readonly Dictionary pendingItemReconciliation = []; /* @@ -92,6 +98,12 @@ public void AddDirtyItemSnapshot(NetworkedItem netItem, ItemUpdateData snapshot) { DestroyedItems.Add(snapshot); + ForgetItem(netItem); + } + + public void ForgetItem(NetworkedItem netItem) + { + foreach(var player in NetworkLifecycle.Instance.Server.ServerPlayers) { if(player.KnownItems.ContainsKey(netItem)) @@ -187,7 +199,7 @@ private void UpdatePlayerItemLists() continue; } - float sqrDistance = (player.WorldPosition - item.transform.position).sqrMagnitude; + float sqrDistance = (player.WorldPosition - item.WorldPosition).sqrMagnitude; if (sqrDistance <= MAX_DISTANCE_TO_ITEM_SQR) { @@ -217,11 +229,25 @@ private void ProcessChanged(uint tick) foreach (var item in NetworkedItem.GetAll()) { + if (item == null || item.NetId == 0) + continue; + ItemUpdateData snapshot = item.GetSnapshot(); if (snapshot != null) dirtyItems.Add(snapshot); } + // Applying a client snapshot marks the host's tracked values clean. Preserve the + // accepted wire representation so it can still be forwarded to the other clients. + foreach (var acceptedUpdate in AcceptedClientUpdates.Values) + { + int existingIndex = dirtyItems.FindIndex(item => item.ItemNetId == acceptedUpdate.ItemNetId); + if (existingIndex >= 0) + dirtyItems[existingIndex] = acceptedUpdate; + else + dirtyItems.Add(acceptedUpdate); + } + //NetworkLifecycle.Instance.Server.LogDebug(() => $"ProcessChanged({tick}) DirtyItems: {dirtyItems.Count}"); foreach (var player in NetworkLifecycle.Instance.Server.ServerPlayers) @@ -264,6 +290,15 @@ private void ProcessChanged(uint tick) if (dirtyUpdate != null) { + // The originating client already applied its own inventory/hand + // transition locally. Echoing it back would hide the real item + // currently attached to that client's hand. + if (dirtyUpdate.Player != 0 && dirtyUpdate.Player == player.PlayerId) + { + player.KnownItems[nearbyItem] = tick; + continue; + } + Multiplayer.LogDebug(() => $"ProcessChanged({tick}) Update Type: {dirtyUpdate.UpdateType}, Item State: {dirtyUpdate.ItemState}"); playerUpdates.Add(dirtyUpdate); player.KnownItems[nearbyItem] = tick; @@ -283,6 +318,7 @@ private void ProcessChanged(uint tick) } DestroyedItems.Clear(); + AcceptedClientUpdates.Clear(); } private void ProcessReceivedAsHost(ItemUpdateData snapshot, ServerPlayer player) @@ -297,8 +333,12 @@ private void ProcessReceivedAsHost(ItemUpdateData snapshot, ServerPlayer player) { if (ValidatePlayerAction(snapshot, player)) //Ensure the player can do this { + // ItemUpdateData only serializes Player for hand/inventory states. Stamp + // the authenticated sender here instead of trusting the client's value. + snapshot.Player = player.PlayerId; NetworkLifecycle.Instance.Server.LogWarning($"NetworkedItemManager.ProcessReceivedAsHost() ItemNetId: {snapshot.ItemNetId}, snapshot type: {snapshot.UpdateType}"); netItem.ReceiveSnapshot(snapshot); + AcceptedClientUpdates[snapshot.ItemNetId] = snapshot; } else { @@ -342,6 +382,7 @@ private bool ValidatePlayerAction(ItemUpdateData snapshot, ServerPlayer player) case ItemState.Dropped: case ItemState.Thrown: case ItemState.Attached: //needs additional checks for distance to coupler + case ItemState.InstalledGadget: // Only owner can drop/throw if (!player.OwnsItem(snapshot.ItemNetId)) return false; @@ -369,6 +410,9 @@ private void ProcessClientChanges(uint tick) foreach (var item in NetworkedItem.GetAll()) { + if (item == null || item.NetId == 0) + continue; + ItemUpdateData snapshot = item.GetSnapshot(); if (snapshot != null) { @@ -444,6 +488,25 @@ private void CreateItem(ItemUpdateData snapshot) newItem.ReceiveSnapshot(snapshot); } + public void MarkKnownToCurrentPlayers(NetworkedItem item) + { + if (item == null || !NetworkLifecycle.Instance.IsHost()) + return; + + uint tick = NetworkLifecycle.Instance.Tick; + foreach (var player in NetworkLifecycle.Instance.Server.ServerPlayers) + { + if (player.LoadingState >= PlayerLoadingState.ReadyForItems) + player.KnownItems[item] = tick; + } + } + + public void CreateCustomizationItem(ItemUpdateData snapshot) + { + if (!NetworkLifecycle.Instance.IsHost()) + ProcessReceivedAsClient(snapshot); + } + private void BuildPrefabLookup() { NetworkLifecycle.Instance.Client.LogDebug(() => $"BuildPrefabLookup()"); @@ -481,9 +544,161 @@ public void CacheWorldItems() } } + ClientInitialised = false; + } + + internal ServerboundItemReconciliationPacket CreateItemReconciliationRequest() + { + pendingItemReconciliation.Clear(); + var packet = new ServerboundItemReconciliationPacket(); + int localId = 1; + + foreach (var item in NetworkedItem.GetAll()) + { + if (packet.Items.Count >= MAX_RECONCILED_ITEMS) + { + Multiplayer.LogWarning($"Local item reconciliation exceeded the {MAX_RECONCILED_ITEMS}-item limit"); + break; + } + + if (!IsReconciliationCandidate(item)) + continue; + + var snapshot = item.CreateCurrentUpdateData(ItemUpdateData.ItemUpdateType.Create); + if (snapshot == null) + continue; + + snapshot.ItemNetId = 0; + pendingItemReconciliation[localId] = item; + packet.Items.Add(new ItemReconciliationRequest( + localId, + item.Item.InventorySpecs.BelongsToPlayer, + snapshot)); + localId++; + } + + Multiplayer.LogDebug(() => $"Prepared {packet.Items.Count} local items for authoritative reconciliation"); + return packet; + } + + internal void ApplyItemReconciliation(ClientboundItemReconciliationPacket packet) + { + var resolvedLocalIds = new HashSet(); + + foreach (var result in packet?.Items ?? []) + { + if (!pendingItemReconciliation.TryGetValue(result.LocalId, out var localItem)) + { + Multiplayer.LogWarning($"Received item reconciliation for unknown local item {result.LocalId}"); + continue; + } + + resolvedLocalIds.Add(result.LocalId); + if (result.NetId == 0) + { + Multiplayer.LogWarning($"Host rejected reconciliation for local item {result.LocalId} ({localItem?.name})"); + continue; + } + + if (NetworkedItem.TryGet(result.NetId, out var existingItem) && existingItem != localItem) + SendToCache(existingItem); + + localItem.NetId = result.NetId; + localItem.MarkAsSynchronized(); + Multiplayer.LogDebug(() => $"Reconciled local item {result.LocalId} ({localItem.name}) as NetId {result.NetId}"); + } + + foreach (var unresolved in pendingItemReconciliation.Where(pair => !resolvedLocalIds.Contains(pair.Key))) + Multiplayer.LogWarning($"Host did not return reconciliation data for local item {unresolved.Key} ({unresolved.Value?.name})"); + + pendingItemReconciliation.Clear(); ClientInitialised = true; } + internal ClientboundItemReconciliationPacket ReconcileClientItems( + ServerboundItemReconciliationPacket packet, + ServerPlayer player) + { + var response = new ClientboundItemReconciliationPacket(); + if (!NetworkLifecycle.Instance.IsHost() || packet?.Items == null || player == null) + return response; + + foreach (var request in packet.Items) + { + ushort netId = 0; + var snapshot = request.Snapshot; + GameObject gameObject = null; + + try + { + if (snapshot != null && snapshot.ItemNetId == 0 && + !string.IsNullOrEmpty(snapshot.PrefabName) && + ItemPrefabs.TryGetValue(snapshot.PrefabName, out var spec) && + IsAllowedReconciliationState(snapshot.ItemState, spec)) + { + gameObject = Instantiate( + spec.gameObject, + snapshot.ItemPosition + WorldMover.currentMove, + snapshot.ItemRotation); + var networkedItem = gameObject.GetOrAddComponent(); + netId = networkedItem.NetId; + + if (netId != 0) + { + networkedItem.Item.InventorySpecs.BelongsToPlayer = request.BelongsToPlayer; + snapshot.ItemNetId = netId; + snapshot.Player = player.PlayerId; + networkedItem.ReceiveSnapshot(snapshot); + + if (snapshot.ItemState is ItemState.InHand or ItemState.InInventory) + player.AddOwnedItem(netId); + + player.KnownItems[networkedItem] = NetworkLifecycle.Instance.Tick; + player.NearbyItems[networkedItem] = Time.time; + } + } + } + catch (Exception ex) + { + Multiplayer.LogError($"Failed to reconcile local item {request.LocalId} ({snapshot?.PrefabName}) for {player.Username}: {ex}"); + netId = 0; + } + + if (netId == 0 && gameObject != null) + { + try + { + Destroy(gameObject); + } + catch (Exception ex) + { + Multiplayer.LogWarning($"Failed to clean up rejected reconciliation item {request.LocalId}: {ex.Message}"); + } + } + + response.Items.Add(new ItemReconciliationResult(request.LocalId, netId)); + } + + Multiplayer.LogDebug(() => $"Reconciled {response.Items.Count(item => item.NetId != 0)}/{packet.Items.Count} items for {player.Username}"); + return response; + } + + private static bool IsAllowedReconciliationState(ItemState state, InventoryItemSpec spec) => + state is ItemState.InHand or ItemState.InInventory || + state == ItemState.Dropped && spec.IsEssential; + + private static bool IsReconciliationCandidate(NetworkedItem item) + { + if (item?.Item == null || item.NetId != 0) + return false; + + var inventory = Inventory.Instance; + var storage = StorageController.Instance; + return item.Item.IsEssential() || item.Item.IsGrabbed() || + inventory != null && inventory.Contains(item.gameObject, true) || + storage?.StorageInventory != null && storage.StorageInventory.ContainsItem(item.Item); + } + private NetworkedItem GetFromCache(string prefabName) { if (CachedItems.TryGetValue(prefabName, out var items) && items.Count > 0) @@ -504,16 +719,6 @@ 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; - if (SingletonBehaviour.Instance.StorageWorld.ContainsItem(netItem.Item)) { SingletonBehaviour.Instance.RemoveItemFromWorldStorage(netItem.Item); diff --git a/Multiplayer/Multiplayer.csproj b/Multiplayer/Multiplayer.csproj index 90ebffd4..2d763dee 100644 --- a/Multiplayer/Multiplayer.csproj +++ b/Multiplayer/Multiplayer.csproj @@ -14,6 +14,7 @@ + diff --git a/Multiplayer/Networking/Data/Customization/CustomizationStateData.cs b/Multiplayer/Networking/Data/Customization/CustomizationStateData.cs new file mode 100644 index 00000000..887dd302 --- /dev/null +++ b/Multiplayer/Networking/Data/Customization/CustomizationStateData.cs @@ -0,0 +1,257 @@ +using LiteNetLib.Utils; +using Multiplayer.Networking.Serialization; +using Multiplayer.Networking.Data.Items; +using System.Collections.Generic; +using UnityEngine; + +namespace Multiplayer.Networking.Data.Customization; + +public enum CustomizationTargetKind : byte +{ + TrainCar, + World, + Storage, + PlayerHouse, + PaintStation, +} + +public struct CustomizationRefData +{ + public CustomizationTargetKind Kind; + public ushort TrainCarNetId; + + public void Serialize(NetDataWriter writer) + { + writer.Put((byte)Kind); + writer.Put(TrainCarNetId); + } + + public static CustomizationRefData Deserialize(NetDataReader reader) + { + return new CustomizationRefData + { + Kind = (CustomizationTargetKind)reader.GetByte(), + TrainCarNetId = reader.GetUShort(), + }; + } +} + +public sealed class GadgetPlacementData +{ + public ushort ItemNetId; + public string PrefabName; + public CustomizationRefData Target; + public Vector3 LocalPosition; + public Quaternion LocalRotation; + public bool IsOnGlass; + public Dictionary TrackedValues = new(); + + public void Serialize(NetDataWriter writer) + { + writer.Put(ItemNetId); + writer.Put(PrefabName ?? string.Empty); + Target.Serialize(writer); + Vector3Serializer.Serialize(writer, LocalPosition); + QuaternionSerializer.Serialize(writer, LocalRotation); + writer.Put(IsOnGlass); + TrackedStateSerializer.Serialize(writer, TrackedValues); + } + + public static GadgetPlacementData Deserialize(NetDataReader reader) + { + return new GadgetPlacementData + { + ItemNetId = reader.GetUShort(), + PrefabName = reader.GetString(), + Target = CustomizationRefData.Deserialize(reader), + LocalPosition = Vector3Serializer.Deserialize(reader), + LocalRotation = QuaternionSerializer.Deserialize(reader), + IsOnGlass = reader.GetBool(), + TrackedValues = TrackedStateSerializer.Deserialize(reader), + }; + } +} + +public sealed class CustomizationHoleData +{ + public CustomizationRefData Target; + public Vector3 LocalPosition; + public Vector3 LocalNormal; + + public void Serialize(NetDataWriter writer) + { + Target.Serialize(writer); + Vector3Serializer.Serialize(writer, LocalPosition); + Vector3Serializer.Serialize(writer, LocalNormal); + } + + public static CustomizationHoleData Deserialize(NetDataReader reader) + { + return new CustomizationHoleData + { + Target = CustomizationRefData.Deserialize(reader), + LocalPosition = Vector3Serializer.Deserialize(reader), + LocalNormal = Vector3Serializer.Deserialize(reader), + }; + } +} + +public sealed class CustomizationStateData +{ + public List RelatedItems = new(); + public List Gadgets = new(); + public List Relationships = new(); + public List Holes = new(); + + public void Serialize(NetDataWriter writer) + { + writer.Put(RelatedItems.Count); + foreach (var item in RelatedItems) + item.Serialize(writer); + + writer.Put(Gadgets.Count); + foreach (var gadget in Gadgets) + gadget.Serialize(writer); + + writer.Put(Relationships.Count); + foreach (var relationship in Relationships) + relationship.Serialize(writer); + + writer.Put(Holes.Count); + foreach (var hole in Holes) + hole.Serialize(writer); + } + + public static CustomizationStateData Deserialize(NetDataReader reader) + { + var result = new CustomizationStateData(); + + int relatedItemCount = reader.GetInt(); + for (int i = 0; i < relatedItemCount; i++) + { + var item = new ItemUpdateData(); + item.Deserialize(reader); + result.RelatedItems.Add(item); + } + + int gadgetCount = reader.GetInt(); + for (int i = 0; i < gadgetCount; i++) + result.Gadgets.Add(GadgetPlacementData.Deserialize(reader)); + + int relationshipCount = reader.GetInt(); + for (int i = 0; i < relationshipCount; i++) + result.Relationships.Add(GadgetRelationshipData.Deserialize(reader)); + + int holeCount = reader.GetInt(); + for (int i = 0; i < holeCount; i++) + result.Holes.Add(CustomizationHoleData.Deserialize(reader)); + + return result; + } +} + +public sealed class GadgetRelationshipData +{ + public byte Action; + public ushort ItemA; + public ushort ItemB; + public int IndexA; + public int IndexB; + public bool HasPosition; + public Vector3 Position; + + public void Serialize(NetDataWriter writer) + { + writer.Put(Action); + writer.Put(ItemA); + writer.Put(ItemB); + writer.Put(IndexA); + writer.Put(IndexB); + writer.Put(HasPosition); + if (HasPosition) + Vector3Serializer.Serialize(writer, Position); + } + + public static GadgetRelationshipData Deserialize(NetDataReader reader) + { + var relationship = new GadgetRelationshipData + { + Action = reader.GetByte(), + ItemA = reader.GetUShort(), + ItemB = reader.GetUShort(), + IndexA = reader.GetInt(), + IndexB = reader.GetInt(), + HasPosition = reader.GetBool(), + }; + if (relationship.HasPosition) + relationship.Position = Vector3Serializer.Deserialize(reader); + return relationship; + } +} + +internal static class TrackedStateSerializer +{ + public static void Serialize(NetDataWriter writer, Dictionary values) + { + writer.Put(values?.Count ?? 0); + if (values == null) + return; + + foreach (var value in values) + { + writer.Put(value.Key); + SerializeValue(writer, value.Value); + } + } + + public static Dictionary Deserialize(NetDataReader reader) + { + int count = reader.GetInt(); + var result = new Dictionary(count); + for (int i = 0; i < count; i++) + result[reader.GetString()] = DeserializeValue(reader); + return result; + } + + private static void SerializeValue(NetDataWriter writer, object value) + { + switch (value) + { + case bool boolValue: + writer.Put((byte)0); + writer.Put(boolValue); + break; + case int intValue: + writer.Put((byte)1); + writer.Put(intValue); + break; + case uint uintValue: + writer.Put((byte)2); + writer.Put(uintValue); + break; + case float floatValue: + writer.Put((byte)3); + writer.Put(floatValue); + break; + case string stringValue: + writer.Put((byte)4); + writer.Put(stringValue); + break; + default: + throw new System.NotSupportedException($"Unsupported customization tracked value type: {value?.GetType()}"); + } + } + + private static object DeserializeValue(NetDataReader reader) + { + return reader.GetByte() switch + { + 0 => reader.GetBool(), + 1 => reader.GetInt(), + 2 => reader.GetUInt(), + 3 => reader.GetFloat(), + 4 => reader.GetString(), + byte typeCode => throw new System.NotSupportedException($"Unsupported customization tracked value type code: {typeCode}"), + }; + } +} diff --git a/Multiplayer/Networking/Data/Items/TrackedValue.cs b/Multiplayer/Networking/Data/Items/TrackedValue.cs index bdb44ece..604746d9 100644 --- a/Multiplayer/Networking/Data/Items/TrackedValue.cs +++ b/Multiplayer/Networking/Data/Items/TrackedValue.cs @@ -60,7 +60,7 @@ public void SetValueFromObject(object value) private bool DefaultComparer(T current, T last) { - return !current.Equals(last); + return !EqualityComparer.Default.Equals(current, last); } public string GetDebugString() diff --git a/Multiplayer/Networking/Managers/Client/NetworkClient.cs b/Multiplayer/Networking/Managers/Client/NetworkClient.cs index 515533f9..7b48c795 100644 --- a/Multiplayer/Networking/Managers/Client/NetworkClient.cs +++ b/Multiplayer/Networking/Managers/Client/NetworkClient.cs @@ -25,6 +25,7 @@ using Multiplayer.Components.Networking.World; using Multiplayer.Components.SaveGame; using Multiplayer.Networking.Data; +using Multiplayer.Networking.Data.Customization; using Multiplayer.Networking.Data.Items; using Multiplayer.Networking.Data.Player; using Multiplayer.Networking.Data.Train; @@ -76,6 +77,9 @@ public class NetworkClient : NetworkManager internal uint trainSetsToSpawn = uint.MaxValue; internal uint trainSetsSpawned = 0; internal bool railwayStateLoaded = false; + private bool customizerStateLoaded; + private CustomizationStateData pendingCustomizationState; + private bool itemReconciliationComplete; // One way ping in milliseconds public int Ping { get; private set; } @@ -244,6 +248,9 @@ protected override void Subscribe() // World Sync netPacketProcessor.SubscribeNetSerializable(OnCommonItemChangePacket); + netPacketProcessor.SubscribeNetSerializable(OnClientboundItemReconciliationPacket); + netPacketProcessor.SubscribeNetSerializable(OnClientboundCustomizationStatePacket); + netPacketProcessor.SubscribeNetSerializable(OnCommonCustomizationPacket); netPacketProcessor.SubscribeReusable(OnCommonPitStopInteractionPacket); netPacketProcessor.SubscribeNetSerializable(OnCommonPitStopPlugInteractionPacket); netPacketProcessor.SubscribeReusable(OnClientboundPitStopBulkUpdatePacket); @@ -371,17 +378,40 @@ private IEnumerator SyncWorldState() * ReadyForCustomizers */ - //TODO: implement - yield return new WaitForSeconds(0.25f); + customizerStateLoaded = false; + pendingCustomizationState = null; + Log("Requesting customization state"); + SendLoadStateUpdate(PlayerLoadingState.ReadyForCustomizers); + displayLoadingInfo.OnLoadingStatusChanged("Syncing customizations", false, ((float)LoadingState / (float)PlayerLoadingState.Complete) * 100); + + while (!customizerStateLoaded) + { + if (pendingCustomizationState == null) + { + yield return null; + continue; + } + + yield return CustomizationStateManager.ApplyCurrentStateWhenReady(pendingCustomizationState); + Log($"Customization state loaded ({pendingCustomizationState.Gadgets.Count} gadgets, {pendingCustomizationState.Holes.Count} holes)"); + pendingCustomizationState = null; + customizerStateLoaded = true; + } /* * ReadyForItems */ - Log($"Train sets spawned, requesting items"); + itemReconciliationComplete = false; + Log("Reconciling local items"); SendLoadStateUpdate(PlayerLoadingState.ReadyForItems); + SendNetSerializablePacketToServer( + NetworkedItemManager.Instance.CreateItemReconciliationRequest(), + DeliveryMethod.ReliableOrdered); + displayLoadingInfo.OnLoadingStatusChanged("Syncing items", false, ((float)LoadingState / (float)PlayerLoadingState.Complete) * 100); - yield return new WaitForSeconds(0.25f); + while (!itemReconciliationComplete) + yield return null; /* * ReadyForJobs @@ -1330,9 +1360,32 @@ private void OnCommonItemChangePacket(CommonItemChangePacket packet) // return debug; //}); - //NetworkedItemManager.Instance.ReceiveSnapshots(packet.Items, null); + if (packet?.Items == null) + return; + + NetworkedItemManager.Instance.ReceiveSnapshots(packet.Items, null); } + private void OnClientboundItemReconciliationPacket(ClientboundItemReconciliationPacket packet) + { + NetworkedItemManager.Instance.ApplyItemReconciliation(packet); + itemReconciliationComplete = true; + Log($"Item reconciliation complete ({packet?.Items?.Count ?? 0} local items)"); + } + + private void OnClientboundCustomizationStatePacket(ClientboundCustomizationStatePacket packet) + { + if (packet?.State == null) + { + LogWarning("Received an invalid customization state packet"); + return; + } + + pendingCustomizationState = packet.State; + } + + private void OnCommonCustomizationPacket(CommonCustomizationPacket packet) => CustomizationStateManager.ApplyAction(packet); + private void OnCommonPaintThemePacket(CommonPaintThemePacket packet) { if (!NetworkedTrainCar.TryGet(packet.NetId, out NetworkedTrainCar netTrainCar)) @@ -1892,6 +1945,9 @@ public void SendItemsChangePacket(List items) DeliveryMethod.ReliableOrdered); } + public void SendCustomizationAction(CommonCustomizationPacket packet) => + SendNetSerializablePacketToServer(packet, DeliveryMethod.ReliableOrdered); + public void SendPaintThemeChange(NetworkedTrainCar netTraincar, TrainCarPaint.Target targetArea, uint themeId) { Log($"Sending paint theme change for {netTraincar.CurrentID}"); diff --git a/Multiplayer/Networking/Managers/Server/NetworkServer.cs b/Multiplayer/Networking/Managers/Server/NetworkServer.cs index 53734a83..fcfee89d 100644 --- a/Multiplayer/Networking/Managers/Server/NetworkServer.cs +++ b/Multiplayer/Networking/Managers/Server/NetworkServer.cs @@ -233,6 +233,8 @@ protected override void Subscribe() // Items netPacketProcessor.SubscribeNetSerializable(OnCommonItemChangePacket); + netPacketProcessor.SubscribeNetSerializable(OnServerboundItemReconciliationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCommonCustomizationPacket); } //allow mods to register their own packets @@ -1309,6 +1311,20 @@ private void OnServerboundLoadStateUpdatePacket(ServerboundLoadStateUpdatePacket break; + case PlayerLoadingState.ReadyForCustomizers: + var customizationState = CustomizationStateManager.CaptureCurrentState(); + foreach (ushort itemNetId in customizationState.Gadgets.Select(gadget => gadget.ItemNetId) + .Concat(customizationState.RelatedItems.Select(item => item.ItemNetId)).Distinct()) + if (NetworkedItem.TryGet(itemNetId, out var knownItem)) + player.KnownItems[knownItem] = NetworkLifecycle.Instance.Tick; + + SendNetSerializablePacket(peer, new ClientboundCustomizationStatePacket + { + State = customizationState, + }, DeliveryMethod.ReliableOrdered); + + break; + case PlayerLoadingState.ReadyForItems: // Send Inventory and world items @@ -2095,8 +2111,8 @@ 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}\"))"); @@ -2129,7 +2145,38 @@ private void OnCommonItemChangePacket(CommonItemChangePacket packet, ITransportP //); - //NetworkedItemManager.Instance.ReceiveSnapshots(packet.Items, player); + if (packet?.Items == null) + return; + + NetworkedItemManager.Instance.ReceiveSnapshots(packet.Items, player); + } + + private void OnServerboundItemReconciliationPacket(ServerboundItemReconciliationPacket packet, ITransportPeer peer) + { + if (!TryGetServerPlayer(peer, out var player)) + return; + + if (player.LoadingState != PlayerLoadingState.ReadyForItems) + { + LogWarning($"Ignoring item reconciliation from {player.Username} in loading state {player.LoadingState}"); + return; + } + + var response = NetworkedItemManager.Instance.ReconcileClientItems(packet, player); + SendNetSerializablePacket(peer, response, DeliveryMethod.ReliableOrdered); + } + + public void SendCustomizationAction(CommonCustomizationPacket packet) => + SendNetSerializablePacketToAll(packet, DeliveryMethod.ReliableOrdered, excludeSelf: true); + + private void OnCommonCustomizationPacket(CommonCustomizationPacket packet, ITransportPeer peer) + { + if (!TryGetServerPlayer(peer, out _)) return; + CustomizationStateManager.ApplyAction(packet); + if (packet.Action == CustomizationAction.ReplaceSpool || packet.Action == CustomizationAction.ReplaceDuctTape) + SendNetSerializablePacketToAll(packet, DeliveryMethod.ReliableOrdered, excludeSelf: true); + else + SendNetSerializablePacketToAll(packet, DeliveryMethod.ReliableOrdered, peer, excludeSelf: true); } private void OnCommonCashRegisterWithModulesActionPacket(CommonCashRegisterWithModulesActionPacket packet, ITransportPeer peer) diff --git a/Multiplayer/Networking/Packets/Clientbound/ClientboundCustomizationStatePacket.cs b/Multiplayer/Networking/Packets/Clientbound/ClientboundCustomizationStatePacket.cs new file mode 100644 index 00000000..d9c8e0f7 --- /dev/null +++ b/Multiplayer/Networking/Packets/Clientbound/ClientboundCustomizationStatePacket.cs @@ -0,0 +1,19 @@ +using LiteNetLib.Utils; +using Multiplayer.Networking.Data.Customization; + +namespace Multiplayer.Networking.Packets.Clientbound; + +public sealed class ClientboundCustomizationStatePacket : INetSerializable +{ + public CustomizationStateData State = new(); + + public void Serialize(NetDataWriter writer) + { + State.Serialize(writer); + } + + public void Deserialize(NetDataReader reader) + { + State = CustomizationStateData.Deserialize(reader); + } +} diff --git a/Multiplayer/Networking/Packets/Clientbound/ClientboundItemReconciliationPacket.cs b/Multiplayer/Networking/Packets/Clientbound/ClientboundItemReconciliationPacket.cs new file mode 100644 index 00000000..4dd6e6d8 --- /dev/null +++ b/Multiplayer/Networking/Packets/Clientbound/ClientboundItemReconciliationPacket.cs @@ -0,0 +1,44 @@ +using LiteNetLib.Utils; +using System.Collections.Generic; + +namespace Multiplayer.Networking.Packets.Clientbound; + +internal sealed class ClientboundItemReconciliationPacket : INetSerializable +{ + private const int MaxItems = 128; + public List Items = []; + + public void Serialize(NetDataWriter writer) + { + int count = System.Math.Min(Items.Count, MaxItems); + writer.Put(count); + for (int i = 0; i < count; i++) + { + var item = Items[i]; + writer.Put(item.LocalId); + writer.Put(item.NetId); + } + } + + public void Deserialize(NetDataReader reader) + { + Items.Clear(); + int count = reader.GetInt(); + if (count < 0 || count > MaxItems) + throw new System.InvalidOperationException($"Invalid item reconciliation result count: {count}"); + for (int i = 0; i < count; i++) + Items.Add(new ItemReconciliationResult(reader.GetInt(), reader.GetUShort())); + } +} + +internal readonly struct ItemReconciliationResult +{ + public readonly int LocalId; + public readonly ushort NetId; + + public ItemReconciliationResult(int localId, ushort netId) + { + LocalId = localId; + NetId = netId; + } +} diff --git a/Multiplayer/Networking/Packets/Common/CommonCustomizationPacket.cs b/Multiplayer/Networking/Packets/Common/CommonCustomizationPacket.cs new file mode 100644 index 00000000..1cc8ccf2 --- /dev/null +++ b/Multiplayer/Networking/Packets/Common/CommonCustomizationPacket.cs @@ -0,0 +1,38 @@ +using LiteNetLib.Utils; +using Multiplayer.Networking.Serialization; +using UnityEngine; + +namespace Multiplayer.Networking.Packets.Common; + +public enum CustomizationAction : byte { PlaceGadget, RemoveGadget, AddHole, MoveHole, RemoveHole, Mount, Unmount, Wire, Unwire, DropEmptySpool, LoadSpool, SnapItem, UnsnapItem, ReplaceSpool, ReplaceDuctTape } + +public sealed class CommonCustomizationPacket : INetSerializable +{ + public CustomizationAction Action; + public ushort ItemNetId; + public ushort OtherItemNetId; + public int IndexA; + public int IndexB; + public byte TargetKind; + public ushort TargetTrainCarNetId; + public Vector3 Position; + public Vector3 PreviousPosition; + public Vector3 Normal; + public Quaternion Rotation; + public bool Flag; + + public void Serialize(NetDataWriter writer) + { + writer.Put((byte)Action); writer.Put(ItemNetId); writer.Put(OtherItemNetId); writer.Put(IndexA); writer.Put(IndexB); writer.Put(TargetKind); writer.Put(TargetTrainCarNetId); + Vector3Serializer.Serialize(writer, Position); Vector3Serializer.Serialize(writer, PreviousPosition); + Vector3Serializer.Serialize(writer, Normal); QuaternionSerializer.Serialize(writer, Rotation); writer.Put(Flag); + } + + public void Deserialize(NetDataReader reader) + { + Action = (CustomizationAction)reader.GetByte(); ItemNetId = reader.GetUShort(); OtherItemNetId = reader.GetUShort(); IndexA = reader.GetInt(); IndexB = reader.GetInt(); TargetKind = reader.GetByte(); + TargetTrainCarNetId = reader.GetUShort(); Position = Vector3Serializer.Deserialize(reader); + PreviousPosition = Vector3Serializer.Deserialize(reader); Normal = Vector3Serializer.Deserialize(reader); + Rotation = QuaternionSerializer.Deserialize(reader); Flag = reader.GetBool(); + } +} diff --git a/Multiplayer/Networking/Packets/Serverbound/ServerboundItemReconciliationPacket.cs b/Multiplayer/Networking/Packets/Serverbound/ServerboundItemReconciliationPacket.cs new file mode 100644 index 00000000..7e3c2c7f --- /dev/null +++ b/Multiplayer/Networking/Packets/Serverbound/ServerboundItemReconciliationPacket.cs @@ -0,0 +1,54 @@ +using LiteNetLib.Utils; +using Multiplayer.Networking.Data.Items; +using System.Collections.Generic; +using System.Linq; + +namespace Multiplayer.Networking.Packets.Serverbound; + +internal sealed class ServerboundItemReconciliationPacket : INetSerializable +{ + private const int MaxItems = 128; + public List Items = []; + + public void Serialize(NetDataWriter writer) + { + int count = System.Math.Min(Items.Count, MaxItems); + writer.Put(count); + foreach (var item in Items.Take(count)) + { + writer.Put(item.LocalId); + writer.Put(item.BelongsToPlayer); + item.Snapshot.Serialize(writer); + } + } + + public void Deserialize(NetDataReader reader) + { + Items.Clear(); + int count = reader.GetInt(); + if (count < 0 || count > MaxItems) + throw new System.InvalidOperationException($"Invalid item reconciliation count: {count}"); + for (int i = 0; i < count; i++) + { + var snapshot = new ItemUpdateData(); + int localId = reader.GetInt(); + bool belongsToPlayer = reader.GetBool(); + snapshot.Deserialize(reader); + Items.Add(new ItemReconciliationRequest(localId, belongsToPlayer, snapshot)); + } + } +} + +internal readonly struct ItemReconciliationRequest +{ + public readonly int LocalId; + public readonly bool BelongsToPlayer; + public readonly ItemUpdateData Snapshot; + + public ItemReconciliationRequest(int localId, bool belongsToPlayer, ItemUpdateData snapshot) + { + LocalId = localId; + BelongsToPlayer = belongsToPlayer; + Snapshot = snapshot; + } +} diff --git a/Multiplayer/Patches/World/CustomizationPatch.cs b/Multiplayer/Patches/World/CustomizationPatch.cs new file mode 100644 index 00000000..ddd83e99 --- /dev/null +++ b/Multiplayer/Patches/World/CustomizationPatch.cs @@ -0,0 +1,101 @@ +using DV.Customization; +using DV.Customization.Gadgets; +using HarmonyLib; +using Multiplayer.Components.Networking.World; +using Multiplayer.Networking.Packets.Common; +using UnityEngine; + +namespace Multiplayer.Patches.World; + +[HarmonyPatch] +public static class CustomizationPatch +{ + [HarmonyPatch(typeof(GadgetItem), nameof(GadgetItem.Place)), HarmonyPostfix] + private static void Placed(GadgetBase __result, Customization destination, Vector3 localPos, Quaternion localRot, GadgetItem gadgetItem) + { + if (__result == null || gadgetItem == null || CustomizationSyncScope.IsApplyingRemote || !NetworkedItem.TryGetNetId(gadgetItem.Item, out var id) || !CustomizationRef.TryCreate(destination, out var target)) return; + CustomizationStateManager.SendAction(new CommonCustomizationPacket { Action = CustomizationAction.PlaceGadget, ItemNetId = id, + TargetKind = (byte)target.Kind, TargetTrainCarNetId = target.TrainCarNetId, Position = localPos, Rotation = localRot, Flag = __result.IsOnGlass }); + } + + [HarmonyPatch(typeof(GadgetBase), nameof(GadgetBase.Remove)), HarmonyPrefix] + private static void BeforeRemoveGadget(GadgetBase __instance, bool reparentToTrainCar, out RemoveGadgetState __state) + { + __state = default; + if (CustomizationSyncScope.IsApplyingRemote || __instance?.GadgetItem?.Item == null || + !NetworkedItem.TryGetNetId(__instance.GadgetItem.Item, out var id)) return; + __state = new RemoveGadgetState { ShouldSend = true, ItemNetId = id, ReparentToTrainCar = reparentToTrainCar }; + } + + [HarmonyPatch(typeof(GadgetBase), nameof(GadgetBase.Remove)), HarmonyPostfix] + private static void RemovedGadget(GadgetItem __result, bool reparentToTrainCar, RemoveGadgetState __state) + { + if (__result == null) return; + + if (reparentToTrainCar) + StabilizeRemovedGadget(__result); + + if (!__state.ShouldSend) return; + CustomizationStateManager.SendAction(new CommonCustomizationPacket { Action = CustomizationAction.RemoveGadget, + ItemNetId = __state.ItemNetId, Flag = __state.ReparentToTrainCar }); + } + + private static void StabilizeRemovedGadget(GadgetItem gadgetItem) + { + var rigidbody = gadgetItem?.Item?.ItemRigidbody; + if (rigidbody == null) + return; + + var trainCar = gadgetItem.GetComponentInParent(); + if (trainCar?.rb != null) + { + // The backing item was inactive while installed and can retain its old + // hand/drop velocity. Start it with the train's motion at this exact + // point so re-enabling physics does not launch it across the interior. + rigidbody.velocity = trainCar.rb.GetPointVelocity(rigidbody.worldCenterOfMass); + rigidbody.angularVelocity = trainCar.rb.angularVelocity; + } + else + { + rigidbody.velocity = Vector3.zero; + rigidbody.angularVelocity = Vector3.zero; + } + } + + [HarmonyPatch(typeof(Customization), nameof(Customization.AddHole)), HarmonyPostfix] + private static void AddedHole(Customization __instance, Vector3 localPosition, Vector3 localNormal, Collider __result) + { if (__result != null) SendHole(__instance, CustomizationAction.AddHole, localPosition, default, localNormal); } + + [HarmonyPatch(typeof(Customization), nameof(Customization.MoveHole)), HarmonyPrefix] + private static void BeforeMove(Collider hole, out HoleMoveState __state) => __state = new HoleMoveState + { IsValid = hole != null, PreviousPosition = hole != null ? hole.transform.localPosition : default }; + [HarmonyPatch(typeof(Customization), nameof(Customization.MoveHole)), HarmonyPostfix] + private static void MovedHole(Customization __instance, Vector3 localPosition, Vector3 localNormal, HoleMoveState __state) + { if (__state.IsValid) SendHole(__instance, CustomizationAction.MoveHole, localPosition, __state.PreviousPosition, localNormal); } + + [HarmonyPatch(typeof(Customization), nameof(Customization.RemoveHole)), HarmonyPrefix] + private static void BeforeRemove(Collider holeCollider, out Vector3 __state) => __state = holeCollider != null ? holeCollider.transform.localPosition : default; + [HarmonyPatch(typeof(Customization), nameof(Customization.RemoveHole)), HarmonyPostfix] + private static void RemovedHole(Customization __instance, bool __result, Vector3 __state) + { if (__result) SendHole(__instance, CustomizationAction.RemoveHole, default, __state, default); } + + private static void SendHole(Customization c, CustomizationAction action, Vector3 pos, Vector3 old, Vector3 normal) + { + if (CustomizationSyncScope.IsApplyingRemote || !CustomizationRef.TryCreate(c, out var target)) return; + CustomizationStateManager.SendAction(new CommonCustomizationPacket { Action = action, TargetKind = (byte)target.Kind, + TargetTrainCarNetId = target.TrainCarNetId, Position = pos, PreviousPosition = old, Normal = normal }); + } + + private struct RemoveGadgetState + { + public bool ShouldSend; + public ushort ItemNetId; + public bool ReparentToTrainCar; + } + + private struct HoleMoveState + { + public bool IsValid; + public Vector3 PreviousPosition; + } +} diff --git a/Multiplayer/Patches/World/GadgetRelationshipPatch.cs b/Multiplayer/Patches/World/GadgetRelationshipPatch.cs new file mode 100644 index 00000000..96e9a0eb --- /dev/null +++ b/Multiplayer/Patches/World/GadgetRelationshipPatch.cs @@ -0,0 +1,221 @@ +using DV.Customization.Gadgets; +using DV.Customization.Gadgets.Implementations; +using DV.CabControls; +using HarmonyLib; +using Multiplayer.Components.Networking.World; +using Multiplayer.Networking.Packets.Common; +using System; +using System.Linq; +using DV.Items; +using DV.Items.Snapping; + +namespace Multiplayer.Patches.World; + +[HarmonyPatch] +public static class GadgetRelationshipPatch +{ + private static int authoritativeSlidingSnapDepth; + + [HarmonyPatch(typeof(Mount), nameof(Mount.MountGadget)), HarmonyPostfix] + private static void Mounted(Mount __instance, GadgetBase gadget) + { + if (CustomizationSyncScope.IsApplyingRemote || __instance.MountedGadget != gadget || + !Id(__instance.ThisGadget, out var a) || !Id(gadget, out var b)) return; + int index = Array.IndexOf(__instance.ThisGadget.GetComponents(), __instance); + if (index >= 0) Send(CustomizationAction.Mount, a, b, index); + } + + [HarmonyPatch(typeof(Mount), nameof(Mount.UnmountGadget)), HarmonyPrefix] + private static void BeforeUnmount(Mount __instance, out MountActionState __state) + { + __state = default; + if (CustomizationSyncScope.IsApplyingRemote || __instance.MountedGadget == null || !Id(__instance.ThisGadget, out var ownerId)) return; + int index = Array.IndexOf(__instance.ThisGadget.GetComponents(), __instance); + if (index >= 0) __state = new MountActionState { ShouldSend = true, OwnerId = ownerId, Index = index }; + } + + [HarmonyPatch(typeof(Mount), nameof(Mount.UnmountGadget)), HarmonyPostfix] + private static void Unmounted(Mount __instance, MountActionState __state) + { if (__state.ShouldSend && __instance.MountedGadget == null) Send(CustomizationAction.Unmount, __state.OwnerId, 0, __state.Index); } + + [HarmonyPatch(typeof(GadgetWiringModule.WireLinkPort), nameof(GadgetWiringModule.WireLinkPort.Wire), new[] { typeof(GadgetWiringModule.WireLinkPort), typeof(GadgetWiringModule.WireLinkPort) }), HarmonyPostfix] + private static void Wired(GadgetWiringModule.WireLinkPort a, GadgetWiringModule.WireLinkPort b, bool __result) + { if (__result) SendWire(CustomizationAction.Wire, a, b); } + + [HarmonyPatch(typeof(GadgetWiringModule.WireLinkPort), nameof(GadgetWiringModule.WireLinkPort.Unwire), new[] { typeof(GadgetWiringModule.WireLinkPort), typeof(GadgetWiringModule.WireLinkPort) }), HarmonyPostfix] + private static void Unwired(GadgetWiringModule.WireLinkPort a, GadgetWiringModule.WireLinkPort b, bool __result) + { if (__result) SendWire(CustomizationAction.Unwire, a, b); } + + [HarmonyPatch(typeof(GadgetSolderingTool), nameof(GadgetSolderingTool.DropEmptySpool)), HarmonyPrefix] + private static void BeforeDropSpool(GadgetSolderingTool __instance, out SpoolDropState __state) + { + __state = default; + var magazineItems = __instance.magazine?.items; + var spoolObject = magazineItems != null && magazineItems.Length > 0 ? magazineItems[0] : null; + if (!CustomizationSyncScope.IsApplyingRemote && __instance.HasEjectableSpool && spoolObject != null && + NetworkedItem.TryGetNetworkedItem(__instance.GetComponent(), out var toolItem) && + NetworkedItem.TryGetNetworkedItem(spoolObject.GetComponent(), out var spoolItem)) + __state = new SpoolDropState { ToolItemNetId = toolItem.NetId, SpoolItem = spoolItem }; + } + + [HarmonyPatch(typeof(GadgetSolderingTool), nameof(GadgetSolderingTool.DropEmptySpool)), HarmonyPostfix] + private static void DroppedSpool(GadgetSolderingTool __instance, SpoolDropState __state) + { + if (__state.ToolItemNetId == 0 || __state.SpoolItem == null || __instance.HasEjectableSpool) + return; + + CustomizationStateManager.SendAction(new CommonCustomizationPacket + { + Action = CustomizationAction.DropEmptySpool, + ItemNetId = __state.ToolItemNetId, + OtherItemNetId = __state.SpoolItem.NetId, + Position = __state.SpoolItem.transform.position - WorldMover.currentMove, + Rotation = __state.SpoolItem.transform.rotation, + Flag = true, + }); + } + + [HarmonyPatch(typeof(GadgetSolderingTool), "ProcessSoldering"), HarmonyPrefix] + private static void BeforeSoldering(GadgetSolderingTool __instance, out SpoolReplacementState __state) + { + __state = default; + if (CustomizationSyncScope.IsApplyingRemote || __instance.currentSpool == null || __instance.currentSpool.isSpent || + !NetworkedItem.TryGetNetworkedItem(__instance.GetComponent(), out var toolItem)) + return; + + NetworkedItem.TryGetNetworkedItem(__instance.currentSpool.GetComponent(), out var oldSpool); + __state = new SpoolReplacementState { ToolItemNetId = toolItem.NetId, OldSpool = oldSpool }; + } + + [HarmonyPatch(typeof(GadgetSolderingTool), "ProcessSoldering"), HarmonyPostfix] + private static void AfterSoldering(GadgetSolderingTool __instance, SpoolReplacementState __state) + { + var magazineItems = __instance.magazine?.items; + var spentObject = magazineItems != null && magazineItems.Length > 0 ? magazineItems[0] : null; + var spentAmmo = spentObject?.GetComponent(); + if (__state.ToolItemNetId == 0 || spentAmmo == null || !spentAmmo.isSpent) + return; + + __state.OldSpool?.SuppressDestroySync(); + NetworkedItem.TryGetNetworkedItem(spentObject.GetComponent(), out var spentSpool); + spentSpool?.MarkAsSynchronized(); + CustomizationStateManager.SendAction(new CommonCustomizationPacket + { + Action = CustomizationAction.ReplaceSpool, + ItemNetId = __state.ToolItemNetId, + OtherItemNetId = spentSpool?.NetId ?? 0, + }); + } + + [HarmonyPatch(typeof(ItemMagazine), nameof(ItemMagazine.AddItem)), HarmonyPostfix] + private static void SpoolLoaded(ItemMagazine __instance, UnityEngine.GameObject item, int index, bool __result) + { + var tool = __instance.GetComponent(); + if (!__result || tool == null || tool.ignoreMagazineDataChange || CustomizationSyncScope.IsApplyingRemote || + !NetworkedItem.TryGetNetworkedItem(tool.GetComponent(), out var toolNet) || + !NetworkedItem.TryGetNetworkedItem(item.GetComponent(), out var spoolNet) || + spoolNet.NetId == 0) return; + Send(CustomizationAction.LoadSpool, toolNet.NetId, spoolNet.NetId, 0); + } + + [HarmonyPatch(typeof(ItemSnapPointBase), nameof(ItemSnapPointBase.SnapItem), new[] { typeof(ItemBase), typeof(bool) }), HarmonyPostfix] + private static void Snapped(ItemSnapPointBase __instance, ItemBase itemToSnap, bool __result) + { if (__result) SendSnap(CustomizationAction.SnapItem, __instance, itemToSnap); } + + [HarmonyReversePatch] + [HarmonyPatch(typeof(ItemSnapPointBase), nameof(ItemSnapPointBase.SnapItem), new[] { typeof(ItemBase), typeof(bool) })] + internal static bool SnapItemBase(ItemSnapPointBase instance, ItemBase itemToSnap, bool forced) + => throw new NotImplementedException("Harmony reverse patch was not applied"); + + internal static bool SnapItemBaseAuthoritative(ItemSnapPointBase instance, ItemBase itemToSnap, bool forced) + { + authoritativeSlidingSnapDepth++; + try + { + return SnapItemBase(instance, itemToSnap, forced); + } + finally + { + authoritativeSlidingSnapDepth--; + } + } + + [HarmonyPatch(typeof(SnapPointGadgetSliding), nameof(SnapPointGadgetSliding.CanSnapCheck), + new[] { typeof(SnappableItem), typeof(bool) }), HarmonyPrefix] + private static bool UseAuthoritativeSlidingSnapResult(ref bool __result) + { + if (authoritativeSlidingSnapDepth <= 0) + return true; + + // The relationship packet already contains the host-approved snap point and + // sliding anchor. Re-running CalculateSlide here depends on transient local + // colliders and can reject a valid relationship while a train LOD is loading. + __result = true; + return false; + } + + [HarmonyPatch(typeof(ItemSnapPointBase), nameof(ItemSnapPointBase.UnsnapItem)), HarmonyPrefix] + private static void BeforeUnsnap(ItemSnapPointBase __instance, out ItemBase __state) => __state = __instance.SnappedItem; + + [HarmonyPatch(typeof(ItemSnapPointBase), nameof(ItemSnapPointBase.UnsnapItem)), HarmonyPostfix] + private static void Unsnapped(ItemSnapPointBase __instance, bool __result, ItemBase __state) + { if (__result && __state != null) SendSnap(CustomizationAction.UnsnapItem, __instance, __state); } + + private static void SendSnap(CustomizationAction action, ItemSnapPointBase point, ItemBase item) + { + if (CustomizationSyncScope.IsApplyingRemote) return; + if (!CustomizationStateManager.TryGetSnapPointOwner(point, out var owner) || + !CustomizationStateManager.TryGetSnapPointIndex(owner, point, out var index) || + !Id(owner, out var ownerId) || !NetworkedItem.TryGetNetId(item, out var itemId)) return; + + bool hasAnchorPosition = false; + UnityEngine.Vector3 anchorPosition = default; + if (action == CustomizationAction.SnapItem && item.SnappableItem != null) + { + var anchor = item.SnappableItem.GetAnchor(point.SnapPointType); + if (anchor != null) + { + hasAnchorPosition = true; + anchorPosition = anchor.localPosition; + } + } + + CustomizationStateManager.SendAction(new CommonCustomizationPacket + { + Action = action, + ItemNetId = ownerId, + OtherItemNetId = itemId, + IndexA = index, + Flag = hasAnchorPosition, + Position = anchorPosition, + }); + } + + private static void SendWire(CustomizationAction action, GadgetWiringModule.WireLinkPort a, GadgetWiringModule.WireLinkPort b) + { + if (CustomizationSyncScope.IsApplyingRemote || !Id(a.owner, out var ai) || !Id(b.owner, out var bi)) return; + CustomizationStateManager.SendAction(new CommonCustomizationPacket { Action = action, ItemNetId = ai, OtherItemNetId = bi, + IndexA = a.owner.WireLinkPorts.IndexOf(a), IndexB = b.owner.WireLinkPorts.IndexOf(b) }); + } + private static void Send(CustomizationAction action, ushort a, ushort b, int index) => CustomizationStateManager.SendAction(new CommonCustomizationPacket { Action = action, ItemNetId = a, OtherItemNetId = b, IndexA = index }); + private static bool Id(GadgetBase gadget, out ushort id) { id = 0; return gadget?.GadgetItem?.Item != null && NetworkedItem.TryGetNetId(gadget.GadgetItem.Item, out id); } + + private struct MountActionState + { + public bool ShouldSend; + public ushort OwnerId; + public int Index; + } + + private struct SpoolReplacementState + { + public ushort ToolItemNetId; + public NetworkedItem OldSpool; + } + + private struct SpoolDropState + { + public ushort ToolItemNetId; + public NetworkedItem SpoolItem; + } +} diff --git a/Multiplayer/Patches/World/Items/CustomizationToolPatch.cs b/Multiplayer/Patches/World/Items/CustomizationToolPatch.cs new file mode 100644 index 00000000..1f0a63e3 --- /dev/null +++ b/Multiplayer/Patches/World/Items/CustomizationToolPatch.cs @@ -0,0 +1,87 @@ +using DV.Customization.Gadgets; +using DV.Customization.Gadgets.Implementations; +using HarmonyLib; +using Multiplayer.Components.Networking.World; +using Multiplayer.Utils; +using System; +using DV.InventorySystem; +using Multiplayer.Networking.Packets.Common; + +namespace Multiplayer.Patches.World.Items; + +[HarmonyPatch] +public static class CustomizationToolPatch +{ + [HarmonyPatch(typeof(DuctTape), "Awake"), HarmonyPostfix] + private static void DuctTapeAwake(DuctTape __instance) + { + var item = __instance.gameObject.GetOrAddComponent(); + item.Initialize(__instance); + item.RegisterTrackedValue("ductTape.usesLeft", () => __instance.usesLeft, value => + { + __instance.usesLeft = Math.Max(0, Math.Min(value, __instance.numberOfUses)); + float usesRatio = __instance.numberOfUses > 0 + ? (float)__instance.usesLeft / __instance.numberOfUses + : 0f; + __instance.tapeModelUpdater?.UpdateActiveStates(usesRatio); + }); + item.FinaliseTrackedValues(); + } + + [HarmonyPatch(typeof(DuctTape), nameof(DuctTape.ConsumeOneUse)), HarmonyPrefix] + private static void BeforeConsumeDuctTape(DuctTape __instance, out DuctTapeReplacementState __state) + { + __state = default; + if (CustomizationSyncScope.IsApplyingRemote || __instance.usesLeft != 1 || + !NetworkedItem.TryGetNetworkedItem(__instance.GetComponent(), out var oldItem)) + return; + + int equipSlot = Inventory.Instance.GetEquipSlotForItem(__instance.gameObject); + if (equipSlot >= 0) + __state = new DuctTapeReplacementState { EquipSlot = equipSlot, OldItem = oldItem }; + } + + [HarmonyPatch(typeof(DuctTape), nameof(DuctTape.ConsumeOneUse)), HarmonyPostfix] + private static void AfterConsumeDuctTape(DuctTapeReplacementState __state) + { + if (__state.OldItem == null || __state.EquipSlot < 0) + return; + + var replacementObject = Inventory.Instance.GetEquippedItemAtSlot(__state.EquipSlot); + if (replacementObject == null || replacementObject.GetComponent() == null || + !NetworkedItem.TryGetNetworkedItem(replacementObject.GetComponent(), out var replacementItem)) + return; + + __state.OldItem.SuppressDestroySync(); + replacementItem.MarkAsSynchronized(); + CustomizationStateManager.SendAction(new CommonCustomizationPacket + { + Action = CustomizationAction.ReplaceDuctTape, + ItemNetId = __state.OldItem.NetId, + OtherItemNetId = replacementItem.NetId, + Position = replacementObject.transform.position - WorldMover.currentMove, + Rotation = replacementObject.transform.rotation, + Flag = true, + }); + } + + [HarmonyPatch(typeof(GadgetSolderingTool), "Awake"), HarmonyPostfix] + private static void SolderingToolAwake(GadgetSolderingTool __instance) + { + var item = __instance.gameObject.GetOrAddComponent(); + item.Initialize(__instance); + item.RegisterTrackedValue("soldering.remaining", () => __instance.remainingUnits, value => + { + // -1 is the base game's loaded-empty/ejectable sentinel. + __instance.remainingUnits = Math.Max(-1, value); + __instance.OnUnitsChanged(); + }); + item.FinaliseTrackedValues(); + } + + private struct DuctTapeReplacementState + { + public int EquipSlot; + public NetworkedItem OldItem; + } +} diff --git a/Multiplayer/Patches/World/Items/GadgetItemPatch.cs b/Multiplayer/Patches/World/Items/GadgetItemPatch.cs new file mode 100644 index 00000000..bc051125 --- /dev/null +++ b/Multiplayer/Patches/World/Items/GadgetItemPatch.cs @@ -0,0 +1,20 @@ +using DV.Customization.Gadgets; +using HarmonyLib; +using Multiplayer.Components.Networking.World; +using Multiplayer.Utils; + +namespace Multiplayer.Patches.World.Items; + +[HarmonyPatch(typeof(GadgetItem))] +public static class GadgetItemPatch +{ + [HarmonyPatch(nameof(GadgetItem.Awake))] + [HarmonyPostfix] + private static void Awake(GadgetItem __instance) + { + var networkedItem = __instance.gameObject.GetOrAddComponent(); + networkedItem.Initialize(__instance); + GadgetTrackedValueRegistry.Register(networkedItem, __instance.Gadget); + networkedItem.FinaliseTrackedValues(); + } +} From 119919e4829d7b310af504e21df0bd42d1c7d132 Mon Sep 17 00:00:00 2001 From: Patrick van Halm Date: Sat, 15 Aug 2026 22:09:14 +0200 Subject: [PATCH 02/14] Add shop synchronization and fix lost and found ownership --- .../World/NetworkedCashRegisterWithModules.cs | 166 +++++++++++++- .../Networking/World/NetworkedItem.cs | 93 +++++++- .../Networking/World/NetworkedItemManager.cs | 72 +++++- .../World/ShopPurchaseCoordinator.cs | 210 ++++++++++++++++++ .../Networking/Data/Items/ItemUpdateData.cs | 7 + .../Managers/Client/NetworkClient.cs | 11 +- .../Managers/Server/NetworkServer.cs | 1 + .../ClientboundLoginResponsePacket.cs | 1 + ...mmonCashRegisterWithModulesActionPacket.cs | 7 +- .../Patches/World/CashRegisterBasePatch.cs | 6 + .../World/CashRegisterWithModulesPatch.cs | 47 +++- .../Patches/World/Items/ItemBasePatch.cs | 2 + Multiplayer/Patches/World/ShopPatch.cs | 97 ++++++++ .../Patches/World/StorageControllerPatch.cs | 105 ++++----- 14 files changed, 740 insertions(+), 85 deletions(-) create mode 100644 Multiplayer/Components/Networking/World/ShopPurchaseCoordinator.cs create mode 100644 Multiplayer/Patches/World/ShopPatch.cs diff --git a/Multiplayer/Components/Networking/World/NetworkedCashRegisterWithModules.cs b/Multiplayer/Components/Networking/World/NetworkedCashRegisterWithModules.cs index 7ffa8226..2c3a24be 100644 --- a/Multiplayer/Components/Networking/World/NetworkedCashRegisterWithModules.cs +++ b/Multiplayer/Components/Networking/World/NetworkedCashRegisterWithModules.cs @@ -84,7 +84,7 @@ public static void InitialiseCashRegisters() #endregion #region Common Variables - CashRegisterWithModules CashRegister; + internal CashRegisterWithModules CashRegister; #endregion #region Unity @@ -140,6 +140,12 @@ private void CullingManager_PlayerEnteredActivationRegion(ServerPlayer serverPla public void Server_ProcessCashRegisterAction(ServerPlayer player, CommonCashRegisterWithModulesActionPacket packet) { + if (IsShopRegister) + { + Server_ProcessShopPurchase(player, packet); + return; + } + bool success = false; CashRegisterAction response = CashRegisterAction.RejectGeneric; @@ -235,6 +241,98 @@ public void Server_ProcessCashRegisterAction(ServerPlayer player, CommonCashRegi processingAction = false; } + private void Server_ProcessShopPurchase(ServerPlayer player, CommonCashRegisterWithModulesActionPacket packet) + { + CashRegisterAction rejection = CashRegisterAction.RejectGeneric; + if (packet.Action != CashRegisterAction.Buy || !transform.PlayerCanReach(player, 1)) + { + SendShopRejection(player, rejection); + return; + } + + string[] prefabNames = packet.ItemPrefabNames ?? []; + int[] amounts = packet.ItemAmounts ?? []; + if (prefabNames.Length == 0 || prefabNames.Length > 64 || prefabNames.Length != amounts.Length) + { + SendShopRejection(player, CashRegisterAction.RejectedNoItems); + return; + } + + Shop shop = GlobalShopController.Instance.globalShopList.FirstOrDefault(entry => entry.cashRegister == CashRegister); + var modules = shop?.scanItemResourceModules + .Where(module => module?.sellingItemSpec != null) + .GroupBy(module => module.sellingItemSpec.ItemPrefabName) + .ToDictionary(group => group.Key, group => group.First()); + if (shop == null || modules == null) + { + SendShopRejection(player, rejection); + return; + } + + var purchases = new List(prefabNames.Length); + double totalCost = 0.0; + var requestedByPrefab = new Dictionary(); + for (int i = 0; i < prefabNames.Length; i++) + { + if (string.IsNullOrEmpty(prefabNames[i]) || amounts[i] <= 0 || + !modules.TryGetValue(prefabNames[i], out ScanItemCashRegisterModule module)) + { + SendShopRejection(player, CashRegisterAction.RejectedNoItems); + return; + } + + int requested = amounts[i]; + if (requestedByPrefab.TryGetValue(prefabNames[i], out int previous)) + requested = checked(requested + previous); + requestedByPrefab[prefabNames[i]] = requested; + + ShopItemData data = GlobalShopController.Instance.GetShopItemData(module.sellingItemSpec); + if (data == null || requested > data.ItemsInStock) + { + SendShopRejection(player, CashRegisterAction.RejectedNoItems); + return; + } + + totalCost += data.basePrice * amounts[i]; + } + + if (double.IsNaN(packet.Amount) || double.IsInfinity(packet.Amount) || packet.Amount + 0.001 < totalCost) + { + SendShopRejection(player, CashRegisterAction.RejectFunds); + return; + } + + foreach (var request in requestedByPrefab) + { + ScanItemCashRegisterModule module = modules[request.Key]; + GlobalShopController.Instance.AddItemToInstantiationQueue(module.sellingItemSpec, shop, request.Value); + purchases.Add(new ShopPurchase(request.Key, request.Value, + GlobalShopController.Instance.GetShopItemData(module.sellingItemSpec).basePrice)); + } + + ShopPurchaseCoordinator.QueueOwnership(purchases, player); + NetworkLifecycle.Instance.Server.SendCashRegisterAction(new CommonCashRegisterWithModulesActionPacket + { + NetId = NetId, + Action = CashRegisterAction.Approve, + Amount = totalCost, + BuyerPlayerId = player.PlayerId, + ItemPrefabNames = purchases.Select(item => item.PrefabName).ToArray(), + ItemAmounts = purchases.Select(item => item.Amount).ToArray(), + ItemUnitPrices = purchases.Select(item => item.UnitPrice).ToArray() + }); + } + + private void SendShopRejection(ServerPlayer player, CashRegisterAction action) + { + NetworkLifecycle.Instance.Server.SendCashRegisterAction(new CommonCashRegisterWithModulesActionPacket + { + NetId = NetId, + Action = action, + BuyerPlayerId = player.PlayerId + }, [player]); + } + #endregion #region Client @@ -242,6 +340,16 @@ public void Server_ProcessCashRegisterAction(ServerPlayer player, CommonCashRegi public void Client_ProcessCashRegisterAction(CashRegisterAction action, double amount) { NetworkLifecycle.Instance.Client?.LogDebug(() => $"NetworkedCashRegisterWithModules.Client_ProcessCashRegisterAction({action}, {amount}) isBuying: {isBuying}, isCancelling: {isCancelling}"); + if (IsShopRegister && action is CashRegisterAction.RejectGeneric or CashRegisterAction.RejectFunds or CashRegisterAction.RejectedNoItems) + { + isBuying = false; + isCancelling = false; + isAddingCash = false; + CashRegister.IsProcessingTransaction = false; + CashRegister.Cancel(); + return; + } + switch (action) { case CashRegisterAction.Cancel: @@ -348,6 +456,32 @@ public void Client_ProcessCashRegisterAction(CashRegisterAction action, double a } } + public void Client_ProcessCashRegisterAction(CommonCashRegisterWithModulesActionPacket packet) + { + if (IsShopRegister && packet.Action == CashRegisterAction.Approve) + { + ShopPurchaseCoordinator.ApplyApproval(this, packet); + return; + } + + if (IsShopRegister && packet.Action == CashRegisterAction.ShopStockChanged) + { + ShopPurchaseCoordinator.ApplyStockDelta(packet.ItemPrefabNames, packet.ItemAmounts); + return; + } + + Client_ProcessCashRegisterAction(packet.Action, packet.Amount); + } + + public void CompleteApprovedShopPurchase() + { + isBuying = false; + if (!ShopPurchaseCoordinator.RunApprovedLocalPurchase(CashRegister)) + Multiplayer.LogWarning($"Approved shop purchase could not be completed locally for {CashRegister.GetObjectPath()}"); + else + GlobalShopController.Instance.Fire_GlobalShopDataChanged(); + } + public IEnumerator Buy() { if (isBuying || isCancelling || NetworkLifecycle.Instance.IsProcessingPacket) @@ -369,6 +503,36 @@ public IEnumerator Buy() EnableInteraction(); } + public IEnumerator BuyShop() + { + if (isBuying || isCancelling || NetworkLifecycle.Instance.IsProcessingPacket) + yield break; + + ShopPurchase[] purchases = ShopPurchaseCoordinator.Capture(CashRegister); + if (purchases.Length == 0) + { + CashRegister.notEnoughMoneyAudio?.Play(CashRegister.transform.position, 1f, 1f, 0f, 1f, 500f, + default, null, CashRegister.transform, false, 0f, null); + yield break; + } + + DisableInteraction(); + CashRegister.IsProcessingTransaction = true; + isBuying = true; + + NetworkLifecycle.Instance.Client.SendCashRegisterAction(NetId, CashRegisterAction.Buy, + CashRegister.DepositedCash, + purchases.Select(item => item.PrefabName).ToArray(), + purchases.Select(item => item.Amount).ToArray()); + + float timeOut = Time.time + NetworkLifecycle.Instance.Client.RPC_Timeout; + yield return new WaitUntil(() => Time.time >= timeOut || isBuying == false); + + isBuying = false; + CashRegister.IsProcessingTransaction = false; + EnableInteraction(); + } + public IEnumerator Cancel() { if (isBuying || isCancelling || isAddingCash || NetworkLifecycle.Instance.IsProcessingPacket) diff --git a/Multiplayer/Components/Networking/World/NetworkedItem.cs b/Multiplayer/Components/Networking/World/NetworkedItem.cs index 912c8fa8..fc8c730f 100644 --- a/Multiplayer/Components/Networking/World/NetworkedItem.cs +++ b/Multiplayer/Components/Networking/World/NetworkedItem.cs @@ -99,6 +99,7 @@ public static bool TryGetNetId(ItemBase item, out ushort netID) private ItemState observedState; private bool hasObservedState; private bool stateDirty; + private bool ownerDirty; private bool wasThrown; private bool suppressDestroySync; @@ -109,6 +110,16 @@ public static bool TryGetNetId(ItemBase item, out ushort netID) //Handle ownership public sbyte OwnerId { get; private set; } = -1; // 0 means no owner + public void SetOwner(byte playerId, bool markDirty = false) + { + if (OwnerId == (sbyte)playerId) + return; + + OwnerId = (sbyte)playerId; + if (markDirty) + ownerDirty = true; + } + //public void SetOwner(ushort playerId) //{ // if (OwnerId != playerId) @@ -186,6 +197,16 @@ private bool Register() Item = itemBase; itemBaseToNetworkedItem[Item] = this; + // The host is the default persistent owner for items that make up the + // session world. Explicit ownership (purchase, pickup, reconciliation) + // can transfer it afterward. + if (NetworkLifecycle.Instance.IsHost()) + { + byte hostPlayerId = NetworkLifecycle.Instance.Server?.SelfId ?? 0; + if (hostPlayerId != 0) + SetOwner(hostPlayerId); + } + Item.Grabbed += OnGrabbed; Item.Ungrabbed += OnUngrabbed; @@ -312,11 +333,31 @@ public ItemUpdateData GetSnapshot() if (Item == null && Register() == false) return null; - if (!stateDirty && !hasDirtyVals) + if (!stateDirty && !ownerDirty && !hasDirtyVals) return null; ItemState currentState = GetItemState(); + // A local pickup is observed rather than applied through ReceiveSnapshot. + // It establishes ownership, while a later drop only ends possession: the + // persistent owner must remain so lost and found can return the item. + if (stateDirty && NetworkLifecycle.Instance.IsClientRunning) + { + byte localPlayerId = NetworkLifecycle.Instance.Client?.PlayerId ?? 0; + bool isHeldByLocalPlayer = currentState is ItemState.InHand or ItemState.InInventory; + if (isHeldByLocalPlayer) + SetOwner(localPlayerId); + + if (NetworkLifecycle.Instance.IsHost() && + NetworkLifecycle.Instance.Server.TryGetServerPlayer(localPlayerId, out ServerPlayer localPlayer)) + { + if (isHeldByLocalPlayer && !localPlayer.OwnsItem(NetId)) + localPlayer.AddOwnedItem(NetId); + else if (!isHeldByLocalPlayer && localPlayer.OwnsItem(NetId)) + localPlayer.RemoveOwnedItem(NetId); + } + } + if (!createdDirty) { if (lastState != currentState) @@ -327,6 +368,9 @@ public ItemUpdateData GetSnapshot() Multiplayer.LogDebug(GetDirtyValuesDebugString); updateType |= ItemUpdateData.ItemUpdateType.ObjectState; } + + if (ownerDirty) + updateType |= ItemUpdateData.ItemUpdateType.Ownership; } else { @@ -343,6 +387,7 @@ public ItemUpdateData GetSnapshot() createdDirty = false; stateDirty = false; + ownerDirty = false; wasThrown = false; MarkValuesClean(); @@ -367,6 +412,12 @@ public void ReceiveSnapshot(ItemUpdateData snapshot) private void ApplySnapshot(ItemUpdateData snapshot) { + // Shop clients instantiate the raw prefab, whose local flag can differ from + // the host's purchased instance. Apply this before dropped-state handling so + // the item is restored to the client's world storage list. + if (Item?.InventorySpecs != null) + Item.InventorySpecs.BelongsToPlayer = snapshot.BelongsToPlayer; + if (snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.ItemState) || snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.FullSync) || snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.Create)) { Multiplayer.Log($"NetworkedItem.ApplySnapshot() netId: {snapshot?.ItemNetId}, ItemUpdateType: {snapshot?.UpdateType}, ItemState: {snapshot?.ItemState}, Active state: {gameObject.activeInHierarchy}"); @@ -396,6 +447,16 @@ private void ApplySnapshot(ItemUpdateData snapshot) throw new Exception($"NetworkedItem.ApplySnapshot() Item state not implemented: {snapshot?.ItemState}"); } + + } + + if (snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.Ownership) || + snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.ItemState) || + snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.FullSync) || + snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.Create)) + { + // Ownership-only updates must not activate or relocate the item. + SetOwner(snapshot.OwnerPlayerId); } Multiplayer.Log($"NetworkedItem.ApplySnapshot() netID: {snapshot?.ItemNetId}, ItemUpdateType {snapshot?.UpdateType} About to process states"); @@ -415,6 +476,7 @@ private void ApplySnapshot(ItemUpdateData snapshot) //mark values as clean createdDirty = false; stateDirty = false; + ownerDirty = false; MarkValuesClean(); return; @@ -469,6 +531,8 @@ public ItemUpdateData CreateUpdateData(ItemUpdateData.ItemUpdateType updateType) { UpdateType = updateType, ItemNetId = NetId, + OwnerPlayerId = OwnerId > 0 ? (byte)OwnerId : (byte)0, + BelongsToPlayer = Item.InventorySpecs.BelongsToPlayer, PrefabName = Item.InventorySpecs.ItemPrefabName, ItemState = lastState, ItemPosition = position, @@ -494,6 +558,7 @@ public void MarkAsSynchronized() { createdDirty = false; stateDirty = false; + ownerDirty = false; lastState = GetItemState(); MarkValuesClean(); } @@ -711,9 +776,24 @@ protected override void OnDestroy() if (UnloadWatcher.isQuitting) return; + // Scene unloading used to return before clearing this static lookup. Unity + // then left destroyed-object entries in GetAll(), which were scanned and + // logged as null items on every network tick in the next session. + if (Item != null) + { + Item.Grabbed -= OnGrabbed; + Item.Ungrabbed -= OnUngrabbed; + itemBaseToNetworkedItem.Remove(Item); + } + else if (!UnloadWatcher.isUnloading) + { + Multiplayer.LogWarning($"NetworkedItem.OnDestroy({name}, {NetId}) Item is null!"); + } + if (UnloadWatcher.isUnloading) { itemBaseToNetworkedItem.Clear(); + // The base class resets its own static ID lookup during unload. base.OnDestroy(); return; } @@ -732,17 +812,6 @@ protected override void OnDestroy() } } - if (Item != null) - { - Item.Grabbed -= OnGrabbed; - Item.Ungrabbed -= OnUngrabbed; - itemBaseToNetworkedItem.Remove(Item); - } - else - { - Multiplayer.LogWarning($"NetworkedItem.OnDestroy({name}, {NetId}) Item is null!"); - } - base.OnDestroy(); } diff --git a/Multiplayer/Components/Networking/World/NetworkedItemManager.cs b/Multiplayer/Components/Networking/World/NetworkedItemManager.cs index fe88c65a..da5f647b 100644 --- a/Multiplayer/Components/Networking/World/NetworkedItemManager.cs +++ b/Multiplayer/Components/Networking/World/NetworkedItemManager.cs @@ -10,6 +10,7 @@ using DV; using DV.Interaction; using DV.InventorySystem; +using DV.Shops; using Multiplayer.Networking.Data.Items; using Multiplayer.Networking.Packets.Clientbound; using Multiplayer.Networking.Packets.Serverbound; @@ -48,6 +49,7 @@ public class NetworkedItemManager : SingletonBehaviour private Dictionary ItemPrefabs = new(1024); //Item prefabs private bool ClientInitialised = false; private readonly Dictionary pendingItemReconciliation = []; + private bool initialHostOwnershipAssigned; /* @@ -61,7 +63,7 @@ protected override void Awake() if (!NetworkLifecycle.Instance.IsHost()) return; - //B99 temporary patch NetworkLifecycle.Instance.Server.PlayerDisconnected += PlayerDisconnected; + NetworkLifecycle.Instance.Server.PlayerDisconnected += PlayerDisconnected; try { @@ -73,9 +75,28 @@ protected override void Awake() } } - private void PlayerDisconnected(uint netID) + private void PlayerDisconnected(ServerPlayer disconnectedPlayer) { - throw new NotImplementedException(); + if (disconnectedPlayer == null || !NetworkLifecycle.Instance.IsHost()) + return; + + byte hostPlayerId = NetworkLifecycle.Instance.Server?.SelfId ?? 0; + if (hostPlayerId == 0 || disconnectedPlayer.PlayerId == hostPlayerId) + return; + + int transferredItems = 0; + foreach (var item in NetworkedItem.GetAll()) + { + if (item == null || item.OwnerId != disconnectedPlayer.PlayerId) + continue; + + item.SetOwner(hostPlayerId, true); + transferredItems++; + } + + Multiplayer.LogDebug(() => + $"Transferred {transferredItems} items from disconnected player " + + $"{disconnectedPlayer.PlayerId} to host player {hostPlayerId}"); } protected void Start() @@ -92,6 +113,8 @@ protected override void OnDestroy() return; NetworkLifecycle.Instance.OnTick -= Common_OnTick; + if (NetworkLifecycle.Instance.Server != null) + NetworkLifecycle.Instance.Server.PlayerDisconnected -= PlayerDisconnected; } public void AddDirtyItemSnapshot(NetworkedItem netItem, ItemUpdateData snapshot) @@ -194,10 +217,10 @@ private void UpdatePlayerItemLists() foreach (var item in allItems) { if (item == null) - { - NetworkLifecycle.Instance.Server.LogDebug(() => $"UpdatePlayerItemLists() Null item found in allItems!"); continue; - } + + if (IsLocalShopItem(item)) + continue; float sqrDistance = (player.WorldPosition - item.WorldPosition).sqrMagnitude; @@ -229,7 +252,7 @@ private void ProcessChanged(uint tick) foreach (var item in NetworkedItem.GetAll()) { - if (item == null || item.NetId == 0) + if (item == null || item.NetId == 0 || IsLocalShopItem(item)) continue; ItemUpdateData snapshot = item.GetSnapshot(); @@ -336,6 +359,9 @@ private void ProcessReceivedAsHost(ItemUpdateData snapshot, ServerPlayer player) // ItemUpdateData only serializes Player for hand/inventory states. Stamp // the authenticated sender here instead of trusting the client's value. snapshot.Player = player.PlayerId; + snapshot.OwnerPlayerId = snapshot.ItemState is ItemState.InHand or ItemState.InInventory + ? player.PlayerId + : netItem.OwnerId > 0 ? (byte)netItem.OwnerId : (byte)0; NetworkLifecycle.Instance.Server.LogWarning($"NetworkedItemManager.ProcessReceivedAsHost() ItemNetId: {snapshot.ItemNetId}, snapshot type: {snapshot.UpdateType}"); netItem.ReceiveSnapshot(snapshot); AcceptedClientUpdates[snapshot.ItemNetId] = snapshot; @@ -410,7 +436,7 @@ private void ProcessClientChanges(uint tick) foreach (var item in NetworkedItem.GetAll()) { - if (item == null || item.NetId == 0) + if (item == null || item.NetId == 0 || IsLocalShopItem(item)) continue; ItemUpdateData snapshot = item.GetSnapshot(); @@ -529,7 +555,7 @@ public void CacheWorldItems() { try { - if (item.Item != null && !item.Item.IsEssential() && !item.Item.IsGrabbed() && !StorageController.Instance.StorageInventory.ContainsItem(item.Item)) + if (item.Item != null && !IsLocalShopItem(item) && !item.Item.IsEssential() && !item.Item.IsGrabbed() && !StorageController.Instance.StorageInventory.ContainsItem(item.Item)) { SendToCache(item); } @@ -612,6 +638,28 @@ internal void ApplyItemReconciliation(ClientboundItemReconciliationPacket packet Multiplayer.LogWarning($"Host did not return reconciliation data for local item {unresolved.Key} ({unresolved.Value?.name})"); pendingItemReconciliation.Clear(); + + if (NetworkLifecycle.Instance.IsHost() && !initialHostOwnershipAssigned) + { + byte hostPlayerId = NetworkLifecycle.Instance.Server?.SelfId ?? 0; + if (hostPlayerId != 0) + { + int assignedItems = 0; + foreach (var item in NetworkedItem.GetAll()) + { + if (item == null) + continue; + + item.SetOwner(hostPlayerId); + assignedItems++; + } + + initialHostOwnershipAssigned = true; + Multiplayer.LogDebug(() => + $"Assigned {assignedItems} initial session items to host player {hostPlayerId}"); + } + } + ClientInitialised = true; } @@ -648,6 +696,7 @@ internal ClientboundItemReconciliationPacket ReconcileClientItems( networkedItem.Item.InventorySpecs.BelongsToPlayer = request.BelongsToPlayer; snapshot.ItemNetId = netId; snapshot.Player = player.PlayerId; + snapshot.OwnerPlayerId = player.PlayerId; networkedItem.ReceiveSnapshot(snapshot); if (snapshot.ItemState is ItemState.InHand or ItemState.InInventory) @@ -689,7 +738,7 @@ state is ItemState.InHand or ItemState.InInventory || private static bool IsReconciliationCandidate(NetworkedItem item) { - if (item?.Item == null || item.NetId != 0) + if (item?.Item == null || item.NetId != 0 || IsLocalShopItem(item)) return false; var inventory = Inventory.Instance; @@ -762,6 +811,9 @@ public bool DoNotCreateItem(Type itemType) return false; } + private static bool IsLocalShopItem(NetworkedItem item) => + item != null && item.TryGetComponent(out _); + [UsedImplicitly] public new static string AllowAutoCreate() { diff --git a/Multiplayer/Components/Networking/World/ShopPurchaseCoordinator.cs b/Multiplayer/Components/Networking/World/ShopPurchaseCoordinator.cs new file mode 100644 index 00000000..23023849 --- /dev/null +++ b/Multiplayer/Components/Networking/World/ShopPurchaseCoordinator.cs @@ -0,0 +1,210 @@ +using DV.CabControls; +using DV.CashRegister; +using DV.Shops; +using DV.Utils; +using Multiplayer.Networking.Data; +using Multiplayer.Networking.Packets.Common; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Multiplayer.Components.Networking.World; + +internal readonly struct ShopPurchase +{ + public readonly string PrefabName; + public readonly int Amount; + public readonly float UnitPrice; + + public ShopPurchase(string prefabName, int amount, float unitPrice) + { + PrefabName = prefabName; + Amount = amount; + UnitPrice = unitPrice; + } +} + +internal static class ShopPurchaseCoordinator +{ + private static readonly Dictionary> pendingOwners = []; + private static bool applyingClientApproval; + + public static ShopPurchase[] Capture(CashRegisterWithModules register) => register.registerModules + .OfType() + .Where(module => module.sellingItemSpec != null && module.Data.unitsToBuy >= 1f) + .Select(module => new ShopPurchase(module.sellingItemSpec.ItemPrefabName, (int)module.Data.unitsToBuy, + module.Data.pricePerUnit)) + .ToArray(); + + public static bool IsShopRegister(CashRegisterWithModules register) + { + if (register == null) + return false; + if (NetworkedCashRegisterWithModules.TryGet(register, out var networkedRegister)) + return networkedRegister.IsShopRegister; + if (register.GetComponentInParent() is Shop parentShop && parentShop.cashRegister == register) + return true; + return GlobalShopController.Instance?.globalShopList?.Any(shop => shop.cashRegister == register) == true; + } + + public static void QueueOwnership(IEnumerable purchases, ServerPlayer buyer) + { + if (buyer == null) + return; + + foreach (var purchase in purchases) + { + if (!pendingOwners.TryGetValue(purchase.PrefabName, out var owners)) + pendingOwners[purchase.PrefabName] = owners = new Queue(); + + for (int i = 0; i < purchase.Amount; i++) + owners.Enqueue(buyer); + } + } + + public static void TryAssignOwner(NetworkedItem item) + { + if (!NetworkLifecycle.Instance.IsHost() || item?.Item?.InventorySpecs == null) + return; + + string prefabName = item.Item.InventorySpecs.ItemPrefabName; + if (!pendingOwners.TryGetValue(prefabName, out var owners) || owners.Count == 0) + return; + + ServerPlayer buyer = owners.Dequeue(); + if (owners.Count == 0) + pendingOwners.Remove(prefabName); + + buyer.AddOwnedItem(item.NetId); + item.SetOwner(buyer.PlayerId); + Multiplayer.LogDebug(() => $"Assigned purchased item {item.NetId} ({prefabName}) to {buyer.Username}"); + } + + public static bool SuppressClientInstantiation(InventoryItemSpec itemSpec, int amountBought) + { + if (!applyingClientApproval || NetworkLifecycle.Instance.IsHost()) + return false; + + if (itemSpec == null) + return true; + + ShopItemData data = GlobalShopController.Instance.GetShopItemData(itemSpec); + if (data != null) + data.purchasedItems += amountBought; + + SingletonBehaviour.Instance.UnlockItem(itemSpec.ItemPrefabName, true); + return true; + } + + public static void ApplyApproval(NetworkedCashRegisterWithModules networkedRegister, + CommonCashRegisterWithModulesActionPacket packet) + { + bool isBuyer = NetworkLifecycle.Instance.Client?.PlayerId == packet.BuyerPlayerId; + if (isBuyer) + { + ApplyApprovedPrices(networkedRegister.CashRegister, packet.ItemPrefabNames, packet.ItemUnitPrices); + networkedRegister.CompleteApprovedShopPurchase(); + return; + } + + ApplyStockDelta(packet.ItemPrefabNames, packet.ItemAmounts); + } + + private static void ApplyApprovedPrices(CashRegisterWithModules register, string[] prefabNames, float[] prices) + { + if (prefabNames == null || prices == null || prefabNames.Length != prices.Length) + return; + + var pricesByPrefab = prefabNames + .Select((prefabName, index) => new { prefabName, price = prices[index] }) + .ToDictionary(entry => entry.prefabName, entry => entry.price); + foreach (ScanItemCashRegisterModule module in register.registerModules.OfType()) + if (module.sellingItemSpec != null && + pricesByPrefab.TryGetValue(module.sellingItemSpec.ItemPrefabName, out float price)) + module.Data.pricePerUnit = price; + } + + public static UnityEngine.Vector3 ClosestPlayerDelta(UnityEngine.Vector3 localPlayerPosition, + UnityEngine.Vector3 anchor) + { + UnityEngine.Vector3 closest = localPlayerPosition - anchor; + if (!NetworkLifecycle.Instance.IsHost()) + return closest; + + foreach (ServerPlayer player in NetworkLifecycle.Instance.Server.ServerPlayers) + { + UnityEngine.Vector3 delta = player.WorldPosition - anchor; + if (delta.sqrMagnitude < closest.sqrMagnitude) + closest = delta; + } + + return closest; + } + + public static bool RunApprovedLocalPurchase(CashRegisterWithModules register) + { + applyingClientApproval = true; + try + { + register.IsProcessingTransaction = false; + return register.Buy(); + } + finally + { + applyingClientApproval = false; + } + } + + public static void ApplyStockDelta(string[] prefabNames, int[] amounts) + { + if (prefabNames == null || amounts == null || prefabNames.Length != amounts.Length) + return; + + for (int i = 0; i < prefabNames.Length; i++) + { + ShopItemData data = GlobalShopController.Instance.GetShopItemData(prefabNames[i]); + if (data != null && amounts[i] != 0) + data.purchasedItems = Math.Max(data.purchasedItems + amounts[i], 0); + } + + GlobalShopController.Instance.Fire_GlobalShopDataChanged(); + } + + public static int MaxPlayers + { + get + { + NetworkLifecycle lifecycle = NetworkLifecycle.Instance; + if (lifecycle?.Server != null) + return lifecycle.Server.IsSinglePlayer ? 1 : Math.Max(Multiplayer.Settings.MaxPlayers, 1); + return Math.Max(lifecycle?.Client?.MaxPlayers ?? 1, 1); + } + } + + public static void ScaleShopLimits(GlobalShopController controller) + { + int maxPlayers = MaxPlayers; + foreach (ShopItemData data in controller.shopItemsData) + { + // A zero allowance is meaningful for locked/disabled shop entries. + if (data.allowedToHaveAmount > 0) + data.allowedToHaveAmount = checked(data.initialAmount * maxPlayers); + } + } + + public static void RecountExistingShopItems(GlobalShopController controller) + { + var counts = SingletonBehaviour.Instance.GetAllStorageItems() + .Select(item => item?.GetComponent()) + .Where(restocker => restocker != null && restocker.restockOnItemDestroyed) + .Select(restocker => restocker.GetComponent()?.ItemPrefabName) + .Where(prefabName => !string.IsNullOrEmpty(prefabName)) + .GroupBy(prefabName => prefabName) + .ToDictionary(group => group.Key, group => group.Count()); + + foreach (ShopItemData data in controller.shopItemsData) + data.purchasedItems = counts.TryGetValue(data.item.ItemPrefabName, out int count) ? count : 0; + + controller.Fire_GlobalShopDataChanged(); + } +} diff --git a/Multiplayer/Networking/Data/Items/ItemUpdateData.cs b/Multiplayer/Networking/Data/Items/ItemUpdateData.cs index 3992481c..3f4489f4 100644 --- a/Multiplayer/Networking/Data/Items/ItemUpdateData.cs +++ b/Multiplayer/Networking/Data/Items/ItemUpdateData.cs @@ -18,11 +18,14 @@ public enum ItemUpdateType : byte ItemState = 4, ItemPosition = 8, ObjectState = 16, + Ownership = 32, FullSync = ItemState | ItemPosition | ObjectState, } public ItemUpdateType UpdateType { get; set; } public ushort ItemNetId { get; set; } + public byte OwnerPlayerId { get; set; } + public bool BelongsToPlayer { get; set; } public string PrefabName { get; set; } public ItemState ItemState { get; set; } public Vector3 ItemPosition { get; set; } @@ -41,6 +44,8 @@ public void Serialize(NetDataWriter writer) if (UpdateType == ItemUpdateType.Destroy) return; + writer.Put(OwnerPlayerId); + writer.Put(BelongsToPlayer); writer.Put((byte)ItemState); if (UpdateType.HasFlag(ItemUpdateType.Create)) @@ -91,6 +96,8 @@ public void Deserialize(NetDataReader reader) if (UpdateType == ItemUpdateType.Destroy) return; + OwnerPlayerId = reader.GetByte(); + BelongsToPlayer = reader.GetBool(); ItemState = (ItemState)reader.GetByte(); if (UpdateType.HasFlag(ItemUpdateType.Create)) diff --git a/Multiplayer/Networking/Managers/Client/NetworkClient.cs b/Multiplayer/Networking/Managers/Client/NetworkClient.cs index 7b48c795..0f71116d 100644 --- a/Multiplayer/Networking/Managers/Client/NetworkClient.cs +++ b/Multiplayer/Networking/Managers/Client/NetworkClient.cs @@ -64,6 +64,7 @@ public class NetworkClient : NetworkManager private ITransportPeer selfPeer; public byte PlayerId { get; private set; } + public int MaxPlayers { get; private set; } = 1; public string Username { get; private set; } private string characterModelId; public string CrewName { get; private set; } @@ -504,6 +505,7 @@ private void OnClientboundLoginResponsePacket(ClientboundLoginResponsePacket pac { Log($"Player accepted"); PlayerId = packet.PlayerId; + MaxPlayers = Math.Max(packet.MaxPlayers, 1); if (!string.IsNullOrEmpty(packet.OverrideUsername)) { @@ -1488,7 +1490,7 @@ 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 OnCommonGenericSwitchStatePacket(CommonGenericSwitchStatePacket packet) @@ -1955,14 +1957,17 @@ 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 SendCashRegisterAction(ushort netId, CashRegisterAction action, double amount = 0.0f, + string[] itemPrefabNames = null, int[] itemAmounts = null) { SendPacketToServer( new CommonCashRegisterWithModulesActionPacket { NetId = netId, Action = action, - Amount = amount + Amount = amount, + ItemPrefabNames = itemPrefabNames ?? [], + ItemAmounts = itemAmounts ?? [] }, DeliveryMethod.ReliableOrdered ); diff --git a/Multiplayer/Networking/Managers/Server/NetworkServer.cs b/Multiplayer/Networking/Managers/Server/NetworkServer.cs index fcfee89d..98e6d307 100644 --- a/Multiplayer/Networking/Managers/Server/NetworkServer.cs +++ b/Multiplayer/Networking/Managers/Server/NetworkServer.cs @@ -1194,6 +1194,7 @@ private void OnServerboundClientLoginPacket(ServerboundClientLoginPacket packet, { Accepted = true, PlayerId = serverPlayer.PlayerId, + MaxPlayers = IsSinglePlayer ? 1 : Multiplayer.Settings.MaxPlayers, OverrideUsername = serverPlayer.OriginalUsername == serverPlayer.Username ? string.Empty : overrideUsername, }; diff --git a/Multiplayer/Networking/Packets/Clientbound/ClientboundLoginResponsePacket.cs b/Multiplayer/Networking/Packets/Clientbound/ClientboundLoginResponsePacket.cs index cee2046a..29f065c0 100644 --- a/Multiplayer/Networking/Packets/Clientbound/ClientboundLoginResponsePacket.cs +++ b/Multiplayer/Networking/Packets/Clientbound/ClientboundLoginResponsePacket.cs @@ -6,6 +6,7 @@ public class ClientboundLoginResponsePacket { public bool Accepted { get; set; } public byte PlayerId { get; set; } + public int MaxPlayers { get; set; } = 1; public string OverrideUsername { get; set; } public string ReasonKey { get; set; } public string[] ReasonArgs { get; set; } diff --git a/Multiplayer/Networking/Packets/Common/CommonCashRegisterWithModulesActionPacket.cs b/Multiplayer/Networking/Packets/Common/CommonCashRegisterWithModulesActionPacket.cs index 780a0811..45772781 100644 --- a/Multiplayer/Networking/Packets/Common/CommonCashRegisterWithModulesActionPacket.cs +++ b/Multiplayer/Networking/Packets/Common/CommonCashRegisterWithModulesActionPacket.cs @@ -10,11 +10,16 @@ public enum CashRegisterAction : byte RejectGeneric, RejectFunds, RejectedNoItems, - Approve + Approve, + ShopStockChanged } public class CommonCashRegisterWithModulesActionPacket { public ushort NetId { get; set; } public CashRegisterAction Action { get; set; } public double Amount { get; set; } + public byte BuyerPlayerId { get; set; } + public string[] ItemPrefabNames { get; set; } = []; + public int[] ItemAmounts { get; set; } = []; + public float[] ItemUnitPrices { get; set; } = []; } diff --git a/Multiplayer/Patches/World/CashRegisterBasePatch.cs b/Multiplayer/Patches/World/CashRegisterBasePatch.cs index 8ffead33..822720ad 100644 --- a/Multiplayer/Patches/World/CashRegisterBasePatch.cs +++ b/Multiplayer/Patches/World/CashRegisterBasePatch.cs @@ -49,6 +49,9 @@ private static bool OnEnable(CashRegisterBase __instance) if (__instance is not CashRegisterWithModules) return true; + if (ShopPurchaseCoordinator.IsShopRegister((CashRegisterWithModules)__instance)) + return true; + return NetworkLifecycle.Instance.IsHost(); } @@ -60,6 +63,9 @@ private static bool OnDisable(CashRegisterBase __instance) if (__instance is not CashRegisterWithModules) return true; + if (ShopPurchaseCoordinator.IsShopRegister((CashRegisterWithModules)__instance)) + return true; + // Prevent clients from cancelling/returning cash on cash registers when loading the game or leaving the area __instance.StopAllCoroutines(); return NetworkLifecycle.Instance.IsHost(); diff --git a/Multiplayer/Patches/World/CashRegisterWithModulesPatch.cs b/Multiplayer/Patches/World/CashRegisterWithModulesPatch.cs index 0031ca4c..03a7b554 100644 --- a/Multiplayer/Patches/World/CashRegisterWithModulesPatch.cs +++ b/Multiplayer/Patches/World/CashRegisterWithModulesPatch.cs @@ -4,6 +4,7 @@ using Multiplayer.Components.Networking.World; using Multiplayer.Networking.Packets.Common; using Multiplayer.Utils; +using System.Linq; namespace Multiplayer.Patches.World; @@ -18,6 +19,9 @@ private static bool OnDisable(CashRegisterWithModules __instance) if (__instance == null) return true; + if (ShopPurchaseCoordinator.IsShopRegister(__instance)) + return true; + __instance?.StopAllCoroutines(); __instance?.textController?.Clear(); __instance?.SetupListeners(false); @@ -44,7 +48,10 @@ private static bool OnBuyPressed(CashRegisterWithModules __instance) } if (netCashRegister.IsShopRegister) - return true; + { + CoroutineManager.Instance.StartCoroutine(netCashRegister.BuyShop()); + return false; + } CoroutineManager.Instance.StartCoroutine(netCashRegister.Buy()); @@ -64,6 +71,9 @@ private static void OnBuyPressed_Postfix(CashRegisterWithModules __instance) return; } + if (netCashRegister.IsShopRegister) + return; + // Send buy action to all clients NetworkLifecycle.Instance.Server.SendCashRegisterAction(new CommonCashRegisterWithModulesActionPacket { @@ -73,6 +83,39 @@ private static void OnBuyPressed_Postfix(CashRegisterWithModules __instance) }); } + [HarmonyPrefix] + [HarmonyPatch(nameof(CashRegisterWithModules.Buy))] + private static void Buy_Prefix(CashRegisterWithModules __instance, out ShopPurchase[] __state) + { + __state = NetworkLifecycle.Instance.IsHost() && + NetworkedCashRegisterWithModules.TryGet(__instance, out var networkedRegister) && + networkedRegister.IsShopRegister + ? ShopPurchaseCoordinator.Capture(__instance) + : []; + } + + [HarmonyPostfix] + [HarmonyPatch(nameof(CashRegisterWithModules.Buy))] + private static void Buy_Postfix(CashRegisterWithModules __instance, bool __result, ShopPurchase[] __state) + { + if (!__result || __state == null || __state.Length == 0 || !NetworkLifecycle.Instance.IsHost() || + !NetworkedCashRegisterWithModules.TryGet(__instance, out var networkedRegister) || + !networkedRegister.IsShopRegister || + !NetworkLifecycle.Instance.Server.TryGetServerPlayer(NetworkLifecycle.Instance.Server.SelfId, out var buyer)) + return; + + ShopPurchaseCoordinator.QueueOwnership(__state, buyer); + NetworkLifecycle.Instance.Server.SendCashRegisterAction(new CommonCashRegisterWithModulesActionPacket + { + NetId = networkedRegister.NetId, + Action = CashRegisterAction.Approve, + BuyerPlayerId = buyer.PlayerId, + ItemPrefabNames = __state.Select(item => item.PrefabName).ToArray(), + ItemAmounts = __state.Select(item => item.Amount).ToArray(), + ItemUnitPrices = __state.Select(item => item.UnitPrice).ToArray() + }); + } + [HarmonyPrefix] [HarmonyPatch(nameof(CashRegisterWithModules.Cancel))] private static bool Cancel(CashRegisterWithModules __instance) @@ -80,7 +123,7 @@ private static bool Cancel(CashRegisterWithModules __instance) //Multiplayer.LogDebug(()=>$"CashRegisterWithModules.Cancel({__instance.GetObjectPath()})\r\n{Environment.StackTrace}"); - if (NetworkLifecycle.Instance.IsHost()) + if (NetworkLifecycle.Instance?.IsHost() != false) return true; if (!NetworkedCashRegisterWithModules.TryGet(__instance, out var netCashRegister)) diff --git a/Multiplayer/Patches/World/Items/ItemBasePatch.cs b/Multiplayer/Patches/World/Items/ItemBasePatch.cs index 9c899ec4..317c9e9f 100644 --- a/Multiplayer/Patches/World/Items/ItemBasePatch.cs +++ b/Multiplayer/Patches/World/Items/ItemBasePatch.cs @@ -15,6 +15,8 @@ private static void Awake(ItemBase __instance) //Multiplayer.Log($"ItemBase.Awake() ItemSpec: {__instance?.InventorySpecs?.itemPrefabName}"); var networkedItem = __instance.GetOrAddComponent(); + ShopPurchaseCoordinator.TryAssignOwner(networkedItem); + //networkedItem.FinaliseTrackedValues(); return; } diff --git a/Multiplayer/Patches/World/ShopPatch.cs b/Multiplayer/Patches/World/ShopPatch.cs new file mode 100644 index 00000000..1134d440 --- /dev/null +++ b/Multiplayer/Patches/World/ShopPatch.cs @@ -0,0 +1,97 @@ +using DV.CabControls; +using DV.Shops; +using HarmonyLib; +using Multiplayer.Components.Networking; +using Multiplayer.Components.Networking.World; +using Multiplayer.Networking.Packets.Common; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using UnityEngine; + +namespace Multiplayer.Patches.World; + +[HarmonyPatch(typeof(GlobalShopController))] +internal static class GlobalShopControllerPatch +{ + [HarmonyPostfix] + [HarmonyPatch("InitializeShopData")] + private static void InitializeShopData(GlobalShopController __instance) => + ShopPurchaseCoordinator.ScaleShopLimits(__instance); + + [HarmonyPrefix] + [HarmonyPatch(nameof(GlobalShopController.Fire_GlobalShopDataChanged))] + private static void FireGlobalShopDataChanged(GlobalShopController __instance) => + ShopPurchaseCoordinator.ScaleShopLimits(__instance); + + [HarmonyPostfix] + [HarmonyPatch(nameof(GlobalShopController.UpdateItemStocksOnGameLoad))] + private static void UpdateItemStocksOnGameLoad() => + ShopPurchaseCoordinator.RecountExistingShopItems(GlobalShopController.Instance); + + [HarmonyPrefix] + [HarmonyPatch(nameof(GlobalShopController.AddItemToInstantiationQueue))] + private static bool AddItemToInstantiationQueue(InventoryItemSpec boughtItemSpec, int amountBought) => + !ShopPurchaseCoordinator.SuppressClientInstantiation(boughtItemSpec, amountBought); + + [HarmonyPrefix] + [HarmonyPatch(nameof(GlobalShopController.Restock))] + private static void Restock_Prefix(GlobalShopController __instance, string itemPrefabName, out int __state) => + __state = __instance.GetShopItemData(itemPrefabName)?.purchasedItems ?? 0; + + [HarmonyPostfix] + [HarmonyPatch(nameof(GlobalShopController.Restock))] + private static void Restock_Postfix(GlobalShopController __instance, string itemPrefabName, int __state) + { + ShopItemData data = __instance.GetShopItemData(itemPrefabName); + if (!NetworkLifecycle.Instance.IsHost() || data == null || data.purchasedItems >= __state) + return; + + var register = __instance.globalShopList + .Select(shop => shop.cashRegister) + .FirstOrDefault(cashRegister => NetworkedCashRegisterWithModules.TryGet(cashRegister, out _)); + if (register == null || !NetworkedCashRegisterWithModules.TryGet(register, out var networkedRegister)) + return; + + NetworkLifecycle.Instance.Server.SendCashRegisterAction(new CommonCashRegisterWithModulesActionPacket + { + NetId = networkedRegister.NetId, + Action = CashRegisterAction.ShopStockChanged, + ItemPrefabNames = [itemPrefabName], + ItemAmounts = [-1] + }); + } +} + +[HarmonyPatch] +internal static class GlobalShopControllerInstantiatePurchasedItemsPatch +{ + public static IEnumerable TargetMethods() => typeof(GlobalShopController) + .GetNestedTypes(BindingFlags.NonPublic | BindingFlags.Instance) + .Where(type => type.Name.StartsWith("")) + .SelectMany(type => type.GetMethods(BindingFlags.NonPublic | BindingFlags.Instance)) + .Where(method => method.Name == "MoveNext"); + + private static IEnumerable Transpiler(IEnumerable instructions) + { + MethodInfo subtraction = AccessTools.Method(typeof(Vector3), "op_Subtraction", + [typeof(Vector3), typeof(Vector3)]); + MethodInfo closestPlayerDelta = AccessTools.Method(typeof(ShopPurchaseCoordinator), + nameof(ShopPurchaseCoordinator.ClosestPlayerDelta)); + bool replaced = false; + + foreach (CodeInstruction instruction in instructions) + { + if (!replaced && instruction.Calls(subtraction)) + { + instruction.operand = closestPlayerDelta; + replaced = true; + } + + yield return instruction; + } + + if (!replaced) + Multiplayer.LogError("GlobalShopController.InstantiatePurchasedItems distance patch failed"); + } +} diff --git a/Multiplayer/Patches/World/StorageControllerPatch.cs b/Multiplayer/Patches/World/StorageControllerPatch.cs index df1e9813..b12a614e 100644 --- a/Multiplayer/Patches/World/StorageControllerPatch.cs +++ b/Multiplayer/Patches/World/StorageControllerPatch.cs @@ -1,82 +1,75 @@ using DV.CabControls; using HarmonyLib; +using Multiplayer.Components.Networking; using Multiplayer.Components.Networking.World; -using Multiplayer.Utils; -using System; -using UnityEngine; +using System.Collections.Generic; +using System.Reflection.Emit; namespace Multiplayer.Patches.World.Items; -/* -[HarmonyPatch(typeof(StorageController))] -public static class StorageControllerPatch + +internal static class LostAndFoundOwnership { - [HarmonyPatch(nameof(StorageController.AddItemToLostAndFound))] - [HarmonyPrefix] - static void AddItemToLostAndFound(StorageController __instance, ItemBase item) + public static List FilterForLocalPlayer(List items) { + if (items == null || !IsMultiplayerSession()) + return items; - Multiplayer.LogDebug(() => - { - NetworkedItem.TryGetNetworkedItem(item, out NetworkedItem netItem); - return $"StorageController.AddItemToLostAndFound({item.name}) netId: {netItem?.NetId}\r\n{new System.Diagnostics.StackTrace()}"; - }); - } - - [HarmonyPatch(nameof(StorageController.RemoveItemFromLostAndFound))] - [HarmonyPrefix] - static void RemoveItemFromLostAndFound(StorageController __instance, ItemBase item) - { + byte localPlayerId = NetworkLifecycle.Instance.Client?.PlayerId ?? 0; + if (localPlayerId == 0) + return new List(); - Multiplayer.LogDebug(() => + var ownedItems = new List(items.Count); + foreach (ItemBase item in items) { - NetworkedItem.TryGetNetworkedItem(item, out NetworkedItem netItem); - return $"StorageController.RemoveItemFromLostAndFound({item.name}) netId: {netItem?.NetId}\r\n{new System.Diagnostics.StackTrace()}"; - }); - } + if (item == null || !NetworkedItem.TryGetNetworkedItem(item, out var networkedItem)) + continue; - [HarmonyPatch(nameof(StorageController.RequestLostAndFoundItemActivation))] - [HarmonyPrefix] - static void RequestLostAndFoundItemActivation(StorageController __instance) - { + if (networkedItem.OwnerId == localPlayerId) + ownedItems.Add(item); + } Multiplayer.LogDebug(() => - { - return $"StorageController.RequestLostAndFoundItemActivation()\r\n{new System.Diagnostics.StackTrace()}"; - }); + $"Lost and found filtered {items.Count} items to {ownedItems.Count} for player {localPlayerId}"); + return ownedItems; } - [HarmonyPatch(nameof(StorageController.MoveItemsFromWorldToLostAndFound))] - [HarmonyPrefix] - static void MoveItemsFromWorldToLostAndFound(StorageController __instance, bool ignoreItemsWithRespawnParents) + private static bool IsMultiplayerSession() { + var lifecycle = NetworkLifecycle.Instance; + if (lifecycle == null || !lifecycle.IsClientRunning) + return false; - Multiplayer.LogDebug(() => - { - return $"StorageController.MoveItemsFromWorldToLostAndFound({ignoreItemsWithRespawnParents})\r\n{new System.Diagnostics.StackTrace()}"; - }); + return lifecycle.IsHost() + ? lifecycle.Server?.IsSinglePlayer == false + : lifecycle.Client?.MaxPlayers > 1; } - [HarmonyPatch(nameof(StorageController.ForceSummonAllWorldItemsToLostAndFound))] - [HarmonyPrefix] - static void ForceSummonAllWorldItemsToLostAndFound(StorageController __instance) + public static IEnumerable FilterStorageList(IEnumerable instructions) { + var getStorageItems = AccessTools.Method(typeof(StorageBase), nameof(StorageBase.GetStorageItemList)); + var filter = AccessTools.Method(typeof(LostAndFoundOwnership), nameof(FilterForLocalPlayer)); - Multiplayer.LogDebug(() => + foreach (var instruction in instructions) { - return $"StorageController.ForceSummonAllWorldItemsToLostAndFound()\r\n{new System.Diagnostics.StackTrace()}"; - }); + yield return instruction; + if (instruction.Calls(getStorageItems)) + yield return new CodeInstruction(OpCodes.Call, filter); + } } +} - [HarmonyPatch(nameof(StorageController.RequestItemActivation))] - [HarmonyPrefix] - static void RequestItemActivation(StorageController __instance) - { - - Multiplayer.LogDebug(() => - { - return $"StorageController.RequestItemActivation()\r\n{new System.Diagnostics.StackTrace()}"; - }); - } +[HarmonyPatch(typeof(StorageController), nameof(StorageController.MoveItemsFromWorldToLostAndFound))] +internal static class MoveItemsFromWorldToLostAndFoundPatch +{ + [HarmonyTranspiler] + private static IEnumerable Transpiler(IEnumerable instructions) => + LostAndFoundOwnership.FilterStorageList(instructions); +} +[HarmonyPatch(typeof(StorageItemTransformController), nameof(StorageItemTransformController.ActivateItems))] +internal static class ActivateLostAndFoundItemsPatch +{ + [HarmonyTranspiler] + private static IEnumerable Transpiler(IEnumerable instructions) => + LostAndFoundOwnership.FilterStorageList(instructions); } -*/ From 01393f752fd213b9028904307a5510bc5bae4473 Mon Sep 17 00:00:00 2001 From: Patrick van Halm Date: Sun, 16 Aug 2026 15:04:13 +0200 Subject: [PATCH 03/14] Harden multiplayer state synchronization --- .../Components/Networking/NetworkLifecycle.cs | 56 +- .../Networking/Player/NetworkedPlayer.cs | 26 +- .../Networking/Train/NetworkedTrainCar.cs | 25 + .../Networking/World/CustomizationRef.cs | 53 +- .../World/CustomizationStateManager.cs | 750 ++++++++++++++---- .../World/GadgetTrackedValueRegistry.cs | 61 +- .../World/NetworkedCashRegisterWithModules.cs | 91 ++- .../Networking/World/NetworkedItem.cs | 84 +- .../Networking/World/NetworkedItemManager.cs | 244 +++--- .../World/ShopPurchaseCoordinator.cs | 115 ++- .../Customization/CustomizationStateData.cs | 191 ++--- .../Networking/Data/Items/ItemUpdateData.cs | 100 +-- Multiplayer/Networking/Data/ServerPlayer.cs | 40 +- .../Managers/Client/ClientPlayerManager.cs | 6 +- .../Managers/Client/NetworkClient.cs | 317 +++++++- .../Managers/Server/NetworkServer.cs | 277 ++++++- .../ClientboundItemReconciliationPacket.cs | 33 +- .../ClientboundPlayerJoinedPacket.cs | 1 + .../ClientboundPlayerPositionPacket.cs | 1 + ...mmonCashRegisterWithModulesActionPacket.cs | 8 +- .../Common/CommonCustomizationPacket.cs | 111 ++- .../Packets/Common/CommonShopPacket.cs | 24 + .../Packets/ItemReconciliationProtocol.cs | 6 + .../ServerboundItemReconciliationPacket.cs | 15 +- .../ServerboundPlayerPositionPacket.cs | 1 + .../Serialization/TrackedValueSerializer.cs | 56 ++ .../Patches/Train/CarVisitCheckerPatch.cs | 28 + .../World/CashRegisterWithModulesPatch.cs | 6 +- .../Patches/World/CustomizationPatch.cs | 6 +- .../Patches/World/GadgetRelationshipPatch.cs | 1 + .../World/Items/CustomizationToolPatch.cs | 2 + .../Patches/World/Items/ItemBasePatch.cs | 4 +- Multiplayer/Patches/World/ShopPatch.cs | 30 +- .../Patches/World/StorageControllerPatch.cs | 2 +- 34 files changed, 2047 insertions(+), 724 deletions(-) create mode 100644 Multiplayer/Networking/Packets/Common/CommonShopPacket.cs create mode 100644 Multiplayer/Networking/Packets/ItemReconciliationProtocol.cs create mode 100644 Multiplayer/Networking/Serialization/TrackedValueSerializer.cs diff --git a/Multiplayer/Components/Networking/NetworkLifecycle.cs b/Multiplayer/Components/Networking/NetworkLifecycle.cs index 41a5bd7c..ae79ecec 100644 --- a/Multiplayer/Components/Networking/NetworkLifecycle.cs +++ b/Multiplayer/Components/Networking/NetworkLifecycle.cs @@ -4,6 +4,7 @@ using MPAPI; using Multiplayer.API; using Multiplayer.Components.Networking.UI; +using Multiplayer.Components.Networking.World; using Multiplayer.Networking.Data; using Multiplayer.Networking.Managers.Client; using Multiplayer.Networking.Managers.Server; @@ -33,6 +34,7 @@ public class NetworkLifecycle : SingletonBehaviour public NetworkClient Client { get; private set; } public uint Tick { get; internal set; } + public uint SynchronizedTick => IsServerRunning || clientTickSynchronized ? Tick : 0; public Action OnTick; public bool IsServerRunning => Server?.IsRunning ?? false; @@ -46,6 +48,42 @@ public class NetworkLifecycle : SingletonBehaviour private NetworkStatsGui Stats; private readonly ExecutionTimer tickTimer = new(); private readonly ExecutionTimer tickWatchdog = new(0.25f); + private bool clientTickSynchronized; + private uint clientTicksToHold; + + public static int SignedTickDelta(uint newer, uint older) => unchecked((int)(newer - older)); + + public float SecondsSinceTick(uint sampleTick) + { + return Mathf.Max(0f, SignedTickDelta(Tick, sampleTick) / (float)TICK_RATE); + } + + internal void SynchronizeClientTick(uint serverTick, int oneWayLatencyMs) + { + uint latencyTicks = (uint)Mathf.Max(0, Mathf.RoundToInt(oneWayLatencyMs / 1000f * TICK_RATE)); + uint estimatedServerTick = unchecked(serverTick + latencyTicks); + + if (!clientTickSynchronized) + { + Tick = estimatedServerTick; + clientTickSynchronized = true; + clientTicksToHold = 0; + return; + } + + int drift = SignedTickDelta(estimatedServerTick, Tick); + if (drift > 0) + { + Tick = estimatedServerTick; + clientTicksToHold = 0; + } + else if (drift < 0) + { + // Never rewind the public tick: pause local generation briefly while + // the server clock catches up. Cap one correction against bad samples. + clientTicksToHold = (uint)Math.Min(-(long)drift, TICK_RATE * 2L); + } + } /// /// Whether the provided ITransportPeer is the host. @@ -126,6 +164,8 @@ public bool StartServer(IDifficulty difficulty) if (Server != null) throw new InvalidOperationException("NetworkManager already exists!"); + ShopPurchaseCoordinator.ResetSessionState(); + if (!IsSinglePlayer) { if (serverData != null) @@ -159,6 +199,12 @@ public void StartClient(string address, int port, string password, bool isSingle { if (Client != null) throw new InvalidOperationException("NetworkManager already exists!"); + if (!IsServerRunning) + { + clientTickSynchronized = false; + clientTicksToHold = 0; + } + NetworkClient client = new(Multiplayer.Settings, isSinglePlayer); client.Start(address, port, password, isSinglePlayer, onDisconnect); @@ -175,13 +221,18 @@ private IEnumerator PollEvents() { while (!UnloadWatcher.isQuitting) { - Tick++; + bool advanceTick = IsServerRunning || clientTicksToHold == 0; + if (advanceTick) + Tick++; + else + clientTicksToHold--; + tickTimer.Start(); tickWatchdog.Start(); try { - if (!UnloadWatcher.isUnloading && !UnloadWatcher.isQuitting && !IsReturningToMenu) + if (advanceTick && !UnloadWatcher.isUnloading && !UnloadWatcher.isQuitting && !IsReturningToMenu) OnTick?.Invoke(Tick); } catch (Exception e) @@ -230,6 +281,7 @@ private void TickManager(NetworkManager manager) public void Stop() { Stats?.Hide(); + ShopPurchaseCoordinator.ResetSessionState(); if (Server != null) { diff --git a/Multiplayer/Components/Networking/Player/NetworkedPlayer.cs b/Multiplayer/Components/Networking/Player/NetworkedPlayer.cs index 7eaac01b..62666767 100644 --- a/Multiplayer/Components/Networking/Player/NetworkedPlayer.cs +++ b/Multiplayer/Components/Networking/Player/NetworkedPlayer.cs @@ -93,6 +93,9 @@ public string DisplayName private Quaternion targetRotation; private Vector2 moveDir; private Vector2 targetMoveDir; + private uint lastTrackingTick; + private bool hasTrackingTick; + private float pendingTrackingAge; private float currentLeanAngle; private float angleSmoothRefVel; @@ -243,7 +246,11 @@ public int GetPing() protected void Update() { - float t = Time.deltaTime * LERP_SPEED; + // Consume packet age once so smoothing does not add its full delay on top + // of the time this sample has already spent in transit. + float interpolationDelta = Time.deltaTime + pendingTrackingAge; + pendingTrackingAge = 0f; + float t = 1f - Mathf.Exp(-LERP_SPEED * interpolationDelta); Vector3 position = Vector3.Lerp( IsOnCar ? selfTransform.localPosition : selfTransform.position, @@ -372,6 +379,23 @@ private void ApplyHandTracking() /// /// /// + public bool TryAcceptTrackingTick(uint sampleTick) + { + if (sampleTick == 0) + return true; + + if (hasTrackingTick && NetworkLifecycle.SignedTickDelta(sampleTick, lastTrackingTick) < 0) + return false; + + lastTrackingTick = sampleTick; + hasTrackingTick = true; + pendingTrackingAge = Mathf.Clamp( + NetworkLifecycle.Instance.SecondsSinceTick(sampleTick), + 0f, + 0.25f); + return true; + } + public void UpdatePosition(PlayerTrackingData trackingData, PlayerPostureFlags posture, bool movePacketIsOnCar) { if (trackingData.Position.HasValue) diff --git a/Multiplayer/Components/Networking/Train/NetworkedTrainCar.cs b/Multiplayer/Components/Networking/Train/NetworkedTrainCar.cs index 0ded2ff5..e15e51cb 100644 --- a/Multiplayer/Components/Networking/Train/NetworkedTrainCar.cs +++ b/Multiplayer/Components/Networking/Train/NetworkedTrainCar.cs @@ -45,6 +45,8 @@ public static bool TryGet(ushort netId, out NetworkedTrainCar obj) return b; } + public static IEnumerable GetAll() => trainCarsToNetworkedTrainCars.Values; + public static bool TryGet(ushort netId, out TrainCar trainCar) { bool b = TryGet(netId, out NetworkedTrainCar networkedTrainCar); @@ -160,6 +162,9 @@ static string GetFuse(uint netId) public string CurrentID { get; private set; } public TrainCar TrainCar; + public bool HasAttachedCustomizations => TrainCar?.Customization != null && + ((TrainCar.Customization.Customizers?.Count ?? 0) > 0 || TrainCar.Customization.HoleCount > 0); + public bool IsInteriorPinnedForNetworkSync => interiorSyncLeaseCount > 0; public uint TicksSinceSync = uint.MaxValue; public uint lastTickProcessed = 0; @@ -182,6 +187,7 @@ static string GetFuse(uint netId) private readonly Dictionary lastSentTrainDamages = []; private InteriorControlsManager interiorControlsManager; + private int interiorSyncLeaseCount; private readonly Dictionary> scrollableControlDelegates = []; private Dictionary controlTypeToControl = []; private readonly Dictionary portToBaseControl = []; @@ -1135,6 +1141,25 @@ public void Server_PlayerOnCar(ServerPlayer player) serverPlayersInCar.Add(player); TrainCar?.visitChecker?.OnPlayerCarChanged(TrainCar); + + // The vanilla ECS interior streamer is based on the host's local camera and + // can ignore CarVisitChecker's remote-player state. Customization components + // live in the instantiated interior, so explicitly load it for remote users. + if (TrainCar != null && !TrainCar.IsInteriorLoaded) + TrainCar.LoadInterior(); + } + + public void BeginInteriorNetworkSync() + { + interiorSyncLeaseCount++; + if (TrainCar != null && !TrainCar.IsInteriorLoaded) + TrainCar.LoadInterior(); + } + + public void EndInteriorNetworkSync() + { + if (interiorSyncLeaseCount > 0) + interiorSyncLeaseCount--; } public void Server_RemovePlayer(ServerPlayer player) diff --git a/Multiplayer/Components/Networking/World/CustomizationRef.cs b/Multiplayer/Components/Networking/World/CustomizationRef.cs index db9fa2fb..b3c735fb 100644 --- a/Multiplayer/Components/Networking/World/CustomizationRef.cs +++ b/Multiplayer/Components/Networking/World/CustomizationRef.cs @@ -1,8 +1,5 @@ using DV.Customization; -using Multiplayer.Components.Networking.Train; using Multiplayer.Networking.Data.Customization; -using System.Linq; -using UnityEngine; namespace Multiplayer.Components.Networking.World; @@ -14,56 +11,12 @@ public static bool TryCreate(Customization customization, out CustomizationRefDa if (customization == null) return false; - if (customization is TrainCarCustomization trainCustomization) - { - if (!NetworkedTrainCar.TryGetNetId(trainCustomization.TrainCar, out ushort trainCarNetId)) - return false; - - reference.Kind = CustomizationTargetKind.TrainCar; - reference.TrainCarNetId = trainCarNetId; - return true; - } - - reference.Kind = customization switch - { - WorldCustomization => CustomizationTargetKind.World, - StorageShedCustomization => CustomizationTargetKind.Storage, - PlayerHouseCustomization => CustomizationTargetKind.PlayerHouse, - PaintStationCustomization => CustomizationTargetKind.PaintStation, - _ => reference.Kind, - }; - - return customization is WorldCustomization or StorageShedCustomization or - PlayerHouseCustomization or PaintStationCustomization; + reference.IdentificationKey = customization.GetIdentificationKey(); + return !string.IsNullOrEmpty(reference.IdentificationKey); } public static bool TryResolve(CustomizationRefData reference, out Customization customization) { - customization = null; - - if (reference.Kind == CustomizationTargetKind.TrainCar) - { - if (!NetworkedTrainCar.TryGet(reference.TrainCarNetId, out TrainCar trainCar)) - return false; - - customization = trainCar.GetComponent(); - return customization != null; - } - - customization = Resources.FindObjectsOfTypeAll() - .FirstOrDefault(candidate => candidate != null && candidate.gameObject.scene.IsValid() && Matches(reference.Kind, candidate)); - return customization != null; - } - - private static bool Matches(CustomizationTargetKind kind, Customization customization) - { - return kind switch - { - CustomizationTargetKind.World => customization is WorldCustomization, - CustomizationTargetKind.Storage => customization is StorageShedCustomization, - CustomizationTargetKind.PlayerHouse => customization is PlayerHouseCustomization, - CustomizationTargetKind.PaintStation => customization is PaintStationCustomization, - _ => false, - }; + return Customization.TryGetFromIdentificationKey(reference.IdentificationKey, out customization); } } diff --git a/Multiplayer/Components/Networking/World/CustomizationStateManager.cs b/Multiplayer/Components/Networking/World/CustomizationStateManager.cs index 9a1c38c1..77beeb01 100644 --- a/Multiplayer/Components/Networking/World/CustomizationStateManager.cs +++ b/Multiplayer/Components/Networking/World/CustomizationStateManager.cs @@ -5,7 +5,10 @@ using DV.Items.Snapping; using Multiplayer.Networking.Data.Customization; using Multiplayer.Networking.Data.Items; +using Multiplayer.Networking.Data; +using Multiplayer.Networking.Data.Player; using Multiplayer.Networking.Packets.Common; +using System; using System.Collections; using System.Collections.Generic; using System.Linq; @@ -15,6 +18,16 @@ namespace Multiplayer.Components.Networking.World; public static class CustomizationStateManager { + private static readonly Dictionary pendingLocalDuctTapeReplacements = []; + + public static void RegisterPendingDuctTapeReplacement(ushort consumedItemNetId, NetworkedItem replacement) + { + if (consumedItemNetId != 0 && replacement != null && replacement.NetId == 0) + pendingLocalDuctTapeReplacements[consumedItemNetId] = replacement; + } + + public static void ClearPendingLocalState() => pendingLocalDuctTapeReplacements.Clear(); + public static CustomizationStateData CaptureCurrentState() { var state = new CustomizationStateData(); @@ -27,18 +40,18 @@ public static CustomizationStateData CaptureCurrentState() return state; } - public static IEnumerator ApplyCurrentStateWhenReady(CustomizationStateData state) + public static IEnumerator ApplyCurrentStateWhenReady(CustomizationStateData state, + ICollection deferredPlacements = null, + ICollection deferredRelationships = null, + IDictionary> deferredHoleStates = null) { LogSnapshot("applying", state); - using (CustomizationSyncScope.Remote()) - { - CreateRelatedItems(state.RelatedItems); - CreateGadgetItems(state.Gadgets); - } - + const int maxItemInitializationFrames = 600; + int itemInitializationFrames = 0; bool loggedWait = false; - while (!AreSnapshotItemsReady(state, out string waitingFor)) + bool itemsReady = AreSnapshotItemsReady(state, out string waitingFor); + while (!itemsReady && itemInitializationFrames < maxItemInitializationFrames) { if (!loggedWait) { @@ -46,13 +59,20 @@ public static IEnumerator ApplyCurrentStateWhenReady(CustomizationStateData stat loggedWait = true; } yield return null; + itemInitializationFrames++; + itemsReady = AreSnapshotItemsReady(state, out waitingFor); } - if (loggedWait) + if (!itemsReady) + Multiplayer.LogWarning($"Customization snapshot timed out waiting for {waitingFor}; continuing with available items"); + else if (loggedWait) Multiplayer.LogDebug(() => "Customization snapshot items initialized; continuing reconstruction"); using (CustomizationSyncScope.Remote()) - PlaceGadgets(state.Gadgets); + { + ResetRelationships(); + ReconcileGadgetPlacements(state.Gadgets, deferredPlacements); + } // NetworkedItem registration can finish before SnappableItem.Initialize in // the same Unity frame. Wait for the item's compatible anchor before @@ -75,20 +95,39 @@ public static IEnumerator ApplyCurrentStateWhenReady(CustomizationStateData stat } if (!snapRelationshipsReady) - Multiplayer.LogWarning($"Customization snapshot timed out waiting for {snapWaitingFor}; attempting relationships with diagnostics"); + Multiplayer.LogWarning($"Customization snapshot timed out waiting for {snapWaitingFor}; deferring unavailable relationships"); else if (loggedSnapWait) Multiplayer.LogDebug(() => $"Customization snap relationships ready after {snapInitializationFrames} frame(s)"); using (CustomizationSyncScope.Remote()) { - ApplyRelationships(state.Relationships); - ApplyFreeHoles(state.Holes); + ApplyRelationships(state.Relationships, deferredRelationships); + ApplyFreeHoles(state.CustomizationKeys, state.Holes, deferredHoleStates); ApplyTrackedValues(state.Gadgets); } Multiplayer.LogDebug(() => "Customization snapshot application completed"); } + public static IEnumerable GetReferencedItemIds(CustomizationStateData state) + { + if (state == null) + return []; + + return state.Gadgets.Select(gadget => gadget.ItemNetId) + .Concat(state.Relationships.SelectMany(relationship => new[] { relationship.ItemNetId, relationship.OtherItemNetId })) + .Where(itemNetId => itemNetId != 0) + .Distinct(); + } + + public static HashSet GetItemsRequiringDroppedCreate(CustomizationStateData state) => + state.Relationships + .Where(relationship => relationship.Action is + CustomizationAction.LoadSpool or CustomizationAction.SnapItem) + .Select(relationship => relationship.OtherItemNetId) + .Where(itemNetId => itemNetId != 0) + .ToHashSet(); + public static void SendAction(CommonCustomizationPacket packet) { if (NetworkLifecycle.Instance.IsHost()) @@ -141,6 +180,152 @@ public static void ApplyAction(CommonCustomizationPacket packet) } } + public static bool ProcessActionAsHost(CommonCustomizationPacket packet, ServerPlayer sender) + { + if (packet == null || sender == null || !IsActionReady(packet, out _)) + return false; + + // Customization interactions are client-authoritative. In particular, the + // host may not have the sender's area loaded, making host-side item and + // target positions unsuitable for reach validation. + if (packet.Action is CustomizationAction.ReplaceSpool or CustomizationAction.ReplaceDuctTape) + packet.OwnerPlayerId = sender.PlayerId; + + ApplyAction(packet); + bool applied = WasActionApplied(packet); + if (!applied) + Multiplayer.LogWarning($"Host did not apply customization action {packet.Action} " + + $"({packet.ItemNetId}, {packet.OtherItemNetId}); mutation will not be relayed"); + return applied; + } + + private static bool WasActionApplied(CommonCustomizationPacket packet) + { + switch (packet.Action) + { + case CustomizationAction.PlaceGadget: + return TryGadget(packet.ItemNetId, out var placed) && placed.IsLinked && + CustomizationRef.TryResolve(TargetOf(packet), out var placementTarget) && + placed.Custom == placementTarget; + case CustomizationAction.RemoveGadget: + return TryGadget(packet.ItemNetId, out var removed) && !removed.IsLinked; + case CustomizationAction.AddHole: + case CustomizationAction.MoveHole: + return CustomizationRef.TryResolve(TargetOf(packet), out var holeTarget) && + holeTarget.FindHole(packet.Position, out _); + case CustomizationAction.RemoveHole: + return CustomizationRef.TryResolve(TargetOf(packet), out var removalTarget) && + !removalTarget.FindHole(packet.PreviousPosition, out _); + case CustomizationAction.Mount: + case CustomizationAction.Unmount: + if (!TryGadget(packet.ItemNetId, out var mountOwner)) + return false; + var mounts = mountOwner.GetComponents(); + if (!IsValidIndex(packet.IndexA, mounts.Length)) + return false; + return packet.Action == CustomizationAction.Unmount + ? mounts[packet.IndexA].MountedGadget == null + : TryGadget(packet.OtherItemNetId, out var mounted) && + mounts[packet.IndexA].MountedGadget == mounted; + case CustomizationAction.Wire: + case CustomizationAction.Unwire: + if (!TryGadget(packet.ItemNetId, out var first) || + !TryGadget(packet.OtherItemNetId, out var second) || + !IsValidIndex(packet.IndexA, first.WireLinkPorts.Count) || + !IsValidIndex(packet.IndexB, second.WireLinkPorts.Count)) + return false; + var links = new List(); + first.WireLinkPorts[packet.IndexA].GetLinks(links); + bool linked = links.Contains(second.WireLinkPorts[packet.IndexB]); + return packet.Action == CustomizationAction.Wire ? linked : !linked; + case CustomizationAction.DropEmptySpool: + return NetworkedItem.TryGet(packet.ItemNetId, out var dropToolItem) && + dropToolItem.GetTrackedItem() is { HasEjectableSpool: false }; + case CustomizationAction.LoadSpool: + return NetworkedItem.TryGet(packet.ItemNetId, out var loadToolItem) && + loadToolItem.GetTrackedItem()?.magazine?.items?.FirstOrDefault() is GameObject loaded && + NetworkedItem.TryGetNetworkedItem(loaded.GetComponent(), out var loadedItem) && + loadedItem.NetId == packet.OtherItemNetId; + case CustomizationAction.ReplaceSpool: + return packet.OtherItemNetId != 0 && NetworkedItem.TryGet(packet.OtherItemNetId, out _); + case CustomizationAction.ReplaceDuctTape: + return packet.OtherItemNetId != 0 && NetworkedItem.TryGet(packet.OtherItemNetId, out _); + case CustomizationAction.SnapItem: + case CustomizationAction.UnsnapItem: + if (!TryGadget(packet.ItemNetId, out var snapOwner)) + return false; + var snapPoints = GetSnapPoints(snapOwner); + if (!IsValidIndex(packet.IndexA, snapPoints.Length)) + return false; + if (packet.Action == CustomizationAction.UnsnapItem) + return snapPoints[packet.IndexA].SnappedItem == null; + return snapPoints[packet.IndexA].SnappedItem != null && + NetworkedItem.TryGetNetId(snapPoints[packet.IndexA].SnappedItem, out ushort snappedId) && + snappedId == packet.OtherItemNetId; + default: + return false; + } + } + + public static IEnumerable GetCustomizationRecipients(CommonCustomizationPacket packet) + { + if (packet == null || NetworkLifecycle.Instance?.Server == null) + return []; + + // Customization state is global session state. Every initialized peer must + // receive every mutation even when it is currently in another streamed + // area; otherwise approaching the car later exposes stale gadget state. + // This is routing only—clients are intentionally trusted by this mod. + return NetworkLifecycle.Instance.Server.ServerPlayers.Where(player => + player.LoadingState >= PlayerLoadingState.ReadyForCustomizers && + player.CustomizationSnapshotSent); + } + + public static IEnumerable GetActionItemIds(CommonCustomizationPacket packet) + { + if (packet?.ItemNetId > 0) + yield return packet.ItemNetId; + if (packet?.OtherItemNetId > 0) + yield return packet.OtherItemNetId; + } + + internal static bool ActionsConflict(CommonCustomizationPacket first, CommonCustomizationPacket second) + { + if (first == null || second == null) + return false; + + var dependencies = GetActionDependencyKeys(first).ToHashSet(); + return GetActionDependencyKeys(second).Any(dependencies.Contains); + } + + internal static bool SnapshotPlacementConflicts(GadgetPlacementData placement, + CommonCustomizationPacket action) + { + if (placement == null || action == null) + return false; + + return placement.ItemNetId != 0 && + (placement.ItemNetId == action.ItemNetId || placement.ItemNetId == action.OtherItemNetId) || + !string.IsNullOrEmpty(placement.Target.IdentificationKey) && + placement.Target.IdentificationKey == action.TargetKey; + } + + internal static bool SnapshotHoleStateConflicts(string targetKey, CommonCustomizationPacket action) => + action != null && !string.IsNullOrEmpty(targetKey) && targetKey == action.TargetKey; + + private static IEnumerable GetActionDependencyKeys(CommonCustomizationPacket packet) + { + if (packet == null) + yield break; + + if (packet.ItemNetId != 0) + yield return $"item:{packet.ItemNetId}"; + if (packet.OtherItemNetId != 0) + yield return $"item:{packet.OtherItemNetId}"; + if (!string.IsNullOrEmpty(packet.TargetKey)) + yield return $"target:{packet.TargetKey}"; + } + public static bool TryGadget(ushort itemNetId, out GadgetBase gadget) { gadget = null; @@ -214,8 +399,6 @@ private static void CaptureLoadedSpool(CustomizationStateData state, NetworkedIt if (!NetworkedItem.TryGetNetworkedItem(spoolItemBase, out var spoolItem)) return; - AddRelatedItem(state, toolItem, forceDropped: false); - AddRelatedItem(state, spoolItem, forceDropped: true); AddRelationship(state, CustomizationAction.LoadSpool, toolItem.NetId, spoolItem.NetId); } @@ -229,16 +412,15 @@ private static void CapturePlacement( state.Gadgets.Add(new GadgetPlacementData { ItemNetId = item.NetId, - PrefabName = item.Item.InventorySpecs.ItemPrefabName, + SentTick = NetworkLifecycle.Instance.SynchronizedTick, Target = target, LocalPosition = gadget.transform.localPosition, LocalRotation = gadget.transform.localRotation, - IsOnGlass = gadget.IsOnGlass, TrackedValues = fullState?.States ?? new Dictionary(), }); Multiplayer.LogDebug(() => $"Customization snapshot gadget={item.NetId} type={gadget.GetType().Name} " + - $"target={target.Kind}:{target.TrainCarNetId} pos={gadget.transform.localPosition} " + + $"target={target.IdentificationKey} pos={gadget.transform.localPosition} " + $"solder={gadget.SolderingProgressUnits} tracked={fullState?.States?.Count ?? 0}"); } @@ -261,9 +443,6 @@ private static void CaptureSnappedItems(CustomizationStateData state, NetworkedI if (snappedItem == null || !NetworkedItem.TryGetNetId(snappedItem, out var snappedId)) continue; - if (NetworkedItem.TryGet(snappedId, out var networkedSnappedItem)) - AddRelatedItem(state, networkedSnappedItem, forceDropped: true); - bool hasAnchorPosition = false; Vector3 anchorPosition = Vector3.zero; if (snappedItem.SnappableItem != null) @@ -313,6 +492,9 @@ private static void CaptureFreeHoles(CustomizationStateData state) if (!CustomizationRef.TryCreate(customization, out var target)) continue; + if (!state.CustomizationKeys.Contains(target.IdentificationKey)) + state.CustomizationKeys.Add(target.IdentificationKey); + foreach (var hole in customization.Holes) { state.Holes.Add(new CustomizationHoleData @@ -325,28 +507,13 @@ private static void CaptureFreeHoles(CustomizationStateData state) } } - private static void CreateRelatedItems(IEnumerable relatedItems) - { - foreach (var relatedItem in relatedItems) - { - if (!NetworkedItem.TryGet(relatedItem.ItemNetId, out _)) - NetworkedItemManager.Instance.CreateCustomizationItem(relatedItem); - } - } - - private static void CreateGadgetItems(IEnumerable placements) - { - foreach (var placement in placements) - GetOrCreateGadgetItem(placement); - } - private static bool AreSnapshotItemsReady(CustomizationStateData state, out string waitingFor) { - foreach (var relatedItem in state.RelatedItems) + foreach (ushort itemNetId in GetReferencedItemIds(state)) { - if (!NetworkedItem.TryGet(relatedItem.ItemNetId, out var item) || !item.IsReadyForSnapshots) + if (!NetworkedItem.TryGet(itemNetId, out var item) || !item.IsReadyForSnapshots) { - waitingFor = $"related item {relatedItem.ItemNetId}"; + waitingFor = $"network item {itemNetId}"; return false; } } @@ -371,113 +538,253 @@ private static bool AreSnapshotItemsReady(CustomizationStateData state, out stri return true; } - private static bool AreSnapRelationshipsReady(IEnumerable relationships, out string waitingFor) + private static bool AreSnapRelationshipsReady(IEnumerable relationships, out string waitingFor) { foreach (var relationship in relationships) { - if ((CustomizationAction)relationship.Action != CustomizationAction.SnapItem) + if (relationship.Action != CustomizationAction.SnapItem) continue; - if (!TryGadget(relationship.ItemA, out var owner)) - { - waitingFor = $"snap-point owner {relationship.ItemA} is initialized"; + if (!IsSnapActionReady(relationship, out waitingFor)) return false; - } + } - var snapPoints = GetSnapPoints(owner); - if (!IsValidIndex(relationship.IndexA, snapPoints.Length)) + waitingFor = null; + return true; + } + + private static void ResetRelationships() + { + foreach (var item in NetworkedItem.GetAll()) + { + var gadget = item?.GetTrackedItem()?.Gadget; + if (gadget == null) + continue; + + try { - waitingFor = $"snap point {relationship.IndexA} on gadget {relationship.ItemA} is initialized"; - return false; + foreach (var mount in gadget.GetComponents()) + if (mount.MountedGadget != null) + mount.UnmountGadget(); + + foreach (var port in gadget.WireLinkPorts) + GadgetWiringModule.WireLinkPort.Unwire(port); + + foreach (var snapPoint in GetSnapPoints(gadget)) + if (snapPoint.SnappedItem != null) + snapPoint.UnsnapItem(forced: true); + } + catch (Exception exception) + { + Multiplayer.LogError($"Failed to reset relationships for snapshot gadget {item.NetId}: {exception}"); } + } + } + + private static void ReconcileGadgetPlacements(IEnumerable placements, + ICollection deferredPlacements) + { + var desiredPlacements = placements + .GroupBy(placement => placement.ItemNetId) + .ToDictionary(group => group.Key, group => group.Last()); - if (!NetworkedItem.TryGet(relationship.ItemB, out var snappedItem) || - snappedItem.Item?.SnappableItem == null) + foreach (var item in NetworkedItem.GetAll()) + { + var gadgetItem = item?.GetTrackedItem(); + var gadget = gadgetItem?.Gadget; + if (gadget == null || !gadget.IsLinked) + continue; + + if (desiredPlacements.TryGetValue(item.NetId, out var placement)) { - waitingFor = $"snappable item {relationship.ItemB} is initialized"; - return false; + // A timed-out target is not evidence that the placement is stale. + // Preserve the existing link and let a later snapshot reconcile it. + if (!CustomizationRef.TryResolve(placement.Target, out var target)) + continue; + + if (gadget.Custom == target) + { + gadget.transform.SetParent(target.GetParentingTransform(), false); + gadget.transform.localPosition = placement.LocalPosition; + gadget.transform.localRotation = placement.LocalRotation; + gadget.gameObject.SetActive(true); + desiredPlacements.Remove(item.NetId); + continue; + } } - var snapPoint = snapPoints[relationship.IndexA]; - var snappable = snappedItem.Item.SnappableItem; - if (((int)snappable.AllowedSnapPointTypes & (int)snapPoint.SnapPointType) == 0 || - snappable.GetAnchor(snapPoint.SnapPointType) == null) + try { - waitingFor = $"snap anchor on item {relationship.ItemB} is initialized"; - return false; + if (gadget.Remove(reparentToTrainCar: false) == null) + { + desiredPlacements.Remove(item.NetId); + Multiplayer.LogWarning($"Could not remove stale snapshot gadget {item.NetId}; preserving its current placement"); + continue; + } + + item.gameObject.SetActive(false); + item.MarkAsSynchronized(); + } + catch (Exception exception) + { + desiredPlacements.Remove(item.NetId); + Multiplayer.LogError($"Failed to remove stale snapshot gadget {item.NetId}: {exception}"); } } - waitingFor = null; - return true; + PlaceGadgets(desiredPlacements.Values, deferredPlacements); } - private static void PlaceGadgets(IEnumerable placements) + private static void PlaceGadgets(IEnumerable placements, + ICollection deferredPlacements) { foreach (var placement in placements) { - if (!NetworkedItem.TryGet(placement.ItemNetId, out var item)) + if (!IsSnapshotPlacementReady(placement, out string waitingFor)) + { + deferredPlacements?.Add(placement); + Multiplayer.LogDebug(() => $"Deferring snapshot gadget {placement.ItemNetId} until {waitingFor}"); continue; + } - var gadgetItem = item?.GetTrackedItem(); - if (gadgetItem == null || !CustomizationRef.TryResolve(placement.Target, out var target)) + ApplySnapshotPlacement(placement, applyTrackedValues: false); + } + } + + private static void ApplyRelationships(IEnumerable relationships, + ICollection deferredRelationships) + { + foreach (var relationship in relationships) + { + string waitingFor = null; + bool blockedByEarlierDependency = deferredRelationships?.Any( + earlier => ActionsConflict(earlier, relationship)) == true; + if (blockedByEarlierDependency || !IsActionReady(relationship, out waitingFor)) + { + deferredRelationships?.Add(relationship); + Multiplayer.LogDebug(() => $"Deferring snapshot relationship {relationship.Action} " + + $"({relationship.ItemNetId}, {relationship.OtherItemNetId}) until {waitingFor ?? "an earlier dependency"}"); continue; + } + + try + { + ApplyAction(relationship); + } + catch (Exception exception) + { + Multiplayer.LogError($"Failed to apply snapshot relationship {relationship.Action} " + + $"({relationship.ItemNetId}, {relationship.OtherItemNetId}): {exception}"); + } + } + } - var gadget = GadgetItem.Place( - target, - placement.LocalPosition, - placement.LocalRotation, - gadgetItem, - colliderForPlacementData: null); + internal static bool IsSnapshotPlacementReady(GadgetPlacementData placement, out string waitingFor) + { + if (!NetworkedItem.TryGet(placement.ItemNetId, out var item) || !item.IsReadyForSnapshots) + { + waitingFor = $"gadget item {placement.ItemNetId}"; + return false; + } - if (gadget != null) - gadget.IsOnGlass = placement.IsOnGlass; + var gadgetItem = item.GetTrackedItem(); + if (gadgetItem?.Gadget == null || gadgetItem.Item == null) + { + waitingFor = $"GadgetItem {placement.ItemNetId} Start"; + return false; } + + if (!CustomizationRef.TryResolve(placement.Target, out _)) + { + waitingFor = $"customization target '{placement.Target.IdentificationKey}'"; + return false; + } + + waitingFor = null; + return true; } - private static NetworkedItem GetOrCreateGadgetItem(GadgetPlacementData placement) + internal static void ApplySnapshotPlacement(GadgetPlacementData placement, bool applyTrackedValues = true) { - if (NetworkedItem.TryGet(placement.ItemNetId, out var item)) - return item; + if (!NetworkedItem.TryGet(placement.ItemNetId, out var item) || + !CustomizationRef.TryResolve(placement.Target, out var target)) + return; - NetworkedItemManager.Instance.CreateCustomizationItem(new ItemUpdateData + var gadgetItem = item.GetTrackedItem(); + using (CustomizationSyncScope.Remote()) { - UpdateType = ItemUpdateData.ItemUpdateType.Create, - ItemNetId = placement.ItemNetId, - PrefabName = placement.PrefabName, - ItemState = ItemState.Dropped, - ItemPosition = Vector3.zero, - ItemRotation = Quaternion.identity, - }); + try + { + var gadget = gadgetItem.Gadget; + if (gadget.IsLinked && gadget.Custom != target && gadget.Remove(reparentToTrainCar: false) == null) + { + Multiplayer.LogWarning($"Could not remove stale snapshot gadget {placement.ItemNetId}; preserving its current placement"); + return; + } + + if (gadget.IsLinked && gadget.Custom == target) + { + gadget.transform.SetParent(target.GetParentingTransform(), false); + gadget.transform.localPosition = placement.LocalPosition; + gadget.transform.localRotation = placement.LocalRotation; + gadget.gameObject.SetActive(true); + } + else + { + GadgetItem.Place(target, placement.LocalPosition, placement.LocalRotation, gadgetItem, + colliderForPlacementData: null); + } - NetworkedItem.TryGet(placement.ItemNetId, out item); - return item; + if (applyTrackedValues) + ApplyTrackedValues([placement]); + } + catch (Exception exception) + { + Multiplayer.LogError($"Failed to place snapshot gadget {placement.ItemNetId} on " + + $"'{placement.Target.IdentificationKey}': {exception}"); + } + } } - private static void ApplyRelationships(IEnumerable relationships) + private static void ApplyFreeHoles(IEnumerable customizationKeys, + IEnumerable holes, + IDictionary> deferredHoleStates) { - foreach (var relationship in relationships) + var holesByTarget = holes + .Where(hole => !string.IsNullOrEmpty(hole.Target.IdentificationKey)) + .GroupBy(hole => hole.Target.IdentificationKey) + .ToDictionary(group => group.Key, group => group.ToList()); + var targetKeys = customizationKeys + .Concat(holesByTarget.Keys) + .Where(key => !string.IsNullOrEmpty(key)) + .Distinct(); + + foreach (string key in targetKeys) { - ApplyAction(new CommonCustomizationPacket + List targetHoles = holesByTarget.TryGetValue(key, out var captured) + ? captured + : []; + if (!ApplySnapshotHoleState(key, targetHoles)) { - Action = (CustomizationAction)relationship.Action, - ItemNetId = relationship.ItemA, - OtherItemNetId = relationship.ItemB, - IndexA = relationship.IndexA, - IndexB = relationship.IndexB, - Flag = relationship.HasPosition, - Position = relationship.Position, - }); + deferredHoleStates?[key] = targetHoles; + Multiplayer.LogDebug(() => $"Deferring snapshot holes until customization target '{key}' is initialized"); + } } } - private static void ApplyFreeHoles(IEnumerable holes) + internal static bool ApplySnapshotHoleState(string targetKey, + IEnumerable holes) { - foreach (var hole in holes) + if (!Customization.TryGetFromIdentificationKey(targetKey, out var target)) + return false; + + using (CustomizationSyncScope.Remote()) { - if (CustomizationRef.TryResolve(hole.Target, out var target)) + target.ClearHoles(); + foreach (var hole in holes ?? []) target.AddHole(hole.LocalPosition, hole.LocalNormal); } + return true; } private static void ApplyTrackedValues(IEnumerable placements) @@ -491,11 +798,160 @@ private static void ApplyTrackedValues(IEnumerable placemen { UpdateType = ItemUpdateData.ItemUpdateType.ObjectState, ItemNetId = placement.ItemNetId, + SentTick = placement.SentTick, States = placement.TrackedValues, }); } } + internal static bool IsActionReady(CommonCustomizationPacket packet, out string waitingFor) + { + waitingFor = null; + if (packet == null) + return false; + + bool needsTarget = packet.Action is CustomizationAction.PlaceGadget or + CustomizationAction.AddHole or CustomizationAction.MoveHole or CustomizationAction.RemoveHole; + if (needsTarget && !CustomizationRef.TryResolve(TargetOf(packet), out _)) + { + waitingFor = $"customization target '{packet.TargetKey}'"; + return false; + } + + bool hasLocalDuctTapeReplacement = packet.Action == CustomizationAction.ReplaceDuctTape && + pendingLocalDuctTapeReplacements.TryGetValue(packet.ItemNetId, out var localReplacement) && + localReplacement != null && localReplacement.NetId == 0; + + IEnumerable requiredItems = packet.Action switch + { + // These actions construct or adopt the replacement locally. The old + // duct tape may already have been destroyed on the originating peer. + CustomizationAction.ReplaceDuctTape when hasLocalDuctTapeReplacement => [], + CustomizationAction.ReplaceDuctTape => [packet.ItemNetId], + CustomizationAction.ReplaceSpool => [packet.ItemNetId], + _ => GetActionItemIds(packet), + }; + + foreach (ushort itemNetId in requiredItems.Distinct()) + { + if (!NetworkedItem.TryGet(itemNetId, out var item) || !item.IsReadyForSnapshots) + { + waitingFor = $"network item {itemNetId}"; + return false; + } + } + + if (packet.Action == CustomizationAction.PlaceGadget && + (!NetworkedItem.TryGet(packet.ItemNetId, out var placedItem) || + placedItem.GetTrackedItem() is not { Gadget: not null, Item: not null })) + { + waitingFor = $"gadget item {packet.ItemNetId}"; + return false; + } + + bool needsFirstGadget = packet.Action is CustomizationAction.RemoveGadget or + CustomizationAction.Mount or CustomizationAction.Unmount or CustomizationAction.Wire or + CustomizationAction.Unwire or CustomizationAction.SnapItem or CustomizationAction.UnsnapItem; + if (needsFirstGadget && !TryGadget(packet.ItemNetId, out _)) + { + waitingFor = $"gadget {packet.ItemNetId}"; + return false; + } + + bool needsSecondGadget = packet.Action is CustomizationAction.Mount or + CustomizationAction.Wire or CustomizationAction.Unwire; + if (needsSecondGadget && !TryGadget(packet.OtherItemNetId, out _)) + { + waitingFor = $"gadget {packet.OtherItemNetId}"; + return false; + } + + if (packet.Action is CustomizationAction.Mount or CustomizationAction.Unmount) + { + TryGadget(packet.ItemNetId, out var owner); + if (!IsValidIndex(packet.IndexA, owner.GetComponents().Length)) + { + waitingFor = $"mount {packet.IndexA} on gadget {packet.ItemNetId}"; + return false; + } + } + + if (packet.Action is CustomizationAction.Wire or CustomizationAction.Unwire) + { + TryGadget(packet.ItemNetId, out var first); + TryGadget(packet.OtherItemNetId, out var second); + if (!IsValidIndex(packet.IndexA, first.WireLinkPorts.Count)) + { + waitingFor = $"wire port {packet.IndexA} on gadget {packet.ItemNetId}"; + return false; + } + + if (!IsValidIndex(packet.IndexB, second.WireLinkPorts.Count)) + { + waitingFor = $"wire port {packet.IndexB} on gadget {packet.OtherItemNetId}"; + return false; + } + } + + if ((packet.Action is CustomizationAction.SnapItem or CustomizationAction.UnsnapItem) && + !IsSnapActionReady(packet, out waitingFor)) + return false; + + if (packet.Action is CustomizationAction.DropEmptySpool or CustomizationAction.LoadSpool or + CustomizationAction.ReplaceSpool) + { + NetworkedItem.TryGet(packet.ItemNetId, out var toolItem); + if (toolItem?.GetTrackedItem()?.magazine == null) + { + waitingFor = $"soldering tool {packet.ItemNetId}"; + return false; + } + } + + return true; + } + + private static bool IsSnapActionReady(CommonCustomizationPacket packet, out string waitingFor) + { + if (!TryGadget(packet.ItemNetId, out var owner)) + { + waitingFor = $"snap-point owner {packet.ItemNetId}"; + return false; + } + + var snapPoints = GetSnapPoints(owner); + if (!IsValidIndex(packet.IndexA, snapPoints.Length)) + { + waitingFor = $"snap point {packet.IndexA} on gadget {packet.ItemNetId}"; + return false; + } + + if (packet.Action == CustomizationAction.UnsnapItem) + { + waitingFor = null; + return true; + } + + if (!NetworkedItem.TryGet(packet.OtherItemNetId, out var snappedItem) || + snappedItem.Item?.SnappableItem == null) + { + waitingFor = $"snappable item {packet.OtherItemNetId}"; + return false; + } + + var snapPoint = snapPoints[packet.IndexA]; + var snappable = snappedItem.Item.SnappableItem; + if (((int)snappable.AllowedSnapPointTypes & (int)snapPoint.SnapPointType) == 0 || + snappable.GetAnchor(snapPoint.SnapPointType) == null) + { + waitingFor = $"snap anchor on item {packet.OtherItemNetId}"; + return false; + } + + waitingFor = null; + return true; + } + private static void ApplyPlacement(CommonCustomizationPacket packet) { if (!NetworkedItem.TryGet(packet.ItemNetId, out var item) || @@ -503,9 +959,7 @@ private static void ApplyPlacement(CommonCustomizationPacket packet) !CustomizationRef.TryResolve(TargetOf(packet), out var target)) return; - var gadget = GadgetItem.Place(target, packet.Position, packet.Rotation, gadgetItem, colliderForPlacementData: null); - if (gadget != null) - gadget.IsOnGlass = packet.Flag; + GadgetItem.Place(target, packet.Position, packet.Rotation, gadgetItem, colliderForPlacementData: null); } private static void ApplyRemoval(CommonCustomizationPacket packet) @@ -596,7 +1050,7 @@ private static void ApplySpoolAction(CommonCustomizationPacket packet) var tool = toolItem.GetTrackedItem(); if (packet.Action == CustomizationAction.ReplaceSpool) { - ApplySpoolReplacement(tool, packet); + ApplySpoolReplacement(toolItem, tool, packet); return; } @@ -615,7 +1069,7 @@ private static void ApplySpoolAction(CommonCustomizationPacket packet) } } - private static void ApplySpoolReplacement(GadgetSolderingTool tool, CommonCustomizationPacket packet) + private static void ApplySpoolReplacement(NetworkedItem toolItem, GadgetSolderingTool tool, CommonCustomizationPacket packet) { if (tool?.magazine == null) return; @@ -629,6 +1083,13 @@ private static void ApplySpoolReplacement(GadgetSolderingTool tool, CommonCustom var magazineItems = tool.magazine.items; var current = magazineItems != null && magazineItems.Length > 0 ? magazineItems[0] : null; var currentAmmo = current?.GetComponent(); + NetworkedItem currentItem = null; + if (current != null) + NetworkedItem.TryGetNetworkedItem(current.GetComponent(), out currentItem); + byte replacementOwner = packet.OwnerPlayerId != 0 + ? packet.OwnerPlayerId + : currentItem?.OwnerPlayerId ?? toolItem?.OwnerPlayerId ?? 0; + bool belongsToPlayer = currentItem?.Item?.InventorySpecs?.BelongsToPlayer ?? true; GameObject spentObject = currentAmmo != null && currentAmmo.isSpent ? current : null; if (spentObject == null) @@ -649,7 +1110,7 @@ private static void ApplySpoolReplacement(GadgetSolderingTool tool, CommonCustom } } - spentObject = Object.Instantiate( + spentObject = UnityEngine.Object.Instantiate( tool.emptyCoilItemPrefab, tool.reelInteractionPoint.transform.position, tool.reelInteractionPoint.transform.rotation); @@ -666,8 +1127,10 @@ private static void ApplySpoolReplacement(GadgetSolderingTool tool, CommonCustom else if (packet.OtherItemNetId != 0) spentItem.NetId = packet.OtherItemNetId; + spentItem.SetOwner(replacementOwner); + if (spentItem.Item?.InventorySpecs != null) + spentItem.Item.InventorySpecs.BelongsToPlayer = belongsToPlayer; spentItem.MarkAsSynchronized(); - NetworkedItemManager.Instance.MarkKnownToCurrentPlayers(spentItem); tool.currentSpool = spentObject.GetComponent(); tool.remainingUnits = remainingUnits; tool.OnUnitsChanged(); @@ -709,29 +1172,42 @@ private static void DropEmptySpool(GadgetSolderingTool tool, CommonCustomization if (storage != null && spoolItemBase.BelongsToPlayer() && !storage.AnyStorageContains(spoolItemBase)) storage.AddItemToWorldStorage(spoolItemBase); spoolItem?.MarkAsSynchronized(); - NetworkedItemManager.Instance.MarkKnownToCurrentPlayers(spoolItem); } private static void FinalizeContainedSpool(NetworkedItem spoolItem) { spoolItem.MarkAsSynchronized(); - NetworkedItemManager.Instance.MarkKnownToCurrentPlayers(spoolItem); } private static void ApplyDuctTapeReplacement(CommonCustomizationPacket packet) { NetworkedItem.TryGet(packet.ItemNetId, out var oldItem); var oldTape = oldItem?.GetTrackedItem(); + byte replacementOwner = packet.OwnerPlayerId != 0 ? packet.OwnerPlayerId : oldItem?.OwnerPlayerId ?? 0; + bool belongsToPlayer = oldItem?.Item?.InventorySpecs?.BelongsToPlayer ?? true; Vector3 position = packet.Flag ? packet.Position + WorldMover.currentMove : oldItem?.transform.position ?? Vector3.zero; Quaternion rotation = packet.Flag ? packet.Rotation : oldItem?.transform.rotation ?? Quaternion.identity; - // The originating client already has the native replacement, but it still - // has NetId 0 until the server echoes the authoritative replacement ID. - var replacementItem = NetworkedItem.GetAll().FirstOrDefault(candidate => - candidate != null && candidate.NetId == 0 && - candidate.GetComponent() != null && - Vector3.SqrMagnitude(candidate.transform.position - position) < 4f); - bool existingLocalReplacement = replacementItem != null; + // The originating client records the exact object produced by the native + // replacement path. Do not infer identity from position: inactive inventory + // and cache entries can legitimately share the same transform. + NetworkedItem replacementItem = null; + if (packet.OtherItemNetId != 0) + NetworkedItem.TryGet(packet.OtherItemNetId, out replacementItem); + + if (replacementItem == null) + { + pendingLocalDuctTapeReplacements.TryGetValue(packet.ItemNetId, out replacementItem); + if (replacementItem == null || replacementItem.NetId != 0) + replacementItem = null; + else + pendingLocalDuctTapeReplacements.Remove(packet.ItemNetId); + } + else + { + pendingLocalDuctTapeReplacements.Remove(packet.ItemNetId); + } + bool existingReplacement = replacementItem != null; if (replacementItem == null) { @@ -741,7 +1217,7 @@ private static void ApplyDuctTapeReplacement(CommonCustomizationPacket packet) return; } - var replacementObject = Object.Instantiate(oldTape.emptyTapeItemPrefab, position, rotation); + var replacementObject = UnityEngine.Object.Instantiate(oldTape.emptyTapeItemPrefab, position, rotation); var itemBase = replacementObject.GetComponent(); if (!NetworkedItem.TryGetNetworkedItem(itemBase, out replacementItem)) replacementItem = replacementObject.AddComponent(); @@ -760,7 +1236,10 @@ private static void ApplyDuctTapeReplacement(CommonCustomizationPacket packet) else if (packet.OtherItemNetId != 0) replacementItem.NetId = packet.OtherItemNetId; - if (!existingLocalReplacement) + replacementItem.SetOwner(replacementOwner); + if (replacementItem.Item?.InventorySpecs != null) + replacementItem.Item.InventorySpecs.BelongsToPlayer = belongsToPlayer; + if (!existingReplacement) { replacementItem.transform.SetPositionAndRotation(position, rotation); // A newly instantiated inactive item never reaches Start(), so finish @@ -770,7 +1249,6 @@ private static void ApplyDuctTapeReplacement(CommonCustomizationPacket packet) replacementItem.gameObject.SetActive(false); } replacementItem.MarkAsSynchronized(); - NetworkedItemManager.Instance.MarkKnownToCurrentPlayers(replacementItem); } private static void ApplySnapAction(CommonCustomizationPacket packet) @@ -859,8 +1337,7 @@ private static CustomizationRefData TargetOf(CommonCustomizationPacket packet) { return new CustomizationRefData { - Kind = (CustomizationTargetKind)packet.TargetKind, - TrainCarNetId = packet.TargetTrainCarNetId, + IdentificationKey = packet.TargetKey, }; } @@ -882,40 +1359,21 @@ private static void AddRelationship( bool hasPosition = false, Vector3 position = default) { - state.Relationships.Add(new GadgetRelationshipData + state.Relationships.Add(new CommonCustomizationPacket { - Action = (byte)action, - ItemA = firstItem, - ItemB = secondItem, + Action = action, + ItemNetId = firstItem, + OtherItemNetId = secondItem, IndexA = firstIndex, IndexB = secondIndex, - HasPosition = hasPosition, + Flag = hasPosition, Position = position, }); } - private static void AddRelatedItem(CustomizationStateData state, NetworkedItem item, bool forceDropped) - { - if (item == null || state.RelatedItems.Any(existing => existing.ItemNetId == item.NetId)) - return; - - var snapshot = item.CreateCurrentUpdateData(ItemUpdateData.ItemUpdateType.Create); - if (snapshot == null) - return; - - if (forceDropped) - { - snapshot.ItemState = ItemState.Dropped; - snapshot.ItemPosition = item.transform.position - WorldMover.currentMove; - snapshot.ItemRotation = item.transform.rotation; - } - - state.RelatedItems.Add(snapshot); - } - private static void LogSnapshot(string operation, CustomizationStateData state) { - Multiplayer.LogDebug(() => $"Customization snapshot {operation} related={state.RelatedItems.Count} " + + Multiplayer.LogDebug(() => $"Customization snapshot {operation} targets={state.CustomizationKeys.Count} " + $"gadgets={state.Gadgets.Count} relationships={state.Relationships.Count} holes={state.Holes.Count}"); } } diff --git a/Multiplayer/Components/Networking/World/GadgetTrackedValueRegistry.cs b/Multiplayer/Components/Networking/World/GadgetTrackedValueRegistry.cs index 566c169d..51a85d1a 100644 --- a/Multiplayer/Components/Networking/World/GadgetTrackedValueRegistry.cs +++ b/Multiplayer/Components/Networking/World/GadgetTrackedValueRegistry.cs @@ -2,6 +2,7 @@ using DV.Customization.Gadgets; using DV.Customization.Gadgets.Implementations; using System; +using System.Collections.Generic; using UnityEngine; namespace Multiplayer.Components.Networking.World; @@ -13,6 +14,7 @@ public static void Register(NetworkedItem item, GadgetBase gadget) if (gadget == null) return; + item.RegisterTrackedValue("gadget.onGlass", () => gadget.IsOnGlass, value => gadget.IsOnGlass = value); RegisterSoldering(item, gadget); RegisterDrillable(item, gadget); RegisterText(item, gadget); @@ -119,11 +121,21 @@ private static void RegisterConcreteGadget(NetworkedItem item, GadgetBase gadget if (gadget is AlternatingController alternating) { - // AlternatingController inherits GadgetSwitch; its selected interval is the canonical state. item.RegisterTrackedValue( "alternating.interval", () => alternating.SelectedInterval, value => SetAndRefresh(alternating, () => alternating.SelectedInterval = value)); + item.RegisterTrackedValue( + "alternating.state", + () => alternating.alternatorState, + value => alternating.alternatorState = value); + // The timer changes every frame. It belongs in full/create snapshots for + // late observers, but state transitions provide incremental phase updates. + item.RegisterTrackedValue( + "alternating.timer", + () => alternating.timer, + value => alternating.timer = value, + thresholdComparer: (_, _) => false); return; } @@ -197,6 +209,53 @@ private static void SetAndRefresh(GadgetBase gadget, Action setter) RefreshLoadedLods(gadget); } + internal static void ApplyAlternatingPhase( + AlternatingController alternating, + Dictionary values, + uint sentTick) + { + if (alternating == null || values == null || + !values.TryGetValue("alternating.state", out object stateValue) || stateValue is not int state) + return; + + float interval = alternating.intervals[alternating.SelectedInterval]; + float timer = values.TryGetValue("alternating.timer", out object timerValue) && timerValue is float sentTimer + ? sentTimer + : 0f; + + if (!alternating.PowerState || interval <= 0f) + { + alternating.alternatorState = 0; + alternating.timer = 0f; + } + else if (float.IsInfinity(interval)) + { + alternating.alternatorState = state; + alternating.timer = timer; + } + else + { + double elapsedSeconds = sentTick != 0 + ? NetworkLifecycle.Instance.SecondsSinceTick(sentTick) + : 0d; + double elapsedInCycle = Math.Max(0d, timer) + elapsedSeconds; + long transitions = (long)Math.Floor(elapsedInCycle / interval); + int stateCount = Math.Max(2, alternating.subscribers?.Count ?? 0); + + alternating.alternatorState = PositiveModulo(state + transitions, stateCount); + alternating.timer = (float)(elapsedInCycle % interval); + } + + alternating.FireOnOutputValueUpdated(); + RefreshLoadedLods(alternating); + } + + private static int PositiveModulo(long value, int modulus) + { + long result = value % modulus; + return (int)(result < 0 ? result + modulus : result); + } + private static void RefreshLoadedLods(GadgetBase gadget) { if (gadget == null || !gadget.IsLODLoaded || gadget.LODObjects == null) diff --git a/Multiplayer/Components/Networking/World/NetworkedCashRegisterWithModules.cs b/Multiplayer/Components/Networking/World/NetworkedCashRegisterWithModules.cs index 2c3a24be..f6cddc7f 100644 --- a/Multiplayer/Components/Networking/World/NetworkedCashRegisterWithModules.cs +++ b/Multiplayer/Components/Networking/World/NetworkedCashRegisterWithModules.cs @@ -78,6 +78,8 @@ public static void InitialiseCashRegisters() bool isBuying; bool isCancelling; bool isAddingCash; + uint pendingShopRequestId; + double pendingShopDepositedCash; public bool IsShopRegister { get; set; } = false; double pendingCashToAdd = 0; @@ -142,7 +144,7 @@ public void Server_ProcessCashRegisterAction(ServerPlayer player, CommonCashRegi { if (IsShopRegister) { - Server_ProcessShopPurchase(player, packet); + Multiplayer.LogWarning($"Ignoring cash-register packet for shop register {NetId}"); return; } @@ -241,12 +243,16 @@ public void Server_ProcessCashRegisterAction(ServerPlayer player, CommonCashRegi processingAction = false; } - private void Server_ProcessShopPurchase(ServerPlayer player, CommonCashRegisterWithModulesActionPacket packet) + public void Server_ProcessShopPurchase(ServerPlayer player, CommonShopPacket packet) { - CashRegisterAction rejection = CashRegisterAction.RejectGeneric; - if (packet.Action != CashRegisterAction.Buy || !transform.PlayerCanReach(player, 1)) + ShopAction rejection = ShopAction.RejectGeneric; + // GlobalShopController clears its complete input queue when the current + // staggered spawn finishes. Appending while that coroutine is active would + // approve a purchase that is then cleared without ever being instantiated. + if (packet.Action != ShopAction.Purchase || CashRegister.IsProcessingTransaction || + !transform.PlayerCanReach(player, 1)) { - SendShopRejection(player, rejection); + SendShopRejection(player, packet.RequestId, rejection); return; } @@ -254,7 +260,7 @@ private void Server_ProcessShopPurchase(ServerPlayer player, CommonCashRegisterW int[] amounts = packet.ItemAmounts ?? []; if (prefabNames.Length == 0 || prefabNames.Length > 64 || prefabNames.Length != amounts.Length) { - SendShopRejection(player, CashRegisterAction.RejectedNoItems); + SendShopRejection(player, packet.RequestId, ShopAction.RejectNoItems); return; } @@ -265,7 +271,7 @@ private void Server_ProcessShopPurchase(ServerPlayer player, CommonCashRegisterW .ToDictionary(group => group.Key, group => group.First()); if (shop == null || modules == null) { - SendShopRejection(player, rejection); + SendShopRejection(player, packet.RequestId, rejection); return; } @@ -277,7 +283,7 @@ private void Server_ProcessShopPurchase(ServerPlayer player, CommonCashRegisterW if (string.IsNullOrEmpty(prefabNames[i]) || amounts[i] <= 0 || !modules.TryGetValue(prefabNames[i], out ScanItemCashRegisterModule module)) { - SendShopRejection(player, CashRegisterAction.RejectedNoItems); + SendShopRejection(player, packet.RequestId, ShopAction.RejectNoItems); return; } @@ -289,7 +295,7 @@ private void Server_ProcessShopPurchase(ServerPlayer player, CommonCashRegisterW ShopItemData data = GlobalShopController.Instance.GetShopItemData(module.sellingItemSpec); if (data == null || requested > data.ItemsInStock) { - SendShopRejection(player, CashRegisterAction.RejectedNoItems); + SendShopRejection(player, packet.RequestId, ShopAction.RejectNoItems); return; } @@ -298,7 +304,7 @@ private void Server_ProcessShopPurchase(ServerPlayer player, CommonCashRegisterW if (double.IsNaN(packet.Amount) || double.IsInfinity(packet.Amount) || packet.Amount + 0.001 < totalCost) { - SendShopRejection(player, CashRegisterAction.RejectFunds); + SendShopRejection(player, packet.RequestId, ShopAction.RejectFunds); return; } @@ -311,10 +317,11 @@ private void Server_ProcessShopPurchase(ServerPlayer player, CommonCashRegisterW } ShopPurchaseCoordinator.QueueOwnership(purchases, player); - NetworkLifecycle.Instance.Server.SendCashRegisterAction(new CommonCashRegisterWithModulesActionPacket + NetworkLifecycle.Instance.Server.SendShopAction(new CommonShopPacket { - NetId = NetId, - Action = CashRegisterAction.Approve, + RegisterNetId = NetId, + RequestId = packet.RequestId, + Action = ShopAction.Approved, Amount = totalCost, BuyerPlayerId = player.PlayerId, ItemPrefabNames = purchases.Select(item => item.PrefabName).ToArray(), @@ -323,11 +330,12 @@ private void Server_ProcessShopPurchase(ServerPlayer player, CommonCashRegisterW }); } - private void SendShopRejection(ServerPlayer player, CashRegisterAction action) + private void SendShopRejection(ServerPlayer player, uint requestId, ShopAction action) { - NetworkLifecycle.Instance.Server.SendCashRegisterAction(new CommonCashRegisterWithModulesActionPacket + NetworkLifecycle.Instance.Server.SendShopAction(new CommonShopPacket { - NetId = NetId, + RegisterNetId = NetId, + RequestId = requestId, Action = action, BuyerPlayerId = player.PlayerId }, [player]); @@ -456,26 +464,53 @@ public void Client_ProcessCashRegisterAction(CashRegisterAction action, double a } } - public void Client_ProcessCashRegisterAction(CommonCashRegisterWithModulesActionPacket packet) + public void Client_ProcessShopAction(CommonShopPacket packet) { - if (IsShopRegister && packet.Action == CashRegisterAction.Approve) + bool isBuyer = NetworkLifecycle.Instance.Client?.PlayerId == packet.BuyerPlayerId; + if (isBuyer && (pendingShopRequestId == 0 || packet.RequestId != pendingShopRequestId)) { - ShopPurchaseCoordinator.ApplyApproval(this, packet); + Multiplayer.LogWarning($"Ignoring stale shop response {packet.RequestId} for {CashRegister.GetObjectPath()} " + + $"(pending {pendingShopRequestId})"); return; } - if (IsShopRegister && packet.Action == CashRegisterAction.ShopStockChanged) + if (IsShopRegister && packet.Action == ShopAction.Approved) { - ShopPurchaseCoordinator.ApplyStockDelta(packet.ItemPrefabNames, packet.ItemAmounts); + if (isBuyer) + pendingShopRequestId = 0; + ShopPurchaseCoordinator.ApplyApproval(this, packet); return; } - Client_ProcessCashRegisterAction(packet.Action, packet.Amount); + if (!isBuyer) + return; + + pendingShopRequestId = 0; + pendingShopDepositedCash = 0; + + CashRegisterAction rejection = packet.Action switch + { + ShopAction.RejectFunds => CashRegisterAction.RejectFunds, + ShopAction.RejectNoItems => CashRegisterAction.RejectedNoItems, + _ => CashRegisterAction.RejectGeneric, + }; + Client_ProcessCashRegisterAction(rejection, packet.Amount); } public void CompleteApprovedShopPurchase() { isBuying = false; + // CashRegisterBase.OnDisable returns deposited money to the wallet. A + // register may stream out while its reliable request is in flight, so + // restore the captured deposit before completing that same transaction. + double missingDeposit = pendingShopDepositedCash - CashRegister.DepositedCash; + if (missingDeposit > 0) + { + Inventory.Instance.RemoveMoney(missingDeposit); + CashRegister.SetCash(pendingShopDepositedCash); + } + pendingShopDepositedCash = 0; + if (!ShopPurchaseCoordinator.RunApprovedLocalPurchase(CashRegister)) Multiplayer.LogWarning($"Approved shop purchase could not be completed locally for {CashRegister.GetObjectPath()}"); else @@ -519,16 +554,18 @@ public IEnumerator BuyShop() DisableInteraction(); CashRegister.IsProcessingTransaction = true; isBuying = true; + pendingShopDepositedCash = CashRegister.DepositedCash; - NetworkLifecycle.Instance.Client.SendCashRegisterAction(NetId, CashRegisterAction.Buy, - CashRegister.DepositedCash, + pendingShopRequestId = NetworkLifecycle.Instance.Client.SendShopPurchase(NetId, + pendingShopDepositedCash, purchases.Select(item => item.PrefabName).ToArray(), purchases.Select(item => item.Amount).ToArray()); - float timeOut = Time.time + NetworkLifecycle.Instance.Client.RPC_Timeout; - yield return new WaitUntil(() => Time.time >= timeOut || isBuying == false); + // The request uses ReliableOrdered delivery. Keep this transaction locked + // until its correlated response arrives so a late approval can never run + // against a subsequently edited basket. + yield return new WaitUntil(() => isBuying == false); - isBuying = false; CashRegister.IsProcessingTransaction = false; EnableInteraction(); } diff --git a/Multiplayer/Components/Networking/World/NetworkedItem.cs b/Multiplayer/Components/Networking/World/NetworkedItem.cs index fc8c730f..ae93f2ed 100644 --- a/Multiplayer/Components/Networking/World/NetworkedItem.cs +++ b/Multiplayer/Components/Networking/World/NetworkedItem.cs @@ -3,6 +3,7 @@ using DV.InventorySystem; using DV.Items; using DV.Customization.Gadgets; +using DV.Customization.Gadgets.Implementations; using Multiplayer.Components.Networking.Train; using Multiplayer.Networking.Data; using Multiplayer.Networking.Data.Items; @@ -85,6 +86,7 @@ public static bool TryGetNetId(ItemBase item, out ushort netID) public Type TrackedItemType { get; private set; } public uint LastDirtyTick { get; private set; } public bool IsReadyForSnapshots => registrationComplete && Item != null; + public ItemState CurrentState => lastState; public Vector3 WorldPosition => GetItemState() == ItemState.InstalledGadget && trackedItem is GadgetItem gadgetItem && gadgetItem.Gadget != null ? gadgetItem.Gadget.transform.position @@ -108,34 +110,18 @@ public static bool TryGetNetId(ItemBase item, out ushort netID) private Vector3 throwDirection; //Handle ownership - public sbyte OwnerId { get; private set; } = -1; // 0 means no owner + public byte OwnerPlayerId { get; private set; } // 0 means no owner public void SetOwner(byte playerId, bool markDirty = false) { - if (OwnerId == (sbyte)playerId) + if (OwnerPlayerId == playerId) return; - OwnerId = (sbyte)playerId; + OwnerPlayerId = playerId; if (markDirty) ownerDirty = true; } - //public void SetOwner(ushort playerId) - //{ - // if (OwnerId != playerId) - // { - // if (OwnerId != 0) - // { - // NetworkedItemManager.Instance.RemoveItemFromPlayerInventory(this); - // } - // OwnerId = playerId; - // if (playerId != 0) - // { - // NetworkedItemManager.Instance.AddItemToPlayerInventory(playerId, this); - // } - // } - //} - protected override bool IsIdServerAuthoritative => true; protected override void Awake() @@ -346,16 +332,8 @@ public ItemUpdateData GetSnapshot() byte localPlayerId = NetworkLifecycle.Instance.Client?.PlayerId ?? 0; bool isHeldByLocalPlayer = currentState is ItemState.InHand or ItemState.InInventory; if (isHeldByLocalPlayer) - SetOwner(localPlayerId); + SetOwner(localPlayerId, markDirty: true); - if (NetworkLifecycle.Instance.IsHost() && - NetworkLifecycle.Instance.Server.TryGetServerPlayer(localPlayerId, out ServerPlayer localPlayer)) - { - if (isHeldByLocalPlayer && !localPlayer.OwnsItem(NetId)) - localPlayer.AddOwnedItem(NetId); - else if (!isHeldByLocalPlayer && localPlayer.OwnsItem(NetId)) - localPlayer.RemoveOwnedItem(NetId); - } } if (!createdDirty) @@ -415,11 +393,12 @@ private void ApplySnapshot(ItemUpdateData snapshot) // Shop clients instantiate the raw prefab, whose local flag can differ from // the host's purchased instance. Apply this before dropped-state handling so // the item is restored to the client's world storage list. - if (Item?.InventorySpecs != null) + if (Item?.InventorySpecs != null && snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.Create)) Item.InventorySpecs.BelongsToPlayer = snapshot.BelongsToPlayer; if (snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.ItemState) || snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.FullSync) || snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.Create)) { + lastState = snapshot.ItemState; Multiplayer.Log($"NetworkedItem.ApplySnapshot() netId: {snapshot?.ItemNetId}, ItemUpdateType: {snapshot?.UpdateType}, ItemState: {snapshot?.ItemState}, Active state: {gameObject.activeInHierarchy}"); switch (snapshot.ItemState) @@ -440,7 +419,6 @@ private void ApplySnapshot(ItemUpdateData snapshot) case ItemState.InstalledGadget: gameObject.SetActive(false); - OwnerId = 0; break; default: @@ -451,8 +429,6 @@ private void ApplySnapshot(ItemUpdateData snapshot) } if (snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.Ownership) || - snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.ItemState) || - snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.FullSync) || snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.Create)) { // Ownership-only updates must not activate or relocate the item. @@ -468,6 +444,15 @@ private void ApplySnapshot(ItemUpdateData snapshot) if (trackedItem != null && snapshot.States != null) { ApplyTrackedValues(snapshot.States); + + if (trackedItem is GadgetItem gadgetItem && + gadgetItem.Gadget is AlternatingController alternating) + { + GadgetTrackedValueRegistry.ApplyAlternatingPhase( + alternating, + snapshot.States, + snapshot.SentTick); + } } } @@ -531,13 +516,16 @@ public ItemUpdateData CreateUpdateData(ItemUpdateData.ItemUpdateType updateType) { UpdateType = updateType, ItemNetId = NetId, - OwnerPlayerId = OwnerId > 0 ? (byte)OwnerId : (byte)0, + OwnerPlayerId = this.OwnerPlayerId, BelongsToPlayer = Item.InventorySpecs.BelongsToPlayer, PrefabName = Item.InventorySpecs.ItemPrefabName, ItemState = lastState, ItemPosition = position, ItemRotation = rotation, ThrowDirection = throwDirection, + Player = lastState is ItemState.InHand or ItemState.InInventory + ? OwnerPlayerId + : (byte)0, CarNetId = carId, AttachedFront = frontCoupler, States = states, @@ -554,6 +542,11 @@ public ItemUpdateData CreateUpdateData(ItemUpdateData.ItemUpdateType updateType) public void SuppressDestroySync() => suppressDestroySync = true; + internal void MarkRemoteUpdateAccepted() + { + LastDirtyTick = NetworkLifecycle.Instance.Tick; + } + public void MarkAsSynchronized() { createdDirty = false; @@ -574,6 +567,16 @@ private ItemState GetItemState() if (trackedItem is GadgetItem gadgetItem && gadgetItem.Gadget != null && gadgetItem.Gadget.IsLinked) return ObserveState(ItemState.InstalledGadget); + // Remote hand/inventory proxies are inactive and reparented outside the + // host's Inventory. Inspecting that local representation makes them look + // dropped and can echo a false drop back with an unrelated object-state + // update (for example when ejecting a soldering reel). Their most recent + // client-supplied state is authoritative instead. + if (NetworkLifecycle.Instance.IsHost() && OwnerPlayerId != 0 && + OwnerPlayerId != NetworkLifecycle.Instance.Server.SelfId && + lastState is ItemState.InHand or ItemState.InInventory) + return ObserveState(lastState); + if (Item.transform.parent == WorldMover.OriginShiftParent && !wasThrown) return ObserveState(ItemState.Dropped); @@ -664,16 +667,10 @@ private void HandleDroppedOrThrownState(ItemUpdateData snapshot) Item.SnappableItem.SnappedTo.UnsnapItem(false); } - //resolve ownership - if (NetworkLifecycle.Instance.IsHost()) - if (NetworkLifecycle.Instance.Server.TryGetServerPlayer(snapshot.Player, out ServerPlayer player) && player.OwnsItem(NetId)) - player.RemoveOwnedItem(NetId); - //activate and relocate item gameObject.SetActive(true); transform.position = snapshot.ItemPosition + WorldMover.currentMove; transform.rotation = snapshot.ItemRotation; - OwnerId = 0; // Network-created/cached items were removed from every StorageController // list. ItemMagazine.AddItem expects a dropped item to belong to world @@ -708,11 +705,6 @@ private void HandleDroppedOrThrownState(ItemUpdateData snapshot) private void HandleAttachedState(ItemUpdateData snapshot) { - //resovle ownership - if (NetworkLifecycle.Instance.IsHost()) - if (NetworkLifecycle.Instance.Server.TryGetServerPlayer(snapshot.Player, out ServerPlayer player) && player.OwnsItem(NetId)) - player.RemoveOwnedItem(NetId); - //handle attaching the item gameObject.SetActive(true); Multiplayer.LogDebug(() => $"NetworkedItem.HandleAttachedState() ItemNetId: {snapshot?.ItemNetId} attempting attachment to car {snapshot.CarNetId}, at the front {snapshot.AttachedFront}"); @@ -748,10 +740,6 @@ private void HandleInventoryOrHandState(ItemUpdateData snapshot) Item.SnappableItem.SnappedTo.UnsnapItem(false); } - if (NetworkLifecycle.Instance.IsHost()) - if (NetworkLifecycle.Instance.Server.TryGetServerPlayer(snapshot.Player, out ServerPlayer player) && !player.OwnsItem(NetId)) - player.AddOwnedItem(NetId); - // Mirror ItemReparentingBase.OnGrab for a remote pickup. This removes the // proxy from the train's item activity/LOD container and notifies // RespawnOnDrop that its spawn parent is no longer the train interior. diff --git a/Multiplayer/Components/Networking/World/NetworkedItemManager.cs b/Multiplayer/Components/Networking/World/NetworkedItemManager.cs index da5f647b..b8abd638 100644 --- a/Multiplayer/Components/Networking/World/NetworkedItemManager.cs +++ b/Multiplayer/Components/Networking/World/NetworkedItemManager.cs @@ -14,12 +14,13 @@ using Multiplayer.Networking.Data.Items; using Multiplayer.Networking.Packets.Clientbound; using Multiplayer.Networking.Packets.Serverbound; +using Multiplayer.Networking.Packets; +using System.Collections; namespace Multiplayer.Components.Networking.World; public class NetworkedItemManager : SingletonBehaviour { - private const int MAX_RECONCILED_ITEMS = 128; /* * Server */ @@ -35,11 +36,6 @@ public class NetworkedItemManager : SingletonBehaviour private List DestroyedItems = new(64); private Dictionary AcceptedClientUpdates = new(64); - //Item ownership - //private Dictionary playerInventories = new Dictionary(); - //private Dictionary itemToPlayerMap = new Dictionary(); - - /* * Client */ @@ -49,7 +45,6 @@ public class NetworkedItemManager : SingletonBehaviour private Dictionary ItemPrefabs = new(1024); //Item prefabs private bool ClientInitialised = false; private readonly Dictionary pendingItemReconciliation = []; - private bool initialHostOwnershipAssigned; /* @@ -81,13 +76,18 @@ private void PlayerDisconnected(ServerPlayer disconnectedPlayer) return; byte hostPlayerId = NetworkLifecycle.Instance.Server?.SelfId ?? 0; + ServerPlayer hostPlayer = null; + if (hostPlayerId != 0 && disconnectedPlayer.PlayerId != hostPlayerId) + NetworkLifecycle.Instance.Server.TryGetServerPlayer(hostPlayerId, out hostPlayer); + ShopPurchaseCoordinator.TransferPendingOwnership(disconnectedPlayer, hostPlayer); + if (hostPlayerId == 0 || disconnectedPlayer.PlayerId == hostPlayerId) return; int transferredItems = 0; foreach (var item in NetworkedItem.GetAll()) { - if (item == null || item.OwnerId != disconnectedPlayer.PlayerId) + if (item == null || item.OwnerPlayerId != disconnectedPlayer.PlayerId) continue; item.SetOwner(hostPlayerId, true); @@ -307,7 +307,9 @@ private void ProcessChanged(uint tick) //NetworkLifecycle.Instance.Server.LogDebug(() => $"ProcessChanged({tick}) Item exists for: {player.Username}, LastDirtyTick: {player.KnownItems[nearbyItem] < nearbyItem.LastDirtyTick}"); if (player.KnownItems[nearbyItem] < nearbyItem.LastDirtyTick) { - dirtyUpdate = nearbyItem.CreateUpdateData(ItemUpdateData.ItemUpdateType.FullSync); + dirtyUpdate = nearbyItem.CreateUpdateData( + ItemUpdateData.ItemUpdateType.FullSync | + ItemUpdateData.ItemUpdateType.Ownership); } } @@ -354,22 +356,17 @@ private void ProcessReceivedAsHost(ItemUpdateData snapshot, ServerPlayer player) if (NetworkedItem.TryGet(snapshot.ItemNetId, out NetworkedItem netItem)) { - if (ValidatePlayerAction(snapshot, player)) //Ensure the player can do this - { - // ItemUpdateData only serializes Player for hand/inventory states. Stamp - // the authenticated sender here instead of trusting the client's value. - snapshot.Player = player.PlayerId; - snapshot.OwnerPlayerId = snapshot.ItemState is ItemState.InHand or ItemState.InInventory - ? player.PlayerId - : netItem.OwnerId > 0 ? (byte)netItem.OwnerId : (byte)0; - NetworkLifecycle.Instance.Server.LogWarning($"NetworkedItemManager.ProcessReceivedAsHost() ItemNetId: {snapshot.ItemNetId}, snapshot type: {snapshot.UpdateType}"); - netItem.ReceiveSnapshot(snapshot); - AcceptedClientUpdates[snapshot.ItemNetId] = snapshot; - } - else - { - NetworkLifecycle.Instance.Server.LogWarning($"NetworkedItemManager.ProcessReceivedAsHost() Player action validation failed for ItemNetId: {snapshot.ItemNetId}"); - } + // Clients are authoritative for their item interactions. The host may not + // have the sender's area loaded, so its physical item position is not a + // reliable basis for ownership or reach validation. + snapshot.Player = player.PlayerId; + snapshot.OwnerPlayerId = snapshot.ItemState is ItemState.InHand or ItemState.InInventory + ? player.PlayerId + : netItem.OwnerPlayerId; + NetworkLifecycle.Instance.Server.LogWarning($"NetworkedItemManager.ProcessReceivedAsHost() ItemNetId: {snapshot.ItemNetId}, snapshot type: {snapshot.UpdateType}"); + netItem.ReceiveSnapshot(snapshot); + netItem.MarkRemoteUpdateAccepted(); + AcceptedClientUpdates[snapshot.ItemNetId] = snapshot; } else { @@ -377,52 +374,6 @@ private void ProcessReceivedAsHost(ItemUpdateData snapshot, ServerPlayer player) } } - private bool ValidatePlayerAction(ItemUpdateData snapshot, ServerPlayer player) - { - return true; - // Must have valid item - if (!NetworkedItem.TryGet(snapshot.ItemNetId, out NetworkedItem networkedItem)) - return false; - - Multiplayer.LogDebug(() => $"ValidatePlayerAction() ItemId: {snapshot.ItemNetId}, name: {networkedItem.name} Update Type: {snapshot.UpdateType}, Item State: {snapshot.ItemState}, Player: {player.Username}"); - - switch (snapshot.ItemState) - { - case ItemState.InHand: - case ItemState.InInventory: - // Check if someone else owns it - GetItemOwner(snapshot.ItemNetId, out ServerPlayer currentOwner); - Multiplayer.LogDebug(() => $"ValidatePlayerAction() ItemId: {snapshot.ItemNetId}, name: {networkedItem.name} Update Type: {snapshot.UpdateType}, Item State: {snapshot.ItemState}, Player: {player?.Username}, Current Owner: {currentOwner?.Username}"); - - if (currentOwner != null && currentOwner != player) - return false; - - // Check pickup distance - float distance = Vector3.Distance(player.WorldPosition, networkedItem.transform.position); - if (distance > MAX_REACH_DISTANCE) - return false; - - Multiplayer.LogDebug(() => $"ValidatePlayerAction() ItemId: {snapshot.ItemNetId}, name: {networkedItem.name} Update Type: {snapshot.UpdateType}, Item State: {snapshot.ItemState}, Player: {player.Username}, Distance check: {distance}"); - break; - - case ItemState.Dropped: - case ItemState.Thrown: - case ItemState.Attached: //needs additional checks for distance to coupler - case ItemState.InstalledGadget: - // Only owner can drop/throw - if (!player.OwnsItem(snapshot.ItemNetId)) - return false; - break; - } - - return true; - } - - private bool GetItemOwner(ushort itemNetId, out ServerPlayer owner) - { - owner = NetworkLifecycle.Instance.Server.ServerPlayers.FirstOrDefault(p => p.OwnsItem(itemNetId)); - return owner != null; - } #endregion #region Client @@ -467,7 +418,8 @@ private void ProcessReceivedAsClient(ItemUpdateData snapshot) } else if (snapshot.UpdateType == ItemUpdateData.ItemUpdateType.Destroy) { - SendToCache(netItem); + if (netItem != null) + SendToCache(netItem); } else if (netItem != null) { @@ -511,26 +463,72 @@ private void CreateItem(ItemUpdateData snapshot) newItem.gameObject.SetActive(true); newItem.NetId = snapshot.ItemNetId; + // Applying InstalledGadget immediately deactivates the physical item object. + // A freshly instantiated/cached GadgetItem has only run Awake at this point, + // so doing that here prevents Unity from ever calling GadgetItem.Start and + // leaves GadgetItem.Item null during customization reconstruction. + if (snapshot.ItemState == ItemState.InstalledGadget && + newItem.GetTrackedItem() is { Item: null }) + { + CoroutineManager.Instance.StartCoroutine(ApplyInstalledGadgetCreateAfterStart(newItem, snapshot)); + return; + } + newItem.ReceiveSnapshot(snapshot); } - public void MarkKnownToCurrentPlayers(NetworkedItem item) + private static IEnumerator ApplyInstalledGadgetCreateAfterStart(NetworkedItem item, ItemUpdateData snapshot) { - if (item == null || !NetworkLifecycle.Instance.IsHost()) - return; + const int maxInitializationFrames = 120; + int frames = 0; + var gadgetItem = item?.GetTrackedItem(); - uint tick = NetworkLifecycle.Instance.Tick; - foreach (var player in NetworkLifecycle.Instance.Server.ServerPlayers) + while (item != null && item.NetId == snapshot.ItemNetId && gadgetItem?.Item == null && + frames++ < maxInitializationFrames) + yield return null; + + if (item == null || item.NetId != snapshot.ItemNetId) + yield break; + + if (gadgetItem?.Item == null) { - if (player.LoadingState >= PlayerLoadingState.ReadyForItems) - player.KnownItems[item] = tick; + Multiplayer.LogError($"Installed gadget {snapshot.ItemNetId} ({snapshot.PrefabName}) did not initialize; create snapshot was not applied"); + yield break; } + + item.ReceiveSnapshot(snapshot); } - public void CreateCustomizationItem(ItemUpdateData snapshot) + public void SendInitialItems(ServerPlayer player, IEnumerable itemNetIds, + ISet forceDropped = null) { - if (!NetworkLifecycle.Instance.IsHost()) - ProcessReceivedAsClient(snapshot); + if (player == null || itemNetIds == null || !NetworkLifecycle.Instance.IsHost()) + return; + + var creates = new List(); + uint tick = NetworkLifecycle.Instance.Tick; + foreach (ushort itemNetId in itemNetIds.Distinct()) + { + if (!NetworkedItem.TryGet(itemNetId, out var item) || player.KnownItems.ContainsKey(item)) + continue; + + var snapshot = item.CreateCurrentUpdateData(ItemUpdateData.ItemUpdateType.Create); + if (snapshot == null) + continue; + + if (forceDropped?.Contains(itemNetId) == true) + { + snapshot.ItemState = ItemState.Dropped; + snapshot.ItemPosition = item.transform.position - WorldMover.currentMove; + snapshot.ItemRotation = item.transform.rotation; + } + + creates.Add(snapshot); + player.KnownItems[item] = tick; + } + + if (creates.Count > 0) + NetworkLifecycle.Instance.Server.SendItemsChangePacket(creates, player); } private void BuildPrefabLookup() @@ -573,18 +571,19 @@ public void CacheWorldItems() ClientInitialised = false; } - internal ServerboundItemReconciliationPacket CreateItemReconciliationRequest() + internal IReadOnlyList CreateItemReconciliationRequests() { pendingItemReconciliation.Clear(); + var packets = new List(); var packet = new ServerboundItemReconciliationPacket(); int localId = 1; foreach (var item in NetworkedItem.GetAll()) { - if (packet.Items.Count >= MAX_RECONCILED_ITEMS) + if (packet.Items.Count >= ItemReconciliationProtocol.MaxItems) { - Multiplayer.LogWarning($"Local item reconciliation exceeded the {MAX_RECONCILED_ITEMS}-item limit"); - break; + packets.Add(packet); + packet = new ServerboundItemReconciliationPacket(); } if (!IsReconciliationCandidate(item)) @@ -603,14 +602,14 @@ internal ServerboundItemReconciliationPacket CreateItemReconciliationRequest() localId++; } - Multiplayer.LogDebug(() => $"Prepared {packet.Items.Count} local items for authoritative reconciliation"); - return packet; + packet.IsFinalBatch = true; + packets.Add(packet); + Multiplayer.LogDebug(() => $"Prepared {localId - 1} local items in {packets.Count} batch(es) for authoritative reconciliation"); + return packets; } - internal void ApplyItemReconciliation(ClientboundItemReconciliationPacket packet) + internal bool ApplyItemReconciliation(ClientboundItemReconciliationPacket packet) { - var resolvedLocalIds = new HashSet(); - foreach (var result in packet?.Items ?? []) { if (!pendingItemReconciliation.TryGetValue(result.LocalId, out var localItem)) @@ -619,47 +618,49 @@ internal void ApplyItemReconciliation(ClientboundItemReconciliationPacket packet continue; } - resolvedLocalIds.Add(result.LocalId); + pendingItemReconciliation.Remove(result.LocalId); if (result.NetId == 0) { Multiplayer.LogWarning($"Host rejected reconciliation for local item {result.LocalId} ({localItem?.name})"); continue; } + // Loading is allowed to continue after the bounded reconciliation + // wait. The local item can therefore be consumed or destroyed before + // a delayed reliable response arrives; let the host-created canonical + // item take over instead of dereferencing Unity's destroyed wrapper. + if (localItem == null) + { + Multiplayer.LogWarning($"Local reconciliation item {result.LocalId} was destroyed before " + + $"authoritative NetId {result.NetId} arrived"); + if (result.Snapshot != null) + CreateItem(result.Snapshot); + continue; + } + if (NetworkedItem.TryGet(result.NetId, out var existingItem) && existingItem != localItem) SendToCache(existingItem); localItem.NetId = result.NetId; + localItem.SetOwner(NetworkLifecycle.Instance.Client?.PlayerId ?? 0); localItem.MarkAsSynchronized(); Multiplayer.LogDebug(() => $"Reconciled local item {result.LocalId} ({localItem.name}) as NetId {result.NetId}"); } - foreach (var unresolved in pendingItemReconciliation.Where(pair => !resolvedLocalIds.Contains(pair.Key))) - Multiplayer.LogWarning($"Host did not return reconciliation data for local item {unresolved.Key} ({unresolved.Value?.name})"); + if (packet?.IsFinalBatch != true) + return false; + foreach (var unresolved in pendingItemReconciliation) + Multiplayer.LogWarning($"Host did not return reconciliation data for local item {unresolved.Key} ({unresolved.Value?.name})"); pendingItemReconciliation.Clear(); + ClientInitialised = true; + return true; + } - if (NetworkLifecycle.Instance.IsHost() && !initialHostOwnershipAssigned) - { - byte hostPlayerId = NetworkLifecycle.Instance.Server?.SelfId ?? 0; - if (hostPlayerId != 0) - { - int assignedItems = 0; - foreach (var item in NetworkedItem.GetAll()) - { - if (item == null) - continue; - - item.SetOwner(hostPlayerId); - assignedItems++; - } - - initialHostOwnershipAssigned = true; - Multiplayer.LogDebug(() => - $"Assigned {assignedItems} initial session items to host player {hostPlayerId}"); - } - } - + internal void ContinueAfterItemReconciliationTimeout() + { + // Allow ordinary item updates to resume, but retain the local-ID mapping so + // a delayed reliable response can still assign authoritative IDs. ClientInitialised = true; } @@ -667,13 +668,17 @@ internal ClientboundItemReconciliationPacket ReconcileClientItems( ServerboundItemReconciliationPacket packet, ServerPlayer player) { - var response = new ClientboundItemReconciliationPacket(); + var response = new ClientboundItemReconciliationPacket + { + IsFinalBatch = packet?.IsFinalBatch == true, + }; if (!NetworkLifecycle.Instance.IsHost() || packet?.Items == null || player == null) return response; foreach (var request in packet.Items) { ushort netId = 0; + ItemUpdateData authoritativeSnapshot = null; var snapshot = request.Snapshot; GameObject gameObject = null; @@ -699,11 +704,10 @@ internal ClientboundItemReconciliationPacket ReconcileClientItems( snapshot.OwnerPlayerId = player.PlayerId; networkedItem.ReceiveSnapshot(snapshot); - if (snapshot.ItemState is ItemState.InHand or ItemState.InInventory) - player.AddOwnedItem(netId); - player.KnownItems[networkedItem] = NetworkLifecycle.Instance.Tick; player.NearbyItems[networkedItem] = Time.time; + authoritativeSnapshot = networkedItem.CreateCurrentUpdateData( + ItemUpdateData.ItemUpdateType.Create); } } } @@ -725,7 +729,7 @@ internal ClientboundItemReconciliationPacket ReconcileClientItems( } } - response.Items.Add(new ItemReconciliationResult(request.LocalId, netId)); + response.Items.Add(new ItemReconciliationResult(request.LocalId, netId, authoritativeSnapshot)); } Multiplayer.LogDebug(() => $"Reconciled {response.Items.Count(item => item.NetId != 0)}/{packet.Items.Count} items for {player.Username}"); diff --git a/Multiplayer/Components/Networking/World/ShopPurchaseCoordinator.cs b/Multiplayer/Components/Networking/World/ShopPurchaseCoordinator.cs index 23023849..4aaff899 100644 --- a/Multiplayer/Components/Networking/World/ShopPurchaseCoordinator.cs +++ b/Multiplayer/Components/Networking/World/ShopPurchaseCoordinator.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; using System.Linq; +using UnityEngine; namespace Multiplayer.Components.Networking.World; @@ -27,6 +28,7 @@ public ShopPurchase(string prefabName, int amount, float unitPrice) internal static class ShopPurchaseCoordinator { private static readonly Dictionary> pendingOwners = []; + private static readonly Dictionary scaledAllowances = []; private static bool applyingClientApproval; public static ShopPurchase[] Capture(CashRegisterWithModules register) => register.registerModules @@ -62,8 +64,41 @@ public static void QueueOwnership(IEnumerable purchases, ServerPla } } - public static void TryAssignOwner(NetworkedItem item) + public static void TransferPendingOwnership(ServerPlayer from, ServerPlayer to) { + if (from == null) + return; + + foreach (string prefabName in pendingOwners.Keys.ToArray()) + { + var owners = pendingOwners[prefabName]; + var replacements = new Queue(owners.Count); + while (owners.Count > 0) + { + ServerPlayer owner = owners.Dequeue(); + if (owner == from) + owner = to; + if (owner != null) + replacements.Enqueue(owner); + } + + if (replacements.Count == 0) + pendingOwners.Remove(prefabName); + else + pendingOwners[prefabName] = replacements; + } + } + + public static void ResetSessionState() + { + pendingOwners.Clear(); + scaledAllowances.Clear(); + } + + public static void AssignPurchasedObject(GameObject purchasedObject) + { + var itemBase = purchasedObject?.GetComponent(); + NetworkedItem.TryGetNetworkedItem(itemBase, out var item); if (!NetworkLifecycle.Instance.IsHost() || item?.Item?.InventorySpecs == null) return; @@ -75,7 +110,6 @@ public static void TryAssignOwner(NetworkedItem item) if (owners.Count == 0) pendingOwners.Remove(prefabName); - buyer.AddOwnedItem(item.NetId); item.SetOwner(buyer.PlayerId); Multiplayer.LogDebug(() => $"Assigned purchased item {item.NetId} ({prefabName}) to {buyer.Username}"); } @@ -97,12 +131,13 @@ public static bool SuppressClientInstantiation(InventoryItemSpec itemSpec, int a } public static void ApplyApproval(NetworkedCashRegisterWithModules networkedRegister, - CommonCashRegisterWithModulesActionPacket packet) + CommonShopPacket packet) { bool isBuyer = NetworkLifecycle.Instance.Client?.PlayerId == packet.BuyerPlayerId; if (isBuyer) { - ApplyApprovedPrices(networkedRegister.CashRegister, packet.ItemPrefabNames, packet.ItemUnitPrices); + ApplyApprovedBasket(networkedRegister.CashRegister, packet.ItemPrefabNames, + packet.ItemAmounts, packet.ItemUnitPrices); networkedRegister.CompleteApprovedShopPurchase(); return; } @@ -110,18 +145,30 @@ public static void ApplyApproval(NetworkedCashRegisterWithModules networkedRegis ApplyStockDelta(packet.ItemPrefabNames, packet.ItemAmounts); } - private static void ApplyApprovedPrices(CashRegisterWithModules register, string[] prefabNames, float[] prices) + private static void ApplyApprovedBasket(CashRegisterWithModules register, string[] prefabNames, + int[] amounts, float[] prices) { - if (prefabNames == null || prices == null || prefabNames.Length != prices.Length) + if (prefabNames == null || amounts == null || prices == null || + prefabNames.Length != amounts.Length || prefabNames.Length != prices.Length) return; - var pricesByPrefab = prefabNames - .Select((prefabName, index) => new { prefabName, price = prices[index] }) - .ToDictionary(entry => entry.prefabName, entry => entry.price); + var approvedByPrefab = prefabNames + .Select((prefabName, index) => new { prefabName, amount = amounts[index], price = prices[index] }) + .ToDictionary(entry => entry.prefabName, entry => (entry.amount, entry.price)); foreach (ScanItemCashRegisterModule module in register.registerModules.OfType()) - if (module.sellingItemSpec != null && - pricesByPrefab.TryGetValue(module.sellingItemSpec.ItemPrefabName, out float price)) - module.Data.pricePerUnit = price; + { + if (module.sellingItemSpec != null && approvedByPrefab.TryGetValue( + module.sellingItemSpec.ItemPrefabName, out var approved)) + { + module.Data.unitsToBuy = approved.amount; + module.Data.pricePerUnit = approved.price; + } + else + { + module.Data.unitsToBuy = 0; + } + } + register.OnUnitsToBuyChanged(); } public static UnityEngine.Vector3 ClosestPlayerDelta(UnityEngine.Vector3 localPlayerPosition, @@ -170,6 +217,35 @@ public static void ApplyStockDelta(string[] prefabNames, int[] amounts) GlobalShopController.Instance.Fire_GlobalShopDataChanged(); } + public static CommonShopPacket CreateStockSnapshot() + { + IReadOnlyList items = GlobalShopController.Instance?.shopItemsData ?? []; + return new CommonShopPacket + { + Action = ShopAction.StockSnapshot, + ItemPrefabNames = items.Select(data => data.item.ItemPrefabName).ToArray(), + ItemAmounts = items.Select(data => data.purchasedItems).ToArray(), + }; + } + + public static void ApplyStockSnapshot(string[] prefabNames, int[] purchasedAmounts) + { + if (prefabNames == null || purchasedAmounts == null || prefabNames.Length != purchasedAmounts.Length) + return; + + var purchasedByPrefab = prefabNames + .Select((prefabName, index) => new { prefabName, amount = purchasedAmounts[index] }) + .Where(entry => !string.IsNullOrEmpty(entry.prefabName)) + .GroupBy(entry => entry.prefabName) + .ToDictionary(group => group.Key, group => Math.Max(group.Last().amount, 0)); + foreach (ShopItemData data in GlobalShopController.Instance.shopItemsData) + data.purchasedItems = purchasedByPrefab.TryGetValue(data.item.ItemPrefabName, out int amount) + ? amount + : 0; + + GlobalShopController.Instance.Fire_GlobalShopDataChanged(); + } + public static int MaxPlayers { get @@ -187,8 +263,19 @@ public static void ScaleShopLimits(GlobalShopController controller) foreach (ShopItemData data in controller.shopItemsData) { // A zero allowance is meaningful for locked/disabled shop entries. - if (data.allowedToHaveAmount > 0) - data.allowedToHaveAmount = checked(data.initialAmount * maxPlayers); + if (data.allowedToHaveAmount <= 0) + continue; + + // Only rescale the vanilla allowance or a value previously produced by + // this method. Preserve positive runtime overrides from the game or + // another mod just as we preserve the game's zero-valued lockouts. + if (data.allowedToHaveAmount != data.initialAmount && + (!scaledAllowances.TryGetValue(data, out int previous) || data.allowedToHaveAmount != previous)) + continue; + + int scaled = checked(data.initialAmount * maxPlayers); + data.allowedToHaveAmount = scaled; + scaledAllowances[data] = scaled; } } diff --git a/Multiplayer/Networking/Data/Customization/CustomizationStateData.cs b/Multiplayer/Networking/Data/Customization/CustomizationStateData.cs index 887dd302..a898e01f 100644 --- a/Multiplayer/Networking/Data/Customization/CustomizationStateData.cs +++ b/Multiplayer/Networking/Data/Customization/CustomizationStateData.cs @@ -1,37 +1,25 @@ using LiteNetLib.Utils; using Multiplayer.Networking.Serialization; -using Multiplayer.Networking.Data.Items; using System.Collections.Generic; using UnityEngine; +using Multiplayer.Networking.Packets.Common; namespace Multiplayer.Networking.Data.Customization; -public enum CustomizationTargetKind : byte -{ - TrainCar, - World, - Storage, - PlayerHouse, - PaintStation, -} - public struct CustomizationRefData { - public CustomizationTargetKind Kind; - public ushort TrainCarNetId; + public string IdentificationKey; public void Serialize(NetDataWriter writer) { - writer.Put((byte)Kind); - writer.Put(TrainCarNetId); + writer.Put(IdentificationKey ?? string.Empty); } public static CustomizationRefData Deserialize(NetDataReader reader) { return new CustomizationRefData { - Kind = (CustomizationTargetKind)reader.GetByte(), - TrainCarNetId = reader.GetUShort(), + IdentificationKey = reader.GetString(), }; } } @@ -39,22 +27,20 @@ public static CustomizationRefData Deserialize(NetDataReader reader) public sealed class GadgetPlacementData { public ushort ItemNetId; - public string PrefabName; + public uint SentTick; public CustomizationRefData Target; public Vector3 LocalPosition; public Quaternion LocalRotation; - public bool IsOnGlass; public Dictionary TrackedValues = new(); public void Serialize(NetDataWriter writer) { writer.Put(ItemNetId); - writer.Put(PrefabName ?? string.Empty); + writer.Put(SentTick); Target.Serialize(writer); Vector3Serializer.Serialize(writer, LocalPosition); QuaternionSerializer.Serialize(writer, LocalRotation); - writer.Put(IsOnGlass); - TrackedStateSerializer.Serialize(writer, TrackedValues); + TrackedValueSerializer.SerializeDictionary(writer, TrackedValues); } public static GadgetPlacementData Deserialize(NetDataReader reader) @@ -62,12 +48,11 @@ public static GadgetPlacementData Deserialize(NetDataReader reader) return new GadgetPlacementData { ItemNetId = reader.GetUShort(), - PrefabName = reader.GetString(), + SentTick = reader.GetUInt(), Target = CustomizationRefData.Deserialize(reader), LocalPosition = Vector3Serializer.Deserialize(reader), LocalRotation = QuaternionSerializer.Deserialize(reader), - IsOnGlass = reader.GetBool(), - TrackedValues = TrackedStateSerializer.Deserialize(reader), + TrackedValues = TrackedValueSerializer.DeserializeDictionary(reader), }; } } @@ -98,16 +83,33 @@ public static CustomizationHoleData Deserialize(NetDataReader reader) public sealed class CustomizationStateData { - public List RelatedItems = new(); + private const int MaxTargets = 4096; + private const int MaxGadgets = 4096; + private const int MaxRelationships = 16384; + private const int MaxHoles = 16384; + private const int MaxProcessedActions = 65536; + + public List ProcessedOriginActionIds = new(); + public List CustomizationKeys = new(); public List Gadgets = new(); - public List Relationships = new(); + public List Relationships = new(); public List Holes = new(); public void Serialize(NetDataWriter writer) { - writer.Put(RelatedItems.Count); - foreach (var item in RelatedItems) - item.Serialize(writer); + ValidateCount(CustomizationKeys.Count, MaxTargets, nameof(CustomizationKeys)); + ValidateCount(Gadgets.Count, MaxGadgets, nameof(Gadgets)); + ValidateCount(Relationships.Count, MaxRelationships, nameof(Relationships)); + ValidateCount(Holes.Count, MaxHoles, nameof(Holes)); + ValidateCount(ProcessedOriginActionIds.Count, MaxProcessedActions, nameof(ProcessedOriginActionIds)); + + writer.Put(ProcessedOriginActionIds.Count); + foreach (uint actionId in ProcessedOriginActionIds) + writer.Put(actionId); + + writer.Put(CustomizationKeys.Count); + foreach (var key in CustomizationKeys) + writer.Put(key); writer.Put(Gadgets.Count); foreach (var gadget in Gadgets) @@ -126,132 +128,43 @@ public static CustomizationStateData Deserialize(NetDataReader reader) { var result = new CustomizationStateData(); - int relatedItemCount = reader.GetInt(); - for (int i = 0; i < relatedItemCount; i++) - { - var item = new ItemUpdateData(); - item.Deserialize(reader); - result.RelatedItems.Add(item); - } + int processedActionCount = ReadCount(reader, MaxProcessedActions, nameof(ProcessedOriginActionIds)); + for (int i = 0; i < processedActionCount; i++) + result.ProcessedOriginActionIds.Add(reader.GetUInt()); + + int customizationCount = ReadCount(reader, MaxTargets, nameof(CustomizationKeys)); + for (int i = 0; i < customizationCount; i++) + result.CustomizationKeys.Add(reader.GetString()); - int gadgetCount = reader.GetInt(); + int gadgetCount = ReadCount(reader, MaxGadgets, nameof(Gadgets)); for (int i = 0; i < gadgetCount; i++) result.Gadgets.Add(GadgetPlacementData.Deserialize(reader)); - int relationshipCount = reader.GetInt(); + int relationshipCount = ReadCount(reader, MaxRelationships, nameof(Relationships)); for (int i = 0; i < relationshipCount; i++) - result.Relationships.Add(GadgetRelationshipData.Deserialize(reader)); + { + var relationship = new CommonCustomizationPacket(); + relationship.Deserialize(reader); + result.Relationships.Add(relationship); + } - int holeCount = reader.GetInt(); + int holeCount = ReadCount(reader, MaxHoles, nameof(Holes)); for (int i = 0; i < holeCount; i++) result.Holes.Add(CustomizationHoleData.Deserialize(reader)); return result; } -} - -public sealed class GadgetRelationshipData -{ - public byte Action; - public ushort ItemA; - public ushort ItemB; - public int IndexA; - public int IndexB; - public bool HasPosition; - public Vector3 Position; - - public void Serialize(NetDataWriter writer) - { - writer.Put(Action); - writer.Put(ItemA); - writer.Put(ItemB); - writer.Put(IndexA); - writer.Put(IndexB); - writer.Put(HasPosition); - if (HasPosition) - Vector3Serializer.Serialize(writer, Position); - } - - public static GadgetRelationshipData Deserialize(NetDataReader reader) - { - var relationship = new GadgetRelationshipData - { - Action = reader.GetByte(), - ItemA = reader.GetUShort(), - ItemB = reader.GetUShort(), - IndexA = reader.GetInt(), - IndexB = reader.GetInt(), - HasPosition = reader.GetBool(), - }; - if (relationship.HasPosition) - relationship.Position = Vector3Serializer.Deserialize(reader); - return relationship; - } -} -internal static class TrackedStateSerializer -{ - public static void Serialize(NetDataWriter writer, Dictionary values) - { - writer.Put(values?.Count ?? 0); - if (values == null) - return; - - foreach (var value in values) - { - writer.Put(value.Key); - SerializeValue(writer, value.Value); - } - } - - public static Dictionary Deserialize(NetDataReader reader) + private static int ReadCount(NetDataReader reader, int maximum, string field) { int count = reader.GetInt(); - var result = new Dictionary(count); - for (int i = 0; i < count; i++) - result[reader.GetString()] = DeserializeValue(reader); - return result; + ValidateCount(count, maximum, field); + return count; } - private static void SerializeValue(NetDataWriter writer, object value) + private static void ValidateCount(int count, int maximum, string field) { - switch (value) - { - case bool boolValue: - writer.Put((byte)0); - writer.Put(boolValue); - break; - case int intValue: - writer.Put((byte)1); - writer.Put(intValue); - break; - case uint uintValue: - writer.Put((byte)2); - writer.Put(uintValue); - break; - case float floatValue: - writer.Put((byte)3); - writer.Put(floatValue); - break; - case string stringValue: - writer.Put((byte)4); - writer.Put(stringValue); - break; - default: - throw new System.NotSupportedException($"Unsupported customization tracked value type: {value?.GetType()}"); - } - } - - private static object DeserializeValue(NetDataReader reader) - { - return reader.GetByte() switch - { - 0 => reader.GetBool(), - 1 => reader.GetInt(), - 2 => reader.GetUInt(), - 3 => reader.GetFloat(), - 4 => reader.GetString(), - byte typeCode => throw new System.NotSupportedException($"Unsupported customization tracked value type code: {typeCode}"), - }; + if (count < 0 || count > maximum) + throw new System.InvalidOperationException($"Invalid customization {field} count: {count}"); } } diff --git a/Multiplayer/Networking/Data/Items/ItemUpdateData.cs b/Multiplayer/Networking/Data/Items/ItemUpdateData.cs index 3f4489f4..f60133e2 100644 --- a/Multiplayer/Networking/Data/Items/ItemUpdateData.cs +++ b/Multiplayer/Networking/Data/Items/ItemUpdateData.cs @@ -1,4 +1,5 @@ using LiteNetLib.Utils; +using Multiplayer.Components.Networking; using Multiplayer.Components.Networking.World; using Multiplayer.Networking.Serialization; using System; @@ -9,6 +10,8 @@ namespace Multiplayer.Networking.Data.Items; public class ItemUpdateData { + private const int MaxTrackedValues = 4096; + [Flags] public enum ItemUpdateType : byte { @@ -35,6 +38,7 @@ public enum ItemUpdateType : byte public ushort CarNetId { get; set; } public bool AttachedFront { get; set; } public Dictionary States { get; set; } + public uint SentTick { get; set; } public void Serialize(NetDataWriter writer) { @@ -44,15 +48,27 @@ public void Serialize(NetDataWriter writer) if (UpdateType == ItemUpdateType.Destroy) return; - writer.Put(OwnerPlayerId); - writer.Put(BelongsToPlayer); - writer.Put((byte)ItemState); + // Stamp newly-created snapshots when they first go on the wire, but retain + // the sender's capture tick when the host relays a client update. Replacing + // that tick at the host would pair old phase values with a newer timestamp. + if (SentTick == 0) + SentTick = NetworkLifecycle.Instance.SynchronizedTick; + writer.Put(SentTick); if (UpdateType.HasFlag(ItemUpdateType.Create)) + { + writer.Put(OwnerPlayerId); + writer.Put(BelongsToPlayer); writer.Put(PrefabName); + } + else if (UpdateType.HasFlag(ItemUpdateType.Ownership)) + { + writer.Put(OwnerPlayerId); + } if (UpdateType.HasFlag(ItemUpdateType.Create) || UpdateType.HasFlag(ItemUpdateType.ItemState)) { + writer.Put((byte)ItemState); if (ItemState == ItemState.Dropped || ItemState == ItemState.Thrown) // || UpdateType.HasFlag(ItemUpdateType.ItemPosition) { Vector3Serializer.Serialize(writer, ItemPosition); @@ -74,15 +90,17 @@ public void Serialize(NetDataWriter writer) if (UpdateType.HasFlag(ItemUpdateType.Create) || UpdateType.HasFlag(ItemUpdateType.ObjectState)) { - if (States == null) - writer.Put(0); - else + int stateCount = States?.Count ?? 0; + if (stateCount > MaxTrackedValues) + throw new InvalidOperationException($"Too many tracked item values: {stateCount}"); + + writer.Put(stateCount); + if (States != null) { - writer.Put(States.Count); foreach (var state in States) { writer.Put(state.Key); - SerializeTrackedValue(writer, state.Value); + TrackedValueSerializer.Serialize(writer, state.Value); } } } @@ -96,15 +114,22 @@ public void Deserialize(NetDataReader reader) if (UpdateType == ItemUpdateType.Destroy) return; - OwnerPlayerId = reader.GetByte(); - BelongsToPlayer = reader.GetBool(); - ItemState = (ItemState)reader.GetByte(); + SentTick = reader.GetUInt(); if (UpdateType.HasFlag(ItemUpdateType.Create)) + { + OwnerPlayerId = reader.GetByte(); + BelongsToPlayer = reader.GetBool(); PrefabName = reader.GetString(); + } + else if (UpdateType.HasFlag(ItemUpdateType.Ownership)) + { + OwnerPlayerId = reader.GetByte(); + } if (UpdateType.HasFlag(ItemUpdateType.Create) || UpdateType.HasFlag(ItemUpdateType.ItemState)) { + ItemState = (ItemState)reader.GetByte(); if (ItemState == ItemState.Dropped || ItemState == ItemState.Thrown) // || UpdateType.HasFlag(ItemUpdateType.ItemPosition) { ItemPosition = Vector3Serializer.Deserialize(reader); @@ -131,65 +156,20 @@ public void Deserialize(NetDataReader reader) if (UpdateType.HasFlag(ItemUpdateType.Create) || UpdateType.HasFlag(ItemUpdateType.ObjectState)) { int stateCount = reader.GetInt(); + if (stateCount < 0 || stateCount > MaxTrackedValues) + throw new InvalidOperationException($"Invalid tracked item value count: {stateCount}"); + if (stateCount > 0) { States = new Dictionary(); for (int i = 0; i < stateCount; i++) { string key = reader.GetString(); - object value = DeserializeTrackedValue(reader); + object value = TrackedValueSerializer.Deserialize(reader); States[key] = value; } } } } - private void SerializeTrackedValue(NetDataWriter writer, object value) - { - if (value is bool boolValue) - { - writer.Put((byte)0); - writer.Put(boolValue); - } - else if (value is int intValue) - { - writer.Put((byte)1); - writer.Put(intValue); - } - else if (value is uint uintValue) - { - writer.Put((byte)2); - writer.Put(uintValue); - } - else if (value is float floatValue) - { - writer.Put((byte)3); - writer.Put(floatValue); - } - else if (value is string stringValue) - { - writer.Put((byte)4); - writer.Put(stringValue); - } - else - { - throw new NotSupportedException($"ItemUpdateData.SerializeTrackedValue({ItemNetId}, {PrefabName??""}) Unsupported type for serialization: {value.GetType()}"); - } - } - - private object DeserializeTrackedValue(NetDataReader reader) - { - byte typeCode = reader.GetByte(); - switch (typeCode) - { - case 0: return reader.GetBool(); - case 1: return reader.GetInt(); - case 2: return reader.GetUInt(); - case 3: return reader.GetFloat(); - case 4: return reader.GetString(); - - default: - throw new NotSupportedException($"ItemUpdateData.DeserializeTrackedValue({ItemNetId}, {PrefabName ?? ""}) Unsupported type code for deserialization: {typeCode}"); - } - } } diff --git a/Multiplayer/Networking/Data/ServerPlayer.cs b/Multiplayer/Networking/Data/ServerPlayer.cs index f8f9eb57..1d60921b 100644 --- a/Multiplayer/Networking/Data/ServerPlayer.cs +++ b/Multiplayer/Networking/Data/ServerPlayer.cs @@ -10,6 +10,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Linq; using UnityEngine; namespace Multiplayer.Networking.Data; @@ -43,6 +44,10 @@ public void Dispose() public string CharacterId { get; set; } public bool IsVR { get; } public uint LastHighPingTickLogged { get; set; } + public uint LastTrackingTick { get; set; } + public bool HasTrackingTick { get; set; } + public bool CustomizationSnapshotSent { get; set; } + public HashSet ProcessedCustomizationActionIds { get; } = []; public PlayerTrackingData TrackingData { get; set; } public PlayerPostureFlags Posture { get; set; } // already exists — keep @@ -96,7 +101,9 @@ public string DisplayName 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(); + public IEnumerable OwnedItems => NetworkedItem.GetAll() + .Where(item => item != null && item.OwnerPlayerId == PlayerId) + .Select(item => item.NetId); public StorageBase Storage { get; set; } = new StorageBase(); private Vector3 _lastWorldPos = Vector3.zero; @@ -198,37 +205,12 @@ public Vector3 WorldPosition #endregion #region Item Ownership - public bool OwnsItem(ushort itemNetId) => OwnedItems.Contains(itemNetId); - - public void AddOwnedItem(ushort itemNetId) - { - OwnedItems.Add(itemNetId); - NetworkLifecycle.Instance.Server.LogDebug(() => $"Player {Username} now owns item {itemNetId}"); - } - - public void AddOwnedItems(IEnumerable itemNetIds) - { - OwnedItems.UnionWith(itemNetIds); - NetworkLifecycle.Instance.Server.LogDebug(() => $"Player {Username} batch added items: {string.Join(", ", itemNetIds)}"); - } - - public void RemoveOwnedItem(ushort itemNetId) - { - if (OwnedItems.Remove(itemNetId)) - { - NetworkLifecycle.Instance.Server.LogDebug(() => $"Player {Username} no longer owns item {itemNetId}"); - } - } - - public void ClearOwnedItems() - { - OwnedItems.Clear(); - NetworkLifecycle.Instance.Server.LogDebug(() => $"Cleared all owned items for player {Username}"); - } + public bool OwnsItem(ushort itemNetId) => + NetworkedItem.TryGet(itemNetId, out var item) && item.OwnerPlayerId == PlayerId; public bool TryGetOwnedItem(ushort itemNetId, out NetworkedItem item) { - if (OwnedItems.Contains(itemNetId) && NetworkedItem.TryGet(itemNetId, out item)) + if (NetworkedItem.TryGet(itemNetId, out item) && item.OwnerPlayerId == PlayerId) { return true; } diff --git a/Multiplayer/Networking/Managers/Client/ClientPlayerManager.cs b/Multiplayer/Networking/Managers/Client/ClientPlayerManager.cs index 6b666f36..56c8db2e 100644 --- a/Multiplayer/Networking/Managers/Client/ClientPlayerManager.cs +++ b/Multiplayer/Networking/Managers/Client/ClientPlayerManager.cs @@ -72,10 +72,14 @@ public void UpdatePing(byte playerId, int ping) player.SetPing(ping); } - public void UpdatePosition(byte playerId, PlayerTrackingData trackingData, PlayerPostureFlags posture, bool isOnCar, ushort carId) + public void UpdatePosition(byte playerId, PlayerTrackingData trackingData, PlayerPostureFlags posture, bool isOnCar, ushort carId, uint tick) { if (!TryGetPlayer(playerId, out NetworkedPlayer player)) return; + + if (!player.TryAcceptTrackingTick(tick)) + return; + player.UpdateCar(carId); player.UpdatePosition(trackingData, posture, isOnCar); } diff --git a/Multiplayer/Networking/Managers/Client/NetworkClient.cs b/Multiplayer/Networking/Managers/Client/NetworkClient.cs index 0f71116d..b9d6d654 100644 --- a/Multiplayer/Networking/Managers/Client/NetworkClient.cs +++ b/Multiplayer/Networking/Managers/Client/NetworkClient.cs @@ -79,8 +79,19 @@ public class NetworkClient : NetworkManager internal uint trainSetsSpawned = 0; internal bool railwayStateLoaded = false; private bool customizerStateLoaded; + private bool customizationSnapshotApplied; + private bool applyingCustomizationSnapshot; + private bool customizationActionDrainRunning; + private bool customizationSnapshotTimedOut; private CustomizationStateData pendingCustomizationState; + private readonly List pendingCustomizationSnapshotPlacements = []; + private readonly List pendingCustomizationSnapshotRelationships = []; + private readonly Dictionary> pendingCustomizationSnapshotHoleStates = []; + private readonly List pendingCustomizationActions = []; + private readonly List appliedActionsSinceSnapshotTimeout = []; private bool itemReconciliationComplete; + private uint nextCustomizationActionId = 1; + private uint nextShopRequestId = 1; // One way ping in milliseconds public int Ping { get; private set; } @@ -145,6 +156,17 @@ public void Start(string address, int port, string password, bool isSinglePlayer public override void Stop() { Log("Stopping client"); + pendingCustomizationState = null; + pendingCustomizationSnapshotPlacements.Clear(); + pendingCustomizationSnapshotRelationships.Clear(); + pendingCustomizationSnapshotHoleStates.Clear(); + pendingCustomizationActions.Clear(); + appliedActionsSinceSnapshotTimeout.Clear(); + customizationSnapshotApplied = false; + customizationSnapshotTimedOut = false; + customizerStateLoaded = false; + CustomizationStateManager.ClearPendingLocalState(); + if (!isAlsoHost && originalSession != null) { LogDebug(() => $"NetworkClient.Stop() destroying session... Original session is Null: {originalSession == null}"); @@ -256,6 +278,7 @@ protected override void Subscribe() netPacketProcessor.SubscribeNetSerializable(OnCommonPitStopPlugInteractionPacket); netPacketProcessor.SubscribeReusable(OnClientboundPitStopBulkUpdatePacket); netPacketProcessor.SubscribeReusable(OnCommonCashRegisterWithModulesActionPacket); + netPacketProcessor.SubscribeReusable(OnCommonShopPacket); netPacketProcessor.SubscribeReusable(OnCommonGenericSwitchStatePacket); netPacketProcessor.SubscribeReusable(OnCommonChatPacket); @@ -380,22 +403,43 @@ private IEnumerator SyncWorldState() */ customizerStateLoaded = false; + customizationSnapshotApplied = false; + applyingCustomizationSnapshot = false; + customizationActionDrainRunning = false; + customizationSnapshotTimedOut = false; pendingCustomizationState = null; + pendingCustomizationSnapshotPlacements.Clear(); + pendingCustomizationSnapshotRelationships.Clear(); + pendingCustomizationSnapshotHoleStates.Clear(); + pendingCustomizationActions.Clear(); + appliedActionsSinceSnapshotTimeout.Clear(); + CustomizationStateManager.ClearPendingLocalState(); Log("Requesting customization state"); SendLoadStateUpdate(PlayerLoadingState.ReadyForCustomizers); displayLoadingInfo.OnLoadingStatusChanged("Syncing customizations", false, ((float)LoadingState / (float)PlayerLoadingState.Complete) * 100); + // The host may need to stream customized train-car interiors before taking + // the complete world snapshot. Do not mistake that preparation for a lost + // packet on low-latency connections where RPC_Timeout is very small. + float customizationDeadline = Time.time + Mathf.Max(RPC_Timeout, 5f); while (!customizerStateLoaded) { if (pendingCustomizationState == null) { + if (Time.time >= customizationDeadline) + { + LogWarning("Timed out waiting for customization state; continuing while retaining the late-snapshot path"); + customizationSnapshotTimedOut = true; + customizationSnapshotApplied = true; + customizerStateLoaded = true; + StartCustomizationActionDrain(); + break; + } yield return null; continue; } - yield return CustomizationStateManager.ApplyCurrentStateWhenReady(pendingCustomizationState); - Log($"Customization state loaded ({pendingCustomizationState.Gadgets.Count} gadgets, {pendingCustomizationState.Holes.Count} holes)"); - pendingCustomizationState = null; + yield return ApplyPendingCustomizationStates(); customizerStateLoaded = true; } @@ -406,13 +450,19 @@ private IEnumerator SyncWorldState() itemReconciliationComplete = false; Log("Reconciling local items"); SendLoadStateUpdate(PlayerLoadingState.ReadyForItems); - SendNetSerializablePacketToServer( - NetworkedItemManager.Instance.CreateItemReconciliationRequest(), - DeliveryMethod.ReliableOrdered); + foreach (var reconciliationPacket in NetworkedItemManager.Instance.CreateItemReconciliationRequests()) + SendNetSerializablePacketToServer(reconciliationPacket, DeliveryMethod.ReliableOrdered); displayLoadingInfo.OnLoadingStatusChanged("Syncing items", false, ((float)LoadingState / (float)PlayerLoadingState.Complete) * 100); - while (!itemReconciliationComplete) + float reconciliationDeadline = Time.time + Mathf.Max(RPC_Timeout, 5f); + while (!itemReconciliationComplete && Time.time < reconciliationDeadline) yield return null; + if (!itemReconciliationComplete) + { + LogWarning("Timed out waiting for item reconciliation; continuing in degraded state"); + NetworkedItemManager.Instance.ContinueAfterItemReconciliationTimeout(); + itemReconciliationComplete = true; + } /* * ReadyForJobs @@ -593,7 +643,7 @@ private void OnClientboundPlayerJoinedPacket(ClientboundPlayerJoinedPacket packe Log($"Received player joined packet for player id: {packet.PlayerId}, username: {packet.Username}"); ClientPlayerManager.AddPlayer(packet.PlayerId, packet.Username, packet.CrewName, packet.CharacterId, packet.IsVR); - ClientPlayerManager.UpdatePosition(packet.PlayerId, packet.TrackingData, packet.Posture, packet.IsOnCar, packet.CarID); + ClientPlayerManager.UpdatePosition(packet.PlayerId, packet.TrackingData, packet.Posture, packet.IsOnCar, packet.CarID, packet.Tick); } //For other player left the game @@ -620,7 +670,7 @@ private void OnClientboundDisconnectPacket(ClientboundDisconnectPacket packet) private void OnClientboundPlayerPositionPacket(ClientboundPlayerPositionPacket packet) { - ClientPlayerManager.UpdatePosition(packet.PlayerId, packet.TrackingData, packet.Posture, packet.IsOnCar, packet.CarID); + ClientPlayerManager.UpdatePosition(packet.PlayerId, packet.TrackingData, packet.Posture, packet.IsOnCar, packet.CarID, packet.Tick); } private void OnClientboundPlayerPreferencesUpdatePacket(ClientboundPlayerPreferencesUpdatePacket packet) @@ -643,7 +693,8 @@ private void OnClientboundPingUpdatePacket(ClientboundPingUpdatePacket packet) private void OnClientboundTickSyncPacket(ClientboundTickSyncPacket packet) { - NetworkLifecycle.Instance.Tick = (uint)(packet.ServerTick + Ping / 2.0f * (1f / NetworkLifecycle.TICK_RATE)); + // Both transports normalize this to one-way latency in milliseconds. + NetworkLifecycle.Instance.SynchronizeClientTick(packet.ServerTick, Ping); } private void OnClientboundServerLoadingPacket(ClientboundServerLoadingPacket packet) @@ -1370,9 +1421,11 @@ private void OnCommonItemChangePacket(CommonItemChangePacket packet) private void OnClientboundItemReconciliationPacket(ClientboundItemReconciliationPacket packet) { - NetworkedItemManager.Instance.ApplyItemReconciliation(packet); - itemReconciliationComplete = true; - Log($"Item reconciliation complete ({packet?.Items?.Count ?? 0} local items)"); + if (NetworkedItemManager.Instance.ApplyItemReconciliation(packet)) + { + itemReconciliationComplete = true; + Log($"Item reconciliation complete ({packet?.Items?.Count ?? 0} items in final batch)"); + } } private void OnClientboundCustomizationStatePacket(ClientboundCustomizationStatePacket packet) @@ -1384,9 +1437,177 @@ private void OnClientboundCustomizationStatePacket(ClientboundCustomizationState } pendingCustomizationState = packet.State; + // Pause live mutation application immediately. If the timeout path already + // released actions, they are replayed after this older baseline is applied. + customizationSnapshotApplied = false; + if (customizerStateLoaded && !applyingCustomizationSnapshot) + CoroutineManager.Instance.StartCoroutine(ApplyPendingCustomizationStates()); } - private void OnCommonCustomizationPacket(CommonCustomizationPacket packet) => CustomizationStateManager.ApplyAction(packet); + private void OnCommonCustomizationPacket(CommonCustomizationPacket packet) + { + if (packet == null) + return; + + pendingCustomizationActions.Add(packet); + StartCustomizationActionDrain(); + } + + private IEnumerator ApplyPendingCustomizationStates() + { + if (applyingCustomizationSnapshot) + yield break; + + applyingCustomizationSnapshot = true; + try + { + while (pendingCustomizationState != null) + { + CustomizationStateData state = pendingCustomizationState; + pendingCustomizationState = null; + pendingCustomizationSnapshotPlacements.Clear(); + pendingCustomizationSnapshotRelationships.Clear(); + pendingCustomizationSnapshotHoleStates.Clear(); + yield return CustomizationStateManager.ApplyCurrentStateWhenReady(state, + pendingCustomizationSnapshotPlacements, pendingCustomizationSnapshotRelationships, + pendingCustomizationSnapshotHoleStates); + + if (customizationSnapshotTimedOut) + { + var capturedLocalActions = state.ProcessedOriginActionIds.ToHashSet(); + pendingCustomizationActions.InsertRange(0, appliedActionsSinceSnapshotTimeout.Where( + action => !capturedLocalActions.Contains(action.OriginActionId))); + appliedActionsSinceSnapshotTimeout.Clear(); + customizationSnapshotTimedOut = false; + } + + customizationSnapshotApplied = true; + Log($"Customization state loaded ({state.Gadgets.Count} gadgets, {state.Holes.Count} holes)"); + } + } + finally + { + applyingCustomizationSnapshot = false; + } + + StartCustomizationActionDrain(); + } + + private void StartCustomizationActionDrain() + { + if (customizationActionDrainRunning || + pendingCustomizationSnapshotPlacements.Count == 0 && + pendingCustomizationSnapshotRelationships.Count == 0 && + pendingCustomizationSnapshotHoleStates.Count == 0 && + pendingCustomizationActions.Count == 0) + return; + + CoroutineManager.Instance.StartCoroutine(DrainPendingCustomizationActions()); + } + + private IEnumerator DrainPendingCustomizationActions() + { + customizationActionDrainRunning = true; + try + { + while (IsRunning && (pendingCustomizationSnapshotPlacements.Count > 0 || + pendingCustomizationSnapshotRelationships.Count > 0 || + pendingCustomizationSnapshotHoleStates.Count > 0 || pendingCustomizationActions.Count > 0)) + { + if (!customizationSnapshotApplied || applyingCustomizationSnapshot) + { + yield return null; + continue; + } + + bool appliedAny = false; + for (int index = 0; index < pendingCustomizationSnapshotPlacements.Count; index++) + { + var placement = pendingCustomizationSnapshotPlacements[index]; + if (!CustomizationStateManager.IsSnapshotPlacementReady(placement, out _)) + continue; + + pendingCustomizationSnapshotPlacements.RemoveAt(index--); + // The initial object-state pass already applied or queued these + // values. If the item did not exist, its reliable Create carries + // a newer full state. Reapplying this older baseline here could + // roll back changes received while the target was unavailable. + CustomizationStateManager.ApplySnapshotPlacement(placement, applyTrackedValues: false); + appliedAny = true; + } + + foreach (string targetKey in pendingCustomizationSnapshotHoleStates.Keys.ToArray()) + { + if (!CustomizationStateManager.ApplySnapshotHoleState(targetKey, + pendingCustomizationSnapshotHoleStates[targetKey])) + continue; + + pendingCustomizationSnapshotHoleStates.Remove(targetKey); + appliedAny = true; + } + + for (int index = 0; index < pendingCustomizationSnapshotRelationships.Count; index++) + { + var action = pendingCustomizationSnapshotRelationships[index]; + bool blockedByEarlierDependency = pendingCustomizationSnapshotRelationships.Take(index) + .Any(earlier => CustomizationStateManager.ActionsConflict(earlier, action)); + bool blockedByPlacement = pendingCustomizationSnapshotPlacements + .Any(placement => CustomizationStateManager.SnapshotPlacementConflicts(placement, action)); + bool blockedByHoleState = pendingCustomizationSnapshotHoleStates.Keys + .Any(targetKey => CustomizationStateManager.SnapshotHoleStateConflicts(targetKey, action)); + if (blockedByEarlierDependency || blockedByPlacement || blockedByHoleState || + !CustomizationStateManager.IsActionReady(action, out _)) + continue; + + pendingCustomizationSnapshotRelationships.RemoveAt(index--); + try + { + CustomizationStateManager.ApplyAction(action); + } + catch (Exception exception) + { + LogError($"Failed to apply deferred snapshot relationship {action.Action}: {exception}"); + } + appliedAny = true; + } + + for (int index = 0; index < pendingCustomizationActions.Count; index++) + { + var action = pendingCustomizationActions[index]; + bool blockedByEarlierDependency = pendingCustomizationActions.Take(index) + .Any(earlier => CustomizationStateManager.ActionsConflict(earlier, action)); + bool blockedBySnapshotPlacement = pendingCustomizationSnapshotPlacements + .Any(placement => CustomizationStateManager.SnapshotPlacementConflicts(placement, action)); + bool blockedBySnapshotRelationship = pendingCustomizationSnapshotRelationships + .Any(relationship => CustomizationStateManager.ActionsConflict(relationship, action)); + bool blockedBySnapshotHoleState = pendingCustomizationSnapshotHoleStates.Keys + .Any(targetKey => CustomizationStateManager.SnapshotHoleStateConflicts(targetKey, action)); + if (blockedByEarlierDependency || blockedBySnapshotPlacement || + blockedBySnapshotRelationship || blockedBySnapshotHoleState || + !CustomizationStateManager.IsActionReady(action, out _)) + continue; + + pendingCustomizationActions.RemoveAt(index--); + try + { + CustomizationStateManager.ApplyAction(action); + } + catch (Exception exception) + { + LogError($"Failed to apply deferred customization action {action.Action}: {exception}"); + } + appliedAny = true; + } + + if (!appliedAny) + yield return null; + } + } + finally + { + customizationActionDrainRunning = false; + } + } private void OnCommonPaintThemePacket(CommonPaintThemePacket packet) { @@ -1490,7 +1711,30 @@ private void OnCommonCashRegisterWithModulesActionPacket(CommonCashRegisterWithM Log($"Cash Register With Modules Action received for {netCashRegister.GetObjectPath()}, Action: {packet.Action}, Amount: {packet.Amount}"); - netCashRegister.Client_ProcessCashRegisterAction(packet); + netCashRegister.Client_ProcessCashRegisterAction(packet.Action, packet.Amount); + } + + private void OnCommonShopPacket(CommonShopPacket packet) + { + if (packet.Action == ShopAction.StockSnapshot) + { + ShopPurchaseCoordinator.ApplyStockSnapshot(packet.ItemPrefabNames, packet.ItemAmounts); + return; + } + + if (packet.Action == ShopAction.StockChanged) + { + ShopPurchaseCoordinator.ApplyStockDelta(packet.ItemPrefabNames, packet.ItemAmounts); + return; + } + + if (!NetworkedCashRegisterWithModules.Get(packet.RegisterNetId, out var register) || !register.IsShopRegister) + { + LogWarning($"Shop action received for invalid register {packet.RegisterNetId}"); + return; + } + + register.Client_ProcessShopAction(packet); } private void OnCommonGenericSwitchStatePacket(CommonGenericSwitchStatePacket packet) @@ -1547,6 +1791,7 @@ public void SendPlayerPosition(PlayerTrackingData trackingData, PlayerPostureFla SendPacketToServer(new ServerboundPlayerPositionPacket { + Tick = NetworkLifecycle.Instance.SynchronizedTick, TrackingData = trackingData, Posture = posture, IsOnCar = isOnCar, @@ -1947,8 +2192,22 @@ public void SendItemsChangePacket(List items) DeliveryMethod.ReliableOrdered); } - public void SendCustomizationAction(CommonCustomizationPacket packet) => + public void SendCustomizationAction(CommonCustomizationPacket packet) + { + packet.OriginActionId = nextCustomizationActionId++; + if (nextCustomizationActionId == 0) + nextCustomizationActionId = 1; + + // Ordinary actions are not echoed to their sender. Preserve locally + // originated mutations so a snapshot that arrives after the loading + // timeout cannot erase them. Replacement actions are server-echoed after + // their authoritative replacement item id has been assigned. + if (customizationSnapshotTimedOut && packet.Action is not + (CustomizationAction.ReplaceSpool or CustomizationAction.ReplaceDuctTape)) + appliedActionsSinceSnapshotTimeout.Add(packet); + SendNetSerializablePacketToServer(packet, DeliveryMethod.ReliableOrdered); + } public void SendPaintThemeChange(NetworkedTrainCar netTraincar, TrainCarPaint.Target targetArea, uint themeId) { @@ -1957,22 +2216,38 @@ 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, - string[] itemPrefabNames = null, int[] itemAmounts = null) + public void SendCashRegisterAction(ushort netId, CashRegisterAction action, double amount = 0.0f) { SendPacketToServer( new CommonCashRegisterWithModulesActionPacket { NetId = netId, Action = action, - Amount = amount, - ItemPrefabNames = itemPrefabNames ?? [], - ItemAmounts = itemAmounts ?? [] + Amount = amount }, DeliveryMethod.ReliableOrdered ); } + + public uint SendShopPurchase(ushort registerNetId, double amount, string[] itemPrefabNames, int[] itemAmounts) + { + uint requestId = nextShopRequestId++; + if (nextShopRequestId == 0) + nextShopRequestId = 1; + + SendPacketToServer(new CommonShopPacket + { + RegisterNetId = registerNetId, + RequestId = requestId, + Action = ShopAction.Purchase, + Amount = amount, + ItemPrefabNames = itemPrefabNames ?? [], + ItemAmounts = itemAmounts ?? [], + }, DeliveryMethod.ReliableOrdered); + return requestId; + } + public void SendTrainControlAuthorityRequest(ushort netId, uint portNetId, bool requestAuthority) { SendPacketToServer diff --git a/Multiplayer/Networking/Managers/Server/NetworkServer.cs b/Multiplayer/Networking/Managers/Server/NetworkServer.cs index 98e6d307..59bdf8f0 100644 --- a/Multiplayer/Networking/Managers/Server/NetworkServer.cs +++ b/Multiplayer/Networking/Managers/Server/NetworkServer.cs @@ -42,6 +42,7 @@ using Multiplayer.Patches.World; using Multiplayer.Utils; using System; +using System.Collections; using System.Collections.Generic; using System.Linq; using System.Net; @@ -100,6 +101,8 @@ public ITransportPeer SelfPeer public ChatManager ChatManager => _chatManager; private uint lastTick; + private readonly List pendingHostCustomizationActions = []; + private bool hostCustomizationDrainRunning; public NetworkServer(IDifficulty difficulty, Settings settings, bool singlePlayer, LobbyServerData serverData) : base(settings) { @@ -147,6 +150,8 @@ public override void Stop() { Log($"Stopping server..."); WorldStreamingInit.LoadingFinished -= OnLoaded; + pendingHostCustomizationActions.Clear(); + hostCustomizationDrainRunning = false; if (lobbyServerManager != null) { @@ -190,6 +195,7 @@ protected override void Subscribe() netPacketProcessor.SubscribeNetSerializable(OnCommonPitStopPlugInteractionPacket); netPacketProcessor.SubscribeReusable(OnCommonCashRegisterWithModulesActionPacket); + netPacketProcessor.SubscribeReusable(OnCommonShopPacket); netPacketProcessor.SubscribeReusable(OnCommonGenericSwitchStatePacket); @@ -411,6 +417,9 @@ public override void OnConnectionRequest(NetDataReader requestData, IConnectionR if (excludeSelf && peer == SelfPeer) continue; + if (TryGetServerPlayer(peer, out var player) && player.LoadingState < minimumLoadState) + continue; + peer?.Send(writer, deliveryMethod); } } @@ -1287,6 +1296,11 @@ private void OnServerboundLoadStateUpdatePacket(ServerboundLoadStateUpdatePacket }, DeliveryMethod.ReliableOrdered); } + // Deltas are intentionally withheld until this state because the + // client's shop controller and register lookup do not exist before + // world initialization. This baseline closes that late-join gap. + SendPacket(peer, ShopPurchaseCoordinator.CreateStockSnapshot(), DeliveryMethod.ReliableOrdered); + break; case PlayerLoadingState.ReadyForTrainSets: @@ -1313,16 +1327,8 @@ private void OnServerboundLoadStateUpdatePacket(ServerboundLoadStateUpdatePacket break; case PlayerLoadingState.ReadyForCustomizers: - var customizationState = CustomizationStateManager.CaptureCurrentState(); - foreach (ushort itemNetId in customizationState.Gadgets.Select(gadget => gadget.ItemNetId) - .Concat(customizationState.RelatedItems.Select(item => item.ItemNetId)).Distinct()) - if (NetworkedItem.TryGet(itemNetId, out var knownItem)) - player.KnownItems[knownItem] = NetworkLifecycle.Instance.Tick; - - SendNetSerializablePacket(peer, new ClientboundCustomizationStatePacket - { - State = customizationState, - }, DeliveryMethod.ReliableOrdered); + player.CustomizationSnapshotSent = false; + CoroutineManager.Instance.StartCoroutine(SendInitialCustomizationStateWhenReady(player, peer)); break; @@ -1384,6 +1390,7 @@ private void OnServerboundLoadStateUpdatePacket(ServerboundLoadStateUpdatePacket IsVR = player.IsVR, CharacterId = player.CharacterId, CrewName = player.CrewName, + Tick = player.LastTrackingTick, TrackingData = player.TrackingData, Posture = player.Posture, IsOnCar = player.CarId != 0, @@ -1409,6 +1416,7 @@ private void OnServerboundLoadStateUpdatePacket(ServerboundLoadStateUpdatePacket IsVR = otherPlayer.IsVR, CrewName = otherPlayer.CrewName, CarID = otherPlayer.CarId, + Tick = otherPlayer.LastTrackingTick, TrackingData = otherPlayer.TrackingData, // full merged state Posture = otherPlayer.Posture, IsOnCar = otherPlayer.CarId != 0, @@ -1428,6 +1436,15 @@ private void OnServerboundPlayerPositionPacket(ServerboundPlayerPositionPacket p return; } + if (packet.Tick != 0) + { + if (player.HasTrackingTick && NetworkLifecycle.SignedTickDelta(packet.Tick, player.LastTrackingTick) < 0) + return; + + player.LastTrackingTick = packet.Tick; + player.HasTrackingTick = true; + } + // If the player's car has changed, remove the player from the old car if (packet.CarID == 0 && player.CarId != 0) { @@ -1447,6 +1464,7 @@ private void OnServerboundPlayerPositionPacket(ServerboundPlayerPositionPacket p SendPacketToAll(new ClientboundPlayerPositionPacket { PlayerId = player.PlayerId, + Tick = packet.Tick, TrackingData = packet.TrackingData, Posture = packet.Posture, IsOnCar = packet.IsOnCar, @@ -2152,6 +2170,15 @@ private void OnCommonItemChangePacket(CommonItemChangePacket packet, ITransportP NetworkedItemManager.Instance.ReceiveSnapshots(packet.Items, player); } + public void SendShopAction(CommonShopPacket packet, ServerPlayer[] players = null) + { + if (players == null) + SendPacketToAll(packet, DeliveryMethod.ReliableOrdered, PlayerLoadingState.ReadyForWorldState, true); + else + foreach (var player in players) + SendPacket(player.Peer, packet, DeliveryMethod.ReliableOrdered); + } + private void OnServerboundItemReconciliationPacket(ServerboundItemReconciliationPacket packet, ITransportPeer peer) { if (!TryGetServerPlayer(peer, out var player)) @@ -2168,16 +2195,215 @@ private void OnServerboundItemReconciliationPacket(ServerboundItemReconciliation } public void SendCustomizationAction(CommonCustomizationPacket packet) => - SendNetSerializablePacketToAll(packet, DeliveryMethod.ReliableOrdered, excludeSelf: true); + RelayCustomizationAction(packet); private void OnCommonCustomizationPacket(CommonCustomizationPacket packet, ITransportPeer peer) { - if (!TryGetServerPlayer(peer, out _)) return; - CustomizationStateManager.ApplyAction(packet); - if (packet.Action == CustomizationAction.ReplaceSpool || packet.Action == CustomizationAction.ReplaceDuctTape) - SendNetSerializablePacketToAll(packet, DeliveryMethod.ReliableOrdered, excludeSelf: true); - else - SendNetSerializablePacketToAll(packet, DeliveryMethod.ReliableOrdered, peer, excludeSelf: true); + if (packet == null || !TryGetServerPlayer(peer, out var player)) + return; + + pendingHostCustomizationActions.Add(new PendingHostCustomizationAction(packet, player, peer)); + StartHostCustomizationDrain(); + } + + private void StartHostCustomizationDrain() + { + if (hostCustomizationDrainRunning || pendingHostCustomizationActions.Count == 0) + return; + + CoroutineManager.Instance.StartCoroutine(DrainPendingHostCustomizationActions()); + } + + private IEnumerator DrainPendingHostCustomizationActions() + { + hostCustomizationDrainRunning = true; + try + { + while (IsRunning && pendingHostCustomizationActions.Count > 0) + { + bool appliedAny = false; + for (int index = 0; index < pendingHostCustomizationActions.Count; index++) + { + var pending = pendingHostCustomizationActions[index]; + if (!peerToPlayer.TryGetValue(pending.Peer, out var connectedPlayer) || + connectedPlayer != pending.Player) + { + pendingHostCustomizationActions.RemoveAt(index--); + continue; + } + + bool blockedByEarlierDependency = pendingHostCustomizationActions.Take(index) + .Any(earlier => CustomizationStateManager.ActionsConflict(earlier.Packet, pending.Packet)); + if (blockedByEarlierDependency || + !CustomizationStateManager.IsActionReady(pending.Packet, out _)) + continue; + + pendingHostCustomizationActions.RemoveAt(index--); + bool applied = false; + try + { + applied = CustomizationStateManager.ProcessActionAsHost(pending.Packet, pending.Player); + } + catch (Exception exception) + { + LogError($"Failed to apply customization action {pending.Packet.Action} from " + + $"{pending.Player.Username}: {exception}"); + } + + if (pending.Packet.OriginActionId != 0) + pending.Player.ProcessedCustomizationActionIds.Add(pending.Packet.OriginActionId); + + if (applied) + { + bool echoSender = pending.Packet.Action is CustomizationAction.ReplaceSpool or + CustomizationAction.ReplaceDuctTape; + RelayCustomizationAction(pending.Packet, pending.Peer, echoSender); + } + appliedAny = true; + } + + if (!appliedAny) + yield return null; + } + } + finally + { + hostCustomizationDrainRunning = false; + } + } + + private sealed class PendingHostCustomizationAction + { + public readonly CommonCustomizationPacket Packet; + public readonly ServerPlayer Player; + public readonly ITransportPeer Peer; + + public PendingHostCustomizationAction(CommonCustomizationPacket packet, ServerPlayer player, + ITransportPeer peer) + { + Packet = packet; + Player = player; + Peer = peer; + } + } + + private void RelayCustomizationAction(CommonCustomizationPacket packet, ITransportPeer origin = null, + bool echoOrigin = false) + { + foreach (var player in CustomizationStateManager.GetCustomizationRecipients(packet).ToArray()) + { + if (player.Peer == null || player.Peer == SelfPeer || (!echoOrigin && player.Peer == origin)) + continue; + + EnsureCustomizationItemsKnown(packet, player); + SendNetSerializablePacket(player.Peer, packet, DeliveryMethod.ReliableOrdered); + } + } + + private IEnumerator SendInitialCustomizationStateWhenReady(ServerPlayer player, ITransportPeer peer) + { + // Installed gadget items remain in this storage even while their train-car + // interior is streamed out. Use it as a second source in addition to the + // persistent TrainCarCustomization links/holes. + HashSet carsWithStoredGadgets = []; + var installedGadgets = StorageController.Instance?.StorageInstalledGadgets; + if (installedGadgets != null) + { + foreach (var item in installedGadgets.GetStorageItemList()) + { + if (item == null) + continue; + + var trainCar = TrainCar.Resolve(item.gameObject); + if (trainCar != null) + carsWithStoredGadgets.Add(trainCar); + } + } + + var pinnedCars = NetworkedTrainCar.GetAll() + .Where(car => car != null && car.TrainCar != null && + (car.HasAttachedCustomizations || carsWithStoredGadgets.Contains(car.TrainCar))) + .ToList(); + + try + { + foreach (var car in pinnedCars) + { + try + { + car.BeginInteriorNetworkSync(); + } + catch (Exception exception) + { + LogWarning($"Could not load customized train-car interior [{car.CurrentID}, {car.NetId}]: {exception}"); + } + } + + // Interior streaming may finish asynchronously. Keep the selected cars + // pinned until they load, but bound the wait so one unsupported interior + // cannot block a joining client indefinitely. + float interiorLoadDeadline = Time.realtimeSinceStartup + 2f; + while (pinnedCars.Any(car => car != null && car.TrainCar != null && + !car.TrainCar.IsInteriorLoaded) && + Time.realtimeSinceStartup < interiorLoadDeadline) + yield return null; + + // Allow newly created gadget components to finish their Unity Start pass. + if (pinnedCars.Count > 0) + yield return null; + + foreach (var car in pinnedCars.Where(car => car != null && car.TrainCar != null && + !car.TrainCar.IsInteriorLoaded)) + LogWarning($"Customized train-car interior [{car.CurrentID}, {car.NetId}] did not load before the join snapshot deadline"); + + // The player may disconnect while the interiors are being initialized. + if (!peerToPlayer.TryGetValue(peer, out var connectedPlayer) || connectedPlayer != player) + yield break; + + var customizationState = CustomizationStateManager.CaptureCurrentState(); + customizationState.ProcessedOriginActionIds.AddRange(player.ProcessedCustomizationActionIds); + NetworkedItemManager.Instance.SendInitialItems(player, + CustomizationStateManager.GetReferencedItemIds(customizationState), + CustomizationStateManager.GetItemsRequiringDroppedCreate(customizationState)); + + SendNetSerializablePacket(peer, new ClientboundCustomizationStatePacket + { + State = customizationState, + }, DeliveryMethod.ReliableOrdered); + player.CustomizationSnapshotSent = true; + player.ProcessedCustomizationActionIds.Clear(); + + LogDebug(() => $"Sent join customization snapshot to {player.Username} after loading {pinnedCars.Count} customized train-car interiors"); + } + finally + { + foreach (var car in pinnedCars) + if (car != null) + car.EndInteriorNetworkSync(); + } + } + + private void EnsureCustomizationItemsKnown(CommonCustomizationPacket packet, ServerPlayer player) + { + // Replacement actions construct (or reuse) their replacement object locally + // and assign OtherItemNetId while applying the action. Sending a separate + // Create for that ID first represents the contained reel/tape as Dropped and + // can pull it back out of its tool on the receiving peer. + bool createsReplacementLocally = packet.Action is + CustomizationAction.ReplaceSpool or CustomizationAction.ReplaceDuctTape; + IEnumerable itemIds = createsReplacementLocally + ? [packet.ItemNetId] + : CustomizationStateManager.GetActionItemIds(packet); + HashSet forceDropped = packet.Action is CustomizationAction.LoadSpool or CustomizationAction.SnapItem + ? [packet.OtherItemNetId] + : []; + NetworkedItemManager.Instance.SendInitialItems(player, itemIds, forceDropped); + + // The action itself is the replacement's create message. Record that fact so + // ordinary item discovery does not send a conflicting Create(Dropped) later. + if (createsReplacementLocally && + NetworkedItem.TryGet(packet.OtherItemNetId, out NetworkedItem replacementItem)) + player.KnownItems[replacementItem] = NetworkLifecycle.Instance.Tick; } private void OnCommonCashRegisterWithModulesActionPacket(CommonCashRegisterWithModulesActionPacket packet, ITransportPeer peer) @@ -2198,6 +2424,23 @@ private void OnCommonCashRegisterWithModulesActionPacket(CommonCashRegisterWithM netCashRegister.Server_ProcessCashRegisterAction(player, packet); } + private void OnCommonShopPacket(CommonShopPacket packet, ITransportPeer peer) + { + if (!TryGetServerPlayer(peer, out var player) || packet.Action != ShopAction.Purchase) + { + LogWarning("Ignoring invalid shop request"); + return; + } + + if (!NetworkedCashRegisterWithModules.Get(packet.RegisterNetId, out var register) || !register.IsShopRegister) + { + LogWarning($"Shop purchase received for invalid register {packet.RegisterNetId}"); + return; + } + + register.Server_ProcessShopPurchase(player, packet); + } + private void OnCommonGenericSwitchStatePacket(CommonGenericSwitchStatePacket packet, ITransportPeer peer) { if (!TryGetServerPlayer(peer, out var player)) diff --git a/Multiplayer/Networking/Packets/Clientbound/ClientboundItemReconciliationPacket.cs b/Multiplayer/Networking/Packets/Clientbound/ClientboundItemReconciliationPacket.cs index 4dd6e6d8..608aef75 100644 --- a/Multiplayer/Networking/Packets/Clientbound/ClientboundItemReconciliationPacket.cs +++ b/Multiplayer/Networking/Packets/Clientbound/ClientboundItemReconciliationPacket.cs @@ -1,33 +1,50 @@ using LiteNetLib.Utils; using System.Collections.Generic; +using Multiplayer.Networking.Data.Items; +using Multiplayer.Networking.Packets; namespace Multiplayer.Networking.Packets.Clientbound; internal sealed class ClientboundItemReconciliationPacket : INetSerializable { - private const int MaxItems = 128; public List Items = []; + public bool IsFinalBatch; public void Serialize(NetDataWriter writer) { - int count = System.Math.Min(Items.Count, MaxItems); - writer.Put(count); - for (int i = 0; i < count; i++) + if (Items.Count > ItemReconciliationProtocol.MaxItems) + throw new System.InvalidOperationException($"Too many item reconciliation results: {Items.Count}"); + writer.Put(IsFinalBatch); + writer.Put(Items.Count); + for (int i = 0; i < Items.Count; i++) { var item = Items[i]; writer.Put(item.LocalId); writer.Put(item.NetId); + writer.Put(item.Snapshot != null); + item.Snapshot?.Serialize(writer); } } public void Deserialize(NetDataReader reader) { Items.Clear(); + IsFinalBatch = reader.GetBool(); int count = reader.GetInt(); - if (count < 0 || count > MaxItems) + if (count < 0 || count > ItemReconciliationProtocol.MaxItems) throw new System.InvalidOperationException($"Invalid item reconciliation result count: {count}"); for (int i = 0; i < count; i++) - Items.Add(new ItemReconciliationResult(reader.GetInt(), reader.GetUShort())); + { + int localId = reader.GetInt(); + ushort netId = reader.GetUShort(); + ItemUpdateData snapshot = null; + if (reader.GetBool()) + { + snapshot = new ItemUpdateData(); + snapshot.Deserialize(reader); + } + Items.Add(new ItemReconciliationResult(localId, netId, snapshot)); + } } } @@ -35,10 +52,12 @@ internal readonly struct ItemReconciliationResult { public readonly int LocalId; public readonly ushort NetId; + public readonly ItemUpdateData Snapshot; - public ItemReconciliationResult(int localId, ushort netId) + public ItemReconciliationResult(int localId, ushort netId, ItemUpdateData snapshot) { LocalId = localId; NetId = netId; + Snapshot = snapshot; } } diff --git a/Multiplayer/Networking/Packets/Clientbound/ClientboundPlayerJoinedPacket.cs b/Multiplayer/Networking/Packets/Clientbound/ClientboundPlayerJoinedPacket.cs index 6c9068bb..6bc4c7b5 100644 --- a/Multiplayer/Networking/Packets/Clientbound/ClientboundPlayerJoinedPacket.cs +++ b/Multiplayer/Networking/Packets/Clientbound/ClientboundPlayerJoinedPacket.cs @@ -10,6 +10,7 @@ public class ClientboundPlayerJoinedPacket public bool IsVR { get; set; } public string CharacterId { get; set; } public string CrewName { get; set; } = string.Empty; + public uint Tick { get; set; } public PlayerTrackingData TrackingData { get; set; } public PlayerPostureFlags Posture { get; set; } public bool IsOnCar { get; set; } diff --git a/Multiplayer/Networking/Packets/Clientbound/ClientboundPlayerPositionPacket.cs b/Multiplayer/Networking/Packets/Clientbound/ClientboundPlayerPositionPacket.cs index 4c425cd5..b97def87 100644 --- a/Multiplayer/Networking/Packets/Clientbound/ClientboundPlayerPositionPacket.cs +++ b/Multiplayer/Networking/Packets/Clientbound/ClientboundPlayerPositionPacket.cs @@ -5,6 +5,7 @@ namespace Multiplayer.Networking.Packets.Clientbound; public class ClientboundPlayerPositionPacket { public byte PlayerId { get; set; } + public uint Tick { get; set; } public PlayerTrackingData TrackingData { get; set; } public PlayerPostureFlags Posture { get; set; } public bool IsOnCar { get; set; } diff --git a/Multiplayer/Networking/Packets/Common/CommonCashRegisterWithModulesActionPacket.cs b/Multiplayer/Networking/Packets/Common/CommonCashRegisterWithModulesActionPacket.cs index 45772781..e33e46aa 100644 --- a/Multiplayer/Networking/Packets/Common/CommonCashRegisterWithModulesActionPacket.cs +++ b/Multiplayer/Networking/Packets/Common/CommonCashRegisterWithModulesActionPacket.cs @@ -9,17 +9,11 @@ public enum CashRegisterAction : byte SetFunds, RejectGeneric, RejectFunds, - RejectedNoItems, - Approve, - ShopStockChanged + RejectedNoItems } public class CommonCashRegisterWithModulesActionPacket { public ushort NetId { get; set; } public CashRegisterAction Action { get; set; } public double Amount { get; set; } - public byte BuyerPlayerId { get; set; } - public string[] ItemPrefabNames { get; set; } = []; - public int[] ItemAmounts { get; set; } = []; - public float[] ItemUnitPrices { get; set; } = []; } diff --git a/Multiplayer/Networking/Packets/Common/CommonCustomizationPacket.cs b/Multiplayer/Networking/Packets/Common/CommonCustomizationPacket.cs index 1cc8ccf2..80884fe0 100644 --- a/Multiplayer/Networking/Packets/Common/CommonCustomizationPacket.cs +++ b/Multiplayer/Networking/Packets/Common/CommonCustomizationPacket.cs @@ -8,31 +8,124 @@ public enum CustomizationAction : byte { PlaceGadget, RemoveGadget, AddHole, Mov public sealed class CommonCustomizationPacket : INetSerializable { + public uint OriginActionId; public CustomizationAction Action; public ushort ItemNetId; public ushort OtherItemNetId; public int IndexA; public int IndexB; - public byte TargetKind; - public ushort TargetTrainCarNetId; + public string TargetKey; public Vector3 Position; public Vector3 PreviousPosition; public Vector3 Normal; public Quaternion Rotation; public bool Flag; + public byte OwnerPlayerId; public void Serialize(NetDataWriter writer) { - writer.Put((byte)Action); writer.Put(ItemNetId); writer.Put(OtherItemNetId); writer.Put(IndexA); writer.Put(IndexB); writer.Put(TargetKind); writer.Put(TargetTrainCarNetId); - Vector3Serializer.Serialize(writer, Position); Vector3Serializer.Serialize(writer, PreviousPosition); - Vector3Serializer.Serialize(writer, Normal); QuaternionSerializer.Serialize(writer, Rotation); writer.Put(Flag); + writer.Put(OriginActionId); + writer.Put((byte)Action); + switch (Action) + { + case CustomizationAction.PlaceGadget: + writer.Put(ItemNetId); PutTarget(writer); Vector3Serializer.Serialize(writer, Position); + QuaternionSerializer.Serialize(writer, Rotation); break; + case CustomizationAction.RemoveGadget: + writer.Put(ItemNetId); writer.Put(Flag); break; + case CustomizationAction.AddHole: + PutTarget(writer); Vector3Serializer.Serialize(writer, Position); Vector3Serializer.Serialize(writer, Normal); break; + case CustomizationAction.MoveHole: + PutTarget(writer); Vector3Serializer.Serialize(writer, Position); Vector3Serializer.Serialize(writer, PreviousPosition); + Vector3Serializer.Serialize(writer, Normal); break; + case CustomizationAction.RemoveHole: + PutTarget(writer); Vector3Serializer.Serialize(writer, PreviousPosition); break; + case CustomizationAction.Mount: + case CustomizationAction.SnapItem: + PutRelationship(writer, includeSecondIndex: false); writer.Put(Flag); + if (Flag) Vector3Serializer.Serialize(writer, Position); break; + case CustomizationAction.Unmount: + writer.Put(ItemNetId); writer.Put(IndexA); break; + case CustomizationAction.Wire: + case CustomizationAction.Unwire: + PutRelationship(writer, includeSecondIndex: true); break; + case CustomizationAction.UnsnapItem: + PutRelationship(writer, includeSecondIndex: false); break; + case CustomizationAction.DropEmptySpool: + writer.Put(ItemNetId); writer.Put(OtherItemNetId); writer.Put(Flag); + if (Flag) { Vector3Serializer.Serialize(writer, Position); QuaternionSerializer.Serialize(writer, Rotation); } + break; + case CustomizationAction.ReplaceDuctTape: + writer.Put(ItemNetId); writer.Put(OtherItemNetId); writer.Put(OwnerPlayerId); writer.Put(Flag); + if (Flag) { Vector3Serializer.Serialize(writer, Position); QuaternionSerializer.Serialize(writer, Rotation); } + break; + case CustomizationAction.LoadSpool: + writer.Put(ItemNetId); writer.Put(OtherItemNetId); break; + case CustomizationAction.ReplaceSpool: + writer.Put(ItemNetId); writer.Put(OtherItemNetId); writer.Put(OwnerPlayerId); break; + default: + throw new System.InvalidOperationException($"Unsupported customization action: {Action}"); + } } public void Deserialize(NetDataReader reader) { - Action = (CustomizationAction)reader.GetByte(); ItemNetId = reader.GetUShort(); OtherItemNetId = reader.GetUShort(); IndexA = reader.GetInt(); IndexB = reader.GetInt(); TargetKind = reader.GetByte(); - TargetTrainCarNetId = reader.GetUShort(); Position = Vector3Serializer.Deserialize(reader); - PreviousPosition = Vector3Serializer.Deserialize(reader); Normal = Vector3Serializer.Deserialize(reader); - Rotation = QuaternionSerializer.Deserialize(reader); Flag = reader.GetBool(); + OriginActionId = reader.GetUInt(); + Action = (CustomizationAction)reader.GetByte(); + switch (Action) + { + case CustomizationAction.PlaceGadget: + ItemNetId = reader.GetUShort(); GetTarget(reader); Position = Vector3Serializer.Deserialize(reader); + Rotation = QuaternionSerializer.Deserialize(reader); break; + case CustomizationAction.RemoveGadget: + ItemNetId = reader.GetUShort(); Flag = reader.GetBool(); break; + case CustomizationAction.AddHole: + GetTarget(reader); Position = Vector3Serializer.Deserialize(reader); Normal = Vector3Serializer.Deserialize(reader); break; + case CustomizationAction.MoveHole: + GetTarget(reader); Position = Vector3Serializer.Deserialize(reader); PreviousPosition = Vector3Serializer.Deserialize(reader); + Normal = Vector3Serializer.Deserialize(reader); break; + case CustomizationAction.RemoveHole: + GetTarget(reader); PreviousPosition = Vector3Serializer.Deserialize(reader); break; + case CustomizationAction.Mount: + case CustomizationAction.SnapItem: + GetRelationship(reader, includeSecondIndex: false); Flag = reader.GetBool(); + if (Flag) Position = Vector3Serializer.Deserialize(reader); break; + case CustomizationAction.Unmount: + ItemNetId = reader.GetUShort(); IndexA = reader.GetInt(); break; + case CustomizationAction.Wire: + case CustomizationAction.Unwire: + GetRelationship(reader, includeSecondIndex: true); break; + case CustomizationAction.UnsnapItem: + GetRelationship(reader, includeSecondIndex: false); break; + case CustomizationAction.DropEmptySpool: + ItemNetId = reader.GetUShort(); OtherItemNetId = reader.GetUShort(); Flag = reader.GetBool(); + if (Flag) { Position = Vector3Serializer.Deserialize(reader); Rotation = QuaternionSerializer.Deserialize(reader); } + break; + case CustomizationAction.ReplaceDuctTape: + ItemNetId = reader.GetUShort(); OtherItemNetId = reader.GetUShort(); OwnerPlayerId = reader.GetByte(); Flag = reader.GetBool(); + if (Flag) { Position = Vector3Serializer.Deserialize(reader); Rotation = QuaternionSerializer.Deserialize(reader); } + break; + case CustomizationAction.LoadSpool: + ItemNetId = reader.GetUShort(); OtherItemNetId = reader.GetUShort(); break; + case CustomizationAction.ReplaceSpool: + ItemNetId = reader.GetUShort(); OtherItemNetId = reader.GetUShort(); OwnerPlayerId = reader.GetByte(); break; + default: + throw new System.InvalidOperationException($"Unsupported customization action: {Action}"); + } + } + + private void PutTarget(NetDataWriter writer) => writer.Put(TargetKey ?? string.Empty); + private void GetTarget(NetDataReader reader) => TargetKey = reader.GetString(); + + private void PutRelationship(NetDataWriter writer, bool includeSecondIndex) + { + writer.Put(ItemNetId); writer.Put(OtherItemNetId); writer.Put(IndexA); + if (includeSecondIndex) writer.Put(IndexB); + } + + private void GetRelationship(NetDataReader reader, bool includeSecondIndex) + { + ItemNetId = reader.GetUShort(); OtherItemNetId = reader.GetUShort(); IndexA = reader.GetInt(); + if (includeSecondIndex) IndexB = reader.GetInt(); } } diff --git a/Multiplayer/Networking/Packets/Common/CommonShopPacket.cs b/Multiplayer/Networking/Packets/Common/CommonShopPacket.cs new file mode 100644 index 00000000..3a9c3ca0 --- /dev/null +++ b/Multiplayer/Networking/Packets/Common/CommonShopPacket.cs @@ -0,0 +1,24 @@ +namespace Multiplayer.Networking.Packets.Common; + +public enum ShopAction : byte +{ + Purchase, + Approved, + RejectGeneric, + RejectFunds, + RejectNoItems, + StockChanged, + StockSnapshot, +} + +public sealed class CommonShopPacket +{ + public ushort RegisterNetId { get; set; } + public uint RequestId { get; set; } + public ShopAction Action { get; set; } + public double Amount { get; set; } + public byte BuyerPlayerId { get; set; } + public string[] ItemPrefabNames { get; set; } = []; + public int[] ItemAmounts { get; set; } = []; + public float[] ItemUnitPrices { get; set; } = []; +} diff --git a/Multiplayer/Networking/Packets/ItemReconciliationProtocol.cs b/Multiplayer/Networking/Packets/ItemReconciliationProtocol.cs new file mode 100644 index 00000000..6653fed2 --- /dev/null +++ b/Multiplayer/Networking/Packets/ItemReconciliationProtocol.cs @@ -0,0 +1,6 @@ +namespace Multiplayer.Networking.Packets; + +internal static class ItemReconciliationProtocol +{ + public const int MaxItems = 128; +} diff --git a/Multiplayer/Networking/Packets/Serverbound/ServerboundItemReconciliationPacket.cs b/Multiplayer/Networking/Packets/Serverbound/ServerboundItemReconciliationPacket.cs index 7e3c2c7f..ef1d94ff 100644 --- a/Multiplayer/Networking/Packets/Serverbound/ServerboundItemReconciliationPacket.cs +++ b/Multiplayer/Networking/Packets/Serverbound/ServerboundItemReconciliationPacket.cs @@ -1,20 +1,22 @@ using LiteNetLib.Utils; using Multiplayer.Networking.Data.Items; using System.Collections.Generic; -using System.Linq; +using Multiplayer.Networking.Packets; namespace Multiplayer.Networking.Packets.Serverbound; internal sealed class ServerboundItemReconciliationPacket : INetSerializable { - private const int MaxItems = 128; public List Items = []; + public bool IsFinalBatch; public void Serialize(NetDataWriter writer) { - int count = System.Math.Min(Items.Count, MaxItems); - writer.Put(count); - foreach (var item in Items.Take(count)) + if (Items.Count > ItemReconciliationProtocol.MaxItems) + throw new System.InvalidOperationException($"Too many item reconciliation requests: {Items.Count}"); + writer.Put(IsFinalBatch); + writer.Put(Items.Count); + foreach (var item in Items) { writer.Put(item.LocalId); writer.Put(item.BelongsToPlayer); @@ -25,8 +27,9 @@ public void Serialize(NetDataWriter writer) public void Deserialize(NetDataReader reader) { Items.Clear(); + IsFinalBatch = reader.GetBool(); int count = reader.GetInt(); - if (count < 0 || count > MaxItems) + if (count < 0 || count > ItemReconciliationProtocol.MaxItems) throw new System.InvalidOperationException($"Invalid item reconciliation count: {count}"); for (int i = 0; i < count; i++) { diff --git a/Multiplayer/Networking/Packets/Serverbound/ServerboundPlayerPositionPacket.cs b/Multiplayer/Networking/Packets/Serverbound/ServerboundPlayerPositionPacket.cs index d650710d..93e86493 100644 --- a/Multiplayer/Networking/Packets/Serverbound/ServerboundPlayerPositionPacket.cs +++ b/Multiplayer/Networking/Packets/Serverbound/ServerboundPlayerPositionPacket.cs @@ -5,6 +5,7 @@ namespace Multiplayer.Networking.Packets.Serverbound; public class ServerboundPlayerPositionPacket { + public uint Tick { get; set; } public PlayerTrackingData TrackingData { get; set; } public PlayerPostureFlags Posture { get; set; } public bool IsOnCar { get; set; } diff --git a/Multiplayer/Networking/Serialization/TrackedValueSerializer.cs b/Multiplayer/Networking/Serialization/TrackedValueSerializer.cs new file mode 100644 index 00000000..e1f998d0 --- /dev/null +++ b/Multiplayer/Networking/Serialization/TrackedValueSerializer.cs @@ -0,0 +1,56 @@ +using LiteNetLib.Utils; +using System; +using System.Collections.Generic; + +namespace Multiplayer.Networking.Serialization; + +internal static class TrackedValueSerializer +{ + public static void SerializeDictionary(NetDataWriter writer, Dictionary values) + { + writer.Put(values?.Count ?? 0); + if (values == null) + return; + + foreach (var entry in values) + { + writer.Put(entry.Key); + Serialize(writer, entry.Value); + } + } + + public static Dictionary DeserializeDictionary(NetDataReader reader) + { + int count = reader.GetInt(); + if (count < 0 || count > 4096) + throw new InvalidOperationException($"Invalid tracked value count: {count}"); + + var values = new Dictionary(count); + for (int i = 0; i < count; i++) + values[reader.GetString()] = Deserialize(reader); + return values; + } + + public static void Serialize(NetDataWriter writer, object value) + { + switch (value) + { + case bool boolValue: writer.Put((byte)0); writer.Put(boolValue); break; + case int intValue: writer.Put((byte)1); writer.Put(intValue); break; + case uint uintValue: writer.Put((byte)2); writer.Put(uintValue); break; + case float floatValue: writer.Put((byte)3); writer.Put(floatValue); break; + case string stringValue: writer.Put((byte)4); writer.Put(stringValue); break; + default: throw new NotSupportedException($"Unsupported tracked value type: {value?.GetType()}"); + } + } + + public static object Deserialize(NetDataReader reader) => reader.GetByte() switch + { + 0 => reader.GetBool(), + 1 => reader.GetInt(), + 2 => reader.GetUInt(), + 3 => reader.GetFloat(), + 4 => reader.GetString(), + byte typeCode => throw new NotSupportedException($"Unsupported tracked value type code: {typeCode}"), + }; +} diff --git a/Multiplayer/Patches/Train/CarVisitCheckerPatch.cs b/Multiplayer/Patches/Train/CarVisitCheckerPatch.cs index ef8c1c82..661bfb42 100644 --- a/Multiplayer/Patches/Train/CarVisitCheckerPatch.cs +++ b/Multiplayer/Patches/Train/CarVisitCheckerPatch.cs @@ -3,12 +3,40 @@ using Multiplayer.Components.Networking; using Multiplayer.Components.Networking.Train; using Multiplayer.Networking.Data; +using System; namespace Multiplayer.Patches.Train; [HarmonyPatch(typeof(CarVisitChecker))] public static class CarVisitCheckerPatch { + [ThreadStatic] + private static int physicsLodTransitionDepth; + + [HarmonyPrefix] + [HarmonyPatch(typeof(TrainPhysicsLod), "SetLod")] + private static void BeginPhysicsLodTransition() => physicsLodTransitionDepth++; + + [HarmonyFinalizer] + [HarmonyPatch(typeof(TrainPhysicsLod), "SetLod")] + private static Exception EndPhysicsLodTransition(Exception __exception) + { + physicsLodTransitionDepth--; + return __exception; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(TrainCar), nameof(TrainCar.UnloadInterior))] + private static bool KeepOccupiedNetworkCarInteriorLoaded(TrainCar __instance) + { + if (physicsLodTransitionDepth == 0 || + !NetworkLifecycle.Instance.IsHost() || + !NetworkedTrainCar.TryGetFromTrainCar(__instance, out NetworkedTrainCar networkedCar)) + return true; + + return !networkedCar.HasPlayers() && !networkedCar.IsInteriorPinnedForNetworkSync; + } + [HarmonyPrefix] [HarmonyPatch(nameof(CarVisitChecker.IsRecentlyVisited), MethodType.Getter)] public static bool IsRecentlyVisited_Prefix(CarVisitChecker __instance, ref bool __result) diff --git a/Multiplayer/Patches/World/CashRegisterWithModulesPatch.cs b/Multiplayer/Patches/World/CashRegisterWithModulesPatch.cs index 03a7b554..31728332 100644 --- a/Multiplayer/Patches/World/CashRegisterWithModulesPatch.cs +++ b/Multiplayer/Patches/World/CashRegisterWithModulesPatch.cs @@ -105,10 +105,10 @@ private static void Buy_Postfix(CashRegisterWithModules __instance, bool __resul return; ShopPurchaseCoordinator.QueueOwnership(__state, buyer); - NetworkLifecycle.Instance.Server.SendCashRegisterAction(new CommonCashRegisterWithModulesActionPacket + NetworkLifecycle.Instance.Server.SendShopAction(new CommonShopPacket { - NetId = networkedRegister.NetId, - Action = CashRegisterAction.Approve, + RegisterNetId = networkedRegister.NetId, + Action = ShopAction.Approved, BuyerPlayerId = buyer.PlayerId, ItemPrefabNames = __state.Select(item => item.PrefabName).ToArray(), ItemAmounts = __state.Select(item => item.Amount).ToArray(), diff --git a/Multiplayer/Patches/World/CustomizationPatch.cs b/Multiplayer/Patches/World/CustomizationPatch.cs index ddd83e99..519855bd 100644 --- a/Multiplayer/Patches/World/CustomizationPatch.cs +++ b/Multiplayer/Patches/World/CustomizationPatch.cs @@ -15,7 +15,7 @@ private static void Placed(GadgetBase __result, Customization destination, Vecto { if (__result == null || gadgetItem == null || CustomizationSyncScope.IsApplyingRemote || !NetworkedItem.TryGetNetId(gadgetItem.Item, out var id) || !CustomizationRef.TryCreate(destination, out var target)) return; CustomizationStateManager.SendAction(new CommonCustomizationPacket { Action = CustomizationAction.PlaceGadget, ItemNetId = id, - TargetKind = (byte)target.Kind, TargetTrainCarNetId = target.TrainCarNetId, Position = localPos, Rotation = localRot, Flag = __result.IsOnGlass }); + TargetKey = target.IdentificationKey, Position = localPos, Rotation = localRot }); } [HarmonyPatch(typeof(GadgetBase), nameof(GadgetBase.Remove)), HarmonyPrefix] @@ -82,8 +82,8 @@ private static void RemovedHole(Customization __instance, bool __result, Vector3 private static void SendHole(Customization c, CustomizationAction action, Vector3 pos, Vector3 old, Vector3 normal) { if (CustomizationSyncScope.IsApplyingRemote || !CustomizationRef.TryCreate(c, out var target)) return; - CustomizationStateManager.SendAction(new CommonCustomizationPacket { Action = action, TargetKind = (byte)target.Kind, - TargetTrainCarNetId = target.TrainCarNetId, Position = pos, PreviousPosition = old, Normal = normal }); + CustomizationStateManager.SendAction(new CommonCustomizationPacket { Action = action, TargetKey = target.IdentificationKey, + Position = pos, PreviousPosition = old, Normal = normal }); } private struct RemoveGadgetState diff --git a/Multiplayer/Patches/World/GadgetRelationshipPatch.cs b/Multiplayer/Patches/World/GadgetRelationshipPatch.cs index 96e9a0eb..e829402e 100644 --- a/Multiplayer/Patches/World/GadgetRelationshipPatch.cs +++ b/Multiplayer/Patches/World/GadgetRelationshipPatch.cs @@ -104,6 +104,7 @@ private static void AfterSoldering(GadgetSolderingTool __instance, SpoolReplacem Action = CustomizationAction.ReplaceSpool, ItemNetId = __state.ToolItemNetId, OtherItemNetId = spentSpool?.NetId ?? 0, + OwnerPlayerId = __state.OldSpool?.OwnerPlayerId ?? 0, }); } diff --git a/Multiplayer/Patches/World/Items/CustomizationToolPatch.cs b/Multiplayer/Patches/World/Items/CustomizationToolPatch.cs index 1f0a63e3..ec0679a5 100644 --- a/Multiplayer/Patches/World/Items/CustomizationToolPatch.cs +++ b/Multiplayer/Patches/World/Items/CustomizationToolPatch.cs @@ -54,11 +54,13 @@ private static void AfterConsumeDuctTape(DuctTapeReplacementState __state) __state.OldItem.SuppressDestroySync(); replacementItem.MarkAsSynchronized(); + CustomizationStateManager.RegisterPendingDuctTapeReplacement(__state.OldItem.NetId, replacementItem); CustomizationStateManager.SendAction(new CommonCustomizationPacket { Action = CustomizationAction.ReplaceDuctTape, ItemNetId = __state.OldItem.NetId, OtherItemNetId = replacementItem.NetId, + OwnerPlayerId = __state.OldItem.OwnerPlayerId, Position = replacementObject.transform.position - WorldMover.currentMove, Rotation = replacementObject.transform.rotation, Flag = true, diff --git a/Multiplayer/Patches/World/Items/ItemBasePatch.cs b/Multiplayer/Patches/World/Items/ItemBasePatch.cs index 317c9e9f..ab9b424c 100644 --- a/Multiplayer/Patches/World/Items/ItemBasePatch.cs +++ b/Multiplayer/Patches/World/Items/ItemBasePatch.cs @@ -13,9 +13,7 @@ public static class ItemBase_Patch private static void Awake(ItemBase __instance) { //Multiplayer.Log($"ItemBase.Awake() ItemSpec: {__instance?.InventorySpecs?.itemPrefabName}"); - var networkedItem = __instance.GetOrAddComponent(); - - ShopPurchaseCoordinator.TryAssignOwner(networkedItem); + __instance.GetOrAddComponent(); //networkedItem.FinaliseTrackedValues(); return; diff --git a/Multiplayer/Patches/World/ShopPatch.cs b/Multiplayer/Patches/World/ShopPatch.cs index 1134d440..cf2b11f5 100644 --- a/Multiplayer/Patches/World/ShopPatch.cs +++ b/Multiplayer/Patches/World/ShopPatch.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Reflection.Emit; using UnityEngine; namespace Multiplayer.Patches.World; @@ -47,16 +48,9 @@ private static void Restock_Postfix(GlobalShopController __instance, string item if (!NetworkLifecycle.Instance.IsHost() || data == null || data.purchasedItems >= __state) return; - var register = __instance.globalShopList - .Select(shop => shop.cashRegister) - .FirstOrDefault(cashRegister => NetworkedCashRegisterWithModules.TryGet(cashRegister, out _)); - if (register == null || !NetworkedCashRegisterWithModules.TryGet(register, out var networkedRegister)) - return; - - NetworkLifecycle.Instance.Server.SendCashRegisterAction(new CommonCashRegisterWithModulesActionPacket + NetworkLifecycle.Instance.Server.SendShopAction(new CommonShopPacket { - NetId = networkedRegister.NetId, - Action = CashRegisterAction.ShopStockChanged, + Action = ShopAction.StockChanged, ItemPrefabNames = [itemPrefabName], ItemAmounts = [-1] }); @@ -79,6 +73,7 @@ private static IEnumerable Transpiler(IEnumerable Transpiler(IEnumerable FilterForLocalPlayer(List items) if (item == null || !NetworkedItem.TryGetNetworkedItem(item, out var networkedItem)) continue; - if (networkedItem.OwnerId == localPlayerId) + if (networkedItem.OwnerPlayerId == localPlayerId) ownedItems.Add(item); } From 5bccf2b40d6bece6ef3e342da10f2737e562aaaf Mon Sep 17 00:00:00 2001 From: Patrick van Halm Date: Sun, 16 Aug 2026 20:13:19 +0200 Subject: [PATCH 04/14] Implement fixes for wire cutters and soldering reels --- .../World/CustomizationStateManager.cs | 220 ++++++++++++++++-- .../Networking/World/NetworkedItem.cs | 33 ++- .../Networking/World/NetworkedItemManager.cs | 19 ++ .../Patches/World/GadgetRelationshipPatch.cs | 51 +++- 4 files changed, 290 insertions(+), 33 deletions(-) diff --git a/Multiplayer/Components/Networking/World/CustomizationStateManager.cs b/Multiplayer/Components/Networking/World/CustomizationStateManager.cs index 77beeb01..ee3721c1 100644 --- a/Multiplayer/Components/Networking/World/CustomizationStateManager.cs +++ b/Multiplayer/Components/Networking/World/CustomizationStateManager.cs @@ -829,6 +829,9 @@ internal static bool IsActionReady(CommonCustomizationPacket packet, out string CustomizationAction.ReplaceDuctTape when hasLocalDuctTapeReplacement => [], CustomizationAction.ReplaceDuctTape => [packet.ItemNetId], CustomizationAction.ReplaceSpool => [packet.ItemNetId], + // The contained reel is recoverable from the tool's magazine even when + // a replacement reassigned its ID before the lookup cache caught up. + CustomizationAction.DropEmptySpool => [packet.ItemNetId], _ => GetActionItemIds(packet), }; @@ -891,6 +894,17 @@ CustomizationAction.Mount or CustomizationAction.Unmount or CustomizationAction. waitingFor = $"wire port {packet.IndexB} on gadget {packet.OtherItemNetId}"; return false; } + + // Gadget registration can complete while placement is still being + // reconciled. Wire() rejects ports whose owners do not yet reference + // the same Customization, and the old client drain discarded that + // failed action permanently. + if (packet.Action == CustomizationAction.Wire && + (first.Custom == null || second.Custom == null || first.Custom != second.Custom)) + { + waitingFor = $"gadgets {packet.ItemNetId} and {packet.OtherItemNetId} to share a customization target"; + return false; + } } if ((packet.Action is CustomizationAction.SnapItem or CustomizationAction.UnsnapItem) && @@ -1037,9 +1051,55 @@ private static void ApplyWireAction(CommonCustomizationPacket packet) var firstPort = first.WireLinkPorts[packet.IndexA]; var secondPort = second.WireLinkPorts[packet.IndexB]; if (packet.Action == CustomizationAction.Wire) - GadgetWiringModule.WireLinkPort.Wire(firstPort, secondPort); + { + if (GadgetWiringModule.WireLinkPort.AreWired(firstPort, secondPort)) + return; + + // The relationship packet is authoritative. A mono port can retain a + // stale local counterpart after placement/LOD reconstruction, causing + // native Wire() to fail silently. Clear only mono ports; multi ports + // (such as an alternating controller) must retain their other lights. + ClearStaleMonoWireLinks(firstPort); + ClearStaleMonoWireLinks(secondPort); + + bool wired = GadgetWiringModule.WireLinkPort.Wire(firstPort, secondPort); + if (!wired || !GadgetWiringModule.WireLinkPort.AreWired(firstPort, secondPort)) + { + Multiplayer.LogWarning($"Could not apply authoritative wire {packet.ItemNetId}:{packet.IndexA} -> " + + $"{packet.OtherItemNetId}:{packet.IndexB}; first={first.GetType().Name}, " + + $"second={second.GetType().Name}, sameTarget={first.Custom != null && first.Custom == second.Custom}"); + } + else + { + Multiplayer.LogDebug(() => $"Wired gadget {packet.ItemNetId}:{packet.IndexA} to " + + $"{packet.OtherItemNetId}:{packet.IndexB}"); + } + } else - GadgetWiringModule.WireLinkPort.Unwire(firstPort, secondPort); + { + if (!GadgetWiringModule.WireLinkPort.AreWired(firstPort, secondPort)) + return; + + if (!GadgetWiringModule.WireLinkPort.Unwire(firstPort, secondPort)) + Multiplayer.LogWarning($"Could not unwire gadget {packet.ItemNetId}:{packet.IndexA} from " + + $"{packet.OtherItemNetId}:{packet.IndexB}"); + } + } + + private static void ClearStaleMonoWireLinks(GadgetWiringModule.WireLinkPort port) + { + Type type = port?.GetType(); + while (type != null) + { + if (type.IsGenericType && + type.GetGenericTypeDefinition() == typeof(GadgetWiringModule.WireLinkPortMono<>)) + { + GadgetWiringModule.WireLinkPort.Unwire(port); + return; + } + + type = type.BaseType; + } } private static void ApplySpoolAction(CommonCustomizationPacket packet) @@ -1061,12 +1121,63 @@ private static void ApplySpoolAction(CommonCustomizationPacket packet) } if (tool?.magazine != null && NetworkedItem.TryGet(packet.OtherItemNetId, out var spoolItem)) + LoadSpool(toolItem, tool, spoolItem); + } + + private static void LoadSpool(NetworkedItem toolItem, GadgetSolderingTool tool, NetworkedItem spoolItem) + { + var magazineItems = tool.magazine.items; + var current = magazineItems != null && magazineItems.Length > 0 ? magazineItems[0] : null; + NetworkedItem currentItem = null; + if (current != null) + NetworkedItem.TryGetNetworkedItem(current.GetComponent(), out currentItem); + + if (currentItem == spoolItem) { - if (tool.magazine.AddItem(spoolItem.gameObject, 0)) - FinalizeContainedSpool(spoolItem); - else - Multiplayer.LogWarning($"Could not load reel {spoolItem.NetId} into soldering tool {toolItem.NetId}"); + FinalizeContainedSpool(spoolItem); + return; + } + + if (current != null) + { + var currentAmmo = current.GetComponent(); + if (currentItem == null || currentAmmo == null) + { + Multiplayer.LogWarning($"Could not reconcile reel load for soldering tool {toolItem.NetId}: " + + $"contained item hasNetworkIdentity={currentItem != null}, isMagazineAmmo={currentAmmo != null}"); + return; + } + + ushort loadedNetId = spoolItem.NetId; + byte loadedOwner = spoolItem.OwnerPlayerId; + bool belongsToPlayer = spoolItem.Item?.InventorySpecs?.BelongsToPlayer ?? true; + + Multiplayer.LogWarning($"Reconcile reel load for soldering tool {toolItem.NetId}: updating contained reel " + + $"{currentItem.NetId} with loaded reel {loadedNetId} instead of ejecting it"); + + // Keep the object already held by ItemMagazine and transfer the loaded + // reel's network identity and state onto it. The separately represented + // incoming object is only a duplicate on this peer. + spoolItem.SuppressDestroySync(); + spoolItem.NetId = 0; + Inventory.Instance.DestroyItem(spoolItem.gameObject); + + currentItem.NetId = loadedNetId; + currentItem.SetOwner(loadedOwner); + if (currentItem.Item?.InventorySpecs != null) + currentItem.Item.InventorySpecs.BelongsToPlayer = belongsToPlayer; + currentAmmo.isSpent = false; + tool.currentSpool = currentAmmo; + tool.ReloadResource(); + FinalizeContainedSpool(currentItem); + return; } + + EnsureInWorldStorage(spoolItem.Item); + if (tool.magazine.AddItem(spoolItem.gameObject, 0)) + FinalizeContainedSpool(spoolItem); + else + Multiplayer.LogWarning($"Could not load reel {spoolItem.NetId} into soldering tool {toolItem.NetId}"); } private static void ApplySpoolReplacement(NetworkedItem toolItem, GadgetSolderingTool tool, CommonCustomizationPacket packet) @@ -1138,21 +1249,79 @@ private static void ApplySpoolReplacement(NetworkedItem toolItem, GadgetSolderin private static void DropEmptySpool(GadgetSolderingTool tool, CommonCustomizationPacket packet) { - var magazineItems = tool?.magazine?.items; - var spoolObject = magazineItems != null && magazineItems.Length > 0 ? magazineItems[0] : null; - var spoolAmmo = spoolObject?.GetComponent(); - NetworkedItem spoolItem = null; - if (packet.OtherItemNetId != 0) + if (tool?.magazine == null) + { + Multiplayer.LogWarning($"Cannot apply empty-reel ejection: tool={tool != null}, magazine={tool?.magazine != null}"); + return; + } + + var magazineItems = tool.magazine.items; + var current = magazineItems != null && magazineItems.Length > 0 ? magazineItems[0] : null; + NetworkedItem currentItem = null; + if (current != null) + NetworkedItem.TryGetNetworkedItem(current.GetComponent(), out currentItem); + + NetworkedItem spoolItem = currentItem != null && currentItem.NetId == packet.OtherItemNetId + ? currentItem + : null; + if (spoolItem == null) NetworkedItem.TryGet(packet.OtherItemNetId, out spoolItem); - if (spoolObject != null) - NetworkedItem.TryGetNetworkedItem(spoolObject.GetComponent(), out spoolItem); + if (spoolItem == null && currentItem != null) + { + Multiplayer.LogWarning($"Recover empty-reel identity for soldering tool {packet.ItemNetId}: " + + $"binding contained reel {currentItem.NetId} to packet reel {packet.OtherItemNetId}"); + currentItem.NetId = packet.OtherItemNetId; + spoolItem = currentItem; + } + + if (spoolItem == null) + { + Multiplayer.LogWarning($"Cannot apply empty-reel ejection for tool {packet.ItemNetId}: " + + $"reel {packet.OtherItemNetId} is unavailable and magazine slot 0 has no networked reel"); + return; + } + + if (currentItem != spoolItem) + { + Multiplayer.LogWarning($"Reconcile empty-reel ejection for soldering tool {packet.ItemNetId}: replacing stale reel " + + $"{currentItem?.NetId ?? 0} with authoritative reel {spoolItem.NetId}"); + if (current != null) + { + if (!tool.magazine.RemoveItem(0, true, true)) + return; + + // This object represents the full reel that was logically consumed. + // Let its normal host-side destroy sync remove any phantom copy on + // peers that missed the physical full-to-empty replacement. + Inventory.Instance.DestroyItem(current); + } + + EnsureInWorldStorage(spoolItem.Item); + if (!tool.magazine.AddItem(spoolItem.gameObject, 0)) + { + Multiplayer.LogWarning($"Could not place authoritative reel {spoolItem.NetId} in soldering tool {packet.ItemNetId} before ejection"); + return; + } + } - if (tool == null || spoolAmmo == null || !spoolAmmo.isSpent) + var spoolObject = spoolItem.gameObject; + var spoolAmmo = spoolObject.GetComponent(); + if (spoolAmmo == null) { - Multiplayer.LogWarning($"Cannot apply empty-spool ejection: tool={tool != null}, spool={spoolObject != null}, spent={spoolAmmo?.isSpent}"); + Multiplayer.LogWarning($"Cannot apply empty-reel ejection: item {spoolItem.NetId} is not magazine ammunition"); return; } + // The action and synchronized remainingUnits are authoritative. A peer may + // still have the full-reel prefab/flag after a missed replacement or load. + spoolAmmo.isSpent = true; + tool.currentSpool = spoolAmmo; + if (!tool.HasEjectableSpool) + { + tool.remainingUnits = -1; + tool.OnUnitsChanged(); + } + tool.DropEmptySpool(); if (tool.HasEjectableSpool) { @@ -1167,11 +1336,7 @@ private static void DropEmptySpool(GadgetSolderingTool tool, CommonCustomization packet.Position + WorldMover.currentMove, packet.Rotation); } - var storage = StorageController.Instance; - var spoolItemBase = spoolObject.GetComponent(); - if (storage != null && spoolItemBase.BelongsToPlayer() && !storage.AnyStorageContains(spoolItemBase)) - storage.AddItemToWorldStorage(spoolItemBase); - spoolItem?.MarkAsSynchronized(); + FinalizeDroppedSpool(spoolItem, spoolObject); } private static void FinalizeContainedSpool(NetworkedItem spoolItem) @@ -1179,6 +1344,21 @@ private static void FinalizeContainedSpool(NetworkedItem spoolItem) spoolItem.MarkAsSynchronized(); } + private static void FinalizeDroppedSpool(NetworkedItem spoolItem, GameObject spoolObject) + { + spoolObject.SetActive(true); + var spoolItemBase = spoolObject.GetComponent(); + EnsureInWorldStorage(spoolItemBase); + spoolItem?.MarkAsSynchronized(); + } + + private static void EnsureInWorldStorage(DV.CabControls.ItemBase item) + { + var storage = StorageController.Instance; + if (storage != null && item != null && item.BelongsToPlayer() && !storage.AnyStorageContains(item)) + storage.AddItemToWorldStorage(item); + } + private static void ApplyDuctTapeReplacement(CommonCustomizationPacket packet) { NetworkedItem.TryGet(packet.ItemNetId, out var oldItem); diff --git a/Multiplayer/Components/Networking/World/NetworkedItem.cs b/Multiplayer/Components/Networking/World/NetworkedItem.cs index ae93f2ed..92aa6ff8 100644 --- a/Multiplayer/Components/Networking/World/NetworkedItem.cs +++ b/Multiplayer/Components/Networking/World/NetworkedItem.cs @@ -322,6 +322,26 @@ public ItemUpdateData GetSnapshot() if (!stateDirty && !ownerDirty && !hasDirtyVals) return null; + // A standalone client must never publish incidental mutations from another + // player's hand/inventory proxy. Remote customization can dirty tracked + // values locally (for example while ejecting a soldering reel), but that + // does not grant authority over the tool's item state or position. + if (!NetworkLifecycle.Instance.IsHost() && NetworkLifecycle.Instance.IsClientRunning) + { + byte localPlayerId = NetworkLifecycle.Instance.Client?.PlayerId ?? 0; + bool belongsToAnotherPlayer = OwnerPlayerId != 0 && localPlayerId != 0 && + OwnerPlayerId != localPlayerId; + bool locallyHeld = Item.IsGrabbed() || Inventory.Instance.Contains(gameObject, false); + if (belongsToAnotherPlayer && !locallyHeld && + lastState is ItemState.InHand or ItemState.InInventory) + { + stateDirty = false; + ownerDirty = false; + MarkValuesClean(); + return null; + } + } + ItemState currentState = GetItemState(); // A local pickup is observed rather than applied through ReceiveSnapshot. @@ -572,10 +592,15 @@ private ItemState GetItemState() // dropped and can echo a false drop back with an unrelated object-state // update (for example when ejecting a soldering reel). Their most recent // client-supplied state is authoritative instead. - if (NetworkLifecycle.Instance.IsHost() && OwnerPlayerId != 0 && - OwnerPlayerId != NetworkLifecycle.Instance.Server.SelfId && - lastState is ItemState.InHand or ItemState.InInventory) - return ObserveState(lastState); + if (OwnerPlayerId != 0 && lastState is ItemState.InHand or ItemState.InInventory) + { + byte localPlayerId = NetworkLifecycle.Instance.IsHost() + ? NetworkLifecycle.Instance.Server?.SelfId ?? 0 + : NetworkLifecycle.Instance.Client?.PlayerId ?? 0; + bool locallyHeld = Item.IsGrabbed() || Inventory.Instance.Contains(gameObject, false); + if (localPlayerId != 0 && OwnerPlayerId != localPlayerId && !locallyHeld) + return ObserveState(lastState); + } if (Item.transform.parent == WorldMover.OriginShiftParent && !wasThrown) return ObserveState(ItemState.Dropped); diff --git a/Multiplayer/Components/Networking/World/NetworkedItemManager.cs b/Multiplayer/Components/Networking/World/NetworkedItemManager.cs index b8abd638..237b632a 100644 --- a/Multiplayer/Components/Networking/World/NetworkedItemManager.cs +++ b/Multiplayer/Components/Networking/World/NetworkedItemManager.cs @@ -356,6 +356,25 @@ private void ProcessReceivedAsHost(ItemUpdateData snapshot, ServerPlayer player) if (NetworkedItem.TryGet(snapshot.ItemNetId, out NetworkedItem netItem)) { + // A remote hand/inventory proxy exists outside the observing client's + // real Inventory and can therefore look dropped at the hidden parking + // coordinates. Only the current owner may transition a held item to a + // dropped/thrown state. This still permits ordinary world-item pickup + // and the owning player's genuine drop. + bool changesItemState = snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.ItemState); + bool dropsHeldItem = netItem.CurrentState is ItemState.InHand or ItemState.InInventory && + snapshot.ItemState is ItemState.Dropped or ItemState.Thrown; + bool belongsToAnotherPlayer = netItem.OwnerPlayerId != 0 && + netItem.OwnerPlayerId != player.PlayerId; + if (changesItemState && dropsHeldItem && belongsToAnotherPlayer) + { + NetworkLifecycle.Instance.Server.LogWarning( + $"NetworkedItemManager.ProcessReceivedAsHost() Ignoring non-owner drop for item " + + $"{snapshot.ItemNetId}: sender={player.PlayerId}, owner={netItem.OwnerPlayerId}, " + + $"state={snapshot.ItemState}"); + return; + } + // Clients are authoritative for their item interactions. The host may not // have the sender's area loaded, so its physical item position is not a // reliable basis for ownership or reach validation. diff --git a/Multiplayer/Patches/World/GadgetRelationshipPatch.cs b/Multiplayer/Patches/World/GadgetRelationshipPatch.cs index e829402e..fc878107 100644 --- a/Multiplayer/Patches/World/GadgetRelationshipPatch.cs +++ b/Multiplayer/Patches/World/GadgetRelationshipPatch.cs @@ -50,27 +50,58 @@ private static void Unwired(GadgetWiringModule.WireLinkPort a, GadgetWiringModul private static void BeforeDropSpool(GadgetSolderingTool __instance, out SpoolDropState __state) { __state = default; + if (CustomizationSyncScope.IsApplyingRemote || !__instance.HasEjectableSpool) + return; + var magazineItems = __instance.magazine?.items; var spoolObject = magazineItems != null && magazineItems.Length > 0 ? magazineItems[0] : null; - if (!CustomizationSyncScope.IsApplyingRemote && __instance.HasEjectableSpool && spoolObject != null && - NetworkedItem.TryGetNetworkedItem(__instance.GetComponent(), out var toolItem) && - NetworkedItem.TryGetNetworkedItem(spoolObject.GetComponent(), out var spoolItem)) - __state = new SpoolDropState { ToolItemNetId = toolItem.NetId, SpoolItem = spoolItem }; + if (spoolObject == null) + { + Multiplayer.LogWarning($"Cannot capture empty-reel ejection for {__instance.name}: magazine slot 0 is empty"); + return; + } + + if (!NetworkedItem.TryGetNetworkedItem(__instance.GetComponent(), out var toolItem) || toolItem.NetId == 0) + { + Multiplayer.LogWarning($"Cannot capture empty-reel ejection for {__instance.name}: soldering tool has no network identity"); + return; + } + + if (!NetworkedItem.TryGetNetworkedItem(spoolObject.GetComponent(), out var spoolItem) || spoolItem.NetId == 0) + { + Multiplayer.LogWarning($"Cannot capture empty-reel ejection for tool {toolItem.NetId}: contained reel {spoolObject.name} has no network identity"); + return; + } + + __state = new SpoolDropState + { + ToolItemNetId = toolItem.NetId, + SpoolItemNetId = spoolItem.NetId, + Position = __instance.reelInteractionPoint.transform.position - WorldMover.currentMove, + Rotation = __instance.reelInteractionPoint.transform.rotation, + }; } [HarmonyPatch(typeof(GadgetSolderingTool), nameof(GadgetSolderingTool.DropEmptySpool)), HarmonyPostfix] private static void DroppedSpool(GadgetSolderingTool __instance, SpoolDropState __state) { - if (__state.ToolItemNetId == 0 || __state.SpoolItem == null || __instance.HasEjectableSpool) + if (__state.ToolItemNetId == 0 || __state.SpoolItemNetId == 0) return; + if (__instance.HasEjectableSpool) + { + Multiplayer.LogWarning($"Did not send empty-reel ejection for tool {__state.ToolItemNetId}: native ejection left reel {__state.SpoolItemNetId} loaded"); + return; + } + + Multiplayer.LogDebug(() => $"Sending empty-reel ejection for tool {__state.ToolItemNetId}, reel {__state.SpoolItemNetId}"); CustomizationStateManager.SendAction(new CommonCustomizationPacket { Action = CustomizationAction.DropEmptySpool, ItemNetId = __state.ToolItemNetId, - OtherItemNetId = __state.SpoolItem.NetId, - Position = __state.SpoolItem.transform.position - WorldMover.currentMove, - Rotation = __state.SpoolItem.transform.rotation, + OtherItemNetId = __state.SpoolItemNetId, + Position = __state.Position, + Rotation = __state.Rotation, Flag = true, }); } @@ -217,6 +248,8 @@ private struct SpoolReplacementState private struct SpoolDropState { public ushort ToolItemNetId; - public NetworkedItem SpoolItem; + public ushort SpoolItemNetId; + public UnityEngine.Vector3 Position; + public UnityEngine.Quaternion Rotation; } } From 6e6e52ec57b6dd3f36d66bad01b5a0b367dc0fd6 Mon Sep 17 00:00:00 2001 From: Patrick van Halm Date: Wed, 19 Aug 2026 22:19:13 +0200 Subject: [PATCH 05/14] Fix up some packets and fix lost and found sync --- .../World/CustomizationStateManager.cs | 704 +++++++++--------- .../Networking/World/NetworkedItem.cs | 71 +- .../Networking/World/NetworkedItemManager.cs | 74 +- .../Customization/CustomizationStateData.cs | 50 +- .../Networking/Data/Items/ItemUpdateData.cs | 13 + .../Managers/Client/NetworkClient.cs | 48 +- .../Managers/Server/NetworkServer.cs | 81 +- .../Common/CommonCustomizationPacket.cs | 131 ---- .../Common/Customization/AddHolePacket.cs | 24 + .../Customization/DropEmptySpoolPacket.cs | 27 + .../ICustomizationActionPacket.cs | 15 + .../Common/Customization/LoadSpoolPacket.cs | 19 + .../Common/Customization/MountGadgetPacket.cs | 21 + .../Common/Customization/MoveHolePacket.cs | 25 + .../Common/Customization/PlaceGadgetPacket.cs | 25 + .../Customization/RemoveGadgetPacket.cs | 19 + .../Common/Customization/RemoveHolePacket.cs | 23 + .../Customization/ReplaceDuctTapePacket.cs | 29 + .../Customization/ReplaceSpoolPacket.cs | 22 + .../Common/Customization/SnapItemPacket.cs | 28 + .../Customization/UnmountGadgetPacket.cs | 19 + .../Common/Customization/UnsnapItemPacket.cs | 21 + .../Customization/UnwireGadgetsPacket.cs | 23 + .../Common/Customization/WireGadgetsPacket.cs | 23 + .../Patches/World/CustomizationPatch.cs | 37 +- .../Patches/World/GadgetRelationshipPatch.cs | 77 +- .../World/Items/CustomizationToolPatch.cs | 11 +- .../Patches/World/StorageControllerPatch.cs | 59 ++ 28 files changed, 1136 insertions(+), 583 deletions(-) delete mode 100644 Multiplayer/Networking/Packets/Common/CommonCustomizationPacket.cs create mode 100644 Multiplayer/Networking/Packets/Common/Customization/AddHolePacket.cs create mode 100644 Multiplayer/Networking/Packets/Common/Customization/DropEmptySpoolPacket.cs create mode 100644 Multiplayer/Networking/Packets/Common/Customization/ICustomizationActionPacket.cs create mode 100644 Multiplayer/Networking/Packets/Common/Customization/LoadSpoolPacket.cs create mode 100644 Multiplayer/Networking/Packets/Common/Customization/MountGadgetPacket.cs create mode 100644 Multiplayer/Networking/Packets/Common/Customization/MoveHolePacket.cs create mode 100644 Multiplayer/Networking/Packets/Common/Customization/PlaceGadgetPacket.cs create mode 100644 Multiplayer/Networking/Packets/Common/Customization/RemoveGadgetPacket.cs create mode 100644 Multiplayer/Networking/Packets/Common/Customization/RemoveHolePacket.cs create mode 100644 Multiplayer/Networking/Packets/Common/Customization/ReplaceDuctTapePacket.cs create mode 100644 Multiplayer/Networking/Packets/Common/Customization/ReplaceSpoolPacket.cs create mode 100644 Multiplayer/Networking/Packets/Common/Customization/SnapItemPacket.cs create mode 100644 Multiplayer/Networking/Packets/Common/Customization/UnmountGadgetPacket.cs create mode 100644 Multiplayer/Networking/Packets/Common/Customization/UnsnapItemPacket.cs create mode 100644 Multiplayer/Networking/Packets/Common/Customization/UnwireGadgetsPacket.cs create mode 100644 Multiplayer/Networking/Packets/Common/Customization/WireGadgetsPacket.cs diff --git a/Multiplayer/Components/Networking/World/CustomizationStateManager.cs b/Multiplayer/Components/Networking/World/CustomizationStateManager.cs index ee3721c1..43f751e1 100644 --- a/Multiplayer/Components/Networking/World/CustomizationStateManager.cs +++ b/Multiplayer/Components/Networking/World/CustomizationStateManager.cs @@ -3,11 +3,12 @@ using DV.Customization.Gadgets.Implementations; using DV.InventorySystem; using DV.Items.Snapping; +using LiteNetLib.Utils; using Multiplayer.Networking.Data.Customization; using Multiplayer.Networking.Data.Items; using Multiplayer.Networking.Data; using Multiplayer.Networking.Data.Player; -using Multiplayer.Networking.Packets.Common; +using Multiplayer.Networking.Packets.Common.Customization; using System; using System.Collections; using System.Collections.Generic; @@ -40,9 +41,9 @@ public static CustomizationStateData CaptureCurrentState() return state; } - public static IEnumerator ApplyCurrentStateWhenReady(CustomizationStateData state, + internal static IEnumerator ApplyCurrentStateWhenReady(CustomizationStateData state, ICollection deferredPlacements = null, - ICollection deferredRelationships = null, + ICollection deferredRelationships = null, IDictionary> deferredHoleStates = null) { LogSnapshot("applying", state); @@ -81,7 +82,7 @@ public static IEnumerator ApplyCurrentStateWhenReady(CustomizationStateData stat const int maxSnapInitializationFrames = 120; int snapInitializationFrames = 0; bool loggedSnapWait = false; - bool snapRelationshipsReady = AreSnapRelationshipsReady(state.Relationships, out string snapWaitingFor); + bool snapRelationshipsReady = AreSnapRelationshipsReady(state.SnappedItems, out string snapWaitingFor); while (!snapRelationshipsReady && snapInitializationFrames < maxSnapInitializationFrames) { if (!loggedSnapWait) @@ -91,7 +92,7 @@ public static IEnumerator ApplyCurrentStateWhenReady(CustomizationStateData stat } yield return null; snapInitializationFrames++; - snapRelationshipsReady = AreSnapRelationshipsReady(state.Relationships, out snapWaitingFor); + snapRelationshipsReady = AreSnapRelationshipsReady(state.SnappedItems, out snapWaitingFor); } if (!snapRelationshipsReady) @@ -101,7 +102,7 @@ public static IEnumerator ApplyCurrentStateWhenReady(CustomizationStateData stat using (CustomizationSyncScope.Remote()) { - ApplyRelationships(state.Relationships, deferredRelationships); + ApplyRelationships(GetSnapshotRelationships(state), deferredRelationships); ApplyFreeHoles(state.CustomizationKeys, state.Holes, deferredHoleStates); ApplyTrackedValues(state.Gadgets); } @@ -115,20 +116,19 @@ public static IEnumerable GetReferencedItemIds(CustomizationStateData st return []; return state.Gadgets.Select(gadget => gadget.ItemNetId) - .Concat(state.Relationships.SelectMany(relationship => new[] { relationship.ItemNetId, relationship.OtherItemNetId })) + .Concat(GetSnapshotRelationships(state).SelectMany(GetActionItemIds)) .Where(itemNetId => itemNetId != 0) .Distinct(); } public static HashSet GetItemsRequiringDroppedCreate(CustomizationStateData state) => - state.Relationships - .Where(relationship => relationship.Action is - CustomizationAction.LoadSpool or CustomizationAction.SnapItem) - .Select(relationship => relationship.OtherItemNetId) + state.LoadedSpools.Select(packet => packet.SpoolItemNetId) + .Concat(state.SnappedItems.Select(packet => packet.SnappedItemNetId)) .Where(itemNetId => itemNetId != 0) .ToHashSet(); - public static void SendAction(CommonCustomizationPacket packet) + internal static void SendAction(T packet) + where T : CustomizationActionPacket, INetSerializable, new() { if (NetworkLifecycle.Instance.IsHost()) NetworkLifecycle.Instance.Server.SendCustomizationAction(packet); @@ -136,51 +136,34 @@ public static void SendAction(CommonCustomizationPacket packet) NetworkLifecycle.Instance.Client.SendCustomizationAction(packet); } - public static void ApplyAction(CommonCustomizationPacket packet) + internal static void ApplyAction(ICustomizationActionPacket packet) { - Multiplayer.LogDebug(() => $"Applying customization action={packet.Action} item={packet.ItemNetId} " + - $"other={packet.OtherItemNetId} indices={packet.IndexA}:{packet.IndexB}"); + Multiplayer.LogDebug(() => $"Applying customization action={packet.GetType().Name}"); using (CustomizationSyncScope.Remote()) { - switch (packet.Action) + switch (packet) { - case CustomizationAction.PlaceGadget: - ApplyPlacement(packet); - break; - case CustomizationAction.RemoveGadget: - ApplyRemoval(packet); - break; - case CustomizationAction.AddHole: - case CustomizationAction.MoveHole: - case CustomizationAction.RemoveHole: - ApplyHoleAction(packet); - break; - case CustomizationAction.Mount: - case CustomizationAction.Unmount: - ApplyMountAction(packet); - break; - case CustomizationAction.Wire: - case CustomizationAction.Unwire: - ApplyWireAction(packet); - break; - case CustomizationAction.DropEmptySpool: - case CustomizationAction.LoadSpool: - case CustomizationAction.ReplaceSpool: - ApplySpoolAction(packet); - break; - case CustomizationAction.ReplaceDuctTape: - ApplyDuctTapeReplacement(packet); - break; - case CustomizationAction.SnapItem: - case CustomizationAction.UnsnapItem: - ApplySnapAction(packet); - break; + case PlaceGadgetPacket action: ApplyPlacement(action); break; + case RemoveGadgetPacket action: ApplyRemoval(action); break; + case AddHolePacket action: ApplyHoleAction(action); break; + case MoveHolePacket action: ApplyHoleAction(action); break; + case RemoveHolePacket action: ApplyHoleAction(action); break; + case MountGadgetPacket action: ApplyMountAction(action); break; + case UnmountGadgetPacket action: ApplyMountAction(action); break; + case WireGadgetsPacket action: ApplyWireAction(action); break; + case UnwireGadgetsPacket action: ApplyWireAction(action); break; + case DropEmptySpoolPacket action: ApplySpoolAction(action); break; + case LoadSpoolPacket action: ApplySpoolAction(action); break; + case ReplaceSpoolPacket action: ApplySpoolAction(action); break; + case ReplaceDuctTapePacket action: ApplyDuctTapeReplacement(action); break; + case SnapItemPacket action: ApplySnapAction(action); break; + case UnsnapItemPacket action: ApplySnapAction(action); break; } } } - public static bool ProcessActionAsHost(CommonCustomizationPacket packet, ServerPlayer sender) + internal static bool ProcessActionAsHost(ICustomizationActionPacket packet, ServerPlayer sender) { if (packet == null || sender == null || !IsActionReady(packet, out _)) return false; @@ -188,86 +171,87 @@ public static bool ProcessActionAsHost(CommonCustomizationPacket packet, ServerP // Customization interactions are client-authoritative. In particular, the // host may not have the sender's area loaded, making host-side item and // target positions unsuitable for reach validation. - if (packet.Action is CustomizationAction.ReplaceSpool or CustomizationAction.ReplaceDuctTape) - packet.OwnerPlayerId = sender.PlayerId; + if (packet is ReplaceSpoolPacket spool) + spool.OwnerPlayerId = sender.PlayerId; + else if (packet is ReplaceDuctTapePacket tape) + tape.OwnerPlayerId = sender.PlayerId; ApplyAction(packet); bool applied = WasActionApplied(packet); if (!applied) - Multiplayer.LogWarning($"Host did not apply customization action {packet.Action} " + - $"({packet.ItemNetId}, {packet.OtherItemNetId}); mutation will not be relayed"); + Multiplayer.LogWarning($"Host did not apply customization action {packet.GetType().Name}; mutation will not be relayed"); return applied; } - private static bool WasActionApplied(CommonCustomizationPacket packet) + private static bool WasActionApplied(ICustomizationActionPacket packet) { - switch (packet.Action) + switch (packet) { - case CustomizationAction.PlaceGadget: - return TryGadget(packet.ItemNetId, out var placed) && placed.IsLinked && - CustomizationRef.TryResolve(TargetOf(packet), out var placementTarget) && + case PlaceGadgetPacket action: + return TryGadget(action.ItemNetId, out var placed) && placed.IsLinked && + CustomizationRef.TryResolve(TargetOf(action.TargetKey), out var placementTarget) && placed.Custom == placementTarget; - case CustomizationAction.RemoveGadget: - return TryGadget(packet.ItemNetId, out var removed) && !removed.IsLinked; - case CustomizationAction.AddHole: - case CustomizationAction.MoveHole: - return CustomizationRef.TryResolve(TargetOf(packet), out var holeTarget) && - holeTarget.FindHole(packet.Position, out _); - case CustomizationAction.RemoveHole: - return CustomizationRef.TryResolve(TargetOf(packet), out var removalTarget) && - !removalTarget.FindHole(packet.PreviousPosition, out _); - case CustomizationAction.Mount: - case CustomizationAction.Unmount: - if (!TryGadget(packet.ItemNetId, out var mountOwner)) + case RemoveGadgetPacket action: + return TryGadget(action.ItemNetId, out var removed) && !removed.IsLinked; + case AddHolePacket action: + return CustomizationRef.TryResolve(TargetOf(action.TargetKey), out var addedTarget) && + addedTarget.FindHole(action.Position, out _); + case MoveHolePacket action: + return CustomizationRef.TryResolve(TargetOf(action.TargetKey), out var movedTarget) && + movedTarget.FindHole(action.Position, out _); + case RemoveHolePacket action: + return CustomizationRef.TryResolve(TargetOf(action.TargetKey), out var removalTarget) && + !removalTarget.FindHole(action.PreviousPosition, out _); + case MountGadgetPacket action: + if (!TryGadget(action.MountItemNetId, out var mountOwner)) return false; var mounts = mountOwner.GetComponents(); - if (!IsValidIndex(packet.IndexA, mounts.Length)) + if (!IsValidIndex(action.MountIndex, mounts.Length)) return false; - return packet.Action == CustomizationAction.Unmount - ? mounts[packet.IndexA].MountedGadget == null - : TryGadget(packet.OtherItemNetId, out var mounted) && - mounts[packet.IndexA].MountedGadget == mounted; - case CustomizationAction.Wire: - case CustomizationAction.Unwire: - if (!TryGadget(packet.ItemNetId, out var first) || - !TryGadget(packet.OtherItemNetId, out var second) || - !IsValidIndex(packet.IndexA, first.WireLinkPorts.Count) || - !IsValidIndex(packet.IndexB, second.WireLinkPorts.Count)) - return false; - var links = new List(); - first.WireLinkPorts[packet.IndexA].GetLinks(links); - bool linked = links.Contains(second.WireLinkPorts[packet.IndexB]); - return packet.Action == CustomizationAction.Wire ? linked : !linked; - case CustomizationAction.DropEmptySpool: - return NetworkedItem.TryGet(packet.ItemNetId, out var dropToolItem) && + return TryGadget(action.MountedItemNetId, out var mounted) && + mounts[action.MountIndex].MountedGadget == mounted; + case UnmountGadgetPacket action: + return TryGadget(action.MountItemNetId, out var unmountOwner) && + IsValidIndex(action.MountIndex, unmountOwner.GetComponents().Length) && + unmountOwner.GetComponents()[action.MountIndex].MountedGadget == null; + case WireGadgetsPacket action: + return IsWireStateApplied(action.FirstItemNetId, action.SecondItemNetId, + action.FirstPortIndex, action.SecondPortIndex, shouldBeLinked: true); + case UnwireGadgetsPacket action: + return IsWireStateApplied(action.FirstItemNetId, action.SecondItemNetId, + action.FirstPortIndex, action.SecondPortIndex, shouldBeLinked: false); + case DropEmptySpoolPacket action: + return NetworkedItem.TryGet(action.ToolItemNetId, out var dropToolItem) && dropToolItem.GetTrackedItem() is { HasEjectableSpool: false }; - case CustomizationAction.LoadSpool: - return NetworkedItem.TryGet(packet.ItemNetId, out var loadToolItem) && + case LoadSpoolPacket action: + return NetworkedItem.TryGet(action.ToolItemNetId, out var loadToolItem) && loadToolItem.GetTrackedItem()?.magazine?.items?.FirstOrDefault() is GameObject loaded && NetworkedItem.TryGetNetworkedItem(loaded.GetComponent(), out var loadedItem) && - loadedItem.NetId == packet.OtherItemNetId; - case CustomizationAction.ReplaceSpool: - return packet.OtherItemNetId != 0 && NetworkedItem.TryGet(packet.OtherItemNetId, out _); - case CustomizationAction.ReplaceDuctTape: - return packet.OtherItemNetId != 0 && NetworkedItem.TryGet(packet.OtherItemNetId, out _); - case CustomizationAction.SnapItem: - case CustomizationAction.UnsnapItem: - if (!TryGadget(packet.ItemNetId, out var snapOwner)) + loadedItem.NetId == action.SpoolItemNetId; + case ReplaceSpoolPacket action: + return action.ReplacementSpoolItemNetId != 0 && NetworkedItem.TryGet(action.ReplacementSpoolItemNetId, out _); + case ReplaceDuctTapePacket action: + return action.ReplacementItemNetId != 0 && NetworkedItem.TryGet(action.ReplacementItemNetId, out _); + case SnapItemPacket action: + if (!TryGadget(action.GadgetItemNetId, out var snapOwner)) return false; var snapPoints = GetSnapPoints(snapOwner); - if (!IsValidIndex(packet.IndexA, snapPoints.Length)) + return IsValidIndex(action.SnapPointIndex, snapPoints.Length) && + snapPoints[action.SnapPointIndex].SnappedItem != null && + NetworkedItem.TryGetNetId(snapPoints[action.SnapPointIndex].SnappedItem, out ushort snappedId) && + snappedId == action.SnappedItemNetId; + case UnsnapItemPacket action: + if (!TryGadget(action.GadgetItemNetId, out var unsnapOwner)) return false; - if (packet.Action == CustomizationAction.UnsnapItem) - return snapPoints[packet.IndexA].SnappedItem == null; - return snapPoints[packet.IndexA].SnappedItem != null && - NetworkedItem.TryGetNetId(snapPoints[packet.IndexA].SnappedItem, out ushort snappedId) && - snappedId == packet.OtherItemNetId; + var unsnapPoints = GetSnapPoints(unsnapOwner); + return IsValidIndex(action.SnapPointIndex, unsnapPoints.Length) && + unsnapPoints[action.SnapPointIndex].SnappedItem == null; default: return false; } } - public static IEnumerable GetCustomizationRecipients(CommonCustomizationPacket packet) + internal static IEnumerable GetCustomizationRecipients(ICustomizationActionPacket packet) { if (packet == null || NetworkLifecycle.Instance?.Server == null) return []; @@ -281,15 +265,23 @@ public static IEnumerable GetCustomizationRecipients(CommonCustomi player.CustomizationSnapshotSent); } - public static IEnumerable GetActionItemIds(CommonCustomizationPacket packet) + internal static IEnumerable GetActionItemIds(ICustomizationActionPacket packet) => packet switch { - if (packet?.ItemNetId > 0) - yield return packet.ItemNetId; - if (packet?.OtherItemNetId > 0) - yield return packet.OtherItemNetId; - } - - internal static bool ActionsConflict(CommonCustomizationPacket first, CommonCustomizationPacket second) + PlaceGadgetPacket p => NonZero(p.ItemNetId), RemoveGadgetPacket p => NonZero(p.ItemNetId), + MountGadgetPacket p => NonZero(p.MountItemNetId, p.MountedItemNetId), + UnmountGadgetPacket p => NonZero(p.MountItemNetId), + WireGadgetsPacket p => NonZero(p.FirstItemNetId, p.SecondItemNetId), + UnwireGadgetsPacket p => NonZero(p.FirstItemNetId, p.SecondItemNetId), + LoadSpoolPacket p => NonZero(p.ToolItemNetId, p.SpoolItemNetId), + ReplaceSpoolPacket p => NonZero(p.ToolItemNetId, p.ReplacementSpoolItemNetId), + DropEmptySpoolPacket p => NonZero(p.ToolItemNetId, p.SpoolItemNetId), + SnapItemPacket p => NonZero(p.GadgetItemNetId, p.SnappedItemNetId), + UnsnapItemPacket p => NonZero(p.GadgetItemNetId, p.SnappedItemNetId), + ReplaceDuctTapePacket p => NonZero(p.ConsumedItemNetId, p.ReplacementItemNetId), + _ => [], + }; + + internal static bool ActionsConflict(ICustomizationActionPacket first, ICustomizationActionPacket second) { if (first == null || second == null) return false; @@ -299,31 +291,49 @@ internal static bool ActionsConflict(CommonCustomizationPacket first, CommonCust } internal static bool SnapshotPlacementConflicts(GadgetPlacementData placement, - CommonCustomizationPacket action) + ICustomizationActionPacket action) { if (placement == null || action == null) return false; - return placement.ItemNetId != 0 && - (placement.ItemNetId == action.ItemNetId || placement.ItemNetId == action.OtherItemNetId) || + return placement.ItemNetId != 0 && GetActionItemIds(action).Contains(placement.ItemNetId) || !string.IsNullOrEmpty(placement.Target.IdentificationKey) && - placement.Target.IdentificationKey == action.TargetKey; + placement.Target.IdentificationKey == GetTargetKey(action); } - internal static bool SnapshotHoleStateConflicts(string targetKey, CommonCustomizationPacket action) => - action != null && !string.IsNullOrEmpty(targetKey) && targetKey == action.TargetKey; + internal static bool SnapshotHoleStateConflicts(string targetKey, ICustomizationActionPacket action) => + action != null && !string.IsNullOrEmpty(targetKey) && targetKey == GetTargetKey(action); - private static IEnumerable GetActionDependencyKeys(CommonCustomizationPacket packet) + private static IEnumerable GetActionDependencyKeys(ICustomizationActionPacket packet) { if (packet == null) yield break; - if (packet.ItemNetId != 0) - yield return $"item:{packet.ItemNetId}"; - if (packet.OtherItemNetId != 0) - yield return $"item:{packet.OtherItemNetId}"; - if (!string.IsNullOrEmpty(packet.TargetKey)) - yield return $"target:{packet.TargetKey}"; + foreach (ushort itemNetId in GetActionItemIds(packet)) + yield return $"item:{itemNetId}"; + string targetKey = GetTargetKey(packet); + if (!string.IsNullOrEmpty(targetKey)) + yield return $"target:{targetKey}"; + } + + private static IEnumerable NonZero(params ushort[] values) => values.Where(value => value != 0); + + private static string GetTargetKey(ICustomizationActionPacket packet) => packet switch + { + PlaceGadgetPacket p => p.TargetKey, AddHolePacket p => p.TargetKey, + MoveHolePacket p => p.TargetKey, RemoveHolePacket p => p.TargetKey, _ => null, + }; + + private static bool IsWireStateApplied(ushort firstId, ushort secondId, int firstIndex, + int secondIndex, bool shouldBeLinked) + { + if (!TryGadget(firstId, out var first) || !TryGadget(secondId, out var second) || + !IsValidIndex(firstIndex, first.WireLinkPorts.Count) || + !IsValidIndex(secondIndex, second.WireLinkPorts.Count)) + return false; + var links = new List(); + first.WireLinkPorts[firstIndex].GetLinks(links); + return links.Contains(second.WireLinkPorts[secondIndex]) == shouldBeLinked; } public static bool TryGadget(ushort itemNetId, out GadgetBase gadget) @@ -399,7 +409,8 @@ private static void CaptureLoadedSpool(CustomizationStateData state, NetworkedIt if (!NetworkedItem.TryGetNetworkedItem(spoolItemBase, out var spoolItem)) return; - AddRelationship(state, CustomizationAction.LoadSpool, toolItem.NetId, spoolItem.NetId); + state.LoadedSpools.Add(new LoadSpoolPacket + { ToolItemNetId = toolItem.NetId, SpoolItemNetId = spoolItem.NetId }); } private static void CapturePlacement( @@ -430,7 +441,8 @@ private static void CaptureMounts(CustomizationStateData state, NetworkedItem ow for (int index = 0; index < mounts.Length; index++) { if (mounts[index].MountedGadget != null && TryItemId(mounts[index].MountedGadget, out var mountedId)) - AddRelationship(state, CustomizationAction.Mount, ownerItem.NetId, mountedId, index); + state.Mounts.Add(new MountGadgetPacket + { MountItemNetId = ownerItem.NetId, MountedItemNetId = mountedId, MountIndex = index }); } } @@ -455,8 +467,9 @@ private static void CaptureSnappedItems(CustomizationStateData state, NetworkedI } } - AddRelationship(state, CustomizationAction.SnapItem, ownerItem.NetId, snappedId, index, - hasPosition: hasAnchorPosition, position: anchorPosition); + state.SnappedItems.Add(new SnapItemPacket { GadgetItemNetId = ownerItem.NetId, + SnappedItemNetId = snappedId, SnapPointIndex = index, + HasAnchorPosition = hasAnchorPosition, AnchorPosition = anchorPosition }); Multiplayer.LogDebug(() => $"Customization snapshot snap owner={ownerItem.NetId} item={snappedId} " + $"point={snapPoints[index].GetType().Name}:{snapPoints[index].SnapPointType} " + $"anchor={hasAnchorPosition} offset={anchorPosition}"); @@ -477,7 +490,8 @@ private static void CaptureWires(CustomizationStateData state, NetworkedItem own int linkedPortIndex = link.owner.WireLinkPorts.ToList().IndexOf(link); if (linkedPortIndex >= 0) - AddRelationship(state, CustomizationAction.Wire, ownerItem.NetId, linkedId, portIndex, linkedPortIndex); + state.Wires.Add(new WireGadgetsPacket { FirstItemNetId = ownerItem.NetId, + SecondItemNetId = linkedId, FirstPortIndex = portIndex, SecondPortIndex = linkedPortIndex }); } } } @@ -538,13 +552,10 @@ private static bool AreSnapshotItemsReady(CustomizationStateData state, out stri return true; } - private static bool AreSnapRelationshipsReady(IEnumerable relationships, out string waitingFor) + private static bool AreSnapRelationshipsReady(IEnumerable relationships, out string waitingFor) { foreach (var relationship in relationships) { - if (relationship.Action != CustomizationAction.SnapItem) - continue; - if (!IsSnapActionReady(relationship, out waitingFor)) return false; } @@ -651,8 +662,8 @@ private static void PlaceGadgets(IEnumerable placements, } } - private static void ApplyRelationships(IEnumerable relationships, - ICollection deferredRelationships) + private static void ApplyRelationships(IEnumerable relationships, + ICollection deferredRelationships) { foreach (var relationship in relationships) { @@ -662,8 +673,8 @@ private static void ApplyRelationships(IEnumerable re if (blockedByEarlierDependency || !IsActionReady(relationship, out waitingFor)) { deferredRelationships?.Add(relationship); - Multiplayer.LogDebug(() => $"Deferring snapshot relationship {relationship.Action} " + - $"({relationship.ItemNetId}, {relationship.OtherItemNetId}) until {waitingFor ?? "an earlier dependency"}"); + Multiplayer.LogDebug(() => $"Deferring snapshot relationship {relationship.GetType().Name} " + + $"until {waitingFor ?? "an earlier dependency"}"); continue; } @@ -673,8 +684,7 @@ private static void ApplyRelationships(IEnumerable re } catch (Exception exception) { - Multiplayer.LogError($"Failed to apply snapshot relationship {relationship.Action} " + - $"({relationship.ItemNetId}, {relationship.OtherItemNetId}): {exception}"); + Multiplayer.LogError($"Failed to apply snapshot relationship {relationship.GetType().Name}: {exception}"); } } } @@ -804,34 +814,33 @@ private static void ApplyTrackedValues(IEnumerable placemen } } - internal static bool IsActionReady(CommonCustomizationPacket packet, out string waitingFor) + internal static bool IsActionReady(ICustomizationActionPacket packet, out string waitingFor) { waitingFor = null; if (packet == null) return false; - bool needsTarget = packet.Action is CustomizationAction.PlaceGadget or - CustomizationAction.AddHole or CustomizationAction.MoveHole or CustomizationAction.RemoveHole; - if (needsTarget && !CustomizationRef.TryResolve(TargetOf(packet), out _)) + string targetKey = GetTargetKey(packet); + if (targetKey != null && !CustomizationRef.TryResolve(TargetOf(targetKey), out _)) { - waitingFor = $"customization target '{packet.TargetKey}'"; + waitingFor = $"customization target '{targetKey}'"; return false; } - bool hasLocalDuctTapeReplacement = packet.Action == CustomizationAction.ReplaceDuctTape && - pendingLocalDuctTapeReplacements.TryGetValue(packet.ItemNetId, out var localReplacement) && + bool hasLocalDuctTapeReplacement = packet is ReplaceDuctTapePacket tape && + pendingLocalDuctTapeReplacements.TryGetValue(tape.ConsumedItemNetId, out var localReplacement) && localReplacement != null && localReplacement.NetId == 0; - IEnumerable requiredItems = packet.Action switch + IEnumerable requiredItems = packet switch { // These actions construct or adopt the replacement locally. The old // duct tape may already have been destroyed on the originating peer. - CustomizationAction.ReplaceDuctTape when hasLocalDuctTapeReplacement => [], - CustomizationAction.ReplaceDuctTape => [packet.ItemNetId], - CustomizationAction.ReplaceSpool => [packet.ItemNetId], + ReplaceDuctTapePacket when hasLocalDuctTapeReplacement => [], + ReplaceDuctTapePacket action => NonZero(action.ConsumedItemNetId), + ReplaceSpoolPacket action => NonZero(action.ToolItemNetId), // The contained reel is recoverable from the tool's magazine even when // a replacement reassigned its ID before the lookup cache caught up. - CustomizationAction.DropEmptySpool => [packet.ItemNetId], + DropEmptySpoolPacket action => NonZero(action.ToolItemNetId), _ => GetActionItemIds(packet), }; @@ -844,54 +853,64 @@ internal static bool IsActionReady(CommonCustomizationPacket packet, out string } } - if (packet.Action == CustomizationAction.PlaceGadget && - (!NetworkedItem.TryGet(packet.ItemNetId, out var placedItem) || + if (packet is PlaceGadgetPacket placement && + (!NetworkedItem.TryGet(placement.ItemNetId, out var placedItem) || placedItem.GetTrackedItem() is not { Gadget: not null, Item: not null })) { - waitingFor = $"gadget item {packet.ItemNetId}"; + waitingFor = $"gadget item {placement.ItemNetId}"; return false; } - bool needsFirstGadget = packet.Action is CustomizationAction.RemoveGadget or - CustomizationAction.Mount or CustomizationAction.Unmount or CustomizationAction.Wire or - CustomizationAction.Unwire or CustomizationAction.SnapItem or CustomizationAction.UnsnapItem; - if (needsFirstGadget && !TryGadget(packet.ItemNetId, out _)) + ushort firstGadgetId = packet switch { - waitingFor = $"gadget {packet.ItemNetId}"; + RemoveGadgetPacket p => p.ItemNetId, MountGadgetPacket p => p.MountItemNetId, + UnmountGadgetPacket p => p.MountItemNetId, WireGadgetsPacket p => p.FirstItemNetId, + UnwireGadgetsPacket p => p.FirstItemNetId, SnapItemPacket p => p.GadgetItemNetId, + UnsnapItemPacket p => p.GadgetItemNetId, _ => 0, + }; + if (firstGadgetId != 0 && !TryGadget(firstGadgetId, out _)) + { + waitingFor = $"gadget {firstGadgetId}"; return false; } - bool needsSecondGadget = packet.Action is CustomizationAction.Mount or - CustomizationAction.Wire or CustomizationAction.Unwire; - if (needsSecondGadget && !TryGadget(packet.OtherItemNetId, out _)) + ushort secondGadgetId = packet switch + { + MountGadgetPacket p => p.MountedItemNetId, WireGadgetsPacket p => p.SecondItemNetId, + UnwireGadgetsPacket p => p.SecondItemNetId, _ => 0, + }; + if (secondGadgetId != 0 && !TryGadget(secondGadgetId, out _)) { - waitingFor = $"gadget {packet.OtherItemNetId}"; + waitingFor = $"gadget {secondGadgetId}"; return false; } - if (packet.Action is CustomizationAction.Mount or CustomizationAction.Unmount) + if (packet is MountGadgetPacket or UnmountGadgetPacket) { - TryGadget(packet.ItemNetId, out var owner); - if (!IsValidIndex(packet.IndexA, owner.GetComponents().Length)) + int mountIndex = packet is MountGadgetPacket mount ? mount.MountIndex : ((UnmountGadgetPacket)packet).MountIndex; + TryGadget(firstGadgetId, out var owner); + if (!IsValidIndex(mountIndex, owner.GetComponents().Length)) { - waitingFor = $"mount {packet.IndexA} on gadget {packet.ItemNetId}"; + waitingFor = $"mount {mountIndex} on gadget {firstGadgetId}"; return false; } } - if (packet.Action is CustomizationAction.Wire or CustomizationAction.Unwire) + if (packet is WireGadgetsPacket or UnwireGadgetsPacket) { - TryGadget(packet.ItemNetId, out var first); - TryGadget(packet.OtherItemNetId, out var second); - if (!IsValidIndex(packet.IndexA, first.WireLinkPorts.Count)) + int firstPort = packet is WireGadgetsPacket wire ? wire.FirstPortIndex : ((UnwireGadgetsPacket)packet).FirstPortIndex; + int secondPort = packet is WireGadgetsPacket wire2 ? wire2.SecondPortIndex : ((UnwireGadgetsPacket)packet).SecondPortIndex; + TryGadget(firstGadgetId, out var first); + TryGadget(secondGadgetId, out var second); + if (!IsValidIndex(firstPort, first.WireLinkPorts.Count)) { - waitingFor = $"wire port {packet.IndexA} on gadget {packet.ItemNetId}"; + waitingFor = $"wire port {firstPort} on gadget {firstGadgetId}"; return false; } - if (!IsValidIndex(packet.IndexB, second.WireLinkPorts.Count)) + if (!IsValidIndex(secondPort, second.WireLinkPorts.Count)) { - waitingFor = $"wire port {packet.IndexB} on gadget {packet.OtherItemNetId}"; + waitingFor = $"wire port {secondPort} on gadget {secondGadgetId}"; return false; } @@ -899,25 +918,30 @@ CustomizationAction.Mount or CustomizationAction.Unmount or CustomizationAction. // reconciled. Wire() rejects ports whose owners do not yet reference // the same Customization, and the old client drain discarded that // failed action permanently. - if (packet.Action == CustomizationAction.Wire && + if (packet is WireGadgetsPacket && (first.Custom == null || second.Custom == null || first.Custom != second.Custom)) { - waitingFor = $"gadgets {packet.ItemNetId} and {packet.OtherItemNetId} to share a customization target"; + waitingFor = $"gadgets {firstGadgetId} and {secondGadgetId} to share a customization target"; return false; } } - if ((packet.Action is CustomizationAction.SnapItem or CustomizationAction.UnsnapItem) && - !IsSnapActionReady(packet, out waitingFor)) + if (packet is SnapItemPacket snap && !IsSnapActionReady(snap, out waitingFor)) + return false; + if (packet is UnsnapItemPacket unsnap && !IsSnapActionReady(unsnap, out waitingFor)) return false; - if (packet.Action is CustomizationAction.DropEmptySpool or CustomizationAction.LoadSpool or - CustomizationAction.ReplaceSpool) + ushort toolId = packet switch { - NetworkedItem.TryGet(packet.ItemNetId, out var toolItem); + DropEmptySpoolPacket p => p.ToolItemNetId, LoadSpoolPacket p => p.ToolItemNetId, + ReplaceSpoolPacket p => p.ToolItemNetId, _ => 0, + }; + if (toolId != 0) + { + NetworkedItem.TryGet(toolId, out var toolItem); if (toolItem?.GetTrackedItem()?.magazine == null) { - waitingFor = $"soldering tool {packet.ItemNetId}"; + waitingFor = $"soldering tool {toolId}"; return false; } } @@ -925,40 +949,34 @@ CustomizationAction.Mount or CustomizationAction.Unmount or CustomizationAction. return true; } - private static bool IsSnapActionReady(CommonCustomizationPacket packet, out string waitingFor) + private static bool IsSnapActionReady(SnapItemPacket packet, out string waitingFor) { - if (!TryGadget(packet.ItemNetId, out var owner)) + if (!TryGadget(packet.GadgetItemNetId, out var owner)) { - waitingFor = $"snap-point owner {packet.ItemNetId}"; + waitingFor = $"snap-point owner {packet.GadgetItemNetId}"; return false; } var snapPoints = GetSnapPoints(owner); - if (!IsValidIndex(packet.IndexA, snapPoints.Length)) + if (!IsValidIndex(packet.SnapPointIndex, snapPoints.Length)) { - waitingFor = $"snap point {packet.IndexA} on gadget {packet.ItemNetId}"; + waitingFor = $"snap point {packet.SnapPointIndex} on gadget {packet.GadgetItemNetId}"; return false; } - if (packet.Action == CustomizationAction.UnsnapItem) - { - waitingFor = null; - return true; - } - - if (!NetworkedItem.TryGet(packet.OtherItemNetId, out var snappedItem) || + if (!NetworkedItem.TryGet(packet.SnappedItemNetId, out var snappedItem) || snappedItem.Item?.SnappableItem == null) { - waitingFor = $"snappable item {packet.OtherItemNetId}"; + waitingFor = $"snappable item {packet.SnappedItemNetId}"; return false; } - var snapPoint = snapPoints[packet.IndexA]; + var snapPoint = snapPoints[packet.SnapPointIndex]; var snappable = snappedItem.Item.SnappableItem; if (((int)snappable.AllowedSnapPointTypes & (int)snapPoint.SnapPointType) == 0 || snappable.GetAnchor(snapPoint.SnapPointType) == null) { - waitingFor = $"snap anchor on item {packet.OtherItemNetId}"; + waitingFor = $"snap anchor on item {packet.SnappedItemNetId}"; return false; } @@ -966,25 +984,41 @@ private static bool IsSnapActionReady(CommonCustomizationPacket packet, out stri return true; } - private static void ApplyPlacement(CommonCustomizationPacket packet) + private static bool IsSnapActionReady(UnsnapItemPacket packet, out string waitingFor) + { + if (!TryGadget(packet.GadgetItemNetId, out var owner)) + { + waitingFor = $"snap-point owner {packet.GadgetItemNetId}"; + return false; + } + if (!IsValidIndex(packet.SnapPointIndex, GetSnapPoints(owner).Length)) + { + waitingFor = $"snap point {packet.SnapPointIndex} on gadget {packet.GadgetItemNetId}"; + return false; + } + waitingFor = null; + return true; + } + + private static void ApplyPlacement(PlaceGadgetPacket packet) { if (!NetworkedItem.TryGet(packet.ItemNetId, out var item) || item.GetTrackedItem() is not GadgetItem gadgetItem || - !CustomizationRef.TryResolve(TargetOf(packet), out var target)) + !CustomizationRef.TryResolve(TargetOf(packet.TargetKey), out var target)) return; GadgetItem.Place(target, packet.Position, packet.Rotation, gadgetItem, colliderForPlacementData: null); } - private static void ApplyRemoval(CommonCustomizationPacket packet) + private static void ApplyRemoval(RemoveGadgetPacket packet) { if (!NetworkedItem.TryGet(packet.ItemNetId, out var item)) return; - var removedItem = item.GetTrackedItem()?.Gadget?.Remove(packet.Flag); + var removedItem = item.GetTrackedItem()?.Gadget?.Remove(packet.ReparentToTrainCar); if (removedItem != null) { - if (packet.Flag) + if (packet.ReparentToTrainCar) { // Gadget remover: native removal reparents the item to the train car, // where it must remain visible until somebody actually picks it up. @@ -1000,57 +1034,61 @@ private static void ApplyRemoval(CommonCustomizationPacket packet) } } - private static void ApplyHoleAction(CommonCustomizationPacket packet) + private static void ApplyHoleAction(AddHolePacket packet) { - if (!CustomizationRef.TryResolve(TargetOf(packet), out var target)) - return; + if (CustomizationRef.TryResolve(TargetOf(packet.TargetKey), out var target)) + target.AddHole(packet.Position, packet.Normal); + } - switch (packet.Action) - { - case CustomizationAction.AddHole: - target.AddHole(packet.Position, packet.Normal); - break; - case CustomizationAction.MoveHole: - if (target.FindHole(packet.PreviousPosition, out var movedHole)) - target.MoveHole(movedHole, packet.Position, packet.Normal); - break; - case CustomizationAction.RemoveHole: - if (target.FindHole(packet.PreviousPosition, out var removedHole)) - target.RemoveHole(removedHole); - break; - } + private static void ApplyHoleAction(MoveHolePacket packet) + { + if (CustomizationRef.TryResolve(TargetOf(packet.TargetKey), out var target) && + target.FindHole(packet.PreviousPosition, out var movedHole)) + target.MoveHole(movedHole, packet.Position, packet.Normal); } - private static void ApplyMountAction(CommonCustomizationPacket packet) + private static void ApplyHoleAction(RemoveHolePacket packet) { - if (!TryGadget(packet.ItemNetId, out var owner)) - return; + if (CustomizationRef.TryResolve(TargetOf(packet.TargetKey), out var target) && + target.FindHole(packet.PreviousPosition, out var removedHole)) + target.RemoveHole(removedHole); + } + private static void ApplyMountAction(MountGadgetPacket packet) + { + if (!TryGadget(packet.MountItemNetId, out var owner)) return; var mounts = owner.GetComponents(); - if (!IsValidIndex(packet.IndexA, mounts.Length)) - return; - - if (packet.Action == CustomizationAction.Unmount) - { - mounts[packet.IndexA].UnmountGadget(); - return; - } + if (IsValidIndex(packet.MountIndex, mounts.Length) && TryGadget(packet.MountedItemNetId, out var mounted)) + mounts[packet.MountIndex].MountGadget(mounted); + } - if (TryGadget(packet.OtherItemNetId, out var mounted)) - mounts[packet.IndexA].MountGadget(mounted); + private static void ApplyMountAction(UnmountGadgetPacket packet) + { + if (!TryGadget(packet.MountItemNetId, out var owner)) return; + var mounts = owner.GetComponents(); + if (IsValidIndex(packet.MountIndex, mounts.Length)) + mounts[packet.MountIndex].UnmountGadget(); } - private static void ApplyWireAction(CommonCustomizationPacket packet) + private static void ApplyWireAction(WireGadgetsPacket packet) => + ApplyWireAction(packet.FirstItemNetId, packet.SecondItemNetId, packet.FirstPortIndex, + packet.SecondPortIndex, unwire: false); + + private static void ApplyWireAction(UnwireGadgetsPacket packet) => + ApplyWireAction(packet.FirstItemNetId, packet.SecondItemNetId, packet.FirstPortIndex, + packet.SecondPortIndex, unwire: true); + + private static void ApplyWireAction(ushort firstItemNetId, ushort secondItemNetId, + int firstPortIndex, int secondPortIndex, bool unwire) { - if (!TryGadget(packet.ItemNetId, out var first) || - !TryGadget(packet.OtherItemNetId, out var second) || - !IsValidIndex(packet.IndexA, first.WireLinkPorts.Count) || - !IsValidIndex(packet.IndexB, second.WireLinkPorts.Count)) + if (!TryGadget(firstItemNetId, out var first) || !TryGadget(secondItemNetId, out var second) || + !IsValidIndex(firstPortIndex, first.WireLinkPorts.Count) || + !IsValidIndex(secondPortIndex, second.WireLinkPorts.Count)) return; - var firstPort = first.WireLinkPorts[packet.IndexA]; - var secondPort = second.WireLinkPorts[packet.IndexB]; - if (packet.Action == CustomizationAction.Wire) + var firstPort = first.WireLinkPorts[firstPortIndex]; + var secondPort = second.WireLinkPorts[secondPortIndex]; + if (!unwire) { if (GadgetWiringModule.WireLinkPort.AreWired(firstPort, secondPort)) return; @@ -1065,14 +1103,14 @@ private static void ApplyWireAction(CommonCustomizationPacket packet) bool wired = GadgetWiringModule.WireLinkPort.Wire(firstPort, secondPort); if (!wired || !GadgetWiringModule.WireLinkPort.AreWired(firstPort, secondPort)) { - Multiplayer.LogWarning($"Could not apply authoritative wire {packet.ItemNetId}:{packet.IndexA} -> " + - $"{packet.OtherItemNetId}:{packet.IndexB}; first={first.GetType().Name}, " + + Multiplayer.LogWarning($"Could not apply authoritative wire {firstItemNetId}:{firstPortIndex} -> " + + $"{secondItemNetId}:{secondPortIndex}; first={first.GetType().Name}, " + $"second={second.GetType().Name}, sameTarget={first.Custom != null && first.Custom == second.Custom}"); } else { - Multiplayer.LogDebug(() => $"Wired gadget {packet.ItemNetId}:{packet.IndexA} to " + - $"{packet.OtherItemNetId}:{packet.IndexB}"); + Multiplayer.LogDebug(() => $"Wired gadget {firstItemNetId}:{firstPortIndex} to " + + $"{secondItemNetId}:{secondPortIndex}"); } } else @@ -1081,8 +1119,8 @@ private static void ApplyWireAction(CommonCustomizationPacket packet) return; if (!GadgetWiringModule.WireLinkPort.Unwire(firstPort, secondPort)) - Multiplayer.LogWarning($"Could not unwire gadget {packet.ItemNetId}:{packet.IndexA} from " + - $"{packet.OtherItemNetId}:{packet.IndexB}"); + Multiplayer.LogWarning($"Could not unwire gadget {firstItemNetId}:{firstPortIndex} from " + + $"{secondItemNetId}:{secondPortIndex}"); } } @@ -1102,25 +1140,23 @@ private static void ClearStaleMonoWireLinks(GadgetWiringModule.WireLinkPort port } } - private static void ApplySpoolAction(CommonCustomizationPacket packet) + private static void ApplySpoolAction(ReplaceSpoolPacket packet) { - if (!NetworkedItem.TryGet(packet.ItemNetId, out var toolItem)) - return; - - var tool = toolItem.GetTrackedItem(); - if (packet.Action == CustomizationAction.ReplaceSpool) - { - ApplySpoolReplacement(toolItem, tool, packet); - return; - } + if (NetworkedItem.TryGet(packet.ToolItemNetId, out var toolItem)) + ApplySpoolReplacement(toolItem, toolItem.GetTrackedItem(), packet); + } - if (packet.Action == CustomizationAction.DropEmptySpool) - { - DropEmptySpool(tool, packet); - return; - } + private static void ApplySpoolAction(DropEmptySpoolPacket packet) + { + if (NetworkedItem.TryGet(packet.ToolItemNetId, out var toolItem)) + DropEmptySpool(toolItem.GetTrackedItem(), packet); + } - if (tool?.magazine != null && NetworkedItem.TryGet(packet.OtherItemNetId, out var spoolItem)) + private static void ApplySpoolAction(LoadSpoolPacket packet) + { + if (NetworkedItem.TryGet(packet.ToolItemNetId, out var toolItem) && + toolItem.GetTrackedItem() is { magazine: not null } tool && + NetworkedItem.TryGet(packet.SpoolItemNetId, out var spoolItem)) LoadSpool(toolItem, tool, spoolItem); } @@ -1180,7 +1216,7 @@ private static void LoadSpool(NetworkedItem toolItem, GadgetSolderingTool tool, Multiplayer.LogWarning($"Could not load reel {spoolItem.NetId} into soldering tool {toolItem.NetId}"); } - private static void ApplySpoolReplacement(NetworkedItem toolItem, GadgetSolderingTool tool, CommonCustomizationPacket packet) + private static void ApplySpoolReplacement(NetworkedItem toolItem, GadgetSolderingTool tool, ReplaceSpoolPacket packet) { if (tool?.magazine == null) return; @@ -1233,10 +1269,10 @@ private static void ApplySpoolReplacement(NetworkedItem toolItem, GadgetSolderin if (!NetworkedItem.TryGetNetworkedItem(spentItemBase, out var spentItem)) return; - if (packet.OtherItemNetId == 0 && NetworkLifecycle.Instance.IsHost()) - packet.OtherItemNetId = spentItem.NetId; - else if (packet.OtherItemNetId != 0) - spentItem.NetId = packet.OtherItemNetId; + if (packet.ReplacementSpoolItemNetId == 0 && NetworkLifecycle.Instance.IsHost()) + packet.ReplacementSpoolItemNetId = spentItem.NetId; + else if (packet.ReplacementSpoolItemNetId != 0) + spentItem.NetId = packet.ReplacementSpoolItemNetId; spentItem.SetOwner(replacementOwner); if (spentItem.Item?.InventorySpecs != null) @@ -1247,7 +1283,7 @@ private static void ApplySpoolReplacement(NetworkedItem toolItem, GadgetSolderin tool.OnUnitsChanged(); } - private static void DropEmptySpool(GadgetSolderingTool tool, CommonCustomizationPacket packet) + private static void DropEmptySpool(GadgetSolderingTool tool, DropEmptySpoolPacket packet) { if (tool?.magazine == null) { @@ -1261,29 +1297,29 @@ private static void DropEmptySpool(GadgetSolderingTool tool, CommonCustomization if (current != null) NetworkedItem.TryGetNetworkedItem(current.GetComponent(), out currentItem); - NetworkedItem spoolItem = currentItem != null && currentItem.NetId == packet.OtherItemNetId + NetworkedItem spoolItem = currentItem != null && currentItem.NetId == packet.SpoolItemNetId ? currentItem : null; if (spoolItem == null) - NetworkedItem.TryGet(packet.OtherItemNetId, out spoolItem); + NetworkedItem.TryGet(packet.SpoolItemNetId, out spoolItem); if (spoolItem == null && currentItem != null) { - Multiplayer.LogWarning($"Recover empty-reel identity for soldering tool {packet.ItemNetId}: " + - $"binding contained reel {currentItem.NetId} to packet reel {packet.OtherItemNetId}"); - currentItem.NetId = packet.OtherItemNetId; + Multiplayer.LogWarning($"Recover empty-reel identity for soldering tool {packet.ToolItemNetId}: " + + $"binding contained reel {currentItem.NetId} to packet reel {packet.SpoolItemNetId}"); + currentItem.NetId = packet.SpoolItemNetId; spoolItem = currentItem; } if (spoolItem == null) { - Multiplayer.LogWarning($"Cannot apply empty-reel ejection for tool {packet.ItemNetId}: " + - $"reel {packet.OtherItemNetId} is unavailable and magazine slot 0 has no networked reel"); + Multiplayer.LogWarning($"Cannot apply empty-reel ejection for tool {packet.ToolItemNetId}: " + + $"reel {packet.SpoolItemNetId} is unavailable and magazine slot 0 has no networked reel"); return; } if (currentItem != spoolItem) { - Multiplayer.LogWarning($"Reconcile empty-reel ejection for soldering tool {packet.ItemNetId}: replacing stale reel " + + Multiplayer.LogWarning($"Reconcile empty-reel ejection for soldering tool {packet.ToolItemNetId}: replacing stale reel " + $"{currentItem?.NetId ?? 0} with authoritative reel {spoolItem.NetId}"); if (current != null) { @@ -1299,7 +1335,7 @@ private static void DropEmptySpool(GadgetSolderingTool tool, CommonCustomization EnsureInWorldStorage(spoolItem.Item); if (!tool.magazine.AddItem(spoolItem.gameObject, 0)) { - Multiplayer.LogWarning($"Could not place authoritative reel {spoolItem.NetId} in soldering tool {packet.ItemNetId} before ejection"); + Multiplayer.LogWarning($"Could not place authoritative reel {spoolItem.NetId} in soldering tool {packet.ToolItemNetId} before ejection"); return; } } @@ -1330,7 +1366,7 @@ private static void DropEmptySpool(GadgetSolderingTool tool, CommonCustomization } spoolObject.SetActive(true); - if (packet.Flag) + if (packet.HasWorldTransform) { spoolObject.transform.SetPositionAndRotation( packet.Position + WorldMover.currentMove, @@ -1359,33 +1395,33 @@ private static void EnsureInWorldStorage(DV.CabControls.ItemBase item) storage.AddItemToWorldStorage(item); } - private static void ApplyDuctTapeReplacement(CommonCustomizationPacket packet) + private static void ApplyDuctTapeReplacement(ReplaceDuctTapePacket packet) { - NetworkedItem.TryGet(packet.ItemNetId, out var oldItem); + NetworkedItem.TryGet(packet.ConsumedItemNetId, out var oldItem); var oldTape = oldItem?.GetTrackedItem(); byte replacementOwner = packet.OwnerPlayerId != 0 ? packet.OwnerPlayerId : oldItem?.OwnerPlayerId ?? 0; bool belongsToPlayer = oldItem?.Item?.InventorySpecs?.BelongsToPlayer ?? true; - Vector3 position = packet.Flag ? packet.Position + WorldMover.currentMove : oldItem?.transform.position ?? Vector3.zero; - Quaternion rotation = packet.Flag ? packet.Rotation : oldItem?.transform.rotation ?? Quaternion.identity; + Vector3 position = packet.HasWorldTransform ? packet.Position + WorldMover.currentMove : oldItem?.transform.position ?? Vector3.zero; + Quaternion rotation = packet.HasWorldTransform ? packet.Rotation : oldItem?.transform.rotation ?? Quaternion.identity; // The originating client records the exact object produced by the native // replacement path. Do not infer identity from position: inactive inventory // and cache entries can legitimately share the same transform. NetworkedItem replacementItem = null; - if (packet.OtherItemNetId != 0) - NetworkedItem.TryGet(packet.OtherItemNetId, out replacementItem); + if (packet.ReplacementItemNetId != 0) + NetworkedItem.TryGet(packet.ReplacementItemNetId, out replacementItem); if (replacementItem == null) { - pendingLocalDuctTapeReplacements.TryGetValue(packet.ItemNetId, out replacementItem); + pendingLocalDuctTapeReplacements.TryGetValue(packet.ConsumedItemNetId, out replacementItem); if (replacementItem == null || replacementItem.NetId != 0) replacementItem = null; else - pendingLocalDuctTapeReplacements.Remove(packet.ItemNetId); + pendingLocalDuctTapeReplacements.Remove(packet.ConsumedItemNetId); } else { - pendingLocalDuctTapeReplacements.Remove(packet.ItemNetId); + pendingLocalDuctTapeReplacements.Remove(packet.ConsumedItemNetId); } bool existingReplacement = replacementItem != null; @@ -1393,7 +1429,7 @@ private static void ApplyDuctTapeReplacement(CommonCustomizationPacket packet) { if (oldTape?.emptyTapeItemPrefab == null) { - Multiplayer.LogWarning($"Cannot replace consumed duct tape {packet.ItemNetId}: empty-tape prefab unavailable"); + Multiplayer.LogWarning($"Cannot replace consumed duct tape {packet.ConsumedItemNetId}: empty-tape prefab unavailable"); return; } @@ -1411,10 +1447,10 @@ private static void ApplyDuctTapeReplacement(CommonCustomizationPacket packet) Inventory.Instance.DestroyItem(oldItem.gameObject); } - if (packet.OtherItemNetId == 0 && NetworkLifecycle.Instance.IsHost()) - packet.OtherItemNetId = replacementItem.NetId; - else if (packet.OtherItemNetId != 0) - replacementItem.NetId = packet.OtherItemNetId; + if (packet.ReplacementItemNetId == 0 && NetworkLifecycle.Instance.IsHost()) + packet.ReplacementItemNetId = replacementItem.NetId; + else if (packet.ReplacementItemNetId != 0) + replacementItem.NetId = packet.ReplacementItemNetId; replacementItem.SetOwner(replacementOwner); if (replacementItem.Item?.InventorySpecs != null) @@ -1431,57 +1467,49 @@ private static void ApplyDuctTapeReplacement(CommonCustomizationPacket packet) replacementItem.MarkAsSynchronized(); } - private static void ApplySnapAction(CommonCustomizationPacket packet) + private static void ApplySnapAction(UnsnapItemPacket packet) { - if (!TryGadget(packet.ItemNetId, out var owner)) - { - Multiplayer.LogWarning($"Cannot apply {packet.Action}: gadget item {packet.ItemNetId} was not found"); - return; - } - + if (!TryGadget(packet.GadgetItemNetId, out var owner)) return; var snapPoints = GetSnapPoints(owner); - if (!IsValidIndex(packet.IndexA, snapPoints.Length)) - { - Multiplayer.LogWarning($"Cannot apply {packet.Action}: snap point {packet.IndexA} was not found on gadget {packet.ItemNetId} ({snapPoints.Length} available)"); - return; - } - - if (packet.Action == CustomizationAction.UnsnapItem) - { - if (!snapPoints[packet.IndexA].UnsnapItem(forced: true)) - Multiplayer.LogWarning($"Could not unsnap item {packet.OtherItemNetId} from gadget {packet.ItemNetId} point {packet.IndexA}"); - return; - } + if (IsValidIndex(packet.SnapPointIndex, snapPoints.Length) && + !snapPoints[packet.SnapPointIndex].UnsnapItem(forced: true)) + Multiplayer.LogWarning($"Could not unsnap item {packet.SnappedItemNetId} from gadget {packet.GadgetItemNetId} point {packet.SnapPointIndex}"); + } - if (!NetworkedItem.TryGet(packet.OtherItemNetId, out var snappedItem)) + private static void ApplySnapAction(SnapItemPacket packet) + { + if (!TryGadget(packet.GadgetItemNetId, out var owner)) return; + var snapPoints = GetSnapPoints(owner); + if (!IsValidIndex(packet.SnapPointIndex, snapPoints.Length)) return; + if (!NetworkedItem.TryGet(packet.SnappedItemNetId, out var snappedItem)) { - Multiplayer.LogWarning($"Cannot snap item {packet.OtherItemNetId}: networked item was not found"); + Multiplayer.LogWarning($"Cannot snap item {packet.SnappedItemNetId}: networked item was not found"); return; } - var snapPoint = snapPoints[packet.IndexA]; + var snapPoint = snapPoints[packet.SnapPointIndex]; bool snapSucceeded; if (snapPoint is SnapPointGadgetSliding) { var anchor = snappedItem.Item.SnappableItem?.GetAnchor(snapPoint.SnapPointType); if (anchor == null) { - Multiplayer.LogWarning($"Could not restore sliding snap for item {packet.OtherItemNetId}: anchor {snapPoint.SnapPointType} was not found"); + Multiplayer.LogWarning($"Could not restore sliding snap for item {packet.SnappedItemNetId}: anchor {snapPoint.SnapPointType} was not found"); return; } // The sliding override performs local physics queries even for a forced // snap. Prefer the authoritative anchor offset. Older/incomplete state // can derive the same offset from the synchronized snapped item pose. - if (packet.Flag) + if (packet.HasAnchorPosition) { - anchor.localPosition = packet.Position; + anchor.localPosition = packet.AnchorPosition; } else { var target = snapPoint.snapPointTarget != null ? snapPoint.snapPointTarget : snapPoint.transform; anchor.localPosition = snappedItem.transform.InverseTransformPoint(target.position); - Multiplayer.LogWarning($"Sliding snap {packet.ItemNetId}:{packet.IndexA} for item {packet.OtherItemNetId} " + + Multiplayer.LogWarning($"Sliding snap {packet.GadgetItemNetId}:{packet.SnapPointIndex} for item {packet.SnappedItemNetId} " + $"had no anchor offset; derived {anchor.localPosition} from synchronized transforms"); } @@ -1501,7 +1529,7 @@ private static void ApplySnapAction(CommonCustomizationPacket packet) if (!snapSucceeded) { var snappable = snappedItem.Item?.SnappableItem; - Multiplayer.LogWarning($"Could not snap item {packet.OtherItemNetId} to gadget {packet.ItemNetId} point {packet.IndexA}: " + + Multiplayer.LogWarning($"Could not snap item {packet.SnappedItemNetId} to gadget {packet.GadgetItemNetId} point {packet.SnapPointIndex}: " + $"pointType={snapPoint.SnapPointType}, allowedTypes={snappable?.AllowedSnapPointTypes}, occupied={snapPoint.SnappedItem != null}"); } else @@ -1509,18 +1537,24 @@ private static void ApplySnapAction(CommonCustomizationPacket packet) if (snappedItem.Item?.ItemRigidbody != null) snappedItem.Item.ItemRigidbody.isKinematic = true; snappedItem.MarkAsSynchronized(); - Multiplayer.LogDebug(() => $"Snapped item {packet.OtherItemNetId} to gadget {packet.ItemNetId} point {packet.IndexA}"); + Multiplayer.LogDebug(() => $"Snapped item {packet.SnappedItemNetId} to gadget {packet.GadgetItemNetId} point {packet.SnapPointIndex}"); } } - private static CustomizationRefData TargetOf(CommonCustomizationPacket packet) + private static CustomizationRefData TargetOf(string targetKey) { return new CustomizationRefData { - IdentificationKey = packet.TargetKey, + IdentificationKey = targetKey, }; } + private static IEnumerable GetSnapshotRelationships(CustomizationStateData state) => + state.Mounts.Cast() + .Concat(state.Wires) + .Concat(state.LoadedSpools) + .Concat(state.SnappedItems); + private static bool TryItemId(GadgetBase gadget, out ushort id) { id = 0; @@ -1529,31 +1563,9 @@ private static bool TryItemId(GadgetBase gadget, out ushort id) private static bool IsValidIndex(int index, int count) => index >= 0 && index < count; - private static void AddRelationship( - CustomizationStateData state, - CustomizationAction action, - ushort firstItem, - ushort secondItem, - int firstIndex = 0, - int secondIndex = 0, - bool hasPosition = false, - Vector3 position = default) - { - state.Relationships.Add(new CommonCustomizationPacket - { - Action = action, - ItemNetId = firstItem, - OtherItemNetId = secondItem, - IndexA = firstIndex, - IndexB = secondIndex, - Flag = hasPosition, - Position = position, - }); - } - private static void LogSnapshot(string operation, CustomizationStateData state) { Multiplayer.LogDebug(() => $"Customization snapshot {operation} targets={state.CustomizationKeys.Count} " + - $"gadgets={state.Gadgets.Count} relationships={state.Relationships.Count} holes={state.Holes.Count}"); + $"gadgets={state.Gadgets.Count} relationships={GetSnapshotRelationships(state).Count()} holes={state.Holes.Count}"); } } diff --git a/Multiplayer/Components/Networking/World/NetworkedItem.cs b/Multiplayer/Components/Networking/World/NetworkedItem.cs index 92aa6ff8..d31729e0 100644 --- a/Multiplayer/Components/Networking/World/NetworkedItem.cs +++ b/Multiplayer/Components/Networking/World/NetworkedItem.cs @@ -24,7 +24,8 @@ public enum ItemState : byte InHand, //held by player InInventory, //in player's inventory Attached, //attached to another object (e.g. EOT Lanterns) - InstalledGadget //backing item is hidden while its Gadget is linked to a customization target + InstalledGadget, //backing item is hidden while its Gadget is linked to a customization target + InLostAndFound } public class NetworkedItem : IdMonoBehaviour @@ -101,6 +102,7 @@ public static bool TryGetNetId(ItemBase item, out ushort netID) private ItemState observedState; private bool hasObservedState; private bool stateDirty; + private bool forceStateSnapshot; private bool ownerDirty; private bool wasThrown; private bool suppressDestroySync; @@ -141,6 +143,8 @@ protected void Start() // Mark registration as complete for items that don't need tracked values if (!registrationComplete && !UsefulItem) FinaliseTrackedValues(); + + NetworkedItemManager.Instance.CacheLateLocalWorldDuplicate(this); } public T GetTrackedItem() where T : Component @@ -195,6 +199,7 @@ private bool Register() Item.Grabbed += OnGrabbed; Item.Ungrabbed += OnUngrabbed; + Item.ItemInventoryStateChanged += OnItemInventoryStateChanged; //Find special interaction components TryGetComponent(out grabHandler); @@ -203,6 +208,7 @@ private bool Register() lastState = GetItemState(); stateDirty = false; + forceStateSnapshot = false; initialised = true; return true; @@ -226,6 +232,22 @@ private void OnGrabbed(ControlImplBase obj) stateDirty = true; } + private void OnItemInventoryStateChanged(ItemBase item, InventoryActionType actionType, + InventoryItemState itemState) + { + const InventoryActionType stateChangingActions = InventoryActionType.Add | + InventoryActionType.Drop | InventoryActionType.Purge | InventoryActionType.Equip | + InventoryActionType.Unequip; + if ((actionType & stateChangingActions) != 0) + stateDirty = true; + } + + internal void MarkItemStateDirty() + { + stateDirty = true; + forceStateSnapshot = true; + } + public void OnThrow(Vector3 direction) { //block a received throw from @@ -336,6 +358,7 @@ public ItemUpdateData GetSnapshot() lastState is ItemState.InHand or ItemState.InInventory) { stateDirty = false; + forceStateSnapshot = false; ownerDirty = false; MarkValuesClean(); return null; @@ -358,7 +381,7 @@ public ItemUpdateData GetSnapshot() if (!createdDirty) { - if (lastState != currentState) + if (lastState != currentState || forceStateSnapshot) updateType |= ItemUpdateData.ItemUpdateType.ItemState; if (hasDirtyVals) @@ -385,6 +408,7 @@ public ItemUpdateData GetSnapshot() createdDirty = false; stateDirty = false; + forceStateSnapshot = false; ownerDirty = false; wasThrown = false; @@ -441,6 +465,10 @@ private void ApplySnapshot(ItemUpdateData snapshot) gameObject.SetActive(false); break; + case ItemState.InLostAndFound: + HandleLostAndFoundState(snapshot); + break; + default: throw new Exception($"NetworkedItem.ApplySnapshot() Item state not implemented: {snapshot?.ItemState}"); @@ -481,6 +509,7 @@ private void ApplySnapshot(ItemUpdateData snapshot) //mark values as clean createdDirty = false; stateDirty = false; + forceStateSnapshot = false; ownerDirty = false; MarkValuesClean(); @@ -540,6 +569,7 @@ public ItemUpdateData CreateUpdateData(ItemUpdateData.ItemUpdateType updateType) BelongsToPlayer = Item.InventorySpecs.BelongsToPlayer, PrefabName = Item.InventorySpecs.ItemPrefabName, ItemState = lastState, + Active = gameObject.activeSelf, ItemPosition = position, ItemRotation = rotation, ThrowDirection = throwDirection, @@ -571,6 +601,7 @@ public void MarkAsSynchronized() { createdDirty = false; stateDirty = false; + forceStateSnapshot = false; ownerDirty = false; lastState = GetItemState(); MarkValuesClean(); @@ -587,6 +618,10 @@ private ItemState GetItemState() if (trackedItem is GadgetItem gadgetItem && gadgetItem.Gadget != null && gadgetItem.Gadget.IsLinked) return ObserveState(ItemState.InstalledGadget); + var storage = StorageController.Instance; + if (storage?.StorageLostAndFound != null && storage.StorageLostAndFound.ContainsItem(Item)) + return ObserveState(ItemState.InLostAndFound); + // Remote hand/inventory proxies are inactive and reparented outside the // host's Inventory. Inspecting that local representation makes them look // dropped and can echo a false drop back with an unrelated object-state @@ -701,7 +736,7 @@ private void HandleDroppedOrThrownState(ItemUpdateData snapshot) // list. ItemMagazine.AddItem expects a dropped item to belong to world // storage and otherwise can pass a null StorageBase into the base game. var storage = StorageController.Instance; - if (storage != null && Item.BelongsToPlayer() && !storage.AnyStorageContains(Item)) + if (storage != null && Item.BelongsToPlayer() && !storage.StorageWorld.ContainsItem(Item)) storage.AddItemToWorldStorage(Item); // Installed gadgets can retain stale rigidbody velocity while their backing @@ -779,9 +814,38 @@ private void HandleInventoryOrHandState(ItemUpdateData snapshot) } } + var storage = StorageController.Instance; + if (storage != null && storage.AnyStorageContains(Item)) + storage.RemoveItemFromStorageItemList(Item); + //todo add to player model's hand this.gameObject.SetActive(false); } + + private void HandleLostAndFoundState(ItemUpdateData snapshot) + { + if (Item.IsSnapped) + Item.SnappableItem.SnappedTo.UnsnapItem(false); + + if (WorldMover.OriginShiftParent != null && transform.parent != WorldMover.OriginShiftParent) + { + if (itemReparenting != null) + itemReparenting.ParentItemExternal(WorldMover.OriginShiftParent, null); + else + { + TrainPhysicsLod.RemoveItemFromAnyCar(Item); + transform.SetParent(WorldMover.OriginShiftParent, true); + } + } + + var storage = StorageController.Instance; + if (storage != null && !storage.StorageLostAndFound.ContainsItem(Item)) + storage.AddItemToLostAndFound(Item, false); + + transform.position = snapshot.ItemPosition + WorldMover.currentMove; + transform.rotation = snapshot.ItemRotation; + gameObject.SetActive(snapshot.Active); + } #endregion protected override void OnDestroy() @@ -796,6 +860,7 @@ protected override void OnDestroy() { Item.Grabbed -= OnGrabbed; Item.Ungrabbed -= OnUngrabbed; + Item.ItemInventoryStateChanged -= OnItemInventoryStateChanged; itemBaseToNetworkedItem.Remove(Item); } else if (!UnloadWatcher.isUnloading) diff --git a/Multiplayer/Components/Networking/World/NetworkedItemManager.cs b/Multiplayer/Components/Networking/World/NetworkedItemManager.cs index 237b632a..a7583ce6 100644 --- a/Multiplayer/Components/Networking/World/NetworkedItemManager.cs +++ b/Multiplayer/Components/Networking/World/NetworkedItemManager.cs @@ -460,7 +460,11 @@ private void CreateItem(ItemUpdateData snapshot) return; } - NetworkedItem newItem = GetFromCache(snapshot.PrefabName); + // Streamed locations instantiate their scene-authored items on every peer. + // Those items can appear after the one-time CacheWorldItems pass. Reuse the + // matching unassigned client item instead of adding a network-created copy + // on top of it. + NetworkedItem newItem = FindMatchingLocalWorldItem(snapshot) ?? GetFromCache(snapshot.PrefabName); if(newItem == null) { @@ -496,6 +500,74 @@ private void CreateItem(ItemUpdateData snapshot) newItem.ReceiveSnapshot(snapshot); } + private static NetworkedItem FindMatchingLocalWorldItem(ItemUpdateData snapshot) + { + const float maxMatchDistance = 1f; + float maxMatchDistanceSqr = maxMatchDistance * maxMatchDistance; + Vector3 authoritativePosition = snapshot.ItemPosition + WorldMover.currentMove; + NetworkedItem closest = null; + float closestDistanceSqr = maxMatchDistanceSqr; + + foreach (var candidate in NetworkedItem.GetAll()) + { + if (candidate?.Item?.InventorySpecs == null || candidate.NetId != 0 || + IsLocalShopItem(candidate) || candidate.Item.IsEssential() || candidate.Item.IsGrabbed() || + !string.Equals(candidate.Item.InventorySpecs.ItemPrefabName, snapshot.PrefabName, + StringComparison.Ordinal)) + continue; + + var inventory = Inventory.Instance; + var storage = StorageController.Instance; + if (inventory != null && inventory.Contains(candidate.gameObject, true) || + storage?.StorageInventory != null && storage.StorageInventory.ContainsItem(candidate.Item)) + continue; + + float distanceSqr = (candidate.transform.position - authoritativePosition).sqrMagnitude; + if (distanceSqr > closestDistanceSqr) + continue; + + closest = candidate; + closestDistanceSqr = distanceSqr; + } + + if (closest != null) + Multiplayer.LogDebug(() => + $"Adopting local world item {closest.name} for authoritative NetId {snapshot.ItemNetId}"); + + return closest; + } + + internal void CacheLateLocalWorldDuplicate(NetworkedItem localItem) + { + const float duplicatePositionTolerance = 0.05f; + + if (!ClientInitialised || NetworkLifecycle.Instance.IsHost() || + localItem?.Item?.InventorySpecs == null || localItem.NetId != 0 || + IsLocalShopItem(localItem) || localItem.Item.IsEssential() || localItem.Item.IsGrabbed()) + return; + + var inventory = Inventory.Instance; + var storage = StorageController.Instance; + if (inventory != null && inventory.Contains(localItem.gameObject, true) || + storage?.StorageInventory != null && storage.StorageInventory.ContainsItem(localItem.Item)) + return; + + string prefabName = localItem.Item.InventorySpecs.ItemPrefabName; + float toleranceSqr = duplicatePositionTolerance * duplicatePositionTolerance; + bool hasAuthoritativeCounterpart = NetworkedItem.GetAll().Any(candidate => + candidate != null && candidate != localItem && candidate.NetId != 0 && + candidate.Item?.InventorySpecs != null && + string.Equals(candidate.Item.InventorySpecs.ItemPrefabName, prefabName, StringComparison.Ordinal) && + (candidate.transform.position - localItem.transform.position).sqrMagnitude <= toleranceSqr); + + if (!hasAuthoritativeCounterpart) + return; + + Multiplayer.LogDebug(() => + $"Caching late local duplicate {localItem.name}; authoritative counterpart already exists"); + SendToCache(localItem); + } + private static IEnumerator ApplyInstalledGadgetCreateAfterStart(NetworkedItem item, ItemUpdateData snapshot) { const int maxInitializationFrames = 120; diff --git a/Multiplayer/Networking/Data/Customization/CustomizationStateData.cs b/Multiplayer/Networking/Data/Customization/CustomizationStateData.cs index a898e01f..37de0dac 100644 --- a/Multiplayer/Networking/Data/Customization/CustomizationStateData.cs +++ b/Multiplayer/Networking/Data/Customization/CustomizationStateData.cs @@ -2,7 +2,7 @@ using Multiplayer.Networking.Serialization; using System.Collections.Generic; using UnityEngine; -using Multiplayer.Networking.Packets.Common; +using Multiplayer.Networking.Packets.Common.Customization; namespace Multiplayer.Networking.Data.Customization; @@ -92,14 +92,18 @@ public sealed class CustomizationStateData public List ProcessedOriginActionIds = new(); public List CustomizationKeys = new(); public List Gadgets = new(); - public List Relationships = new(); + internal List Mounts = new(); + internal List Wires = new(); + internal List LoadedSpools = new(); + internal List SnappedItems = new(); public List Holes = new(); public void Serialize(NetDataWriter writer) { ValidateCount(CustomizationKeys.Count, MaxTargets, nameof(CustomizationKeys)); ValidateCount(Gadgets.Count, MaxGadgets, nameof(Gadgets)); - ValidateCount(Relationships.Count, MaxRelationships, nameof(Relationships)); + ValidateCount(Mounts.Count + Wires.Count + LoadedSpools.Count + SnappedItems.Count, + MaxRelationships, "relationships"); ValidateCount(Holes.Count, MaxHoles, nameof(Holes)); ValidateCount(ProcessedOriginActionIds.Count, MaxProcessedActions, nameof(ProcessedOriginActionIds)); @@ -115,9 +119,10 @@ public void Serialize(NetDataWriter writer) foreach (var gadget in Gadgets) gadget.Serialize(writer); - writer.Put(Relationships.Count); - foreach (var relationship in Relationships) - relationship.Serialize(writer); + WritePackets(writer, Mounts); + WritePackets(writer, Wires); + WritePackets(writer, LoadedSpools); + WritePackets(writer, SnappedItems); writer.Put(Holes.Count); foreach (var hole in Holes) @@ -140,13 +145,12 @@ public static CustomizationStateData Deserialize(NetDataReader reader) for (int i = 0; i < gadgetCount; i++) result.Gadgets.Add(GadgetPlacementData.Deserialize(reader)); - int relationshipCount = ReadCount(reader, MaxRelationships, nameof(Relationships)); - for (int i = 0; i < relationshipCount; i++) - { - var relationship = new CommonCustomizationPacket(); - relationship.Deserialize(reader); - result.Relationships.Add(relationship); - } + ReadPackets(reader, result.Mounts); + ReadPackets(reader, result.Wires); + ReadPackets(reader, result.LoadedSpools); + ReadPackets(reader, result.SnappedItems); + ValidateCount(result.Mounts.Count + result.Wires.Count + result.LoadedSpools.Count + + result.SnappedItems.Count, MaxRelationships, "relationships"); int holeCount = ReadCount(reader, MaxHoles, nameof(Holes)); for (int i = 0; i < holeCount; i++) @@ -162,6 +166,26 @@ private static int ReadCount(NetDataReader reader, int maximum, string field) return count; } + private static void WritePackets(NetDataWriter writer, List packets) + where T : INetSerializable + { + writer.Put(packets.Count); + foreach (var packet in packets) + packet.Serialize(writer); + } + + private static void ReadPackets(NetDataReader reader, List packets) + where T : INetSerializable, new() + { + int count = ReadCount(reader, MaxRelationships, typeof(T).Name); + for (int i = 0; i < count; i++) + { + var packet = new T(); + packet.Deserialize(reader); + packets.Add(packet); + } + } + private static void ValidateCount(int count, int maximum, string field) { if (count < 0 || count > maximum) diff --git a/Multiplayer/Networking/Data/Items/ItemUpdateData.cs b/Multiplayer/Networking/Data/Items/ItemUpdateData.cs index f60133e2..f90516de 100644 --- a/Multiplayer/Networking/Data/Items/ItemUpdateData.cs +++ b/Multiplayer/Networking/Data/Items/ItemUpdateData.cs @@ -31,6 +31,7 @@ public enum ItemUpdateType : byte public bool BelongsToPlayer { get; set; } public string PrefabName { get; set; } public ItemState ItemState { get; set; } + public bool Active { get; set; } public Vector3 ItemPosition { get; set; } public Quaternion ItemRotation { get; set; } public Vector3 ThrowDirection { get; set; } @@ -86,6 +87,12 @@ public void Serialize(NetDataWriter writer) writer.Put(CarNetId); writer.Put(AttachedFront); } + else if (ItemState == ItemState.InLostAndFound) + { + writer.Put(Active); + Vector3Serializer.Serialize(writer, ItemPosition); + QuaternionSerializer.Serialize(writer, ItemRotation); + } } if (UpdateType.HasFlag(ItemUpdateType.Create) || UpdateType.HasFlag(ItemUpdateType.ObjectState)) @@ -151,6 +158,12 @@ public void Deserialize(NetDataReader reader) CarNetId = reader.GetUShort(); AttachedFront = reader.GetBool(); } + else if (ItemState == ItemState.InLostAndFound) + { + Active = reader.GetBool(); + ItemPosition = Vector3Serializer.Deserialize(reader); + ItemRotation = QuaternionSerializer.Deserialize(reader); + } } if (UpdateType.HasFlag(ItemUpdateType.Create) || UpdateType.HasFlag(ItemUpdateType.ObjectState)) diff --git a/Multiplayer/Networking/Managers/Client/NetworkClient.cs b/Multiplayer/Networking/Managers/Client/NetworkClient.cs index b9d6d654..6f1e8d42 100644 --- a/Multiplayer/Networking/Managers/Client/NetworkClient.cs +++ b/Multiplayer/Networking/Managers/Client/NetworkClient.cs @@ -36,6 +36,7 @@ using Multiplayer.Networking.Packets.Clientbound.Train; using Multiplayer.Networking.Packets.Clientbound.World; using Multiplayer.Networking.Packets.Common; +using Multiplayer.Networking.Packets.Common.Customization; using Multiplayer.Networking.Packets.Common.Train; using Multiplayer.Networking.Packets.Serverbound; using Multiplayer.Networking.Packets.Serverbound.Jobs; @@ -85,10 +86,10 @@ public class NetworkClient : NetworkManager private bool customizationSnapshotTimedOut; private CustomizationStateData pendingCustomizationState; private readonly List pendingCustomizationSnapshotPlacements = []; - private readonly List pendingCustomizationSnapshotRelationships = []; + private readonly List pendingCustomizationSnapshotRelationships = []; private readonly Dictionary> pendingCustomizationSnapshotHoleStates = []; - private readonly List pendingCustomizationActions = []; - private readonly List appliedActionsSinceSnapshotTimeout = []; + private readonly List pendingCustomizationActions = []; + private readonly List appliedActionsSinceSnapshotTimeout = []; private bool itemReconciliationComplete; private uint nextCustomizationActionId = 1; private uint nextShopRequestId = 1; @@ -273,7 +274,7 @@ protected override void Subscribe() netPacketProcessor.SubscribeNetSerializable(OnCommonItemChangePacket); netPacketProcessor.SubscribeNetSerializable(OnClientboundItemReconciliationPacket); netPacketProcessor.SubscribeNetSerializable(OnClientboundCustomizationStatePacket); - netPacketProcessor.SubscribeNetSerializable(OnCommonCustomizationPacket); + SubscribeCustomizationActions(); netPacketProcessor.SubscribeReusable(OnCommonPitStopInteractionPacket); netPacketProcessor.SubscribeNetSerializable(OnCommonPitStopPlugInteractionPacket); netPacketProcessor.SubscribeReusable(OnClientboundPitStopBulkUpdatePacket); @@ -1444,12 +1445,35 @@ private void OnClientboundCustomizationStatePacket(ClientboundCustomizationState CoroutineManager.Instance.StartCoroutine(ApplyPendingCustomizationStates()); } - private void OnCommonCustomizationPacket(CommonCustomizationPacket packet) + private void SubscribeCustomizationActions() + { + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + } + + private void OnCustomizationPacket(T packet) + where T : CustomizationActionPacket, INetSerializable, new() { if (packet == null) return; - pendingCustomizationActions.Add(packet); + // LiteNetLib reuses the INetSerializable instance registered for this + // subscription. Keep a value copy because the action can remain queued + // while later packets are deserialized into that same instance. + pendingCustomizationActions.Add(packet.Copy()); StartCustomizationActionDrain(); } @@ -1566,7 +1590,7 @@ private IEnumerator DrainPendingCustomizationActions() } catch (Exception exception) { - LogError($"Failed to apply deferred snapshot relationship {action.Action}: {exception}"); + LogError($"Failed to apply deferred snapshot relationship {action.GetType().Name}: {exception}"); } appliedAny = true; } @@ -1594,7 +1618,7 @@ private IEnumerator DrainPendingCustomizationActions() } catch (Exception exception) { - LogError($"Failed to apply deferred customization action {action.Action}: {exception}"); + LogError($"Failed to apply deferred customization action {action.GetType().Name}: {exception}"); } appliedAny = true; } @@ -2192,7 +2216,8 @@ public void SendItemsChangePacket(List items) DeliveryMethod.ReliableOrdered); } - public void SendCustomizationAction(CommonCustomizationPacket packet) + internal void SendCustomizationAction(T packet) + where T : CustomizationActionPacket, INetSerializable, new() { packet.OriginActionId = nextCustomizationActionId++; if (nextCustomizationActionId == 0) @@ -2202,9 +2227,8 @@ public void SendCustomizationAction(CommonCustomizationPacket packet) // originated mutations so a snapshot that arrives after the loading // timeout cannot erase them. Replacement actions are server-echoed after // their authoritative replacement item id has been assigned. - if (customizationSnapshotTimedOut && packet.Action is not - (CustomizationAction.ReplaceSpool or CustomizationAction.ReplaceDuctTape)) - appliedActionsSinceSnapshotTimeout.Add(packet); + if (customizationSnapshotTimedOut && packet is not ReplaceSpoolPacket and not ReplaceDuctTapePacket) + appliedActionsSinceSnapshotTimeout.Add(packet.Copy()); SendNetSerializablePacketToServer(packet, DeliveryMethod.ReliableOrdered); } diff --git a/Multiplayer/Networking/Managers/Server/NetworkServer.cs b/Multiplayer/Networking/Managers/Server/NetworkServer.cs index 59bdf8f0..20f7ced5 100644 --- a/Multiplayer/Networking/Managers/Server/NetworkServer.cs +++ b/Multiplayer/Networking/Managers/Server/NetworkServer.cs @@ -32,6 +32,7 @@ using Multiplayer.Networking.Packets.Clientbound.Train; using Multiplayer.Networking.Packets.Clientbound.World; using Multiplayer.Networking.Packets.Common; +using Multiplayer.Networking.Packets.Common.Customization; using Multiplayer.Networking.Packets.Common.Train; using Multiplayer.Networking.Packets.Serverbound; using Multiplayer.Networking.Packets.Serverbound.Jobs; @@ -240,7 +241,7 @@ protected override void Subscribe() // Items netPacketProcessor.SubscribeNetSerializable(OnCommonItemChangePacket); netPacketProcessor.SubscribeNetSerializable(OnServerboundItemReconciliationPacket); - netPacketProcessor.SubscribeNetSerializable(OnCommonCustomizationPacket); + SubscribeCustomizationActions(); } //allow mods to register their own packets @@ -2194,15 +2195,41 @@ private void OnServerboundItemReconciliationPacket(ServerboundItemReconciliation SendNetSerializablePacket(peer, response, DeliveryMethod.ReliableOrdered); } - public void SendCustomizationAction(CommonCustomizationPacket packet) => + internal void SendCustomizationAction(T packet) + where T : CustomizationActionPacket, INetSerializable, new() => RelayCustomizationAction(packet); - private void OnCommonCustomizationPacket(CommonCustomizationPacket packet, ITransportPeer peer) + private void SubscribeCustomizationActions() + { + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); + } + + private void OnCustomizationPacket(T packet, ITransportPeer peer) + where T : CustomizationActionPacket, INetSerializable, new() { if (packet == null || !TryGetServerPlayer(peer, out var player)) return; - pendingHostCustomizationActions.Add(new PendingHostCustomizationAction(packet, player, peer)); + // LiteNetLib reuses the INetSerializable instance registered for this + // subscription. Keep a value copy because the action can remain queued + // while later packets are deserialized into that same instance. + var copy = (T)packet.Copy(); + pendingHostCustomizationActions.Add(new PendingHostCustomizationAction(copy, player, peer, + (action, origin, echoOrigin) => RelayCustomizationAction((T)action, origin, echoOrigin))); StartHostCustomizationDrain(); } @@ -2246,7 +2273,7 @@ private IEnumerator DrainPendingHostCustomizationActions() } catch (Exception exception) { - LogError($"Failed to apply customization action {pending.Packet.Action} from " + + LogError($"Failed to apply customization action {pending.Packet.GetType().Name} from " + $"{pending.Player.Username}: {exception}"); } @@ -2255,9 +2282,8 @@ private IEnumerator DrainPendingHostCustomizationActions() if (applied) { - bool echoSender = pending.Packet.Action is CustomizationAction.ReplaceSpool or - CustomizationAction.ReplaceDuctTape; - RelayCustomizationAction(pending.Packet, pending.Peer, echoSender); + bool echoSender = pending.Packet is ReplaceSpoolPacket or ReplaceDuctTapePacket; + pending.Relay(pending.Packet, pending.Peer, echoSender); } appliedAny = true; } @@ -2274,21 +2300,24 @@ private IEnumerator DrainPendingHostCustomizationActions() private sealed class PendingHostCustomizationAction { - public readonly CommonCustomizationPacket Packet; + public readonly ICustomizationActionPacket Packet; public readonly ServerPlayer Player; public readonly ITransportPeer Peer; - public PendingHostCustomizationAction(CommonCustomizationPacket packet, ServerPlayer player, - ITransportPeer peer) + public readonly Action Relay; + + public PendingHostCustomizationAction(ICustomizationActionPacket packet, ServerPlayer player, + ITransportPeer peer, Action relay) { Packet = packet; Player = player; Peer = peer; + Relay = relay; } } - private void RelayCustomizationAction(CommonCustomizationPacket packet, ITransportPeer origin = null, - bool echoOrigin = false) + private void RelayCustomizationAction(T packet, ITransportPeer origin = null, bool echoOrigin = false) + where T : CustomizationActionPacket, INetSerializable, new() { foreach (var player in CustomizationStateManager.GetCustomizationRecipients(packet).ToArray()) { @@ -2383,26 +2412,34 @@ private IEnumerator SendInitialCustomizationStateWhenReady(ServerPlayer player, } } - private void EnsureCustomizationItemsKnown(CommonCustomizationPacket packet, ServerPlayer player) + private void EnsureCustomizationItemsKnown(ICustomizationActionPacket packet, ServerPlayer player) { // Replacement actions construct (or reuse) their replacement object locally // and assign OtherItemNetId while applying the action. Sending a separate // Create for that ID first represents the contained reel/tape as Dropped and // can pull it back out of its tool on the receiving peer. - bool createsReplacementLocally = packet.Action is - CustomizationAction.ReplaceSpool or CustomizationAction.ReplaceDuctTape; + bool createsReplacementLocally = packet is ReplaceSpoolPacket or ReplaceDuctTapePacket; IEnumerable itemIds = createsReplacementLocally - ? [packet.ItemNetId] + ? packet is ReplaceSpoolPacket replacementSpool ? [replacementSpool.ToolItemNetId] + : [((ReplaceDuctTapePacket)packet).ConsumedItemNetId] : CustomizationStateManager.GetActionItemIds(packet); - HashSet forceDropped = packet.Action is CustomizationAction.LoadSpool or CustomizationAction.SnapItem - ? [packet.OtherItemNetId] - : []; + HashSet forceDropped = packet switch + { + LoadSpoolPacket load => [load.SpoolItemNetId], + SnapItemPacket snap => [snap.SnappedItemNetId], + _ => [], + }; NetworkedItemManager.Instance.SendInitialItems(player, itemIds, forceDropped); // The action itself is the replacement's create message. Record that fact so // ordinary item discovery does not send a conflicting Create(Dropped) later. - if (createsReplacementLocally && - NetworkedItem.TryGet(packet.OtherItemNetId, out NetworkedItem replacementItem)) + ushort replacementId = packet switch + { + ReplaceSpoolPacket resolvedSpool => resolvedSpool.ReplacementSpoolItemNetId, + ReplaceDuctTapePacket tape => tape.ReplacementItemNetId, + _ => 0, + }; + if (createsReplacementLocally && NetworkedItem.TryGet(replacementId, out NetworkedItem replacementItem)) player.KnownItems[replacementItem] = NetworkLifecycle.Instance.Tick; } diff --git a/Multiplayer/Networking/Packets/Common/CommonCustomizationPacket.cs b/Multiplayer/Networking/Packets/Common/CommonCustomizationPacket.cs deleted file mode 100644 index 80884fe0..00000000 --- a/Multiplayer/Networking/Packets/Common/CommonCustomizationPacket.cs +++ /dev/null @@ -1,131 +0,0 @@ -using LiteNetLib.Utils; -using Multiplayer.Networking.Serialization; -using UnityEngine; - -namespace Multiplayer.Networking.Packets.Common; - -public enum CustomizationAction : byte { PlaceGadget, RemoveGadget, AddHole, MoveHole, RemoveHole, Mount, Unmount, Wire, Unwire, DropEmptySpool, LoadSpool, SnapItem, UnsnapItem, ReplaceSpool, ReplaceDuctTape } - -public sealed class CommonCustomizationPacket : INetSerializable -{ - public uint OriginActionId; - public CustomizationAction Action; - public ushort ItemNetId; - public ushort OtherItemNetId; - public int IndexA; - public int IndexB; - public string TargetKey; - public Vector3 Position; - public Vector3 PreviousPosition; - public Vector3 Normal; - public Quaternion Rotation; - public bool Flag; - public byte OwnerPlayerId; - - public void Serialize(NetDataWriter writer) - { - writer.Put(OriginActionId); - writer.Put((byte)Action); - switch (Action) - { - case CustomizationAction.PlaceGadget: - writer.Put(ItemNetId); PutTarget(writer); Vector3Serializer.Serialize(writer, Position); - QuaternionSerializer.Serialize(writer, Rotation); break; - case CustomizationAction.RemoveGadget: - writer.Put(ItemNetId); writer.Put(Flag); break; - case CustomizationAction.AddHole: - PutTarget(writer); Vector3Serializer.Serialize(writer, Position); Vector3Serializer.Serialize(writer, Normal); break; - case CustomizationAction.MoveHole: - PutTarget(writer); Vector3Serializer.Serialize(writer, Position); Vector3Serializer.Serialize(writer, PreviousPosition); - Vector3Serializer.Serialize(writer, Normal); break; - case CustomizationAction.RemoveHole: - PutTarget(writer); Vector3Serializer.Serialize(writer, PreviousPosition); break; - case CustomizationAction.Mount: - case CustomizationAction.SnapItem: - PutRelationship(writer, includeSecondIndex: false); writer.Put(Flag); - if (Flag) Vector3Serializer.Serialize(writer, Position); break; - case CustomizationAction.Unmount: - writer.Put(ItemNetId); writer.Put(IndexA); break; - case CustomizationAction.Wire: - case CustomizationAction.Unwire: - PutRelationship(writer, includeSecondIndex: true); break; - case CustomizationAction.UnsnapItem: - PutRelationship(writer, includeSecondIndex: false); break; - case CustomizationAction.DropEmptySpool: - writer.Put(ItemNetId); writer.Put(OtherItemNetId); writer.Put(Flag); - if (Flag) { Vector3Serializer.Serialize(writer, Position); QuaternionSerializer.Serialize(writer, Rotation); } - break; - case CustomizationAction.ReplaceDuctTape: - writer.Put(ItemNetId); writer.Put(OtherItemNetId); writer.Put(OwnerPlayerId); writer.Put(Flag); - if (Flag) { Vector3Serializer.Serialize(writer, Position); QuaternionSerializer.Serialize(writer, Rotation); } - break; - case CustomizationAction.LoadSpool: - writer.Put(ItemNetId); writer.Put(OtherItemNetId); break; - case CustomizationAction.ReplaceSpool: - writer.Put(ItemNetId); writer.Put(OtherItemNetId); writer.Put(OwnerPlayerId); break; - default: - throw new System.InvalidOperationException($"Unsupported customization action: {Action}"); - } - } - - public void Deserialize(NetDataReader reader) - { - OriginActionId = reader.GetUInt(); - Action = (CustomizationAction)reader.GetByte(); - switch (Action) - { - case CustomizationAction.PlaceGadget: - ItemNetId = reader.GetUShort(); GetTarget(reader); Position = Vector3Serializer.Deserialize(reader); - Rotation = QuaternionSerializer.Deserialize(reader); break; - case CustomizationAction.RemoveGadget: - ItemNetId = reader.GetUShort(); Flag = reader.GetBool(); break; - case CustomizationAction.AddHole: - GetTarget(reader); Position = Vector3Serializer.Deserialize(reader); Normal = Vector3Serializer.Deserialize(reader); break; - case CustomizationAction.MoveHole: - GetTarget(reader); Position = Vector3Serializer.Deserialize(reader); PreviousPosition = Vector3Serializer.Deserialize(reader); - Normal = Vector3Serializer.Deserialize(reader); break; - case CustomizationAction.RemoveHole: - GetTarget(reader); PreviousPosition = Vector3Serializer.Deserialize(reader); break; - case CustomizationAction.Mount: - case CustomizationAction.SnapItem: - GetRelationship(reader, includeSecondIndex: false); Flag = reader.GetBool(); - if (Flag) Position = Vector3Serializer.Deserialize(reader); break; - case CustomizationAction.Unmount: - ItemNetId = reader.GetUShort(); IndexA = reader.GetInt(); break; - case CustomizationAction.Wire: - case CustomizationAction.Unwire: - GetRelationship(reader, includeSecondIndex: true); break; - case CustomizationAction.UnsnapItem: - GetRelationship(reader, includeSecondIndex: false); break; - case CustomizationAction.DropEmptySpool: - ItemNetId = reader.GetUShort(); OtherItemNetId = reader.GetUShort(); Flag = reader.GetBool(); - if (Flag) { Position = Vector3Serializer.Deserialize(reader); Rotation = QuaternionSerializer.Deserialize(reader); } - break; - case CustomizationAction.ReplaceDuctTape: - ItemNetId = reader.GetUShort(); OtherItemNetId = reader.GetUShort(); OwnerPlayerId = reader.GetByte(); Flag = reader.GetBool(); - if (Flag) { Position = Vector3Serializer.Deserialize(reader); Rotation = QuaternionSerializer.Deserialize(reader); } - break; - case CustomizationAction.LoadSpool: - ItemNetId = reader.GetUShort(); OtherItemNetId = reader.GetUShort(); break; - case CustomizationAction.ReplaceSpool: - ItemNetId = reader.GetUShort(); OtherItemNetId = reader.GetUShort(); OwnerPlayerId = reader.GetByte(); break; - default: - throw new System.InvalidOperationException($"Unsupported customization action: {Action}"); - } - } - - private void PutTarget(NetDataWriter writer) => writer.Put(TargetKey ?? string.Empty); - private void GetTarget(NetDataReader reader) => TargetKey = reader.GetString(); - - private void PutRelationship(NetDataWriter writer, bool includeSecondIndex) - { - writer.Put(ItemNetId); writer.Put(OtherItemNetId); writer.Put(IndexA); - if (includeSecondIndex) writer.Put(IndexB); - } - - private void GetRelationship(NetDataReader reader, bool includeSecondIndex) - { - ItemNetId = reader.GetUShort(); OtherItemNetId = reader.GetUShort(); IndexA = reader.GetInt(); - if (includeSecondIndex) IndexB = reader.GetInt(); - } -} diff --git a/Multiplayer/Networking/Packets/Common/Customization/AddHolePacket.cs b/Multiplayer/Networking/Packets/Common/Customization/AddHolePacket.cs new file mode 100644 index 00000000..7494a379 --- /dev/null +++ b/Multiplayer/Networking/Packets/Common/Customization/AddHolePacket.cs @@ -0,0 +1,24 @@ +using LiteNetLib.Utils; +using Multiplayer.Networking.Serialization; +using UnityEngine; + +namespace Multiplayer.Networking.Packets.Common.Customization; + +internal sealed class AddHolePacket : CustomizationActionPacket, INetSerializable +{ + public string TargetKey; + public Vector3 Position; + public Vector3 Normal; + + public void Serialize(NetDataWriter writer) + { + writer.Put(OriginActionId); writer.Put(TargetKey ?? string.Empty); + Vector3Serializer.Serialize(writer, Position); Vector3Serializer.Serialize(writer, Normal); + } + + public void Deserialize(NetDataReader reader) + { + OriginActionId = reader.GetUInt(); TargetKey = reader.GetString(); + Position = Vector3Serializer.Deserialize(reader); Normal = Vector3Serializer.Deserialize(reader); + } +} diff --git a/Multiplayer/Networking/Packets/Common/Customization/DropEmptySpoolPacket.cs b/Multiplayer/Networking/Packets/Common/Customization/DropEmptySpoolPacket.cs new file mode 100644 index 00000000..e57262d5 --- /dev/null +++ b/Multiplayer/Networking/Packets/Common/Customization/DropEmptySpoolPacket.cs @@ -0,0 +1,27 @@ +using LiteNetLib.Utils; +using Multiplayer.Networking.Serialization; +using UnityEngine; + +namespace Multiplayer.Networking.Packets.Common.Customization; + +internal sealed class DropEmptySpoolPacket : CustomizationActionPacket, INetSerializable +{ + public ushort ToolItemNetId; + public ushort SpoolItemNetId; + public bool HasWorldTransform; + public Vector3 Position; + public Quaternion Rotation; + + public void Serialize(NetDataWriter writer) + { + writer.Put(OriginActionId); writer.Put(ToolItemNetId); writer.Put(SpoolItemNetId); writer.Put(HasWorldTransform); + if (HasWorldTransform) { Vector3Serializer.Serialize(writer, Position); QuaternionSerializer.Serialize(writer, Rotation); } + } + + public void Deserialize(NetDataReader reader) + { + OriginActionId = reader.GetUInt(); ToolItemNetId = reader.GetUShort(); SpoolItemNetId = reader.GetUShort(); + HasWorldTransform = reader.GetBool(); + if (HasWorldTransform) { Position = Vector3Serializer.Deserialize(reader); Rotation = QuaternionSerializer.Deserialize(reader); } + } +} diff --git a/Multiplayer/Networking/Packets/Common/Customization/ICustomizationActionPacket.cs b/Multiplayer/Networking/Packets/Common/Customization/ICustomizationActionPacket.cs new file mode 100644 index 00000000..393b47bb --- /dev/null +++ b/Multiplayer/Networking/Packets/Common/Customization/ICustomizationActionPacket.cs @@ -0,0 +1,15 @@ +namespace Multiplayer.Networking.Packets.Common.Customization; + +internal interface ICustomizationActionPacket +{ + uint OriginActionId { get; set; } + ICustomizationActionPacket Copy(); +} + +internal abstract class CustomizationActionPacket : ICustomizationActionPacket +{ + public uint OriginActionId { get; set; } + + public ICustomizationActionPacket Copy() => + (ICustomizationActionPacket)MemberwiseClone(); +} diff --git a/Multiplayer/Networking/Packets/Common/Customization/LoadSpoolPacket.cs b/Multiplayer/Networking/Packets/Common/Customization/LoadSpoolPacket.cs new file mode 100644 index 00000000..6fd978d0 --- /dev/null +++ b/Multiplayer/Networking/Packets/Common/Customization/LoadSpoolPacket.cs @@ -0,0 +1,19 @@ +using LiteNetLib.Utils; + +namespace Multiplayer.Networking.Packets.Common.Customization; + +internal sealed class LoadSpoolPacket : CustomizationActionPacket, INetSerializable +{ + public ushort ToolItemNetId; + public ushort SpoolItemNetId; + + public void Serialize(NetDataWriter writer) + { + writer.Put(OriginActionId); writer.Put(ToolItemNetId); writer.Put(SpoolItemNetId); + } + + public void Deserialize(NetDataReader reader) + { + OriginActionId = reader.GetUInt(); ToolItemNetId = reader.GetUShort(); SpoolItemNetId = reader.GetUShort(); + } +} diff --git a/Multiplayer/Networking/Packets/Common/Customization/MountGadgetPacket.cs b/Multiplayer/Networking/Packets/Common/Customization/MountGadgetPacket.cs new file mode 100644 index 00000000..08e45b2e --- /dev/null +++ b/Multiplayer/Networking/Packets/Common/Customization/MountGadgetPacket.cs @@ -0,0 +1,21 @@ +using LiteNetLib.Utils; + +namespace Multiplayer.Networking.Packets.Common.Customization; + +internal sealed class MountGadgetPacket : CustomizationActionPacket, INetSerializable +{ + public ushort MountItemNetId; + public ushort MountedItemNetId; + public int MountIndex; + + public void Serialize(NetDataWriter writer) + { + writer.Put(OriginActionId); writer.Put(MountItemNetId); writer.Put(MountedItemNetId); writer.Put(MountIndex); + } + + public void Deserialize(NetDataReader reader) + { + OriginActionId = reader.GetUInt(); MountItemNetId = reader.GetUShort(); + MountedItemNetId = reader.GetUShort(); MountIndex = reader.GetInt(); + } +} diff --git a/Multiplayer/Networking/Packets/Common/Customization/MoveHolePacket.cs b/Multiplayer/Networking/Packets/Common/Customization/MoveHolePacket.cs new file mode 100644 index 00000000..5921a2ef --- /dev/null +++ b/Multiplayer/Networking/Packets/Common/Customization/MoveHolePacket.cs @@ -0,0 +1,25 @@ +using LiteNetLib.Utils; +using Multiplayer.Networking.Serialization; +using UnityEngine; + +namespace Multiplayer.Networking.Packets.Common.Customization; + +internal sealed class MoveHolePacket : CustomizationActionPacket, INetSerializable +{ + public string TargetKey; + public Vector3 Position; + public Vector3 PreviousPosition; + public Vector3 Normal; + + public void Serialize(NetDataWriter writer) + { + writer.Put(OriginActionId); writer.Put(TargetKey ?? string.Empty); Vector3Serializer.Serialize(writer, Position); + Vector3Serializer.Serialize(writer, PreviousPosition); Vector3Serializer.Serialize(writer, Normal); + } + + public void Deserialize(NetDataReader reader) + { + OriginActionId = reader.GetUInt(); TargetKey = reader.GetString(); Position = Vector3Serializer.Deserialize(reader); + PreviousPosition = Vector3Serializer.Deserialize(reader); Normal = Vector3Serializer.Deserialize(reader); + } +} diff --git a/Multiplayer/Networking/Packets/Common/Customization/PlaceGadgetPacket.cs b/Multiplayer/Networking/Packets/Common/Customization/PlaceGadgetPacket.cs new file mode 100644 index 00000000..e2b283c4 --- /dev/null +++ b/Multiplayer/Networking/Packets/Common/Customization/PlaceGadgetPacket.cs @@ -0,0 +1,25 @@ +using LiteNetLib.Utils; +using Multiplayer.Networking.Serialization; +using UnityEngine; + +namespace Multiplayer.Networking.Packets.Common.Customization; + +internal sealed class PlaceGadgetPacket : CustomizationActionPacket, INetSerializable +{ + public ushort ItemNetId; + public string TargetKey; + public Vector3 Position; + public Quaternion Rotation; + + public void Serialize(NetDataWriter writer) + { + writer.Put(OriginActionId); writer.Put(ItemNetId); writer.Put(TargetKey ?? string.Empty); + Vector3Serializer.Serialize(writer, Position); QuaternionSerializer.Serialize(writer, Rotation); + } + + public void Deserialize(NetDataReader reader) + { + OriginActionId = reader.GetUInt(); ItemNetId = reader.GetUShort(); TargetKey = reader.GetString(); + Position = Vector3Serializer.Deserialize(reader); Rotation = QuaternionSerializer.Deserialize(reader); + } +} diff --git a/Multiplayer/Networking/Packets/Common/Customization/RemoveGadgetPacket.cs b/Multiplayer/Networking/Packets/Common/Customization/RemoveGadgetPacket.cs new file mode 100644 index 00000000..0b36586d --- /dev/null +++ b/Multiplayer/Networking/Packets/Common/Customization/RemoveGadgetPacket.cs @@ -0,0 +1,19 @@ +using LiteNetLib.Utils; + +namespace Multiplayer.Networking.Packets.Common.Customization; + +internal sealed class RemoveGadgetPacket : CustomizationActionPacket, INetSerializable +{ + public ushort ItemNetId; + public bool ReparentToTrainCar; + + public void Serialize(NetDataWriter writer) + { + writer.Put(OriginActionId); writer.Put(ItemNetId); writer.Put(ReparentToTrainCar); + } + + public void Deserialize(NetDataReader reader) + { + OriginActionId = reader.GetUInt(); ItemNetId = reader.GetUShort(); ReparentToTrainCar = reader.GetBool(); + } +} diff --git a/Multiplayer/Networking/Packets/Common/Customization/RemoveHolePacket.cs b/Multiplayer/Networking/Packets/Common/Customization/RemoveHolePacket.cs new file mode 100644 index 00000000..9e43dd3d --- /dev/null +++ b/Multiplayer/Networking/Packets/Common/Customization/RemoveHolePacket.cs @@ -0,0 +1,23 @@ +using LiteNetLib.Utils; +using Multiplayer.Networking.Serialization; +using UnityEngine; + +namespace Multiplayer.Networking.Packets.Common.Customization; + +internal sealed class RemoveHolePacket : CustomizationActionPacket, INetSerializable +{ + public string TargetKey; + public Vector3 PreviousPosition; + + public void Serialize(NetDataWriter writer) + { + writer.Put(OriginActionId); writer.Put(TargetKey ?? string.Empty); + Vector3Serializer.Serialize(writer, PreviousPosition); + } + + public void Deserialize(NetDataReader reader) + { + OriginActionId = reader.GetUInt(); TargetKey = reader.GetString(); + PreviousPosition = Vector3Serializer.Deserialize(reader); + } +} diff --git a/Multiplayer/Networking/Packets/Common/Customization/ReplaceDuctTapePacket.cs b/Multiplayer/Networking/Packets/Common/Customization/ReplaceDuctTapePacket.cs new file mode 100644 index 00000000..633dffb1 --- /dev/null +++ b/Multiplayer/Networking/Packets/Common/Customization/ReplaceDuctTapePacket.cs @@ -0,0 +1,29 @@ +using LiteNetLib.Utils; +using Multiplayer.Networking.Serialization; +using UnityEngine; + +namespace Multiplayer.Networking.Packets.Common.Customization; + +internal sealed class ReplaceDuctTapePacket : CustomizationActionPacket, INetSerializable +{ + public ushort ConsumedItemNetId; + public ushort ReplacementItemNetId; + public byte OwnerPlayerId; + public bool HasWorldTransform; + public Vector3 Position; + public Quaternion Rotation; + + public void Serialize(NetDataWriter writer) + { + writer.Put(OriginActionId); writer.Put(ConsumedItemNetId); writer.Put(ReplacementItemNetId); + writer.Put(OwnerPlayerId); writer.Put(HasWorldTransform); + if (HasWorldTransform) { Vector3Serializer.Serialize(writer, Position); QuaternionSerializer.Serialize(writer, Rotation); } + } + + public void Deserialize(NetDataReader reader) + { + OriginActionId = reader.GetUInt(); ConsumedItemNetId = reader.GetUShort(); ReplacementItemNetId = reader.GetUShort(); + OwnerPlayerId = reader.GetByte(); HasWorldTransform = reader.GetBool(); + if (HasWorldTransform) { Position = Vector3Serializer.Deserialize(reader); Rotation = QuaternionSerializer.Deserialize(reader); } + } +} diff --git a/Multiplayer/Networking/Packets/Common/Customization/ReplaceSpoolPacket.cs b/Multiplayer/Networking/Packets/Common/Customization/ReplaceSpoolPacket.cs new file mode 100644 index 00000000..446ca8ea --- /dev/null +++ b/Multiplayer/Networking/Packets/Common/Customization/ReplaceSpoolPacket.cs @@ -0,0 +1,22 @@ +using LiteNetLib.Utils; + +namespace Multiplayer.Networking.Packets.Common.Customization; + +internal sealed class ReplaceSpoolPacket : CustomizationActionPacket, INetSerializable +{ + public ushort ToolItemNetId; + public ushort ReplacementSpoolItemNetId; + public byte OwnerPlayerId; + + public void Serialize(NetDataWriter writer) + { + writer.Put(OriginActionId); writer.Put(ToolItemNetId); writer.Put(ReplacementSpoolItemNetId); + writer.Put(OwnerPlayerId); + } + + public void Deserialize(NetDataReader reader) + { + OriginActionId = reader.GetUInt(); ToolItemNetId = reader.GetUShort(); + ReplacementSpoolItemNetId = reader.GetUShort(); OwnerPlayerId = reader.GetByte(); + } +} diff --git a/Multiplayer/Networking/Packets/Common/Customization/SnapItemPacket.cs b/Multiplayer/Networking/Packets/Common/Customization/SnapItemPacket.cs new file mode 100644 index 00000000..83021c15 --- /dev/null +++ b/Multiplayer/Networking/Packets/Common/Customization/SnapItemPacket.cs @@ -0,0 +1,28 @@ +using LiteNetLib.Utils; +using Multiplayer.Networking.Serialization; +using UnityEngine; + +namespace Multiplayer.Networking.Packets.Common.Customization; + +internal sealed class SnapItemPacket : CustomizationActionPacket, INetSerializable +{ + public ushort GadgetItemNetId; + public ushort SnappedItemNetId; + public int SnapPointIndex; + public bool HasAnchorPosition; + public Vector3 AnchorPosition; + + public void Serialize(NetDataWriter writer) + { + writer.Put(OriginActionId); writer.Put(GadgetItemNetId); writer.Put(SnappedItemNetId); + writer.Put(SnapPointIndex); writer.Put(HasAnchorPosition); + if (HasAnchorPosition) Vector3Serializer.Serialize(writer, AnchorPosition); + } + + public void Deserialize(NetDataReader reader) + { + OriginActionId = reader.GetUInt(); GadgetItemNetId = reader.GetUShort(); SnappedItemNetId = reader.GetUShort(); + SnapPointIndex = reader.GetInt(); HasAnchorPosition = reader.GetBool(); + if (HasAnchorPosition) AnchorPosition = Vector3Serializer.Deserialize(reader); + } +} diff --git a/Multiplayer/Networking/Packets/Common/Customization/UnmountGadgetPacket.cs b/Multiplayer/Networking/Packets/Common/Customization/UnmountGadgetPacket.cs new file mode 100644 index 00000000..372b1e45 --- /dev/null +++ b/Multiplayer/Networking/Packets/Common/Customization/UnmountGadgetPacket.cs @@ -0,0 +1,19 @@ +using LiteNetLib.Utils; + +namespace Multiplayer.Networking.Packets.Common.Customization; + +internal sealed class UnmountGadgetPacket : CustomizationActionPacket, INetSerializable +{ + public ushort MountItemNetId; + public int MountIndex; + + public void Serialize(NetDataWriter writer) + { + writer.Put(OriginActionId); writer.Put(MountItemNetId); writer.Put(MountIndex); + } + + public void Deserialize(NetDataReader reader) + { + OriginActionId = reader.GetUInt(); MountItemNetId = reader.GetUShort(); MountIndex = reader.GetInt(); + } +} diff --git a/Multiplayer/Networking/Packets/Common/Customization/UnsnapItemPacket.cs b/Multiplayer/Networking/Packets/Common/Customization/UnsnapItemPacket.cs new file mode 100644 index 00000000..251a3bb6 --- /dev/null +++ b/Multiplayer/Networking/Packets/Common/Customization/UnsnapItemPacket.cs @@ -0,0 +1,21 @@ +using LiteNetLib.Utils; + +namespace Multiplayer.Networking.Packets.Common.Customization; + +internal sealed class UnsnapItemPacket : CustomizationActionPacket, INetSerializable +{ + public ushort GadgetItemNetId; + public ushort SnappedItemNetId; + public int SnapPointIndex; + + public void Serialize(NetDataWriter writer) + { + writer.Put(OriginActionId); writer.Put(GadgetItemNetId); writer.Put(SnappedItemNetId); writer.Put(SnapPointIndex); + } + + public void Deserialize(NetDataReader reader) + { + OriginActionId = reader.GetUInt(); GadgetItemNetId = reader.GetUShort(); + SnappedItemNetId = reader.GetUShort(); SnapPointIndex = reader.GetInt(); + } +} diff --git a/Multiplayer/Networking/Packets/Common/Customization/UnwireGadgetsPacket.cs b/Multiplayer/Networking/Packets/Common/Customization/UnwireGadgetsPacket.cs new file mode 100644 index 00000000..034acae7 --- /dev/null +++ b/Multiplayer/Networking/Packets/Common/Customization/UnwireGadgetsPacket.cs @@ -0,0 +1,23 @@ +using LiteNetLib.Utils; + +namespace Multiplayer.Networking.Packets.Common.Customization; + +internal sealed class UnwireGadgetsPacket : CustomizationActionPacket, INetSerializable +{ + public ushort FirstItemNetId; + public ushort SecondItemNetId; + public int FirstPortIndex; + public int SecondPortIndex; + + public void Serialize(NetDataWriter writer) + { + writer.Put(OriginActionId); writer.Put(FirstItemNetId); writer.Put(SecondItemNetId); + writer.Put(FirstPortIndex); writer.Put(SecondPortIndex); + } + + public void Deserialize(NetDataReader reader) + { + OriginActionId = reader.GetUInt(); FirstItemNetId = reader.GetUShort(); SecondItemNetId = reader.GetUShort(); + FirstPortIndex = reader.GetInt(); SecondPortIndex = reader.GetInt(); + } +} diff --git a/Multiplayer/Networking/Packets/Common/Customization/WireGadgetsPacket.cs b/Multiplayer/Networking/Packets/Common/Customization/WireGadgetsPacket.cs new file mode 100644 index 00000000..03f5870c --- /dev/null +++ b/Multiplayer/Networking/Packets/Common/Customization/WireGadgetsPacket.cs @@ -0,0 +1,23 @@ +using LiteNetLib.Utils; + +namespace Multiplayer.Networking.Packets.Common.Customization; + +internal sealed class WireGadgetsPacket : CustomizationActionPacket, INetSerializable +{ + public ushort FirstItemNetId; + public ushort SecondItemNetId; + public int FirstPortIndex; + public int SecondPortIndex; + + public void Serialize(NetDataWriter writer) + { + writer.Put(OriginActionId); writer.Put(FirstItemNetId); writer.Put(SecondItemNetId); + writer.Put(FirstPortIndex); writer.Put(SecondPortIndex); + } + + public void Deserialize(NetDataReader reader) + { + OriginActionId = reader.GetUInt(); FirstItemNetId = reader.GetUShort(); SecondItemNetId = reader.GetUShort(); + FirstPortIndex = reader.GetInt(); SecondPortIndex = reader.GetInt(); + } +} diff --git a/Multiplayer/Patches/World/CustomizationPatch.cs b/Multiplayer/Patches/World/CustomizationPatch.cs index 519855bd..3ddbb709 100644 --- a/Multiplayer/Patches/World/CustomizationPatch.cs +++ b/Multiplayer/Patches/World/CustomizationPatch.cs @@ -2,7 +2,7 @@ using DV.Customization.Gadgets; using HarmonyLib; using Multiplayer.Components.Networking.World; -using Multiplayer.Networking.Packets.Common; +using Multiplayer.Networking.Packets.Common.Customization; using UnityEngine; namespace Multiplayer.Patches.World; @@ -14,7 +14,7 @@ public static class CustomizationPatch private static void Placed(GadgetBase __result, Customization destination, Vector3 localPos, Quaternion localRot, GadgetItem gadgetItem) { if (__result == null || gadgetItem == null || CustomizationSyncScope.IsApplyingRemote || !NetworkedItem.TryGetNetId(gadgetItem.Item, out var id) || !CustomizationRef.TryCreate(destination, out var target)) return; - CustomizationStateManager.SendAction(new CommonCustomizationPacket { Action = CustomizationAction.PlaceGadget, ItemNetId = id, + CustomizationStateManager.SendAction(new PlaceGadgetPacket { ItemNetId = id, TargetKey = target.IdentificationKey, Position = localPos, Rotation = localRot }); } @@ -36,8 +36,8 @@ private static void RemovedGadget(GadgetItem __result, bool reparentToTrainCar, StabilizeRemovedGadget(__result); if (!__state.ShouldSend) return; - CustomizationStateManager.SendAction(new CommonCustomizationPacket { Action = CustomizationAction.RemoveGadget, - ItemNetId = __state.ItemNetId, Flag = __state.ReparentToTrainCar }); + CustomizationStateManager.SendAction(new RemoveGadgetPacket { ItemNetId = __state.ItemNetId, + ReparentToTrainCar = __state.ReparentToTrainCar }); } private static void StabilizeRemovedGadget(GadgetItem gadgetItem) @@ -64,26 +64,41 @@ private static void StabilizeRemovedGadget(GadgetItem gadgetItem) [HarmonyPatch(typeof(Customization), nameof(Customization.AddHole)), HarmonyPostfix] private static void AddedHole(Customization __instance, Vector3 localPosition, Vector3 localNormal, Collider __result) - { if (__result != null) SendHole(__instance, CustomizationAction.AddHole, localPosition, default, localNormal); } + { + if (__result != null && TryTarget(__instance, out string targetKey)) + CustomizationStateManager.SendAction(new AddHolePacket + { TargetKey = targetKey, Position = localPosition, Normal = localNormal }); + } [HarmonyPatch(typeof(Customization), nameof(Customization.MoveHole)), HarmonyPrefix] private static void BeforeMove(Collider hole, out HoleMoveState __state) => __state = new HoleMoveState { IsValid = hole != null, PreviousPosition = hole != null ? hole.transform.localPosition : default }; [HarmonyPatch(typeof(Customization), nameof(Customization.MoveHole)), HarmonyPostfix] private static void MovedHole(Customization __instance, Vector3 localPosition, Vector3 localNormal, HoleMoveState __state) - { if (__state.IsValid) SendHole(__instance, CustomizationAction.MoveHole, localPosition, __state.PreviousPosition, localNormal); } + { + if (__state.IsValid && TryTarget(__instance, out string targetKey)) + CustomizationStateManager.SendAction(new MoveHolePacket { TargetKey = targetKey, + Position = localPosition, PreviousPosition = __state.PreviousPosition, Normal = localNormal }); + } [HarmonyPatch(typeof(Customization), nameof(Customization.RemoveHole)), HarmonyPrefix] private static void BeforeRemove(Collider holeCollider, out Vector3 __state) => __state = holeCollider != null ? holeCollider.transform.localPosition : default; [HarmonyPatch(typeof(Customization), nameof(Customization.RemoveHole)), HarmonyPostfix] private static void RemovedHole(Customization __instance, bool __result, Vector3 __state) - { if (__result) SendHole(__instance, CustomizationAction.RemoveHole, default, __state, default); } + { + if (__result && TryTarget(__instance, out string targetKey)) + CustomizationStateManager.SendAction(new RemoveHolePacket + { TargetKey = targetKey, PreviousPosition = __state }); + } - private static void SendHole(Customization c, CustomizationAction action, Vector3 pos, Vector3 old, Vector3 normal) + private static bool TryTarget(Customization customization, out string targetKey) { - if (CustomizationSyncScope.IsApplyingRemote || !CustomizationRef.TryCreate(c, out var target)) return; - CustomizationStateManager.SendAction(new CommonCustomizationPacket { Action = action, TargetKey = target.IdentificationKey, - Position = pos, PreviousPosition = old, Normal = normal }); + targetKey = null; + if (CustomizationSyncScope.IsApplyingRemote || + !CustomizationRef.TryCreate(customization, out var target)) + return false; + targetKey = target.IdentificationKey; + return true; } private struct RemoveGadgetState diff --git a/Multiplayer/Patches/World/GadgetRelationshipPatch.cs b/Multiplayer/Patches/World/GadgetRelationshipPatch.cs index fc878107..7a21a17b 100644 --- a/Multiplayer/Patches/World/GadgetRelationshipPatch.cs +++ b/Multiplayer/Patches/World/GadgetRelationshipPatch.cs @@ -3,7 +3,7 @@ using DV.CabControls; using HarmonyLib; using Multiplayer.Components.Networking.World; -using Multiplayer.Networking.Packets.Common; +using Multiplayer.Networking.Packets.Common.Customization; using System; using System.Linq; using DV.Items; @@ -22,7 +22,8 @@ private static void Mounted(Mount __instance, GadgetBase gadget) if (CustomizationSyncScope.IsApplyingRemote || __instance.MountedGadget != gadget || !Id(__instance.ThisGadget, out var a) || !Id(gadget, out var b)) return; int index = Array.IndexOf(__instance.ThisGadget.GetComponents(), __instance); - if (index >= 0) Send(CustomizationAction.Mount, a, b, index); + if (index >= 0) CustomizationStateManager.SendAction(new MountGadgetPacket + { MountItemNetId = a, MountedItemNetId = b, MountIndex = index }); } [HarmonyPatch(typeof(Mount), nameof(Mount.UnmountGadget)), HarmonyPrefix] @@ -36,15 +37,16 @@ private static void BeforeUnmount(Mount __instance, out MountActionState __state [HarmonyPatch(typeof(Mount), nameof(Mount.UnmountGadget)), HarmonyPostfix] private static void Unmounted(Mount __instance, MountActionState __state) - { if (__state.ShouldSend && __instance.MountedGadget == null) Send(CustomizationAction.Unmount, __state.OwnerId, 0, __state.Index); } + { if (__state.ShouldSend && __instance.MountedGadget == null) CustomizationStateManager.SendAction( + new UnmountGadgetPacket { MountItemNetId = __state.OwnerId, MountIndex = __state.Index }); } [HarmonyPatch(typeof(GadgetWiringModule.WireLinkPort), nameof(GadgetWiringModule.WireLinkPort.Wire), new[] { typeof(GadgetWiringModule.WireLinkPort), typeof(GadgetWiringModule.WireLinkPort) }), HarmonyPostfix] private static void Wired(GadgetWiringModule.WireLinkPort a, GadgetWiringModule.WireLinkPort b, bool __result) - { if (__result) SendWire(CustomizationAction.Wire, a, b); } + { if (__result) SendWire(a, b, unwire: false); } [HarmonyPatch(typeof(GadgetWiringModule.WireLinkPort), nameof(GadgetWiringModule.WireLinkPort.Unwire), new[] { typeof(GadgetWiringModule.WireLinkPort), typeof(GadgetWiringModule.WireLinkPort) }), HarmonyPostfix] private static void Unwired(GadgetWiringModule.WireLinkPort a, GadgetWiringModule.WireLinkPort b, bool __result) - { if (__result) SendWire(CustomizationAction.Unwire, a, b); } + { if (__result) SendWire(a, b, unwire: true); } [HarmonyPatch(typeof(GadgetSolderingTool), nameof(GadgetSolderingTool.DropEmptySpool)), HarmonyPrefix] private static void BeforeDropSpool(GadgetSolderingTool __instance, out SpoolDropState __state) @@ -95,14 +97,13 @@ private static void DroppedSpool(GadgetSolderingTool __instance, SpoolDropState } Multiplayer.LogDebug(() => $"Sending empty-reel ejection for tool {__state.ToolItemNetId}, reel {__state.SpoolItemNetId}"); - CustomizationStateManager.SendAction(new CommonCustomizationPacket + CustomizationStateManager.SendAction(new DropEmptySpoolPacket { - Action = CustomizationAction.DropEmptySpool, - ItemNetId = __state.ToolItemNetId, - OtherItemNetId = __state.SpoolItemNetId, + ToolItemNetId = __state.ToolItemNetId, + SpoolItemNetId = __state.SpoolItemNetId, Position = __state.Position, Rotation = __state.Rotation, - Flag = true, + HasWorldTransform = true, }); } @@ -130,11 +131,10 @@ private static void AfterSoldering(GadgetSolderingTool __instance, SpoolReplacem __state.OldSpool?.SuppressDestroySync(); NetworkedItem.TryGetNetworkedItem(spentObject.GetComponent(), out var spentSpool); spentSpool?.MarkAsSynchronized(); - CustomizationStateManager.SendAction(new CommonCustomizationPacket + CustomizationStateManager.SendAction(new ReplaceSpoolPacket { - Action = CustomizationAction.ReplaceSpool, - ItemNetId = __state.ToolItemNetId, - OtherItemNetId = spentSpool?.NetId ?? 0, + ToolItemNetId = __state.ToolItemNetId, + ReplacementSpoolItemNetId = spentSpool?.NetId ?? 0, OwnerPlayerId = __state.OldSpool?.OwnerPlayerId ?? 0, }); } @@ -147,12 +147,13 @@ private static void SpoolLoaded(ItemMagazine __instance, UnityEngine.GameObject !NetworkedItem.TryGetNetworkedItem(tool.GetComponent(), out var toolNet) || !NetworkedItem.TryGetNetworkedItem(item.GetComponent(), out var spoolNet) || spoolNet.NetId == 0) return; - Send(CustomizationAction.LoadSpool, toolNet.NetId, spoolNet.NetId, 0); + CustomizationStateManager.SendAction(new LoadSpoolPacket + { ToolItemNetId = toolNet.NetId, SpoolItemNetId = spoolNet.NetId }); } [HarmonyPatch(typeof(ItemSnapPointBase), nameof(ItemSnapPointBase.SnapItem), new[] { typeof(ItemBase), typeof(bool) }), HarmonyPostfix] private static void Snapped(ItemSnapPointBase __instance, ItemBase itemToSnap, bool __result) - { if (__result) SendSnap(CustomizationAction.SnapItem, __instance, itemToSnap); } + { if (__result) SendSnap(__instance, itemToSnap, unsnap: false); } [HarmonyReversePatch] [HarmonyPatch(typeof(ItemSnapPointBase), nameof(ItemSnapPointBase.SnapItem), new[] { typeof(ItemBase), typeof(bool) })] @@ -191,45 +192,45 @@ private static bool UseAuthoritativeSlidingSnapResult(ref bool __result) [HarmonyPatch(typeof(ItemSnapPointBase), nameof(ItemSnapPointBase.UnsnapItem)), HarmonyPostfix] private static void Unsnapped(ItemSnapPointBase __instance, bool __result, ItemBase __state) - { if (__result && __state != null) SendSnap(CustomizationAction.UnsnapItem, __instance, __state); } + { if (__result && __state != null) SendSnap(__instance, __state, unsnap: true); } - private static void SendSnap(CustomizationAction action, ItemSnapPointBase point, ItemBase item) + private static void SendSnap(ItemSnapPointBase point, ItemBase item, bool unsnap) { if (CustomizationSyncScope.IsApplyingRemote) return; if (!CustomizationStateManager.TryGetSnapPointOwner(point, out var owner) || !CustomizationStateManager.TryGetSnapPointIndex(owner, point, out var index) || !Id(owner, out var ownerId) || !NetworkedItem.TryGetNetId(item, out var itemId)) return; - bool hasAnchorPosition = false; - UnityEngine.Vector3 anchorPosition = default; - if (action == CustomizationAction.SnapItem && item.SnappableItem != null) + if (unsnap) { - var anchor = item.SnappableItem.GetAnchor(point.SnapPointType); - if (anchor != null) - { - hasAnchorPosition = true; - anchorPosition = anchor.localPosition; - } + CustomizationStateManager.SendAction(new UnsnapItemPacket + { GadgetItemNetId = ownerId, SnappedItemNetId = itemId, SnapPointIndex = index }); + return; } - CustomizationStateManager.SendAction(new CommonCustomizationPacket + var anchor = item.SnappableItem?.GetAnchor(point.SnapPointType); + CustomizationStateManager.SendAction(new SnapItemPacket { - Action = action, - ItemNetId = ownerId, - OtherItemNetId = itemId, - IndexA = index, - Flag = hasAnchorPosition, - Position = anchorPosition, + GadgetItemNetId = ownerId, + SnappedItemNetId = itemId, + SnapPointIndex = index, + HasAnchorPosition = anchor != null, + AnchorPosition = anchor?.localPosition ?? default, }); } - private static void SendWire(CustomizationAction action, GadgetWiringModule.WireLinkPort a, GadgetWiringModule.WireLinkPort b) + private static void SendWire(GadgetWiringModule.WireLinkPort a, GadgetWiringModule.WireLinkPort b, bool unwire) { if (CustomizationSyncScope.IsApplyingRemote || !Id(a.owner, out var ai) || !Id(b.owner, out var bi)) return; - CustomizationStateManager.SendAction(new CommonCustomizationPacket { Action = action, ItemNetId = ai, OtherItemNetId = bi, - IndexA = a.owner.WireLinkPorts.IndexOf(a), IndexB = b.owner.WireLinkPorts.IndexOf(b) }); + int indexA = a.owner.WireLinkPorts.IndexOf(a); + int indexB = b.owner.WireLinkPorts.IndexOf(b); + if (unwire) + CustomizationStateManager.SendAction(new UnwireGadgetsPacket { FirstItemNetId = ai, SecondItemNetId = bi, + FirstPortIndex = indexA, SecondPortIndex = indexB }); + else + CustomizationStateManager.SendAction(new WireGadgetsPacket { FirstItemNetId = ai, SecondItemNetId = bi, + FirstPortIndex = indexA, SecondPortIndex = indexB }); } - private static void Send(CustomizationAction action, ushort a, ushort b, int index) => CustomizationStateManager.SendAction(new CommonCustomizationPacket { Action = action, ItemNetId = a, OtherItemNetId = b, IndexA = index }); private static bool Id(GadgetBase gadget, out ushort id) { id = 0; return gadget?.GadgetItem?.Item != null && NetworkedItem.TryGetNetId(gadget.GadgetItem.Item, out id); } private struct MountActionState diff --git a/Multiplayer/Patches/World/Items/CustomizationToolPatch.cs b/Multiplayer/Patches/World/Items/CustomizationToolPatch.cs index ec0679a5..1b021f52 100644 --- a/Multiplayer/Patches/World/Items/CustomizationToolPatch.cs +++ b/Multiplayer/Patches/World/Items/CustomizationToolPatch.cs @@ -5,7 +5,7 @@ using Multiplayer.Utils; using System; using DV.InventorySystem; -using Multiplayer.Networking.Packets.Common; +using Multiplayer.Networking.Packets.Common.Customization; namespace Multiplayer.Patches.World.Items; @@ -55,15 +55,14 @@ private static void AfterConsumeDuctTape(DuctTapeReplacementState __state) __state.OldItem.SuppressDestroySync(); replacementItem.MarkAsSynchronized(); CustomizationStateManager.RegisterPendingDuctTapeReplacement(__state.OldItem.NetId, replacementItem); - CustomizationStateManager.SendAction(new CommonCustomizationPacket + CustomizationStateManager.SendAction(new ReplaceDuctTapePacket { - Action = CustomizationAction.ReplaceDuctTape, - ItemNetId = __state.OldItem.NetId, - OtherItemNetId = replacementItem.NetId, + ConsumedItemNetId = __state.OldItem.NetId, + ReplacementItemNetId = replacementItem.NetId, OwnerPlayerId = __state.OldItem.OwnerPlayerId, Position = replacementObject.transform.position - WorldMover.currentMove, Rotation = replacementObject.transform.rotation, - Flag = true, + HasWorldTransform = true, }); } diff --git a/Multiplayer/Patches/World/StorageControllerPatch.cs b/Multiplayer/Patches/World/StorageControllerPatch.cs index 760fd0f8..b076a565 100644 --- a/Multiplayer/Patches/World/StorageControllerPatch.cs +++ b/Multiplayer/Patches/World/StorageControllerPatch.cs @@ -2,6 +2,7 @@ using HarmonyLib; using Multiplayer.Components.Networking; using Multiplayer.Components.Networking.World; +using System.Collections; using System.Collections.Generic; using System.Reflection.Emit; @@ -33,6 +34,35 @@ public static List FilterForLocalPlayer(List items) return ownedItems; } + public static void MarkLocalItemInLostAndFound(ItemBase item) + { + if (item == null || !IsMultiplayerSession() || + !NetworkedItem.TryGetNetworkedItem(item, out var networkedItem)) + return; + + byte localPlayerId = NetworkLifecycle.Instance.Client?.PlayerId ?? 0; + if (localPlayerId != 0 && networkedItem.OwnerPlayerId == localPlayerId) + networkedItem.MarkItemStateDirty(); + } + + public static void MarkLocalItemsInLostAndFound(StorageController storage) + { + if (storage?.StorageLostAndFound == null) + return; + + foreach (var item in storage.StorageLostAndFound.GetStorageItemList()) + MarkLocalItemInLostAndFound(item); + } + + public static IEnumerator MarkLocalItemsAfterActivation(StorageController storage) + { + int itemCount = storage?.StorageLostAndFound?.GetStorageItemList()?.Count ?? 0; + for (int i = 0; i <= itemCount; i++) + yield return null; + + MarkLocalItemsInLostAndFound(storage); + } + private static bool IsMultiplayerSession() { var lifecycle = NetworkLifecycle.Instance; @@ -64,6 +94,18 @@ internal static class MoveItemsFromWorldToLostAndFoundPatch [HarmonyTranspiler] private static IEnumerable Transpiler(IEnumerable instructions) => LostAndFoundOwnership.FilterStorageList(instructions); + + [HarmonyPostfix] + private static void Postfix(StorageController __instance) => + LostAndFoundOwnership.MarkLocalItemsInLostAndFound(__instance); +} + +[HarmonyPatch(typeof(StorageController), nameof(StorageController.AddItemToLostAndFound))] +internal static class AddItemToLostAndFoundPatch +{ + [HarmonyPostfix] + private static void Postfix(ItemBase item) => + LostAndFoundOwnership.MarkLocalItemInLostAndFound(item); } [HarmonyPatch(typeof(StorageItemTransformController), nameof(StorageItemTransformController.ActivateItems))] @@ -72,4 +114,21 @@ internal static class ActivateLostAndFoundItemsPatch [HarmonyTranspiler] private static IEnumerable Transpiler(IEnumerable instructions) => LostAndFoundOwnership.FilterStorageList(instructions); + + [HarmonyPostfix] + private static void Postfix() => + CoroutineManager.Instance.StartCoroutine( + LostAndFoundOwnership.MarkLocalItemsAfterActivation(StorageController.Instance)); +} + +[HarmonyPatch(typeof(StorageItemTransformController), nameof(StorageItemTransformController.DeactivateItems))] +internal static class DeactivateLostAndFoundItemsPatch +{ + [HarmonyTranspiler] + private static IEnumerable Transpiler(IEnumerable instructions) => + LostAndFoundOwnership.FilterStorageList(instructions); + + [HarmonyPostfix] + private static void Postfix() => + LostAndFoundOwnership.MarkLocalItemsInLostAndFound(StorageController.Instance); } From e49fde14f12647a3455485346b8c698227db65fe Mon Sep 17 00:00:00 2001 From: Patrick van Halm Date: Fri, 21 Aug 2026 20:52:19 +0200 Subject: [PATCH 06/14] Start on saved inventories with ability to recover the items by host --- .../Networking/World/NetworkedItem.cs | 152 +++++++++- .../Networking/World/NetworkedItemManager.cs | 283 +++++++++++++++++- .../SaveGame/NetworkedSaveGameManager.cs | 168 ++++++++++- .../SaveGame/StartGameData_ServerSave.cs | 15 +- .../Networking/Data/Items/ItemUpdateData.cs | 28 +- .../Data/Items/PlayerItemSaveData.cs | 3 + Multiplayer/Networking/Data/ServerPlayer.cs | 8 +- .../Managers/Client/NetworkClient.cs | 4 +- .../Networking/Managers/Server/ChatManager.cs | 105 +++++++ .../Managers/Server/NetworkServer.cs | 33 +- .../ClientboundSaveGameDataPacket.cs | 82 +++-- .../ServerboundClientLoginPacket.cs | 1 + .../SaveGame/StartingItemsControllerPatch.cs | 18 ++ 13 files changed, 841 insertions(+), 59 deletions(-) create mode 100644 Multiplayer/Patches/SaveGame/StartingItemsControllerPatch.cs diff --git a/Multiplayer/Components/Networking/World/NetworkedItem.cs b/Multiplayer/Components/Networking/World/NetworkedItem.cs index d31729e0..5a7ee4b3 100644 --- a/Multiplayer/Components/Networking/World/NetworkedItem.cs +++ b/Multiplayer/Components/Networking/World/NetworkedItem.cs @@ -25,7 +25,8 @@ public enum ItemState : byte InInventory, //in player's inventory Attached, //attached to another object (e.g. EOT Lanterns) InstalledGadget, //backing item is hidden while its Gadget is linked to a customization target - InLostAndFound + InLostAndFound, + InContainer } public class NetworkedItem : IdMonoBehaviour @@ -88,6 +89,11 @@ public static bool TryGetNetId(ItemBase item, out ushort netID) public uint LastDirtyTick { get; private set; } public bool IsReadyForSnapshots => registrationComplete && Item != null; public ItemState CurrentState => lastState; + public int InventorySlotIndex { get; private set; } = -1; + public int ContainerSlotIndex { get; private set; } = -1; + public string ContainerId { get; private set; } + public bool InLockedSlot { get; private set; } + public bool IsDroppedInInventory { get; private set; } public Vector3 WorldPosition => GetItemState() == ItemState.InstalledGadget && trackedItem is GadgetItem gadgetItem && gadgetItem.Gadget != null ? gadgetItem.Gadget.transform.position @@ -336,11 +342,20 @@ public ItemUpdateData GetSnapshot() ItemUpdateData snapshot; ItemUpdateData.ItemUpdateType updateType = ItemUpdateData.ItemUpdateType.None; - bool hasDirtyVals = HasDirtyValues(); - if (Item == null && Register() == false) return null; + ItemState currentState = GetItemState(); + if (currentState != lastState) + stateDirty = true; + if (ShouldObserveLocalLayout() && CaptureCurrentLayout(currentState)) + { + stateDirty = true; + forceStateSnapshot = true; + } + + bool hasDirtyVals = HasDirtyValues(); + if (!stateDirty && !ownerDirty && !hasDirtyVals) return null; @@ -355,7 +370,7 @@ public ItemUpdateData GetSnapshot() OwnerPlayerId != localPlayerId; bool locallyHeld = Item.IsGrabbed() || Inventory.Instance.Contains(gameObject, false); if (belongsToAnotherPlayer && !locallyHeld && - lastState is ItemState.InHand or ItemState.InInventory) + IsCarriedState(lastState)) { stateDirty = false; forceStateSnapshot = false; @@ -365,15 +380,13 @@ public ItemUpdateData GetSnapshot() } } - ItemState currentState = GetItemState(); - // A local pickup is observed rather than applied through ReceiveSnapshot. // It establishes ownership, while a later drop only ends possession: the // persistent owner must remain so lost and found can return the item. if (stateDirty && NetworkLifecycle.Instance.IsClientRunning) { byte localPlayerId = NetworkLifecycle.Instance.Client?.PlayerId ?? 0; - bool isHeldByLocalPlayer = currentState is ItemState.InHand or ItemState.InInventory; + bool isHeldByLocalPlayer = IsCarriedState(currentState); if (isHeldByLocalPlayer) SetOwner(localPlayerId, markDirty: true); @@ -454,6 +467,7 @@ private void ApplySnapshot(ItemUpdateData snapshot) case ItemState.InHand: case ItemState.InInventory: + case ItemState.InContainer: HandleInventoryOrHandState(snapshot); break; @@ -483,6 +497,16 @@ private void ApplySnapshot(ItemUpdateData snapshot) SetOwner(snapshot.OwnerPlayerId); } + if (snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.Create) || + snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.ItemState)) + { + InventorySlotIndex = snapshot.InventorySlotIndex; + ContainerSlotIndex = snapshot.ContainerSlotIndex; + ContainerId = snapshot.ContainerId; + InLockedSlot = snapshot.InLockedSlot; + IsDroppedInInventory = snapshot.IsDropped; + } + Multiplayer.Log($"NetworkedItem.ApplySnapshot() netID: {snapshot?.ItemNetId}, ItemUpdateType {snapshot?.UpdateType} About to process states"); if (snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.Create) || snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.ObjectState)) @@ -573,9 +597,14 @@ public ItemUpdateData CreateUpdateData(ItemUpdateData.ItemUpdateType updateType) ItemPosition = position, ItemRotation = rotation, ThrowDirection = throwDirection, - Player = lastState is ItemState.InHand or ItemState.InInventory + Player = IsCarriedState(lastState) ? OwnerPlayerId : (byte)0, + InventorySlotIndex = InventorySlotIndex, + ContainerSlotIndex = ContainerSlotIndex, + ContainerId = ContainerId, + InLockedSlot = InLockedSlot, + IsDropped = IsDroppedInInventory, CarNetId = carId, AttachedFront = frontCoupler, States = states, @@ -607,9 +636,37 @@ public void MarkAsSynchronized() MarkValuesClean(); } + internal void DeferInitialCreate() + { + createdDirty = false; + stateDirty = false; + forceStateSnapshot = false; + ownerDirty = false; + MarkValuesClean(); + } + + internal void PublishDeferredCreate() + { + lastState = GetItemState(); + CaptureCurrentLayout(lastState); + createdDirty = true; + } + + internal ItemState RefreshPersistenceState() + { + lastState = GetItemState(); + // Remote carried objects are inactive host-side proxies. Their layout was + // supplied by the owning client and cannot be rediscovered through the + // host's Inventory; doing so replaces valid slots/container IDs with -1. + if (ShouldObserveLocalLayout()) + CaptureCurrentLayout(lastState); + return lastState; + } + public ItemUpdateData CreateCurrentUpdateData(ItemUpdateData.ItemUpdateType updateType) { lastState = GetItemState(); + CaptureCurrentLayout(lastState); return CreateUpdateData(updateType); } @@ -627,7 +684,7 @@ private ItemState GetItemState() // dropped and can echo a false drop back with an unrelated object-state // update (for example when ejecting a soldering reel). Their most recent // client-supplied state is authoritative instead. - if (OwnerPlayerId != 0 && lastState is ItemState.InHand or ItemState.InInventory) + if (OwnerPlayerId != 0 && IsCarriedState(lastState)) { byte localPlayerId = NetworkLifecycle.Instance.IsHost() ? NetworkLifecycle.Instance.Server?.SelfId ?? 0 @@ -637,18 +694,21 @@ private ItemState GetItemState() return ObserveState(lastState); } - if (Item.transform.parent == WorldMover.OriginShiftParent && !wasThrown) - return ObserveState(ItemState.Dropped); - - if (wasThrown) - return ObserveState(ItemState.Thrown); - if (Item.IsGrabbed()) return ObserveState(ItemState.InHand); if (Inventory.Instance.Contains(this.gameObject, false)) return ObserveState(ItemState.InInventory); + if (Item.InContainer != null) + return ObserveState(ItemState.InContainer); + + if (Item.transform.parent == WorldMover.OriginShiftParent && !wasThrown) + return ObserveState(ItemState.Dropped); + + if (wasThrown) + return ObserveState(ItemState.Thrown); + if (snappableItem != null && snappableItem.IsSnapped) return ObserveState(ItemState.Attached); @@ -795,6 +855,20 @@ private void HandleAttachedState(ItemUpdateData snapshot) private void HandleInventoryOrHandState(ItemUpdateData snapshot) { + // The owner already has the native inventory object that was bound during + // reconciliation. Authoritative echoes must not turn that object into an + // inactive remote proxy; packet timing can otherwise make an arbitrary + // subset of the owner's inventory disappear after a successful join. + byte localPlayerId = NetworkLifecycle.Instance.Client?.PlayerId ?? 0; + bool isLocallyCarried = NetworkLifecycle.Instance.IsClientRunning && + localPlayerId != 0 && snapshot.Player == localPlayerId && + (Item.IsGrabbed() || Inventory.Instance.Contains(gameObject, false)); + if (isLocallyCarried) + { + gameObject.SetActive(true); + return; + } + if (Item.IsSnapped) { Item.SnappableItem.SnappedTo.UnsnapItem(false); @@ -822,6 +896,54 @@ private void HandleInventoryOrHandState(ItemUpdateData snapshot) this.gameObject.SetActive(false); } + public static bool IsCarriedState(ItemState state) => + state is ItemState.InHand or ItemState.InInventory or ItemState.InContainer; + + private bool ShouldObserveLocalLayout() + { + if (!NetworkLifecycle.Instance.IsClientRunning) + return false; + + byte localPlayerId = NetworkLifecycle.Instance.IsHost() + ? NetworkLifecycle.Instance.Server?.SelfId ?? 0 + : NetworkLifecycle.Instance.Client?.PlayerId ?? 0; + return OwnerPlayerId == 0 || OwnerPlayerId == localPlayerId; + } + + private bool CaptureCurrentLayout(ItemState state) + { + int inventorySlot = -1; + int containerSlot = -1; + string containerId = null; + bool locked = false; + bool dropped = false; + var inventory = Inventory.Instance; + + if (state is ItemState.InHand or ItemState.InInventory) + { + inventorySlot = inventory?.IndexOf(gameObject) ?? -1; + if (inventorySlot >= 0) + { + locked = inventory.GetSlotLockState(inventorySlot); + dropped = inventory.GetSlotDroppedState(inventorySlot); + } + } + else if (state == ItemState.InContainer && inventory?.ItemContainerRegistry != null) + { + (containerId, containerSlot) = inventory.ItemContainerRegistry.GetItemContainerIdAndIndex(gameObject); + } + + bool changed = InventorySlotIndex != inventorySlot || ContainerSlotIndex != containerSlot || + !string.Equals(ContainerId, containerId, StringComparison.Ordinal) || + InLockedSlot != locked || IsDroppedInInventory != dropped; + InventorySlotIndex = inventorySlot; + ContainerSlotIndex = containerSlot; + ContainerId = containerId; + InLockedSlot = locked; + IsDroppedInInventory = dropped; + return changed; + } + private void HandleLostAndFoundState(ItemUpdateData snapshot) { if (Item.IsSnapped) diff --git a/Multiplayer/Components/Networking/World/NetworkedItemManager.cs b/Multiplayer/Components/Networking/World/NetworkedItemManager.cs index a7583ce6..7d373798 100644 --- a/Multiplayer/Components/Networking/World/NetworkedItemManager.cs +++ b/Multiplayer/Components/Networking/World/NetworkedItemManager.cs @@ -16,6 +16,9 @@ using Multiplayer.Networking.Packets.Serverbound; using Multiplayer.Networking.Packets; using System.Collections; +using Multiplayer.Components.SaveGame; +using Newtonsoft.Json.Linq; +using DV.Common; namespace Multiplayer.Components.Networking.World; @@ -84,19 +87,270 @@ private void PlayerDisconnected(ServerPlayer disconnectedPlayer) if (hostPlayerId == 0 || disconnectedPlayer.PlayerId == hostPlayerId) return; + HashSet carriedItems = GetCarriedInventoryItems(disconnectedPlayer); + IReadOnlyList savedItems = []; + bool snapshotComplete = disconnectedPlayer.InventoryReconciliationComplete && + TryCreateSaveData(carriedItems, out savedItems); + if (snapshotComplete) + { + NetworkedSaveGameManager.Instance.StorePlayerInventory(disconnectedPlayer, savedItems); + } + else + { + // Position and identity are independently authoritative. A partial + // reconciliation must preserve the previous inventory snapshot, but + // it must not also discard the player's latest position. + NetworkedSaveGameManager.Instance.StorePlayerMetadata(disconnectedPlayer); + } + + int storedItems = snapshotComplete ? carriedItems.Count : 0; + int discardedPartialItems = snapshotComplete ? 0 : carriedItems.Count; + int preservedItems = 0; int transferredItems = 0; - foreach (var item in NetworkedItem.GetAll()) + foreach (var item in NetworkedItem.GetAll().ToArray()) { if (item == null || item.OwnerPlayerId != disconnectedPlayer.PlayerId) continue; + if (carriedItems.Contains(item)) + { + Destroy(item.gameObject); + continue; + } + + preservedItems++; item.SetOwner(hostPlayerId, true); transferredItems++; } Multiplayer.LogDebug(() => - $"Transferred {transferredItems} items from disconnected player " + - $"{disconnectedPlayer.PlayerId} to host player {hostPlayerId}"); + $"Disconnect inventory storage for player {disconnectedPlayer.PlayerId}: " + + $"storedItems={storedItems}, discardedPartialItems={discardedPartialItems}, " + + $"preservedItems={preservedItems}, transferredItems={transferredItems}, " + + $"hostPlayer={hostPlayerId}"); + } + + public bool TryCaptureCarriedInventory(ServerPlayer player, out IReadOnlyList items) => + TryCreateSaveData(GetCarriedInventoryItems(player), out items); + + private static HashSet GetCarriedInventoryItems(ServerPlayer player) + { + var ownedItems = NetworkedItem.GetAll() + .Where(item => item != null && item.OwnerPlayerId == player.PlayerId) + .ToArray(); + foreach (var item in ownedItems) + item.RefreshPersistenceState(); + var selected = ownedItems + .Where(item => item.CurrentState is ItemState.InHand or ItemState.InInventory) + .ToHashSet(); + + bool added; + do + { + var selectedContainerIds = selected + .Select(item => item.Item?.GetComponent()?.ContainerId) + .Where(id => !string.IsNullOrEmpty(id)) + .ToHashSet(StringComparer.Ordinal); + added = false; + foreach (var item in ownedItems) + { + if (selected.Contains(item) || item.CurrentState != ItemState.InContainer || + string.IsNullOrEmpty(item.ContainerId) || !selectedContainerIds.Contains(item.ContainerId)) + continue; + selected.Add(item); + added = true; + } + } while (added); + + return selected; + } + + private static bool TryCreateSaveData(IEnumerable items, + out IReadOnlyList saveData) + { + var result = new List(); + foreach (var item in items) + { + if (item?.Item?.InventorySpecs == null) + { + Multiplayer.LogWarning("Could not save a carried item because its inventory specification is unavailable."); + saveData = []; + return false; + } + + JObject state = null; + try + { + state = item.GetComponent()?.SaveItemData()?.DeepClone() as JObject; + } + catch (Exception exception) + { + // Some inactive remote proxies (notably Flashlight) cannot run + // every native save listener. Preserve the item and its layout + // with default custom state instead of discarding the complete + // inventory snapshot. + Multiplayer.LogWarning($"Could not save custom item state for {item.NetId} ({item.name}); " + + $"using default state: {exception.Message}"); + state = []; + } + + result.Add(new PlayerItemSaveData + { + NetId = 0, + ItemPrefabName = item.Item.InventorySpecs.ItemPrefabName, + BelongsToPlayer = item.Item.InventorySpecs.BelongsToPlayer, + IsGrabbed = item.CurrentState == ItemState.InHand, + State = state ?? [], + InventorySlotIndex = item.InventorySlotIndex, + ContainerSlotIndex = item.ContainerSlotIndex, + ContainerId = item.ContainerId, + InLockedSlot = item.InLockedSlot, + IsDropped = item.IsDroppedInInventory, + }); + } + saveData = result; + return true; + } + + public IEnumerator RecoverStoredInventory(SavedPlayerInventory stored, + Action completed) + { + if (stored == null || !NetworkLifecycle.Instance.IsHost()) + { + completed?.Invoke(false, 0, 0, "Invalid stored inventory."); + yield break; + } + + var recoverableRecords = new List(); + var excludedContainerIds = new HashSet(StringComparer.Ordinal); + int excludedEssentialCount = 0; + foreach (var record in stored.Items) + { + if (string.IsNullOrEmpty(record.ItemPrefabName) || !ItemPrefabs.ContainsKey(record.ItemPrefabName)) + { + completed?.Invoke(false, 0, 0, + $"Missing item prefab '{record.ItemPrefabName ?? ""}'. Nothing was recovered."); + yield break; + } + + if (ItemPrefabs[record.ItemPrefabName].IsEssential) + { + excludedEssentialCount++; + string containerId = record.State?["ContainerId"]?.Value(); + if (!string.IsNullOrEmpty(containerId)) + excludedContainerIds.Add(containerId); + continue; + } + + recoverableRecords.Add(record); + } + + // If an essential item is itself a container, promote any non-essential + // direct contents to Lost and Found instead of discarding them with it. + for (int i = 0; i < recoverableRecords.Count; i++) + { + PlayerItemSaveData record = recoverableRecords[i]; + if (!string.IsNullOrEmpty(record.ContainerId) && excludedContainerIds.Contains(record.ContainerId)) + { + record.ContainerId = null; + record.ContainerSlotIndex = -1; + recoverableRecords[i] = record; + } + } + + var created = new List<(PlayerItemSaveData Record, NetworkedItem Item)>(); + string failure = null; + try + { + foreach (var record in recoverableRecords) + { + GameObject gameObject = Instantiate(ItemPrefabs[record.ItemPrefabName].gameObject, + Vector3.zero, Quaternion.identity); + var networkedItem = gameObject.GetOrAddComponent(); + networkedItem.DeferInitialCreate(); + created.Add((record, networkedItem)); + } + } + catch (Exception exception) + { + failure = $"Could not instantiate recovered inventory: {exception.Message}"; + } + + if (failure == null) + { + // Let Start register ItemSaveData listeners, then restore container IDs and item state. + yield return null; + foreach (var entry in created) + { + try + { + entry.Item.gameObject.SetActive(false); + var saveData = entry.Item.GetComponent(); + saveData?.LoadItemData(entry.Record.State ?? []); + saveData?.PostLoadItemData(); + } + catch (Exception exception) + { + failure = $"Could not restore {entry.Record.ItemPrefabName}: {exception.Message}"; + break; + } + } + } + + if (failure == null) + { + foreach (var entry in created.Where(value => !string.IsNullOrEmpty(value.Record.ContainerId))) + { + AItemContainer container = Inventory.Instance.ItemContainerRegistry.GetContainer(entry.Record.ContainerId); + int slot = entry.Record.ContainerSlotIndex; + if (container == null || slot < 0 || slot >= container.Capacity || + container[slot] != null || !container.AddItem(entry.Item.gameObject, slot)) + { + failure = $"Could not restore {entry.Record.ItemPrefabName} to container " + + $"'{entry.Record.ContainerId}' slot {slot}."; + break; + } + } + } + + if (failure != null) + { + foreach (var entry in created) + { + if (entry.Item == null) + continue; + entry.Item.SuppressDestroySync(); + Inventory.Instance.DestroyItem(entry.Item.gameObject); + } + completed?.Invoke(false, 0, 0, failure + " Nothing was recovered."); + yield break; + } + + byte hostPlayerId = NetworkLifecycle.Instance.Server?.SelfId ?? 0; + int rootCount = 0; + int containerCount = created.Count(entry => entry.Item.GetComponent() != null); + foreach (var entry in created) + { + entry.Item.SetOwner(hostPlayerId); + if (!string.IsNullOrEmpty(entry.Record.ContainerId)) + continue; + + StorageController.Instance.AddItemToLostAndFound(entry.Item.Item, false); + rootCount++; + } + + foreach (var entry in created) + entry.Item.GetComponent()?.PostContainerLoadData(); + + foreach (var entry in created) + entry.Item.PublishDeferredCreate(); + + NetworkedSaveGameManager.Instance.ResetPlayerInventory(stored.Guid); + SaveGameManager.Instance.Save(SaveType.Auto, null, true); + Multiplayer.LogDebug(() => $"Recovered offline inventory for {stored.Guid}: " + + $"items={created.Count}, containers={containerCount}, lostAndFoundRoots={rootCount}, " + + $"excludedEssentials={excludedEssentialCount}, resetToFirstTime=true"); + completed?.Invoke(true, created.Count, containerCount, null); } protected void Start() @@ -362,7 +616,7 @@ private void ProcessReceivedAsHost(ItemUpdateData snapshot, ServerPlayer player) // dropped/thrown state. This still permits ordinary world-item pickup // and the owning player's genuine drop. bool changesItemState = snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.ItemState); - bool dropsHeldItem = netItem.CurrentState is ItemState.InHand or ItemState.InInventory && + bool dropsHeldItem = NetworkedItem.IsCarriedState(netItem.CurrentState) && snapshot.ItemState is ItemState.Dropped or ItemState.Thrown; bool belongsToAnotherPlayer = netItem.OwnerPlayerId != 0 && netItem.OwnerPlayerId != player.PlayerId; @@ -379,7 +633,7 @@ private void ProcessReceivedAsHost(ItemUpdateData snapshot, ServerPlayer player) // have the sender's area loaded, so its physical item position is not a // reliable basis for ownership or reach validation. snapshot.Player = player.PlayerId; - snapshot.OwnerPlayerId = snapshot.ItemState is ItemState.InHand or ItemState.InInventory + snapshot.OwnerPlayerId = NetworkedItem.IsCarriedState(snapshot.ItemState) ? player.PlayerId : netItem.OwnerPlayerId; NetworkLifecycle.Instance.Server.LogWarning($"NetworkedItemManager.ProcessReceivedAsHost() ItemNetId: {snapshot.ItemNetId}, snapshot type: {snapshot.UpdateType}"); @@ -644,7 +898,10 @@ public void CacheWorldItems() { try { - if (item.Item != null && !IsLocalShopItem(item) && !item.Item.IsEssential() && !item.Item.IsGrabbed() && !StorageController.Instance.StorageInventory.ContainsItem(item.Item)) + if (item.Item != null && !IsLocalShopItem(item) && !item.Item.IsEssential() && + !item.Item.IsGrabbed() && + !StorageController.Instance.StorageInventory.ContainsItem(item.Item) && + !StorageController.Instance.StorageItemContainers.ContainsItem(item.Item)) { SendToCache(item); } @@ -821,14 +1078,18 @@ internal ClientboundItemReconciliationPacket ReconcileClientItems( } response.Items.Add(new ItemReconciliationResult(request.LocalId, netId, authoritativeSnapshot)); + if (netId == 0) + player.InventoryReconciliationFailed = true; } Multiplayer.LogDebug(() => $"Reconciled {response.Items.Count(item => item.NetId != 0)}/{packet.Items.Count} items for {player.Username}"); + if (packet.IsFinalBatch) + player.InventoryReconciliationComplete = !player.InventoryReconciliationFailed; return response; } private static bool IsAllowedReconciliationState(ItemState state, InventoryItemSpec spec) => - state is ItemState.InHand or ItemState.InInventory || + NetworkedItem.IsCarriedState(state) || state == ItemState.Dropped && spec.IsEssential; private static bool IsReconciliationCandidate(NetworkedItem item) @@ -840,7 +1101,8 @@ private static bool IsReconciliationCandidate(NetworkedItem item) var storage = StorageController.Instance; return item.Item.IsEssential() || item.Item.IsGrabbed() || inventory != null && inventory.Contains(item.gameObject, true) || - storage?.StorageInventory != null && storage.StorageInventory.ContainsItem(item.Item); + storage?.StorageInventory != null && storage.StorageInventory.ContainsItem(item.Item) || + storage?.StorageItemContainers != null && storage.StorageItemContainers.ContainsItem(item.Item); } private NetworkedItem GetFromCache(string prefabName) @@ -878,6 +1140,11 @@ private void SendToCache(NetworkedItem netItem) SingletonBehaviour.Instance.RemoveItemFromStorageItemList(netItem.Item); } + if (SingletonBehaviour.Instance.StorageItemContainers.ContainsItem(netItem.Item)) + { + SingletonBehaviour.Instance.RemoveItemFromStorageItemList(netItem.Item); + } + netItem.Item.InventorySpecs.BelongsToPlayer = false; netItem.NetId = 0; diff --git a/Multiplayer/Components/SaveGame/NetworkedSaveGameManager.cs b/Multiplayer/Components/SaveGame/NetworkedSaveGameManager.cs index 24b0cee7..d7930923 100644 --- a/Multiplayer/Components/SaveGame/NetworkedSaveGameManager.cs +++ b/Multiplayer/Components/SaveGame/NetworkedSaveGameManager.cs @@ -5,8 +5,14 @@ using JetBrains.Annotations; using Multiplayer.Components.Networking; using Multiplayer.Networking.Data; +using Multiplayer.Networking.Data.Items; +using Multiplayer.Components.Networking.World; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; namespace Multiplayer.Components.SaveGame; @@ -14,6 +20,13 @@ public class NetworkedSaveGameManager : SingletonBehaviour() ?? []; + return true; + } + catch (Exception exception) + { + Multiplayer.LogError($"Failed to deserialize stored inventory for {guid}: {exception}"); + return false; + } + } + + public void StorePlayerInventory(ServerPlayer player, IReadOnlyCollection items) + { + SaveGameData data = SaveGameManager.Instance?.data; + if (data == null || player == null) + return; + + JObject root = data.GetJObject(ROOT_KEY) ?? []; + JObject players = root.GetJObject(PLAYERS_KEY) ?? []; + JObject playerData = players.GetJObject(player.Guid.ToString()) ?? []; + UpdatePlayerMetadata(playerData, player); + SetInventory(playerData, items); + players.SetJObject(player.Guid.ToString(), playerData); + root.SetJObject(PLAYERS_KEY, players); + data.SetJObject(ROOT_KEY, root); + } + + public void StorePlayerMetadata(ServerPlayer player) + { + SaveGameData data = SaveGameManager.Instance?.data; + if (data == null || player == null) + return; + + JObject root = data.GetJObject(ROOT_KEY) ?? []; + JObject players = root.GetJObject(PLAYERS_KEY) ?? []; + JObject playerData = players.GetJObject(player.Guid.ToString()) ?? []; + UpdatePlayerMetadata(playerData, player); + players.SetJObject(player.Guid.ToString(), playerData); + root.SetJObject(PLAYERS_KEY, players); + data.SetJObject(ROOT_KEY, root); + } + + public void RecordPlayerLogin(ServerPlayer player) + { + SaveGameData data = SaveGameManager.Instance?.data; + if (data == null || player == null) + return; + + JObject root = data.GetJObject(ROOT_KEY) ?? []; + JObject players = root.GetJObject(PLAYERS_KEY) ?? []; + JObject playerData = players.GetJObject(player.Guid.ToString()) ?? []; + UpdateIdentityMetadata(playerData, player); + players.SetJObject(player.Guid.ToString(), playerData); + root.SetJObject(PLAYERS_KEY, players); + data.SetJObject(ROOT_KEY, root); + } + + public IReadOnlyList GetSavedPlayerInventories() + { + var result = new List(); + SaveGameData data = SaveGameManager.Instance?.data; + JObject players = data?.GetJObject(ROOT_KEY)?.GetJObject(PLAYERS_KEY); + if (players == null) + return result; + + foreach (JProperty property in players.Properties()) + { + if (!Guid.TryParse(property.Name, out Guid guid) || property.Value is not JObject playerData || + !TryGetPlayerInventory(data, guid, out PlayerItemSaveData[] items)) + continue; + + ulong.TryParse(playerData.GetString(STEAM_ID_KEY), out ulong steamId); + bool hasTimestamp = DateTime.TryParse(playerData.GetString(LAST_CONNECTED_KEY), CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, out DateTime lastConnectedUtc); + if (hasTimestamp) + lastConnectedUtc = lastConnectedUtc.ToUniversalTime(); + else + lastConnectedUtc = DateTime.MinValue; + result.Add(new SavedPlayerInventory(guid, playerData.GetString(USERNAME_KEY), steamId, + lastConnectedUtc, items)); + } + return result; + } + + public void ResetPlayerInventory(Guid guid) + { + SaveGameData data = SaveGameManager.Instance?.data; + JObject playerData = Server_GetPlayerData(data, guid); + if (playerData == null) + return; + // Absence means first-time provisioning on the next connection. Keep the + // identity/timestamp metadata so administrative lookup remains available. + playerData.Remove(INVENTORY_KEY); + } + + private static void UpdatePlayerMetadata(JObject playerData, ServerPlayer player) + { + UpdateIdentityMetadata(playerData, player); + playerData.SetVector3(SaveGameKeys.Player_position, player.AbsoluteWorldPosition); + playerData.SetFloat(SaveGameKeys.Player_rotation, player.WorldRotationY); + } + + private static void UpdateIdentityMetadata(JObject playerData, ServerPlayer player) + { + playerData.SetString(USERNAME_KEY, player.OriginalUsername); + playerData.SetString(STEAM_ID_KEY, player.SteamId == 0 ? string.Empty : player.SteamId.ToString(CultureInfo.InvariantCulture)); + playerData.SetString(LAST_CONNECTED_KEY, player.LastLogin.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture)); + } + + private static void SetInventory(JObject playerData, IReadOnlyCollection items) + { + playerData.SetJObject(INVENTORY_KEY, new JObject + { + [INVENTORY_VERSION_KEY] = INVENTORY_VERSION, + [INVENTORY_ITEMS_KEY] = JArray.FromObject(items ?? [], JsonSerializer.CreateDefault()), + }); + } + #endregion [UsedImplicitly] @@ -95,3 +236,22 @@ public JObject Server_GetPlayerData(SaveGameData data, Guid guid) return $"[{nameof(NetworkedSaveGameManager)}]"; } } + +public sealed class SavedPlayerInventory +{ + public Guid Guid { get; } + public string Username { get; } + public ulong SteamId { get; } + public DateTime LastConnectedUtc { get; } + public PlayerItemSaveData[] Items { get; } + + public SavedPlayerInventory(Guid guid, string username, ulong steamId, DateTime lastConnectedUtc, + PlayerItemSaveData[] items) + { + Guid = guid; + Username = username; + SteamId = steamId; + LastConnectedUtc = lastConnectedUtc; + Items = items ?? []; + } +} diff --git a/Multiplayer/Components/SaveGame/StartGameData_ServerSave.cs b/Multiplayer/Components/SaveGame/StartGameData_ServerSave.cs index 69ce3f2d..e9a43d67 100644 --- a/Multiplayer/Components/SaveGame/StartGameData_ServerSave.cs +++ b/Multiplayer/Components/SaveGame/StartGameData_ServerSave.cs @@ -60,7 +60,8 @@ public void SetFromPacket(ClientboundSaveGameDataPacket packet) }); // Load player inventory - List items = []; + List inventoryItems = []; + List containerItems = []; foreach (var item in packet.PlayerItems) { @@ -80,10 +81,16 @@ public void SetFromPacket(ClientboundSaveGameDataPacket packet) item.ContainerId ); - items.Add(itemData); + if (string.IsNullOrEmpty(item.ContainerId)) + inventoryItems.Add(itemData); + else + containerItems.Add(itemData); } - Multiplayer.LogDebug(() => $"StartGameData_ServerSave.SetFromPacket() PlayerItems count: {packet.PlayerItems.Length}, items string count: {items.Count()}"); - saveGameData.SetObject(SaveGameKeys.Storage_Inventory, items); + Multiplayer.LogDebug(() => $"StartGameData_ServerSave.SetFromPacket() PlayerItems count: {packet.PlayerItems.Length}, inventory: {inventoryItems.Count}, containers: {containerItems.Count}"); + saveGameData.SetObject(SaveGameKeys.Storage_Inventory, inventoryItems); + saveGameData.SetObject(SaveGameKeys.Storage_ItemContainers, containerItems); + saveGameData.SetBool("Multiplayer_AuthoritativeInventory", true); + saveGameData.SetBool("Multiplayer_HasSavedInventory", packet.HasSavedInventory); //For clients we need to have a session - new users may not have a session and this may also be causing problems with licenses syncing if (NetworkLifecycle.Instance.IsHost()) diff --git a/Multiplayer/Networking/Data/Items/ItemUpdateData.cs b/Multiplayer/Networking/Data/Items/ItemUpdateData.cs index f90516de..fe89216c 100644 --- a/Multiplayer/Networking/Data/Items/ItemUpdateData.cs +++ b/Multiplayer/Networking/Data/Items/ItemUpdateData.cs @@ -36,6 +36,11 @@ public enum ItemUpdateType : byte public Quaternion ItemRotation { get; set; } public Vector3 ThrowDirection { get; set; } public byte Player { get; set; } + public int InventorySlotIndex { get; set; } = -1; + public int ContainerSlotIndex { get; set; } = -1; + public string ContainerId { get; set; } + public bool InLockedSlot { get; set; } + public bool IsDropped { get; set; } public ushort CarNetId { get; set; } public bool AttachedFront { get; set; } public Dictionary States { get; set; } @@ -78,9 +83,17 @@ public void Serialize(NetDataWriter writer) if (ItemState == ItemState.Thrown) Vector3Serializer.Serialize(writer, ThrowDirection); } - else if (ItemState == ItemState.InInventory || ItemState == ItemState.InHand) + else if (IsCarriedState(ItemState)) { writer.Put(Player); + writer.Put(InventorySlotIndex); + writer.Put(InLockedSlot); + writer.Put(IsDropped); + if (ItemState == ItemState.InContainer) + { + writer.Put(ContainerId ?? string.Empty); + writer.Put(ContainerSlotIndex); + } } else if (ItemState == ItemState.Attached) { @@ -149,9 +162,17 @@ public void Deserialize(NetDataReader reader) Multiplayer.LogDebug(() => $"ItemUpdateData.Deserialize() Item Thrown after: {ThrowDirection}"); } } - else if (ItemState == ItemState.InInventory || ItemState == ItemState.InHand) + else if (IsCarriedState(ItemState)) { Player = reader.GetByte(); + InventorySlotIndex = reader.GetInt(); + InLockedSlot = reader.GetBool(); + IsDropped = reader.GetBool(); + if (ItemState == ItemState.InContainer) + { + ContainerId = reader.GetString(); + ContainerSlotIndex = reader.GetInt(); + } } else if (ItemState == ItemState.Attached) { @@ -185,4 +206,7 @@ public void Deserialize(NetDataReader reader) } } + private static bool IsCarriedState(ItemState state) => + state is ItemState.InHand or ItemState.InInventory or ItemState.InContainer; + } diff --git a/Multiplayer/Networking/Data/Items/PlayerItemSaveData.cs b/Multiplayer/Networking/Data/Items/PlayerItemSaveData.cs index f094444b..0b6a4e13 100644 --- a/Multiplayer/Networking/Data/Items/PlayerItemSaveData.cs +++ b/Multiplayer/Networking/Data/Items/PlayerItemSaveData.cs @@ -1,5 +1,6 @@ using LiteNetLib.Utils; using Newtonsoft.Json.Linq; +using Newtonsoft.Json; using System; using System.Numerics; @@ -39,6 +40,7 @@ private enum DataFlags : byte public bool IsDropped { get; set; } private Vector3 _position; + [JsonIgnore] public Vector3 Position { get @@ -51,6 +53,7 @@ public Vector3 Position } private Quaternion _rotation; + [JsonIgnore] public Quaternion Rotation { get diff --git a/Multiplayer/Networking/Data/ServerPlayer.cs b/Multiplayer/Networking/Data/ServerPlayer.cs index 1d60921b..e42f4669 100644 --- a/Multiplayer/Networking/Data/ServerPlayer.cs +++ b/Multiplayer/Networking/Data/ServerPlayer.cs @@ -41,12 +41,15 @@ public void Dispose() public string Username { get; set; } public string OriginalUsername { get; set; } public Guid Guid { get; set; } + public ulong SteamId { get; } public string CharacterId { get; set; } public bool IsVR { get; } public uint LastHighPingTickLogged { get; set; } public uint LastTrackingTick { get; set; } public bool HasTrackingTick { get; set; } public bool CustomizationSnapshotSent { get; set; } + internal bool InventoryReconciliationComplete { get; set; } + internal bool InventoryReconciliationFailed { get; set; } public HashSet ProcessedCustomizationActionIds { get; } = []; public PlayerTrackingData TrackingData { get; set; } @@ -104,12 +107,12 @@ public string DisplayName public IEnumerable OwnedItems => NetworkedItem.GetAll() .Where(item => item != null && item.OwnerPlayerId == PlayerId) .Select(item => item.NetId); - public StorageBase Storage { get; set; } = new StorageBase(); private Vector3 _lastWorldPos = Vector3.zero; private Vector3 _lastAbsoluteWorldPosition = Vector3.zero; - public ServerPlayer(ITransportPeer peer, string username, string originalUsername, Guid guid, string characterId, bool isVr) + public ServerPlayer(ITransportPeer peer, string username, string originalUsername, Guid guid, ulong steamId, + string characterId, bool isVr) { PlayerId = idPool.NextId; @@ -119,6 +122,7 @@ public ServerPlayer(ITransportPeer peer, string username, string originalUsernam Username = username; OriginalUsername = originalUsername; Guid = guid; + SteamId = steamId; CharacterId = characterId; IsVR = isVr; diff --git a/Multiplayer/Networking/Managers/Client/NetworkClient.cs b/Multiplayer/Networking/Managers/Client/NetworkClient.cs index 6f1e8d42..5ce4122a 100644 --- a/Multiplayer/Networking/Managers/Client/NetworkClient.cs +++ b/Multiplayer/Networking/Managers/Client/NetworkClient.cs @@ -133,6 +133,7 @@ public void Start(string address, int port, string password, bool isSinglePlayer //netManager.Start(); base.Start(); + SteamworksUtils.GetSteamUser(out _, out ulong steamId); ServerboundClientLoginPacket serverboundClientLoginPacket = new() { Username = this.Username, @@ -141,7 +142,8 @@ public void Start(string address, int port, string password, bool isSinglePlayer BuildVersion = MainMenuControllerPatch.MenuProvider.BuildVersionString, Mods = ModCompatibilityManager.Instance.GetLocalMods(), CharacterId = Multiplayer.Settings.CharacterId, - IsVR = VRManager.IsVREnabled() + IsVR = VRManager.IsVREnabled(), + SteamId = steamId, }; Log("Sending Login Packet"); diff --git a/Multiplayer/Networking/Managers/Server/ChatManager.cs b/Multiplayer/Networking/Managers/Server/ChatManager.cs index acefa3f4..136f7082 100644 --- a/Multiplayer/Networking/Managers/Server/ChatManager.cs +++ b/Multiplayer/Networking/Managers/Server/ChatManager.cs @@ -1,5 +1,8 @@ using Multiplayer.Components.Networking; using Multiplayer.Networking.Data; +using Multiplayer.Components.SaveGame; +using Multiplayer.Components.Networking.World; +using DV.Utils; using System; using System.Collections.Generic; using System.Linq; @@ -24,6 +27,7 @@ public class ChatManager public const string COMMAND_KICK = "kick"; public const string COMMAND_SET_CREW_NAME = "crew"; public const string COMMAND_SET_CREW_NAME_SHORT = "sc"; + public const string COMMAND_RECOVER_ITEMS = "recoveritems"; public const string MESSAGE_COLOUR_SERVER = "9CDCFE"; @@ -32,6 +36,7 @@ public class ChatManager private readonly Dictionary _registeredCommands = []; private readonly Dictionary> _registeredHelpMessages = []; private readonly List _chatFilters = []; + private readonly HashSet recoveringInventories = []; public ChatManager() { @@ -92,6 +97,15 @@ private void RegisterBuiltInCommands() SetCrewNameMessage ); + RegisterChatCommand + ( + COMMAND_RECOVER_ITEMS, + null, + () => "Recover an offline player's stored inventory to Lost and Found (host only)" + + $"\r\n\t\t/{COMMAND_RECOVER_ITEMS} ", + RecoverItemsMessage + ); + #if DEBUG RegisterChatCommand ( @@ -367,6 +381,97 @@ private void HelpMessage(string _, ServerPlayer player) NetworkLifecycle.Instance.Server.SendWhisper(sb.ToString(), player); } + private void RecoverItemsMessage(string message, ServerPlayer sender) + { + if (sender == null || !NetworkLifecycle.Instance.IsHost(sender)) + return; + + string target = message?.Trim(); + if (string.IsNullOrEmpty(target)) + { + SendCommandResponse(sender, $"Usage: /{COMMAND_RECOVER_ITEMS} "); + return; + } + + var savedPlayers = NetworkedSaveGameManager.Instance.GetSavedPlayerInventories(); + List matches; + if (Guid.TryParse(target, out Guid guid)) + matches = savedPlayers.Where(player => player.Guid == guid).ToList(); + else if (ulong.TryParse(target, out ulong steamId) && steamId != 0) + matches = savedPlayers.Where(player => player.SteamId == steamId).ToList(); + else + matches = savedPlayers.Where(player => + string.Equals(player.Username, target, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (matches.Count == 0) + { + SendCommandResponse(sender, $"No stored offline inventory found for '{target}'."); + return; + } + + if (matches.Count > 1) + { + bool duplicateSteamIds = matches + .Where(player => player.SteamId != 0) + .GroupBy(player => player.SteamId) + .Any(group => group.Count() > 1); + var lines = matches + .OrderByDescending(player => player.LastConnectedUtc) + .Select(player => $"{player.Username}: {FormatHostLocalTime(player.LastConnectedUtc)}, " + + (player.SteamId != 0 && !duplicateSteamIds + ? $"SteamID {player.SteamId}" + : $"GUID {player.Guid}")); + SendCommandResponse(sender, "Multiple stored players matched; rerun using SteamID or GUID:\r\n" + + string.Join("\r\n", lines)); + return; + } + + SavedPlayerInventory stored = matches[0]; + if (!recoveringInventories.Add(stored.Guid)) + { + SendCommandResponse(sender, $"Inventory recovery for {stored.Username} is already in progress."); + return; + } + bool isOnline = NetworkLifecycle.Instance.Server.ServerPlayers.Any(player => + player.Peer != NetworkLifecycle.Instance.Server.SelfPeer && player.Guid == stored.Guid); + if (isOnline) + { + recoveringInventories.Remove(stored.Guid); + SendCommandResponse(sender, $"{stored.Username} is currently connected; recovery was not started."); + return; + } + + SendCommandResponse(sender, $"Recovering {stored.Items.Length} stored items for {stored.Username}..."); + CoroutineManager.Instance.StartCoroutine(NetworkedItemManager.Instance.RecoverStoredInventory(stored, + (success, itemCount, containerCount, error) => + { + recoveringInventories.Remove(stored.Guid); + if (success) + SendCommandResponse(sender, + $"Recovered {itemCount} items ({containerCount} containers) for {stored.Username}. " + + "Starting essentials were excluded and will be supplied on their next connection."); + else + SendCommandResponse(sender, error ?? "Inventory recovery failed."); + })); + } + + private static string FormatHostLocalTime(DateTime utc) + { + if (utc == default) + return "last connection unknown"; + + DateTime local = utc.ToLocalTime(); + TimeSpan offset = TimeZoneInfo.Local.GetUtcOffset(local); + string sign = offset < TimeSpan.Zero ? "-" : "+"; + return $"{local:dd MMM yyyy HH:mm} UTC{sign}{offset.Duration():hh\\:mm}"; + } + + private static void SendCommandResponse(ServerPlayer recipient, string message) + { + NetworkLifecycle.Instance.Server.SendWhisper( + $"{message}", recipient); + } + private ServerPlayer ServerPlayerFromUsername(string playerName) { diff --git a/Multiplayer/Networking/Managers/Server/NetworkServer.cs b/Multiplayer/Networking/Managers/Server/NetworkServer.cs index 20f7ced5..d7fd25fc 100644 --- a/Multiplayer/Networking/Managers/Server/NetworkServer.cs +++ b/Multiplayer/Networking/Managers/Server/NetworkServer.cs @@ -1,4 +1,5 @@ using DV; +using DV.Common; using DV.Customization; using DV.Customization.Paint; using DV.Garages; @@ -9,6 +10,8 @@ using DV.ServicePenalty; using DV.ThingTypes; using DV.WeatherSystem; +using DV.UserManagement; +using DV.UserManagement.Data; using Humanizer; using LiteNetLib; using LiteNetLib.Utils; @@ -19,6 +22,7 @@ using Multiplayer.Components.Networking.Jobs; using Multiplayer.Components.Networking.Train; using Multiplayer.Components.Networking.World; +using Multiplayer.Components.SaveGame; using Multiplayer.Networking.Data; using Multiplayer.Networking.Data.Items; using Multiplayer.Networking.Data.Jobs; @@ -342,13 +346,13 @@ public override void OnPeerDisconnected(ITransportPeer peer, DisconnectReason di { LogDebug(() => $"OnPeerDisconnected({peer.Id})"); if (!peerToPlayer.TryGetValue(peer, out ServerPlayer player)) + { LogWarning($"Peer {peer.GetType()}, peerId: {peer.Id} disconnected but no player found"); + return; + } else Log($"Player {player?.Username} disconnected: {disconnectReason}"); - if (WorldStreamingInit.isLoaded) - SaveGameManager.Instance.UpdateInternalData(); - serverPlayers.Remove(player.PlayerId); peers.Remove(player.PlayerId); peerToPlayer.Remove(peer); @@ -365,6 +369,13 @@ public override void OnPeerDisconnected(ITransportPeer peer, DisconnectReason di PlayerDisconnected?.Invoke(player); + var disconnectSave = SaveGameManager.Instance?.Save(SaveType.Auto, null, true); + if (disconnectSave == null) + LogError($"Immediate disconnect autosave failed for {player.Username}; " + + $"saveAllowed={SaveGameManager.Instance?.SaveAllowed()}, worldLoaded={WorldStreamingInit.IsLoaded}"); + else + Log($"Immediate disconnect autosave completed for {player.Username}"); + player?.Dispose(); } @@ -1110,6 +1121,20 @@ private void OnServerboundClientLoginPacket(ServerboundClientLoginPacket packet, Log($"Processing login packet for {packet.Username} ({guid}){(Multiplayer.Settings.LogIps ? $" at {request.RemoteEndPoint.Address}" : "")}"); + // GUID is the canonical identity for persisted remote inventories. Local + // multi-instance testing shares the host's settings (and therefore its + // GUID), but host inventory is not stored in this per-client namespace. + // Keep duplicate protection between remote clients only. + if (ServerPlayers.Any(player => player.Peer != SelfPeer && player.Guid == guid)) + { + LogWarning($"Denied duplicate player identity for {packet.Username} ({guid}, Steam {packet.SteamId})"); + request.Reject(WritePacket(new ClientboundLoginResponsePacket + { + ReasonKey = Locale.DISCONN_REASON__REJECTED_KEY + })); + return; + } + if (Multiplayer.Settings.Password != packet.Password) { LogWarning("Denied login due to invalid password!"); @@ -1193,6 +1218,7 @@ private void OnServerboundClientLoginPacket(ServerboundClientLoginPacket packet, overrideUsername, packet.Username, guid, + packet.SteamId, packet.CharacterId, packet.IsVR ); @@ -1239,6 +1265,7 @@ private void OnServerboundLoadStateUpdatePacket(ServerboundLoadStateUpdatePacket Log($"Player {player.Username} is ready for game data"); PlayerConnected?.Invoke(player); + NetworkedSaveGameManager.Instance.RecordPlayerLogin(player); var gameParamsPacket = ClientboundGameParamsPacket.FromGameParams(Globals.G.GameParams); gameParamsPacket.FastTravelAdvancesTime = fastTravelAdvancesTime; diff --git a/Multiplayer/Networking/Packets/Clientbound/ClientboundSaveGameDataPacket.cs b/Multiplayer/Networking/Packets/Clientbound/ClientboundSaveGameDataPacket.cs index 66234715..7de9837e 100644 --- a/Multiplayer/Networking/Packets/Clientbound/ClientboundSaveGameDataPacket.cs +++ b/Multiplayer/Networking/Packets/Clientbound/ClientboundSaveGameDataPacket.cs @@ -35,6 +35,7 @@ public class ClientboundSaveGameDataPacket // public string Debt_insurance { get; set; } public PlayerItemSaveData[] PlayerItems { get; set; } + public bool HasSavedInventory { get; set; } public float JobManagerTime { get; set; } @@ -62,6 +63,33 @@ public static ClientboundSaveGameDataPacket CreatePacket(ServerPlayer player) return $"ClientboundSaveGameDataPacket.CreatePacket() UnlockedGen: {{{unlockedGen}}}, PacketGen: {{{packetGen}}}, UnlockedJob: {{{unlockedJob}}}, PacketJob: {{{packetJob}}}"; }); + bool hasSavedInventory = NetworkedSaveGameManager.Instance.TryGetPlayerInventory( + data, player.Guid, out PlayerItemSaveData[] savedItems); + List playerItems = hasSavedInventory + ? new List(savedItems) + : CreateDefaultInventory(); + if (hasSavedInventory) + RepairInvalidRootInventorySlots(playerItems, player.Guid); + + return new ClientboundSaveGameDataPacket + { + GameMode = data.GetString(SaveGameKeys.Game_mode), + SerializedDifficulty = difficulty.ToString(Formatting.None), + Money = StartingItemsController.Instance == null || !StartingItemsController.Instance.itemsLoaded ? data.GetFloat(SaveGameKeys.Player_money).GetValueOrDefault(0) : (float)Inventory.Instance.PlayerMoney, + 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, + HasDebt = data.GetFloat(SaveGameKeys.Debt_total).GetValueOrDefault(CareerManagerDebtController.Instance != null ? CareerManagerDebtController.Instance.NumberOfNonZeroPricedDebts : 0) > 0, + JobManagerTime = JobsManager.Instance.Time, + PlayerItems = playerItems.ToArray(), + HasSavedInventory = hasSavedInventory, + }; + } + + private static List CreateDefaultInventory() + { List playerItems = []; string[] items = ["shovel", "lighter", "Oiler", "Lantern", "Flashlight", "Hanger", "DuctTape"]; string[] states = ["", "", "", "", "{\"Restock\": true,\"Battery_power\": 100}", "", ""]; @@ -85,29 +113,43 @@ public static ClientboundSaveGameDataPacket CreatePacket(ServerPlayer player) playerItems.Add(testItem); } - return new ClientboundSaveGameDataPacket + return playerItems; + } + + private static void RepairInvalidRootInventorySlots(List items, System.Guid playerGuid) + { + const int inventoryCapacity = 36; + var usedSlots = new HashSet(); + int repaired = 0; + + for (int i = 0; i < items.Count; i++) { - GameMode = data.GetString(SaveGameKeys.Game_mode), - SerializedDifficulty = difficulty.ToString(Formatting.None), - Money = StartingItemsController.Instance == null || !StartingItemsController.Instance.itemsLoaded ? data.GetFloat(SaveGameKeys.Player_money).GetValueOrDefault(0) : (float)Inventory.Instance.PlayerMoney, - 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, - 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(), - // Debt_existing_jobs = data.GetJObjectArray(SaveGameKeys.Debt_existing_jobs)?.NotNull().Select(j => j.ToString()).ToArray(), - // Debt_staged_jobs = data.GetJObjectArray(SaveGameKeys.Debt_staged_jobs)?.NotNull().Select(j => j.ToString()).ToArray(), - // Debt_existing_jobless_cars = data.GetJObject(SaveGameKeys.Debt_existing_jobless_cars)?.ToString(), - // Debt_deleted_jobless_cars = data.GetJObject(SaveGameKeys.Debt_deleted_jobless_cars)?.ToString(), - // Debt_insurance = data.GetJObject(SaveGameKeys.Debt_insurance)?.ToString() + PlayerItemSaveData item = items[i]; + if (item.IsGrabbed || !string.IsNullOrEmpty(item.ContainerId)) + continue; - JobManagerTime = JobsManager.Instance.Time, + bool slotIsValidAndFree = item.InventorySlotIndex >= 0 && + item.InventorySlotIndex < inventoryCapacity && usedSlots.Add(item.InventorySlotIndex); + if (slotIsValidAndFree) + continue; - PlayerItems = playerItems.ToArray() - }; + int freeSlot = -1; + for (int slot = 0; slot < inventoryCapacity; slot++) + { + if (usedSlots.Add(slot)) + { + freeSlot = slot; + break; + } + } + + item.InventorySlotIndex = freeSlot; + items[i] = item; + repaired++; + } + + if (repaired > 0) + Multiplayer.LogWarning($"Repaired {repaired} invalid persisted inventory slots for {playerGuid}"); } public ClientboundSaveGameDataPacket Clone() diff --git a/Multiplayer/Networking/Packets/Serverbound/ServerboundClientLoginPacket.cs b/Multiplayer/Networking/Packets/Serverbound/ServerboundClientLoginPacket.cs index 1ffcd651..688ea49c 100644 --- a/Multiplayer/Networking/Packets/Serverbound/ServerboundClientLoginPacket.cs +++ b/Multiplayer/Networking/Packets/Serverbound/ServerboundClientLoginPacket.cs @@ -11,4 +11,5 @@ public class ServerboundClientLoginPacket public ModInfo[] Mods { get; set; } public string CharacterId { get; set; } public bool IsVR { get; set; } + public ulong SteamId { get; set; } } diff --git a/Multiplayer/Patches/SaveGame/StartingItemsControllerPatch.cs b/Multiplayer/Patches/SaveGame/StartingItemsControllerPatch.cs new file mode 100644 index 00000000..837ca71e --- /dev/null +++ b/Multiplayer/Patches/SaveGame/StartingItemsControllerPatch.cs @@ -0,0 +1,18 @@ +using HarmonyLib; + +namespace Multiplayer.Patches.SaveGame; + +[HarmonyPatch(typeof(StartingItemsController), "StartingItemsSafeguard")] +internal static class StartingItemsControllerSafeguardPatch +{ + private static bool Prefix(SaveGameData saveGameData) + { + bool authoritative = saveGameData?.GetBool("Multiplayer_AuthoritativeInventory") == true; + bool returningPlayer = saveGameData?.GetBool("Multiplayer_HasSavedInventory") == true; + + // First-time multiplayer players receive the seven supplied tools plus + // native essentials such as the radio and maps. Returning players use + // their persisted record verbatim, including an intentionally empty one. + return !authoritative || !returningPlayer; + } +} From 0b7b61f6e9b66769658750abdf1e2b971b8836ec Mon Sep 17 00:00:00 2001 From: Patrick van Halm Date: Tue, 25 Aug 2026 22:10:57 +0200 Subject: [PATCH 07/14] Increment version --- Multiplayer/Multiplayer.csproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Multiplayer/Multiplayer.csproj b/Multiplayer/Multiplayer.csproj index 2d763dee..ee7f908d 100644 --- a/Multiplayer/Multiplayer.csproj +++ b/Multiplayer/Multiplayer.csproj @@ -3,7 +3,7 @@ net48 latest Multiplayer - 0.1.15.4 + 0.1.16.0 @@ -26,11 +26,11 @@ - + $(NoWarn);CS0436 - + From c3ed2f9be100c85c70648eec2f9e661a1ab07db2 Mon Sep 17 00:00:00 2001 From: Patrick van Halm Date: Sun, 6 Sep 2026 19:16:26 +0200 Subject: [PATCH 08/14] Code cleanup --- .../ClientCustomizationSynchronization.cs | 276 +++++++++ .../World/CustomizationStateManager.cs | 58 +- .../World/HostCustomizationSynchronization.cs | 115 ++++ .../World/InventorySynchronization.cs | 586 ++++++++++++++++++ .../Networking/World/NetworkedItem.cs | 86 ++- .../Networking/World/NetworkedItemManager.cs | 561 +++-------------- .../World/ShopPurchaseCoordinator.cs | 83 ++- .../SaveGame/NetworkedSaveGameManager.cs | 1 + .../Networking/Data/Items/ItemUpdateData.cs | 7 + Multiplayer/Networking/Data/ServerPlayer.cs | 1 + .../Managers/Client/NetworkClient.cs | 295 +-------- .../Managers/Server/NetworkServer.cs | 102 +-- .../Common/Customization/LoadSpoolPacket.cs | 6 + .../SaveGame/StorageSerializerPatch.cs | 48 ++ .../Patches/World/GadgetRelationshipPatch.cs | 3 +- Multiplayer/Patches/World/ShopPatch.cs | 7 +- 16 files changed, 1352 insertions(+), 883 deletions(-) create mode 100644 Multiplayer/Components/Networking/World/ClientCustomizationSynchronization.cs create mode 100644 Multiplayer/Components/Networking/World/HostCustomizationSynchronization.cs create mode 100644 Multiplayer/Components/Networking/World/InventorySynchronization.cs create mode 100644 Multiplayer/Patches/SaveGame/StorageSerializerPatch.cs diff --git a/Multiplayer/Components/Networking/World/ClientCustomizationSynchronization.cs b/Multiplayer/Components/Networking/World/ClientCustomizationSynchronization.cs new file mode 100644 index 00000000..1ea33239 --- /dev/null +++ b/Multiplayer/Components/Networking/World/ClientCustomizationSynchronization.cs @@ -0,0 +1,276 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using DV.Utils; +using LiteNetLib.Utils; +using Multiplayer.Networking.Data.Customization; +using Multiplayer.Networking.Managers.Client; +using Multiplayer.Networking.Packets.Clientbound; +using Multiplayer.Networking.Packets.Common.Customization; +using UnityEngine; + +namespace Multiplayer.Components.Networking.World; + +// Owns baseline/live-action replay while NetworkClient keeps transport and load-state sequencing. +internal sealed class ClientCustomizationSynchronization +{ + private readonly NetworkClient client; + internal ClientCustomizationSynchronization(NetworkClient client) => this.client = client; + private bool customizerStateLoaded; + private bool customizationSnapshotApplied; + private bool applyingCustomizationSnapshot; + private bool customizationActionDrainRunning; + private bool customizationSnapshotTimedOut; + private CustomizationStateData pendingCustomizationState; + private readonly List pendingCustomizationSnapshotPlacements = []; + private readonly List pendingCustomizationSnapshotRelationships = []; + private readonly Dictionary> pendingCustomizationSnapshotHoleStates = []; + private readonly List pendingCustomizationActions = []; + private readonly List appliedActionsSinceSnapshotTimeout = []; + + internal void Reset(bool forLoading = false) + { + customizerStateLoaded = false; + customizationSnapshotApplied = false; + if (forLoading) + { + applyingCustomizationSnapshot = false; + customizationActionDrainRunning = false; + } + customizationSnapshotTimedOut = false; + pendingCustomizationState = null; + pendingCustomizationSnapshotPlacements.Clear(); + pendingCustomizationSnapshotRelationships.Clear(); + pendingCustomizationSnapshotHoleStates.Clear(); + pendingCustomizationActions.Clear(); + appliedActionsSinceSnapshotTimeout.Clear(); + CustomizationStateManager.ClearPendingLocalState(); + } + + internal IEnumerator LoadInitialState(float timeout) + { + // The host may need to stream customized train-car interiors before taking + // the complete world snapshot. Do not mistake that preparation for a lost + // packet on low-latency connections where timeout is very small. + float customizationDeadline = Time.time + Mathf.Max(timeout, 5f); + while (!customizerStateLoaded) + { + if (pendingCustomizationState == null) + { + if (Time.time >= customizationDeadline) + { + client.LogWarning("Timed out waiting for customization state; continuing while retaining the late-snapshot path"); + customizationSnapshotTimedOut = true; + customizationSnapshotApplied = true; + customizerStateLoaded = true; + StartCustomizationActionDrain(); + break; + } + yield return null; + continue; + } + + yield return ApplyPendingCustomizationStates(); + customizerStateLoaded = true; + } + + } + + internal void RecordLocalAction(ICustomizationActionPacket packet) + { + // Ordinary actions are not echoed to their sender. Preserve locally + // originated mutations so a snapshot that arrives after the loading + // timeout cannot erase them. Replacement actions are server-echoed after + // their authoritative replacement item id has been assigned. + if (customizationSnapshotTimedOut && packet is not ReplaceSpoolPacket and not ReplaceDuctTapePacket) + appliedActionsSinceSnapshotTimeout.Add(packet.Copy()); + + } + + internal void ReceiveSnapshot(ClientboundCustomizationStatePacket packet) + { + if (packet?.State == null) + { + client.LogWarning("Received an invalid customization state packet"); + return; + } + + pendingCustomizationState = packet.State; + // Pause live mutation application immediately. If the timeout path already + // released actions, they are replayed after this older baseline is applied. + customizationSnapshotApplied = false; + if (customizerStateLoaded && !applyingCustomizationSnapshot) + CoroutineManager.Instance.StartCoroutine(ApplyPendingCustomizationStates()); + } + + internal void ReceiveAction(T packet) + where T : CustomizationActionPacket, INetSerializable, new() + { + if (packet == null) + return; + + // LiteNetLib reuses the INetSerializable instance registered for this + // subscription. Keep a value copy because the action can remain queued + // while later packets are deserialized into that same instance. + pendingCustomizationActions.Add(packet.Copy()); + StartCustomizationActionDrain(); + } + + private IEnumerator ApplyPendingCustomizationStates() + { + if (applyingCustomizationSnapshot) + yield break; + + applyingCustomizationSnapshot = true; + try + { + while (pendingCustomizationState != null) + { + CustomizationStateData state = pendingCustomizationState; + pendingCustomizationState = null; + pendingCustomizationSnapshotPlacements.Clear(); + pendingCustomizationSnapshotRelationships.Clear(); + pendingCustomizationSnapshotHoleStates.Clear(); + yield return CustomizationStateManager.ApplyCurrentStateWhenReady(state, + pendingCustomizationSnapshotPlacements, pendingCustomizationSnapshotRelationships, + pendingCustomizationSnapshotHoleStates); + + if (customizationSnapshotTimedOut) + { + var capturedLocalActions = state.ProcessedOriginActionIds.ToHashSet(); + pendingCustomizationActions.InsertRange(0, appliedActionsSinceSnapshotTimeout.Where( + action => !capturedLocalActions.Contains(action.OriginActionId))); + appliedActionsSinceSnapshotTimeout.Clear(); + customizationSnapshotTimedOut = false; + } + + customizationSnapshotApplied = true; + client.Log($"Customization state loaded ({state.Gadgets.Count} gadgets, {state.Holes.Count} holes)"); + } + } + finally + { + applyingCustomizationSnapshot = false; + } + + StartCustomizationActionDrain(); + } + + private void StartCustomizationActionDrain() + { + if (customizationActionDrainRunning || + pendingCustomizationSnapshotPlacements.Count == 0 && + pendingCustomizationSnapshotRelationships.Count == 0 && + pendingCustomizationSnapshotHoleStates.Count == 0 && + pendingCustomizationActions.Count == 0) + return; + + CoroutineManager.Instance.StartCoroutine(DrainPendingCustomizationActions()); + } + + private IEnumerator DrainPendingCustomizationActions() + { + customizationActionDrainRunning = true; + try + { + while (client.IsRunning && (pendingCustomizationSnapshotPlacements.Count > 0 || + pendingCustomizationSnapshotRelationships.Count > 0 || + pendingCustomizationSnapshotHoleStates.Count > 0 || pendingCustomizationActions.Count > 0)) + { + if (!customizationSnapshotApplied || applyingCustomizationSnapshot) + { + yield return null; + continue; + } + + bool appliedAny = false; + for (int index = 0; index < pendingCustomizationSnapshotPlacements.Count; index++) + { + var placement = pendingCustomizationSnapshotPlacements[index]; + if (!CustomizationStateManager.IsSnapshotPlacementReady(placement, out _)) + continue; + + pendingCustomizationSnapshotPlacements.RemoveAt(index--); + // The initial object-state pass already applied or queued these + // values. If the item did not exist, its reliable Create carries + // a newer full state. Reapplying this older baseline here could + // roll back changes received while the target was unavailable. + CustomizationStateManager.ApplySnapshotPlacement(placement, applyTrackedValues: false); + appliedAny = true; + } + + foreach (string targetKey in pendingCustomizationSnapshotHoleStates.Keys.ToArray()) + { + if (!CustomizationStateManager.ApplySnapshotHoleState(targetKey, + pendingCustomizationSnapshotHoleStates[targetKey])) + continue; + + pendingCustomizationSnapshotHoleStates.Remove(targetKey); + appliedAny = true; + } + + for (int index = 0; index < pendingCustomizationSnapshotRelationships.Count; index++) + { + var action = pendingCustomizationSnapshotRelationships[index]; + bool blockedByEarlierDependency = pendingCustomizationSnapshotRelationships.Take(index) + .Any(earlier => CustomizationStateManager.ActionsConflict(earlier, action)); + bool blockedByPlacement = pendingCustomizationSnapshotPlacements + .Any(placement => CustomizationStateManager.SnapshotPlacementConflicts(placement, action)); + bool blockedByHoleState = pendingCustomizationSnapshotHoleStates.Keys + .Any(targetKey => CustomizationStateManager.SnapshotHoleStateConflicts(targetKey, action)); + if (blockedByEarlierDependency || blockedByPlacement || blockedByHoleState || + !CustomizationStateManager.IsActionReady(action, out _)) + continue; + + pendingCustomizationSnapshotRelationships.RemoveAt(index--); + try + { + CustomizationStateManager.ApplyAction(action); + } + catch (Exception exception) + { + client.LogError($"Failed to apply deferred snapshot relationship {action.GetType().Name}: {exception}"); + } + appliedAny = true; + } + + for (int index = 0; index < pendingCustomizationActions.Count; index++) + { + var action = pendingCustomizationActions[index]; + bool blockedByEarlierDependency = pendingCustomizationActions.Take(index) + .Any(earlier => CustomizationStateManager.ActionsConflict(earlier, action)); + bool blockedBySnapshotPlacement = pendingCustomizationSnapshotPlacements + .Any(placement => CustomizationStateManager.SnapshotPlacementConflicts(placement, action)); + bool blockedBySnapshotRelationship = pendingCustomizationSnapshotRelationships + .Any(relationship => CustomizationStateManager.ActionsConflict(relationship, action)); + bool blockedBySnapshotHoleState = pendingCustomizationSnapshotHoleStates.Keys + .Any(targetKey => CustomizationStateManager.SnapshotHoleStateConflicts(targetKey, action)); + if (blockedByEarlierDependency || blockedBySnapshotPlacement || + blockedBySnapshotRelationship || blockedBySnapshotHoleState || + !CustomizationStateManager.IsActionReady(action, out _)) + continue; + + pendingCustomizationActions.RemoveAt(index--); + try + { + CustomizationStateManager.ApplyAction(action); + } + catch (Exception exception) + { + client.LogError($"Failed to apply deferred customization action {action.GetType().Name}: {exception}"); + } + appliedAny = true; + } + + if (!appliedAny) + yield return null; + } + } + finally + { + customizationActionDrainRunning = false; + } + } + +} diff --git a/Multiplayer/Components/Networking/World/CustomizationStateManager.cs b/Multiplayer/Components/Networking/World/CustomizationStateManager.cs index 43f751e1..a88fe161 100644 --- a/Multiplayer/Components/Networking/World/CustomizationStateManager.cs +++ b/Multiplayer/Components/Networking/World/CustomizationStateManager.cs @@ -36,6 +36,10 @@ public static CustomizationStateData CaptureCurrentState() foreach (var item in NetworkedItem.GetAll()) CaptureItem(state, item); + // Native OnGadgetWired appends subscribers. Replaying in that same order + // restores the alternating sequence without assigning its private list. + state.Wires = state.Wires.OrderBy(GetWireReplayOrder).ToList(); + CaptureFreeHoles(state); LogSnapshot("captured", state); return state; @@ -410,7 +414,8 @@ private static void CaptureLoadedSpool(CustomizationStateData state, NetworkedIt return; state.LoadedSpools.Add(new LoadSpoolPacket - { ToolItemNetId = toolItem.NetId, SpoolItemNetId = spoolItem.NetId }); + { ToolItemNetId = toolItem.NetId, SpoolItemNetId = spoolItem.NetId, + HasRemainingUnits = true, RemainingUnits = tool.remainingUnits }); } private static void CapturePlacement( @@ -496,6 +501,20 @@ private static void CaptureWires(CustomizationStateData state, NetworkedItem own } } + private static (int ControllerId, int SubscriberIndex) GetWireReplayOrder(WireGadgetsPacket wire) + { + if (TryGadget(wire.FirstItemNetId, out var first) && TryGadget(wire.SecondItemNetId, out var second)) + { + if (first is AlternatingController a && + first.WireLinkPorts[wire.FirstPortIndex] is GadgetWiringModule.WireLinkPortMulti) + return (wire.FirstItemNetId, a.subscribers.IndexOf(second)); + if (second is AlternatingController b && + second.WireLinkPorts[wire.SecondPortIndex] is GadgetWiringModule.WireLinkPortMulti) + return (wire.SecondItemNetId, b.subscribers.IndexOf(first)); + } + return (int.MaxValue, 0); + } + private static void CaptureFreeHoles(CustomizationStateData state) { var customizations = Resources.FindObjectsOfTypeAll() @@ -1157,7 +1176,15 @@ private static void ApplySpoolAction(LoadSpoolPacket packet) if (NetworkedItem.TryGet(packet.ToolItemNetId, out var toolItem) && toolItem.GetTrackedItem() is { magazine: not null } tool && NetworkedItem.TryGet(packet.SpoolItemNetId, out var spoolItem)) + { LoadSpool(toolItem, tool, spoolItem); + if (packet.HasRemainingUnits) + { + // Native loading restores AMMO after magazine callbacks have run. + tool.OnAfterMagazineDataLoaded(new Newtonsoft.Json.Linq.JObject { ["AMMO"] = packet.RemainingUnits }); + toolItem.MarkAsSynchronized(); + } + } } private static void LoadSpool(NetworkedItem toolItem, GadgetSolderingTool tool, NetworkedItem spoolItem) @@ -1184,29 +1211,12 @@ private static void LoadSpool(NetworkedItem toolItem, GadgetSolderingTool tool, return; } - ushort loadedNetId = spoolItem.NetId; - byte loadedOwner = spoolItem.OwnerPlayerId; - bool belongsToPlayer = spoolItem.Item?.InventorySpecs?.BelongsToPlayer ?? true; - - Multiplayer.LogWarning($"Reconcile reel load for soldering tool {toolItem.NetId}: updating contained reel " + - $"{currentItem.NetId} with loaded reel {loadedNetId} instead of ejecting it"); - - // Keep the object already held by ItemMagazine and transfer the loaded - // reel's network identity and state onto it. The separately represented - // incoming object is only a duplicate on this peer. - spoolItem.SuppressDestroySync(); - spoolItem.NetId = 0; - Inventory.Instance.DestroyItem(spoolItem.gameObject); - - currentItem.NetId = loadedNetId; - currentItem.SetOwner(loadedOwner); - if (currentItem.Item?.InventorySpecs != null) - currentItem.Item.InventorySpecs.BelongsToPlayer = belongsToPlayer; - currentAmmo.isSpent = false; - tool.currentSpool = currentAmmo; - tool.ReloadResource(); - FinalizeContainedSpool(currentItem); - return; + // Replace a stale local representation through the native magazine. + // Relabelling a spent reel as full changes both its model and resource. + if (!tool.magazine.RemoveItem(0, true, true)) + return; + currentItem.SuppressDestroySync(); + Inventory.Instance.DestroyItem(current); } EnsureInWorldStorage(spoolItem.Item); diff --git a/Multiplayer/Components/Networking/World/HostCustomizationSynchronization.cs b/Multiplayer/Components/Networking/World/HostCustomizationSynchronization.cs new file mode 100644 index 00000000..f8063896 --- /dev/null +++ b/Multiplayer/Components/Networking/World/HostCustomizationSynchronization.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using DV.Utils; +using Multiplayer.Networking.Data; +using Multiplayer.Networking.Managers.Server; +using Multiplayer.Networking.Packets.Common.Customization; +using Multiplayer.Networking.TransportLayers; + +namespace Multiplayer.Components.Networking.World; + +// Orders native customization operations; NetworkServer retains packet routing. +internal sealed class HostCustomizationSynchronization +{ + private readonly NetworkServer server; + private readonly List pendingHostCustomizationActions = []; + private bool hostCustomizationDrainRunning; + internal HostCustomizationSynchronization(NetworkServer server) => this.server = server; + internal void Reset() + { + pendingHostCustomizationActions.Clear(); + hostCustomizationDrainRunning = false; + } + internal void Enqueue(ICustomizationActionPacket packet, ServerPlayer player, ITransportPeer peer, + Action relay) + { + pendingHostCustomizationActions.Add(new PendingHostCustomizationAction(packet, player, peer, relay)); + StartHostCustomizationDrain(); + } + + private void StartHostCustomizationDrain() + { + if (hostCustomizationDrainRunning || pendingHostCustomizationActions.Count == 0) + return; + + CoroutineManager.Instance.StartCoroutine(DrainPendingHostCustomizationActions()); + } + + private IEnumerator DrainPendingHostCustomizationActions() + { + hostCustomizationDrainRunning = true; + try + { + while (server.IsRunning && pendingHostCustomizationActions.Count > 0) + { + bool appliedAny = false; + for (int index = 0; index < pendingHostCustomizationActions.Count; index++) + { + var pending = pendingHostCustomizationActions[index]; + if (!server.TryGetServerPlayer(pending.Peer, out var connectedPlayer) || + connectedPlayer != pending.Player) + { + pendingHostCustomizationActions.RemoveAt(index--); + continue; + } + + bool blockedByEarlierDependency = pendingHostCustomizationActions.Take(index) + .Any(earlier => CustomizationStateManager.ActionsConflict(earlier.Packet, pending.Packet)); + if (blockedByEarlierDependency || + !CustomizationStateManager.IsActionReady(pending.Packet, out _)) + continue; + + pendingHostCustomizationActions.RemoveAt(index--); + bool applied = false; + try + { + applied = CustomizationStateManager.ProcessActionAsHost(pending.Packet, pending.Player); + } + catch (Exception exception) + { + server.LogError($"Failed to apply customization action {pending.Packet.GetType().Name} from " + + $"{pending.Player.Username}: {exception}"); + } + + if (pending.Packet.OriginActionId != 0) + pending.Player.ProcessedCustomizationActionIds.Add(pending.Packet.OriginActionId); + + if (applied) + { + bool echoSender = pending.Packet is ReplaceSpoolPacket or ReplaceDuctTapePacket; + pending.Relay(pending.Packet, pending.Peer, echoSender); + } + appliedAny = true; + } + + if (!appliedAny) + yield return null; + } + } + finally + { + hostCustomizationDrainRunning = false; + } + } + + private sealed class PendingHostCustomizationAction + { + public readonly ICustomizationActionPacket Packet; + public readonly ServerPlayer Player; + public readonly ITransportPeer Peer; + + public readonly Action Relay; + + public PendingHostCustomizationAction(ICustomizationActionPacket packet, ServerPlayer player, + ITransportPeer peer, Action relay) + { + Packet = packet; + Player = player; + Peer = peer; + Relay = relay; + } + } + +} diff --git a/Multiplayer/Components/Networking/World/InventorySynchronization.cs b/Multiplayer/Components/Networking/World/InventorySynchronization.cs new file mode 100644 index 00000000..c99598d1 --- /dev/null +++ b/Multiplayer/Components/Networking/World/InventorySynchronization.cs @@ -0,0 +1,586 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using DV; +using DV.Common; +using DV.InventorySystem; +using DV.Shops; +using DV.Utils; +using Multiplayer.Components.SaveGame; +using Multiplayer.Networking.Data; +using Multiplayer.Networking.Data.Items; +using Multiplayer.Networking.Packets; +using Multiplayer.Networking.Packets.Clientbound; +using Multiplayer.Networking.Packets.Serverbound; +using Multiplayer.Utils; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace Multiplayer.Components.Networking.World; + +// Session inventory workflows extend item replication without owning its tick or transport. +internal sealed class InventorySynchronization +{ + private readonly Dictionary pendingItemReconciliation = []; + private readonly Queue reconciliationBatches = new(); + private bool drainingReconciliationBatches; + private readonly NetworkedItemManager manager; + + internal InventorySynchronization(NetworkedItemManager manager) => this.manager = manager; + + internal void PlayerDisconnected(ServerPlayer disconnectedPlayer) + { + if (disconnectedPlayer == null || !NetworkLifecycle.Instance.IsHost()) + return; + + byte hostPlayerId = NetworkLifecycle.Instance.Server?.SelfId ?? 0; + ServerPlayer hostPlayer = null; + if (hostPlayerId != 0 && disconnectedPlayer.PlayerId != hostPlayerId) + NetworkLifecycle.Instance.Server.TryGetServerPlayer(hostPlayerId, out hostPlayer); + ShopPurchaseCoordinator.TransferPendingOwnership(disconnectedPlayer, hostPlayer); + + if (hostPlayerId == 0 || disconnectedPlayer.PlayerId == hostPlayerId) + return; + + HashSet carriedItems = GetCarriedInventoryItems(disconnectedPlayer); + IReadOnlyList savedItems = []; + bool snapshotComplete = disconnectedPlayer.InventoryReconciliationComplete && + TryCreateSaveData(carriedItems, out savedItems); + if (snapshotComplete) + { + NetworkedSaveGameManager.Instance.StorePlayerInventory(disconnectedPlayer, savedItems); + } + else + { + // Position and identity are independently authoritative. A partial + // reconciliation must preserve the previous inventory snapshot, but + // it must not also discard the player's latest position. + NetworkedSaveGameManager.Instance.StorePlayerMetadata(disconnectedPlayer); + } + + int storedItems = snapshotComplete ? carriedItems.Count : 0; + int discardedPartialItems = snapshotComplete ? 0 : carriedItems.Count; + int preservedItems = 0; + int transferredItems = 0; + foreach (var item in NetworkedItem.GetAll().ToArray()) + { + if (item == null || (item.OwnerPlayerId != disconnectedPlayer.PlayerId && !carriedItems.Contains(item))) + continue; + + if (carriedItems.Contains(item)) + { + ShopPurchaseCoordinator.SuppressStorageTransitionRestock(item.gameObject); + UnityEngine.Object.Destroy(item.gameObject); + continue; + } + + preservedItems++; + item.SetOwner(hostPlayerId, true); + transferredItems++; + } + + Multiplayer.LogDebug(() => + $"Disconnect inventory storage for player {disconnectedPlayer.PlayerId}: " + + $"storedItems={storedItems}, discardedPartialItems={discardedPartialItems}, " + + $"preservedItems={preservedItems}, transferredItems={transferredItems}, " + + $"hostPlayer={hostPlayerId}"); + ShopPurchaseCoordinator.RequestStockRecount(); + } + + public bool TryCaptureCarriedInventory(ServerPlayer player, out IReadOnlyList items) => + TryCreateSaveData(GetCarriedInventoryItems(player), out items); + + private static HashSet GetCarriedInventoryItems(ServerPlayer player) + { + var allItems = NetworkedItem.GetAll() + .Where(item => item != null && item.NetId != 0) + .ToArray(); + foreach (var item in allItems) + item.RefreshPersistenceState(); + var selected = allItems + .Where(item => item.OwnerPlayerId == player.PlayerId && + item.CurrentState is ItemState.InHand or ItemState.InInventory) + .ToHashSet(); + + bool added; + do + { + var selectedContainerIds = selected + .Select(item => item.Item?.GetComponent()?.ContainerId) + .Where(id => !string.IsNullOrEmpty(id)) + .ToHashSet(StringComparer.Ordinal); + added = false; + // Contents travel with their container even if another player put + // them there. Ownership of the root decides whose inventory is saved. + foreach (var item in allItems) + { + if (selected.Contains(item) || item.CurrentState != ItemState.InContainer || + string.IsNullOrEmpty(item.ContainerId) || !selectedContainerIds.Contains(item.ContainerId)) + continue; + selected.Add(item); + added = true; + } + } while (added); + + return selected; + } + + private static bool TryCreateSaveData(IEnumerable items, + out IReadOnlyList saveData) + { + var result = new List(); + foreach (var item in items) + { + if (item?.Item?.InventorySpecs == null) + { + Multiplayer.LogWarning("Could not save a carried item because its inventory specification is unavailable."); + saveData = []; + return false; + } + + JObject state = null; + try + { + state = item.GetComponent()?.SaveItemData()?.DeepClone() as JObject; + } + catch (Exception exception) + { + // Some inactive remote proxies (notably Flashlight) cannot run + // every native save listener. Preserve the item and its layout + // with default custom state instead of discarding the complete + // inventory snapshot. + Multiplayer.LogWarning($"Could not save custom item state for {item.NetId} ({item.name}); " + + $"using default state: {exception.Message}"); + state = []; + } + + // Preserve native stock bookkeeping even if a different save listener + // on an inactive proxy failed (for example the flashlight listener). + state ??= []; + item.GetComponent()?.OnItemSaveDataRequested(state); + + result.Add(new PlayerItemSaveData + { + NetId = 0, + ItemPrefabName = item.Item.InventorySpecs.ItemPrefabName, + BelongsToPlayer = item.Item.InventorySpecs.BelongsToPlayer, + IsGrabbed = item.CurrentState == ItemState.InHand, + State = state ?? [], + InventorySlotIndex = item.InventorySlotIndex, + ContainerSlotIndex = item.ContainerSlotIndex, + ContainerId = item.ContainerId, + InLockedSlot = item.InLockedSlot, + IsDropped = item.IsDroppedInInventory, + }); + } + saveData = result; + return true; + } + + public IEnumerator RecoverStoredInventory(SavedPlayerInventory stored, + Action completed) + { + if (stored == null || !NetworkLifecycle.Instance.IsHost()) + { + completed?.Invoke(false, 0, 0, "Invalid stored inventory."); + yield break; + } + + var recoverableRecords = new List(); + var excludedContainerIds = new HashSet(StringComparer.Ordinal); + int excludedEssentialCount = 0; + foreach (var record in stored.Items) + { + if (string.IsNullOrEmpty(record.ItemPrefabName) || !manager.InventoryPrefabs.ContainsKey(record.ItemPrefabName)) + { + completed?.Invoke(false, 0, 0, + $"Missing item prefab '{record.ItemPrefabName ?? ""}'. Nothing was recovered."); + yield break; + } + + if (manager.InventoryPrefabs[record.ItemPrefabName].IsEssential) + { + excludedEssentialCount++; + string containerId = record.State?["ContainerId"]?.Value(); + if (!string.IsNullOrEmpty(containerId)) + excludedContainerIds.Add(containerId); + continue; + } + + recoverableRecords.Add(record); + } + + // If an essential item is itself a container, promote any non-essential + // direct contents to Lost and Found instead of discarding them with it. + for (int i = 0; i < recoverableRecords.Count; i++) + { + PlayerItemSaveData record = recoverableRecords[i]; + if (!string.IsNullOrEmpty(record.ContainerId) && excludedContainerIds.Contains(record.ContainerId)) + { + record.ContainerId = null; + record.ContainerSlotIndex = -1; + recoverableRecords[i] = record; + } + } + + var created = new List<(PlayerItemSaveData Record, NetworkedItem Item)>(); + string failure = null; + try + { + foreach (var record in recoverableRecords) + { + GameObject gameObject = UnityEngine.Object.Instantiate(manager.InventoryPrefabs[record.ItemPrefabName].gameObject, + Vector3.zero, Quaternion.identity); + var networkedItem = gameObject.GetOrAddComponent(); + networkedItem.DeferInitialCreate(); + created.Add((record, networkedItem)); + } + } + catch (Exception exception) + { + failure = $"Could not instantiate recovered inventory: {exception.Message}"; + } + + if (failure == null) + { + // Let Start register ItemSaveData listeners, then restore container IDs and item state. + yield return null; + foreach (var entry in created) + { + try + { + entry.Item.gameObject.SetActive(false); + var saveData = entry.Item.GetComponent(); + saveData?.LoadItemData(entry.Record.State ?? []); + saveData?.PostLoadItemData(); + } + catch (Exception exception) + { + failure = $"Could not restore {entry.Record.ItemPrefabName}: {exception.Message}"; + break; + } + } + } + + if (failure == null) + { + foreach (var entry in created.Where(value => !string.IsNullOrEmpty(value.Record.ContainerId))) + { + AItemContainer container = Inventory.Instance.ItemContainerRegistry.GetContainer(entry.Record.ContainerId); + int slot = entry.Record.ContainerSlotIndex; + if (container == null || slot < 0 || slot >= container.Capacity || + container[slot] != null || !container.AddItem(entry.Item.gameObject, slot)) + { + failure = $"Could not restore {entry.Record.ItemPrefabName} to container " + + $"'{entry.Record.ContainerId}' slot {slot}."; + break; + } + } + } + + if (failure != null) + { + foreach (var entry in created) + { + if (entry.Item == null) + continue; + entry.Item.SuppressDestroySync(); + ShopPurchaseCoordinator.SuppressStorageTransitionRestock(entry.Item.gameObject); + Inventory.Instance.DestroyItem(entry.Item.gameObject); + } + completed?.Invoke(false, 0, 0, failure + " Nothing was recovered."); + yield break; + } + + byte hostPlayerId = NetworkLifecycle.Instance.Server?.SelfId ?? 0; + int rootCount = 0; + int containerCount = created.Count(entry => entry.Item.GetComponent() != null); + foreach (var entry in created) + { + entry.Item.SetOwner(hostPlayerId); + if (!string.IsNullOrEmpty(entry.Record.ContainerId)) + continue; + + StorageController.Instance.AddItemToLostAndFound(entry.Item.Item, false); + rootCount++; + } + + foreach (var entry in created) + entry.Item.GetComponent()?.PostContainerLoadData(); + + foreach (var entry in created) + entry.Item.PublishDeferredCreate(); + + NetworkedSaveGameManager.Instance.ResetPlayerInventory(stored.Guid); + SaveGameManager.Instance.Save(SaveType.Auto, null, true); + Multiplayer.LogDebug(() => $"Recovered offline inventory for {stored.Guid}: " + + $"items={created.Count}, containers={containerCount}, lostAndFoundRoots={rootCount}, " + + $"excludedEssentials={excludedEssentialCount}, resetToFirstTime=true"); + completed?.Invoke(true, created.Count, containerCount, null); + } + + internal IReadOnlyList CreateItemReconciliationRequests() + { + pendingItemReconciliation.Clear(); + var packets = new List(); + var packet = new ServerboundItemReconciliationPacket(); + int localId = 1; + + foreach (var item in NetworkedItem.GetAll()) + { + if (packet.Items.Count >= ItemReconciliationProtocol.MaxItems) + { + packets.Add(packet); + packet = new ServerboundItemReconciliationPacket(); + } + + if (!IsReconciliationCandidate(item)) + continue; + + var snapshot = item.CreateCurrentUpdateData(ItemUpdateData.ItemUpdateType.Create); + if (snapshot == null) + continue; + + snapshot.ItemNetId = 0; + pendingItemReconciliation[localId] = item; + packet.Items.Add(new ItemReconciliationRequest( + localId, + item.Item.InventorySpecs.BelongsToPlayer, + snapshot)); + localId++; + } + + packet.IsFinalBatch = true; + packets.Add(packet); + Multiplayer.LogDebug(() => $"Prepared {localId - 1} local items in {packets.Count} batch(es) for authoritative reconciliation"); + return packets; + } + + internal bool ApplyItemReconciliation(ClientboundItemReconciliationPacket packet) + { + foreach (var result in packet?.Items ?? []) + { + if (!pendingItemReconciliation.TryGetValue(result.LocalId, out var localItem)) + { + Multiplayer.LogWarning($"Received item reconciliation for unknown local item {result.LocalId}"); + continue; + } + + pendingItemReconciliation.Remove(result.LocalId); + if (result.NetId == 0) + { + Multiplayer.LogWarning($"Host rejected reconciliation for local item {result.LocalId} ({localItem?.name})"); + continue; + } + + // Loading is allowed to continue after the bounded reconciliation + // wait. The local item can therefore be consumed or destroyed before + // a delayed reliable response arrives; let the host-created canonical + // item take over instead of dereferencing Unity's destroyed wrapper. + if (localItem == null) + { + Multiplayer.LogWarning($"Local reconciliation item {result.LocalId} was destroyed before " + + $"authoritative NetId {result.NetId} arrived"); + if (result.Snapshot != null) + manager.CreateItem(result.Snapshot); + continue; + } + + if (NetworkedItem.TryGet(result.NetId, out var existingItem) && existingItem != localItem) + manager.SendToCache(existingItem); + + localItem.NetId = result.NetId; + localItem.SetOwner(NetworkLifecycle.Instance.Client?.PlayerId ?? 0); + localItem.MarkAsSynchronized(); + Multiplayer.LogDebug(() => $"Reconciled local item {result.LocalId} ({localItem.name}) as NetId {result.NetId}"); + } + + if (packet?.IsFinalBatch != true) + return false; + + foreach (var unresolved in pendingItemReconciliation) + Multiplayer.LogWarning($"Host did not return reconciliation data for local item {unresolved.Key} ({unresolved.Value?.name})"); + pendingItemReconciliation.Clear(); + manager.ClientInitialised = true; + return true; + } + + internal void ContinueAfterItemReconciliationTimeout() + { + // Allow ordinary item updates to resume, but retain the local-ID mapping so + // a delayed reliable response can still assign authoritative IDs. + manager.ClientInitialised = true; + } + + internal void EnqueueClientItemReconciliation(ItemReconciliationRequest[] requests, bool isFinalBatch, + ServerPlayer player, Action completed) + { + reconciliationBatches.Enqueue(ReconcileClientItems(requests, isFinalBatch, player, completed)); + if (!drainingReconciliationBatches) + CoroutineManager.Instance.StartCoroutine(DrainReconciliationBatches()); + } + + private IEnumerator DrainReconciliationBatches() + { + drainingReconciliationBatches = true; + try + { + // In particular, an empty final batch must not acknowledge completion + // before preceding batches have initialized their native objects. + while (reconciliationBatches.Count > 0) + yield return reconciliationBatches.Dequeue(); + } + finally + { + drainingReconciliationBatches = false; + } + } + + private IEnumerator ReconcileClientItems( + ItemReconciliationRequest[] requests, bool isFinalBatch, + ServerPlayer player, Action completed) + { + var response = new ClientboundItemReconciliationPacket + { + IsFinalBatch = isFinalBatch, + }; + if (!NetworkLifecycle.Instance.IsHost() || requests == null || player == null) + yield break; + if (!NetworkLifecycle.Instance.Server.TryGetServerPlayer(player.PlayerId, out var initialPlayer) || + initialPlayer != player) + yield break; + + var created = new List<(ItemReconciliationRequest Request, NetworkedItem Item)>(); + foreach (var request in requests) + { + var snapshot = request.Snapshot; + GameObject gameObject = null; + try + { + if (snapshot != null && snapshot.ItemNetId == 0 && + !string.IsNullOrEmpty(snapshot.PrefabName) && + manager.InventoryPrefabs.TryGetValue(snapshot.PrefabName, out var spec) && + IsAllowedReconciliationState(snapshot.ItemState, spec)) + { + gameObject = UnityEngine.Object.Instantiate( + spec.gameObject, + snapshot.ItemPosition + WorldMover.currentMove, + snapshot.ItemRotation); + var networkedItem = gameObject.GetOrAddComponent(); + if (networkedItem.NetId != 0) + { + networkedItem.SetOwner(player.PlayerId); + networkedItem.IsPendingReconciliation = true; + player.ReconciledInventoryItems.Add(networkedItem); + networkedItem.DeferInitialCreate(); + created.Add((request, networkedItem)); + continue; + } + } + } + catch (Exception ex) + { + Multiplayer.LogError($"Failed to reconcile local item {request.LocalId} ({snapshot?.PrefabName}) for {player.Username}: {ex}"); + } + + if (gameObject != null) + { + try + { + UnityEngine.Object.Destroy(gameObject); + } + catch (Exception ex) + { + Multiplayer.LogWarning($"Failed to clean up rejected reconciliation item {request.LocalId}: {ex.Message}"); + } + } + + response.Items.Add(new ItemReconciliationResult(request.LocalId, 0, null)); + player.InventoryReconciliationFailed = true; + } + + // GadgetItem assigns Item in Start; applying a carried snapshot in the + // instantiation frame disables its GameObject before Start can ever run. + // Initialize the whole batch together, including container registries. + if (created.Count > 0) + yield return null; + + if (!NetworkLifecycle.Instance.IsHost() || + !NetworkLifecycle.Instance.Server.TryGetServerPlayer(player.PlayerId, out var connectedPlayer) || + connectedPlayer != player) + { + foreach (var entry in created) + if (entry.Item != null) + { + ShopPurchaseCoordinator.SuppressStorageTransitionRestock(entry.Item.gameObject); + UnityEngine.Object.Destroy(entry.Item.gameObject); + } + yield break; + } + + foreach (var entry in created) + { + var request = entry.Request; + var item = entry.Item; + try + { + if (item == null || !item.IsReadyForSnapshots || + item.GetTrackedItem() is { Item: null }) + throw new InvalidOperationException("Native item initialization did not complete"); + + var snapshot = request.Snapshot; + snapshot.BelongsToPlayer = request.BelongsToPlayer; + snapshot.ItemNetId = item.NetId; + snapshot.Player = player.PlayerId; + snapshot.OwnerPlayerId = player.PlayerId; + item.ReceiveSnapshot(snapshot); + item.IsPendingReconciliation = false; + player.KnownItems[item] = NetworkLifecycle.Instance.Tick; + player.NearbyItems[item] = Time.time; + response.Items.Add(new ItemReconciliationResult(request.LocalId, item.NetId, + item.CreateCurrentUpdateData(ItemUpdateData.ItemUpdateType.Create))); + } + catch (Exception exception) + { + Multiplayer.LogError($"Failed to initialize reconciled item {request.LocalId} for {player.Username}: {exception}"); + if (item != null) + { + ShopPurchaseCoordinator.SuppressStorageTransitionRestock(item.gameObject); + UnityEngine.Object.Destroy(item.gameObject); + } + response.Items.Add(new ItemReconciliationResult(request.LocalId, 0, null)); + player.InventoryReconciliationFailed = true; + } + } + + Multiplayer.LogDebug(() => $"Reconciled {response.Items.Count(item => item.NetId != 0)}/{requests.Length} items for {player.Username}"); + NetworkedItemManager.RestoreContainerMemberships(); + if (isFinalBatch) + { + player.InventoryReconciliationComplete = !player.InventoryReconciliationFailed; + if (player.InventoryReconciliationComplete) + player.ReconciledInventoryItems.Clear(); + ShopPurchaseCoordinator.RequestStockRecount(); + } + completed(response); + } + + private static bool IsAllowedReconciliationState(ItemState state, InventoryItemSpec spec) => + NetworkedItem.IsCarriedState(state) || + state == ItemState.Dropped && spec.IsEssential; + + private static bool IsReconciliationCandidate(NetworkedItem item) + { + if (item?.Item == null || item.NetId != 0 || NetworkedItemManager.IsLocalShopItem(item)) + return false; + + var inventory = Inventory.Instance; + var storage = StorageController.Instance; + return item.Item.IsEssential() || item.Item.IsGrabbed() || + inventory != null && inventory.Contains(item.gameObject, true) || + storage?.StorageInventory != null && storage.StorageInventory.ContainsItem(item.Item) || + storage?.StorageItemContainers != null && storage.StorageItemContainers.ContainsItem(item.Item); + } + +} diff --git a/Multiplayer/Components/Networking/World/NetworkedItem.cs b/Multiplayer/Components/Networking/World/NetworkedItem.cs index 5a7ee4b3..2c2ecf2d 100644 --- a/Multiplayer/Components/Networking/World/NetworkedItem.cs +++ b/Multiplayer/Components/Networking/World/NetworkedItem.cs @@ -87,7 +87,10 @@ public static bool TryGetNetId(ItemBase item, out ushort netID) public bool UsefulItem { get; private set; } = false; public Type TrackedItemType { get; private set; } public uint LastDirtyTick { get; private set; } - public bool IsReadyForSnapshots => registrationComplete && Item != null; + public bool IsReadyForSnapshots => registrationComplete && Item != null && !IsPendingInitialization; + internal bool IsPendingInitialization { get; set; } + internal bool IsPendingReconciliation { get; set; } + private bool pendingContainerRestore; public ItemState CurrentState => lastState; public int InventorySlotIndex { get; private set; } = -1; public int ContainerSlotIndex { get; private set; } = -1; @@ -339,6 +342,9 @@ private void MarkValuesClean() public ItemUpdateData GetSnapshot() { + if (IsPendingInitialization || IsPendingReconciliation || pendingContainerRestore) + return null; + ItemUpdateData snapshot; ItemUpdateData.ItemUpdateType updateType = ItemUpdateData.ItemUpdateType.None; @@ -453,8 +459,30 @@ private void ApplySnapshot(ItemUpdateData snapshot) if (Item?.InventorySpecs != null && snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.Create)) Item.InventorySpecs.BelongsToPlayer = snapshot.BelongsToPlayer; + if (snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.Create) && + Item.GetComponent() is DV.Shops.ShopRestocker restocker) + restocker.OnItemSaveDataLoaded(new Newtonsoft.Json.Linq.JObject + { [DV.Shops.ShopRestocker.RESTOCK_SHOP_KEY] = snapshot.RestockOnDestroy }); + + if (snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.Create) && + !string.IsNullOrEmpty(snapshot.ContainerIdentity) && + Item.GetComponent() is AItemContainer container && + container.ContainerId != snapshot.ContainerIdentity) + container.AssignIdAndRegister(snapshot.ContainerIdentity); + if (snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.ItemState) || snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.FullSync) || snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.Create)) { + // Leave native containers through RemoveItem so their slot, listeners, + // and magazine state cannot retain an item that has moved elsewhere. + if (Item.InContainer is AItemContainer previousContainer && + (snapshot.ItemState != ItemState.InContainer || + previousContainer.ContainerId != snapshot.ContainerId || + snapshot.ContainerSlotIndex < 0 || snapshot.ContainerSlotIndex >= previousContainer.Capacity || + previousContainer[snapshot.ContainerSlotIndex] != gameObject)) + { + DetachFromContainer(); + } + pendingContainerRestore = snapshot.ItemState == ItemState.InContainer; lastState = snapshot.ItemState; Multiplayer.Log($"NetworkedItem.ApplySnapshot() netId: {snapshot?.ItemNetId}, ItemUpdateType: {snapshot?.UpdateType}, ItemState: {snapshot?.ItemState}, Active state: {gameObject.activeInHierarchy}"); @@ -467,10 +495,14 @@ private void ApplySnapshot(ItemUpdateData snapshot) case ItemState.InHand: case ItemState.InInventory: - case ItemState.InContainer: HandleInventoryOrHandState(snapshot); break; + case ItemState.InContainer: + // Membership is restored after this batch so the parent may + // arrive after its contents, including in a later packet. + break; + case ItemState.Attached: HandleAttachedState(snapshot); break; @@ -591,6 +623,7 @@ public ItemUpdateData CreateUpdateData(ItemUpdateData.ItemUpdateType updateType) ItemNetId = NetId, OwnerPlayerId = this.OwnerPlayerId, BelongsToPlayer = Item.InventorySpecs.BelongsToPlayer, + RestockOnDestroy = Item.GetComponent()?.restockOnItemDestroyed == true, PrefabName = Item.InventorySpecs.ItemPrefabName, ItemState = lastState, Active = gameObject.activeSelf, @@ -603,6 +636,7 @@ public ItemUpdateData CreateUpdateData(ItemUpdateData.ItemUpdateType updateType) InventorySlotIndex = InventorySlotIndex, ContainerSlotIndex = ContainerSlotIndex, ContainerId = ContainerId, + ContainerIdentity = Item.GetComponent()?.ContainerId, InLockedSlot = InLockedSlot, IsDropped = IsDroppedInInventory, CarNetId = carId, @@ -666,7 +700,8 @@ internal ItemState RefreshPersistenceState() public ItemUpdateData CreateCurrentUpdateData(ItemUpdateData.ItemUpdateType updateType) { lastState = GetItemState(); - CaptureCurrentLayout(lastState); + if (ShouldObserveLocalLayout()) + CaptureCurrentLayout(lastState); return CreateUpdateData(updateType); } @@ -899,6 +934,51 @@ private void HandleInventoryOrHandState(ItemUpdateData snapshot) public static bool IsCarriedState(ItemState state) => state is ItemState.InHand or ItemState.InInventory or ItemState.InContainer; + internal void RestoreContainerMembership() + { + if (!pendingContainerRestore || IsPendingReconciliation || !IsReadyForSnapshots || + string.IsNullOrEmpty(ContainerId)) + return; + + var container = Inventory.Instance.ItemContainerRegistry.GetContainer(ContainerId); + if (container == null || ContainerSlotIndex < 0 || ContainerSlotIndex >= container.Capacity) + return; + if (container[ContainerSlotIndex] == gameObject) + { + pendingContainerRestore = false; + return; + } + if (container[ContainerSlotIndex] != null) + return; + + using (CustomizationSyncScope.Remote()) + { + // SaveItemData retains the native state used by PostContainerLoadData. + // In particular AMMO must be restored after ItemMagazine.AddItem fires + // its ordinary loaded/full/empty callbacks. + var containerSave = container.GetComponent(); + containerSave?.SaveItemData(); + var storage = StorageController.Instance; + if (!storage.AnyStorageContains(Item) && Item.BelongsToPlayer()) + storage.AddItemToWorldStorage(Item); + if (!container.AddItem(gameObject, ContainerSlotIndex)) + return; + containerSave?.PostContainerLoadData(); + pendingContainerRestore = false; + MarkAsSynchronized(); + } + } + + internal void DetachFromContainer() + { + pendingContainerRestore = false; + if (Item.InContainer is not AItemContainer container) + return; + var (_, slot) = Inventory.Instance.ItemContainerRegistry.GetItemContainerIdAndIndex(gameObject); + using (CustomizationSyncScope.Remote()) + container.RemoveItem(slot, false, false); + } + private bool ShouldObserveLocalLayout() { if (!NetworkLifecycle.Instance.IsClientRunning) diff --git a/Multiplayer/Components/Networking/World/NetworkedItemManager.cs b/Multiplayer/Components/Networking/World/NetworkedItemManager.cs index 7d373798..b0c46545 100644 --- a/Multiplayer/Components/Networking/World/NetworkedItemManager.cs +++ b/Multiplayer/Components/Networking/World/NetworkedItemManager.cs @@ -37,7 +37,7 @@ public class NetworkedItemManager : SingletonBehaviour //caches for item snapshots private List DestroyedItems = new(64); - private Dictionary AcceptedClientUpdates = new(64); + private readonly List AcceptedClientUpdates = new(64); /* * Client @@ -46,8 +46,9 @@ public class NetworkedItemManager : SingletonBehaviour //cache for client-sided items & spawns private Dictionary> CachedItems = new(1024); //Client cached items private Dictionary ItemPrefabs = new(1024); //Item prefabs - private bool ClientInitialised = false; - private readonly Dictionary pendingItemReconciliation = []; + internal IReadOnlyDictionary InventoryPrefabs => ItemPrefabs; + internal bool ClientInitialised { get; set; } + private readonly Dictionary> pendingGadgetCreates = []; /* @@ -73,285 +74,16 @@ protected override void Awake() } } - private void PlayerDisconnected(ServerPlayer disconnectedPlayer) - { - if (disconnectedPlayer == null || !NetworkLifecycle.Instance.IsHost()) - return; - - byte hostPlayerId = NetworkLifecycle.Instance.Server?.SelfId ?? 0; - ServerPlayer hostPlayer = null; - if (hostPlayerId != 0 && disconnectedPlayer.PlayerId != hostPlayerId) - NetworkLifecycle.Instance.Server.TryGetServerPlayer(hostPlayerId, out hostPlayer); - ShopPurchaseCoordinator.TransferPendingOwnership(disconnectedPlayer, hostPlayer); - - if (hostPlayerId == 0 || disconnectedPlayer.PlayerId == hostPlayerId) - return; - - HashSet carriedItems = GetCarriedInventoryItems(disconnectedPlayer); - IReadOnlyList savedItems = []; - bool snapshotComplete = disconnectedPlayer.InventoryReconciliationComplete && - TryCreateSaveData(carriedItems, out savedItems); - if (snapshotComplete) - { - NetworkedSaveGameManager.Instance.StorePlayerInventory(disconnectedPlayer, savedItems); - } - else - { - // Position and identity are independently authoritative. A partial - // reconciliation must preserve the previous inventory snapshot, but - // it must not also discard the player's latest position. - NetworkedSaveGameManager.Instance.StorePlayerMetadata(disconnectedPlayer); - } + private InventorySynchronization inventorySynchronization; + private InventorySynchronization InventorySynchronization => inventorySynchronization ??= new(this); - int storedItems = snapshotComplete ? carriedItems.Count : 0; - int discardedPartialItems = snapshotComplete ? 0 : carriedItems.Count; - int preservedItems = 0; - int transferredItems = 0; - foreach (var item in NetworkedItem.GetAll().ToArray()) - { - if (item == null || item.OwnerPlayerId != disconnectedPlayer.PlayerId) - continue; - - if (carriedItems.Contains(item)) - { - Destroy(item.gameObject); - continue; - } - - preservedItems++; - item.SetOwner(hostPlayerId, true); - transferredItems++; - } - - Multiplayer.LogDebug(() => - $"Disconnect inventory storage for player {disconnectedPlayer.PlayerId}: " + - $"storedItems={storedItems}, discardedPartialItems={discardedPartialItems}, " + - $"preservedItems={preservedItems}, transferredItems={transferredItems}, " + - $"hostPlayer={hostPlayerId}"); - } + private void PlayerDisconnected(ServerPlayer player) => InventorySynchronization.PlayerDisconnected(player); public bool TryCaptureCarriedInventory(ServerPlayer player, out IReadOnlyList items) => - TryCreateSaveData(GetCarriedInventoryItems(player), out items); - - private static HashSet GetCarriedInventoryItems(ServerPlayer player) - { - var ownedItems = NetworkedItem.GetAll() - .Where(item => item != null && item.OwnerPlayerId == player.PlayerId) - .ToArray(); - foreach (var item in ownedItems) - item.RefreshPersistenceState(); - var selected = ownedItems - .Where(item => item.CurrentState is ItemState.InHand or ItemState.InInventory) - .ToHashSet(); - - bool added; - do - { - var selectedContainerIds = selected - .Select(item => item.Item?.GetComponent()?.ContainerId) - .Where(id => !string.IsNullOrEmpty(id)) - .ToHashSet(StringComparer.Ordinal); - added = false; - foreach (var item in ownedItems) - { - if (selected.Contains(item) || item.CurrentState != ItemState.InContainer || - string.IsNullOrEmpty(item.ContainerId) || !selectedContainerIds.Contains(item.ContainerId)) - continue; - selected.Add(item); - added = true; - } - } while (added); - - return selected; - } - - private static bool TryCreateSaveData(IEnumerable items, - out IReadOnlyList saveData) - { - var result = new List(); - foreach (var item in items) - { - if (item?.Item?.InventorySpecs == null) - { - Multiplayer.LogWarning("Could not save a carried item because its inventory specification is unavailable."); - saveData = []; - return false; - } - - JObject state = null; - try - { - state = item.GetComponent()?.SaveItemData()?.DeepClone() as JObject; - } - catch (Exception exception) - { - // Some inactive remote proxies (notably Flashlight) cannot run - // every native save listener. Preserve the item and its layout - // with default custom state instead of discarding the complete - // inventory snapshot. - Multiplayer.LogWarning($"Could not save custom item state for {item.NetId} ({item.name}); " + - $"using default state: {exception.Message}"); - state = []; - } - - result.Add(new PlayerItemSaveData - { - NetId = 0, - ItemPrefabName = item.Item.InventorySpecs.ItemPrefabName, - BelongsToPlayer = item.Item.InventorySpecs.BelongsToPlayer, - IsGrabbed = item.CurrentState == ItemState.InHand, - State = state ?? [], - InventorySlotIndex = item.InventorySlotIndex, - ContainerSlotIndex = item.ContainerSlotIndex, - ContainerId = item.ContainerId, - InLockedSlot = item.InLockedSlot, - IsDropped = item.IsDroppedInInventory, - }); - } - saveData = result; - return true; - } - - public IEnumerator RecoverStoredInventory(SavedPlayerInventory stored, - Action completed) - { - if (stored == null || !NetworkLifecycle.Instance.IsHost()) - { - completed?.Invoke(false, 0, 0, "Invalid stored inventory."); - yield break; - } - - var recoverableRecords = new List(); - var excludedContainerIds = new HashSet(StringComparer.Ordinal); - int excludedEssentialCount = 0; - foreach (var record in stored.Items) - { - if (string.IsNullOrEmpty(record.ItemPrefabName) || !ItemPrefabs.ContainsKey(record.ItemPrefabName)) - { - completed?.Invoke(false, 0, 0, - $"Missing item prefab '{record.ItemPrefabName ?? ""}'. Nothing was recovered."); - yield break; - } - - if (ItemPrefabs[record.ItemPrefabName].IsEssential) - { - excludedEssentialCount++; - string containerId = record.State?["ContainerId"]?.Value(); - if (!string.IsNullOrEmpty(containerId)) - excludedContainerIds.Add(containerId); - continue; - } - - recoverableRecords.Add(record); - } - - // If an essential item is itself a container, promote any non-essential - // direct contents to Lost and Found instead of discarding them with it. - for (int i = 0; i < recoverableRecords.Count; i++) - { - PlayerItemSaveData record = recoverableRecords[i]; - if (!string.IsNullOrEmpty(record.ContainerId) && excludedContainerIds.Contains(record.ContainerId)) - { - record.ContainerId = null; - record.ContainerSlotIndex = -1; - recoverableRecords[i] = record; - } - } - - var created = new List<(PlayerItemSaveData Record, NetworkedItem Item)>(); - string failure = null; - try - { - foreach (var record in recoverableRecords) - { - GameObject gameObject = Instantiate(ItemPrefabs[record.ItemPrefabName].gameObject, - Vector3.zero, Quaternion.identity); - var networkedItem = gameObject.GetOrAddComponent(); - networkedItem.DeferInitialCreate(); - created.Add((record, networkedItem)); - } - } - catch (Exception exception) - { - failure = $"Could not instantiate recovered inventory: {exception.Message}"; - } - - if (failure == null) - { - // Let Start register ItemSaveData listeners, then restore container IDs and item state. - yield return null; - foreach (var entry in created) - { - try - { - entry.Item.gameObject.SetActive(false); - var saveData = entry.Item.GetComponent(); - saveData?.LoadItemData(entry.Record.State ?? []); - saveData?.PostLoadItemData(); - } - catch (Exception exception) - { - failure = $"Could not restore {entry.Record.ItemPrefabName}: {exception.Message}"; - break; - } - } - } - - if (failure == null) - { - foreach (var entry in created.Where(value => !string.IsNullOrEmpty(value.Record.ContainerId))) - { - AItemContainer container = Inventory.Instance.ItemContainerRegistry.GetContainer(entry.Record.ContainerId); - int slot = entry.Record.ContainerSlotIndex; - if (container == null || slot < 0 || slot >= container.Capacity || - container[slot] != null || !container.AddItem(entry.Item.gameObject, slot)) - { - failure = $"Could not restore {entry.Record.ItemPrefabName} to container " + - $"'{entry.Record.ContainerId}' slot {slot}."; - break; - } - } - } - - if (failure != null) - { - foreach (var entry in created) - { - if (entry.Item == null) - continue; - entry.Item.SuppressDestroySync(); - Inventory.Instance.DestroyItem(entry.Item.gameObject); - } - completed?.Invoke(false, 0, 0, failure + " Nothing was recovered."); - yield break; - } + InventorySynchronization.TryCaptureCarriedInventory(player, out items); - byte hostPlayerId = NetworkLifecycle.Instance.Server?.SelfId ?? 0; - int rootCount = 0; - int containerCount = created.Count(entry => entry.Item.GetComponent() != null); - foreach (var entry in created) - { - entry.Item.SetOwner(hostPlayerId); - if (!string.IsNullOrEmpty(entry.Record.ContainerId)) - continue; - - StorageController.Instance.AddItemToLostAndFound(entry.Item.Item, false); - rootCount++; - } - - foreach (var entry in created) - entry.Item.GetComponent()?.PostContainerLoadData(); - - foreach (var entry in created) - entry.Item.PublishDeferredCreate(); - - NetworkedSaveGameManager.Instance.ResetPlayerInventory(stored.Guid); - SaveGameManager.Instance.Save(SaveType.Auto, null, true); - Multiplayer.LogDebug(() => $"Recovered offline inventory for {stored.Guid}: " + - $"items={created.Count}, containers={containerCount}, lostAndFoundRoots={rootCount}, " + - $"excludedEssentials={excludedEssentialCount}, resetToFirstTime=true"); - completed?.Invoke(true, created.Count, containerCount, null); - } + public IEnumerator RecoverStoredInventory(SavedPlayerInventory stored, Action completed) => + InventorySynchronization.RecoverStoredInventory(stored, completed); protected void Start() { @@ -409,6 +141,7 @@ public void ReceiveSnapshots(List snapshots, ServerPlayer sender private void Common_OnTick(uint tick) { ProcessReceived(); + RestoreContainerMemberships(); if (NetworkLifecycle.Instance.IsHost()) { @@ -470,7 +203,7 @@ private void UpdatePlayerItemLists() foreach (var item in allItems) { - if (item == null) + if (item == null || item.IsPendingReconciliation) continue; if (IsLocalShopItem(item)) @@ -514,16 +247,10 @@ private void ProcessChanged(uint tick) dirtyItems.Add(snapshot); } - // Applying a client snapshot marks the host's tracked values clean. Preserve the - // accepted wire representation so it can still be forwarded to the other clients. - foreach (var acceptedUpdate in AcceptedClientUpdates.Values) - { - int existingIndex = dirtyItems.FindIndex(item => item.ItemNetId == acceptedUpdate.ItemNetId); - if (existingIndex >= 0) - dirtyItems[existingIndex] = acceptedUpdate; - else - dirtyItems.Add(acceptedUpdate); - } + // Each packet is a delta: later packets need not contain earlier state or + // tracked-value changes. Replay all accepted deltas before any subsequent + // native changes observed on the host. + dirtyItems.InsertRange(0, AcceptedClientUpdates); //NetworkLifecycle.Instance.Server.LogDebug(() => $"ProcessChanged({tick}) DirtyItems: {dirtyItems.Count}"); @@ -552,22 +279,22 @@ private void ProcessChanged(uint tick) else { // Check if this item is in the dirty items list - var dirtyUpdate = dirtyItems.FirstOrDefault(di => di.ItemNetId == nearbyItem.NetId); + var itemUpdates = dirtyItems.Where(di => di.ItemNetId == nearbyItem.NetId).ToList(); //NetworkLifecycle.Instance.Server.LogDebug(() => $"ProcessChanged({tick}) Item exists for: {player.Username}, {dirtyUpdate != null}"); - if (dirtyUpdate == null) + if (itemUpdates.Count == 0) { //NetworkLifecycle.Instance.Server.LogDebug(() => $"ProcessChanged({tick}) Item exists for: {player.Username}, LastDirtyTick: {player.KnownItems[nearbyItem] < nearbyItem.LastDirtyTick}"); if (player.KnownItems[nearbyItem] < nearbyItem.LastDirtyTick) { - dirtyUpdate = nearbyItem.CreateUpdateData( + itemUpdates.Add(nearbyItem.CreateUpdateData( ItemUpdateData.ItemUpdateType.FullSync | - ItemUpdateData.ItemUpdateType.Ownership); + ItemUpdateData.ItemUpdateType.Ownership)); } } - if (dirtyUpdate != null) + foreach (var dirtyUpdate in itemUpdates) { // The originating client already applied its own inventory/hand // transition locally. Echoing it back would hide the real item @@ -639,7 +366,7 @@ private void ProcessReceivedAsHost(ItemUpdateData snapshot, ServerPlayer player) NetworkLifecycle.Instance.Server.LogWarning($"NetworkedItemManager.ProcessReceivedAsHost() ItemNetId: {snapshot.ItemNetId}, snapshot type: {snapshot.UpdateType}"); netItem.ReceiveSnapshot(snapshot); netItem.MarkRemoteUpdateAccepted(); - AcceptedClientUpdates[snapshot.ItemNetId] = snapshot; + AcceptedClientUpdates.Add(snapshot); } else { @@ -696,7 +423,10 @@ private void ProcessReceivedAsClient(ItemUpdateData snapshot) } else if (netItem != null) { - netItem.ReceiveSnapshot(snapshot); + if (pendingGadgetCreates.TryGetValue(netItem, out var pending)) + pending.Enqueue(snapshot); + else + netItem.ReceiveSnapshot(snapshot); } else { @@ -706,7 +436,7 @@ private void ProcessReceivedAsClient(ItemUpdateData snapshot) #endregion #region Item Cache And Management - private void CreateItem(ItemUpdateData snapshot) + internal void CreateItem(ItemUpdateData snapshot) { if(snapshot == null || snapshot.ItemNetId == 0) { @@ -740,14 +470,16 @@ private void CreateItem(ItemUpdateData snapshot) newItem.gameObject.SetActive(true); newItem.NetId = snapshot.ItemNetId; - // Applying InstalledGadget immediately deactivates the physical item object. - // A freshly instantiated/cached GadgetItem has only run Awake at this point, - // so doing that here prevents Unity from ever calling GadgetItem.Start and - // leaves GadgetItem.Item null during customization reconstruction. - if (snapshot.ItemState == ItemState.InstalledGadget && - newItem.GetTrackedItem() is { Item: null }) + // Installed and carried snapshots deactivate the physical item object. + // Let every new GadgetItem finish Start before applying its initial state, + // including remote inventory proxies later used in placement actions. + if (newItem.GetTrackedItem() is { Item: null }) { - CoroutineManager.Instance.StartCoroutine(ApplyInstalledGadgetCreateAfterStart(newItem, snapshot)); + var pending = new Queue(); + pending.Enqueue(snapshot); + pendingGadgetCreates[newItem] = pending; + newItem.IsPendingInitialization = true; + CoroutineManager.Instance.StartCoroutine(ApplyGadgetCreateAfterStart(newItem, snapshot, pending)); return; } @@ -822,26 +554,42 @@ internal void CacheLateLocalWorldDuplicate(NetworkedItem localItem) SendToCache(localItem); } - private static IEnumerator ApplyInstalledGadgetCreateAfterStart(NetworkedItem item, ItemUpdateData snapshot) + private IEnumerator ApplyGadgetCreateAfterStart(NetworkedItem item, ItemUpdateData snapshot, + Queue pending) { const int maxInitializationFrames = 120; int frames = 0; var gadgetItem = item?.GetTrackedItem(); - while (item != null && item.NetId == snapshot.ItemNetId && gadgetItem?.Item == null && + while (item != null && item.NetId == snapshot.ItemNetId && + pendingGadgetCreates.TryGetValue(item, out var current) && ReferenceEquals(current, pending) && + gadgetItem?.Item == null && frames++ < maxInitializationFrames) yield return null; - if (item == null || item.NetId != snapshot.ItemNetId) + if (item == null || item.NetId != snapshot.ItemNetId || + !pendingGadgetCreates.TryGetValue(item, out var active) || !ReferenceEquals(active, pending)) yield break; if (gadgetItem?.Item == null) { - Multiplayer.LogError($"Installed gadget {snapshot.ItemNetId} ({snapshot.PrefabName}) did not initialize; create snapshot was not applied"); + Multiplayer.LogError($"Gadget {snapshot.ItemNetId} ({snapshot.PrefabName}) did not initialize; create snapshot was not applied"); + SendToCache(item); yield break; } - item.ReceiveSnapshot(snapshot); + // Start has now run. Apply Create first and every subsequent delta in + // arrival order, before allowing snapshots or customization actions. + try + { + while (pending.Count > 0) + item.ReceiveSnapshot(pending.Dequeue()); + } + finally + { + pendingGadgetCreates.Remove(item); + item.IsPendingInitialization = false; + } } public void SendInitialItems(ServerPlayer player, IEnumerable itemNetIds, @@ -919,190 +667,34 @@ public void CacheWorldItems() ClientInitialised = false; } - internal IReadOnlyList CreateItemReconciliationRequests() - { - pendingItemReconciliation.Clear(); - var packets = new List(); - var packet = new ServerboundItemReconciliationPacket(); - int localId = 1; - - foreach (var item in NetworkedItem.GetAll()) - { - if (packet.Items.Count >= ItemReconciliationProtocol.MaxItems) - { - packets.Add(packet); - packet = new ServerboundItemReconciliationPacket(); - } - - if (!IsReconciliationCandidate(item)) - continue; + internal IReadOnlyList CreateItemReconciliationRequests() => + InventorySynchronization.CreateItemReconciliationRequests(); - var snapshot = item.CreateCurrentUpdateData(ItemUpdateData.ItemUpdateType.Create); - if (snapshot == null) - continue; + internal bool ApplyItemReconciliation(ClientboundItemReconciliationPacket packet) => + InventorySynchronization.ApplyItemReconciliation(packet); - snapshot.ItemNetId = 0; - pendingItemReconciliation[localId] = item; - packet.Items.Add(new ItemReconciliationRequest( - localId, - item.Item.InventorySpecs.BelongsToPlayer, - snapshot)); - localId++; - } + internal void ContinueAfterItemReconciliationTimeout() => + InventorySynchronization.ContinueAfterItemReconciliationTimeout(); - packet.IsFinalBatch = true; - packets.Add(packet); - Multiplayer.LogDebug(() => $"Prepared {localId - 1} local items in {packets.Count} batch(es) for authoritative reconciliation"); - return packets; - } + internal void EnqueueClientItemReconciliation(ItemReconciliationRequest[] requests, bool isFinalBatch, + ServerPlayer player, Action completed) => + InventorySynchronization.EnqueueClientItemReconciliation(requests, isFinalBatch, player, completed); - internal bool ApplyItemReconciliation(ClientboundItemReconciliationPacket packet) + internal static void RestoreContainerMemberships() { - foreach (var result in packet?.Items ?? []) + foreach (var item in NetworkedItem.GetAll().ToArray()) { - if (!pendingItemReconciliation.TryGetValue(result.LocalId, out var localItem)) - { - Multiplayer.LogWarning($"Received item reconciliation for unknown local item {result.LocalId}"); - continue; - } - - pendingItemReconciliation.Remove(result.LocalId); - if (result.NetId == 0) - { - Multiplayer.LogWarning($"Host rejected reconciliation for local item {result.LocalId} ({localItem?.name})"); - continue; - } - - // Loading is allowed to continue after the bounded reconciliation - // wait. The local item can therefore be consumed or destroyed before - // a delayed reliable response arrives; let the host-created canonical - // item take over instead of dereferencing Unity's destroyed wrapper. - if (localItem == null) - { - Multiplayer.LogWarning($"Local reconciliation item {result.LocalId} was destroyed before " + - $"authoritative NetId {result.NetId} arrived"); - if (result.Snapshot != null) - CreateItem(result.Snapshot); + if (item == null) continue; - } - - if (NetworkedItem.TryGet(result.NetId, out var existingItem) && existingItem != localItem) - SendToCache(existingItem); - - localItem.NetId = result.NetId; - localItem.SetOwner(NetworkLifecycle.Instance.Client?.PlayerId ?? 0); - localItem.MarkAsSynchronized(); - Multiplayer.LogDebug(() => $"Reconciled local item {result.LocalId} ({localItem.name}) as NetId {result.NetId}"); - } - - if (packet?.IsFinalBatch != true) - return false; - - foreach (var unresolved in pendingItemReconciliation) - Multiplayer.LogWarning($"Host did not return reconciliation data for local item {unresolved.Key} ({unresolved.Value?.name})"); - pendingItemReconciliation.Clear(); - ClientInitialised = true; - return true; - } - - internal void ContinueAfterItemReconciliationTimeout() - { - // Allow ordinary item updates to resume, but retain the local-ID mapping so - // a delayed reliable response can still assign authoritative IDs. - ClientInitialised = true; - } - - internal ClientboundItemReconciliationPacket ReconcileClientItems( - ServerboundItemReconciliationPacket packet, - ServerPlayer player) - { - var response = new ClientboundItemReconciliationPacket - { - IsFinalBatch = packet?.IsFinalBatch == true, - }; - if (!NetworkLifecycle.Instance.IsHost() || packet?.Items == null || player == null) - return response; - - foreach (var request in packet.Items) - { - ushort netId = 0; - ItemUpdateData authoritativeSnapshot = null; - var snapshot = request.Snapshot; - GameObject gameObject = null; - try { - if (snapshot != null && snapshot.ItemNetId == 0 && - !string.IsNullOrEmpty(snapshot.PrefabName) && - ItemPrefabs.TryGetValue(snapshot.PrefabName, out var spec) && - IsAllowedReconciliationState(snapshot.ItemState, spec)) - { - gameObject = Instantiate( - spec.gameObject, - snapshot.ItemPosition + WorldMover.currentMove, - snapshot.ItemRotation); - var networkedItem = gameObject.GetOrAddComponent(); - netId = networkedItem.NetId; - - if (netId != 0) - { - networkedItem.Item.InventorySpecs.BelongsToPlayer = request.BelongsToPlayer; - snapshot.ItemNetId = netId; - snapshot.Player = player.PlayerId; - snapshot.OwnerPlayerId = player.PlayerId; - networkedItem.ReceiveSnapshot(snapshot); - - player.KnownItems[networkedItem] = NetworkLifecycle.Instance.Tick; - player.NearbyItems[networkedItem] = Time.time; - authoritativeSnapshot = networkedItem.CreateCurrentUpdateData( - ItemUpdateData.ItemUpdateType.Create); - } - } - } - catch (Exception ex) - { - Multiplayer.LogError($"Failed to reconcile local item {request.LocalId} ({snapshot?.PrefabName}) for {player.Username}: {ex}"); - netId = 0; + item.RestoreContainerMembership(); } - - if (netId == 0 && gameObject != null) + catch (Exception exception) { - try - { - Destroy(gameObject); - } - catch (Exception ex) - { - Multiplayer.LogWarning($"Failed to clean up rejected reconciliation item {request.LocalId}: {ex.Message}"); - } + Multiplayer.LogError($"Could not restore container membership for item {item.NetId}: {exception}"); } - - response.Items.Add(new ItemReconciliationResult(request.LocalId, netId, authoritativeSnapshot)); - if (netId == 0) - player.InventoryReconciliationFailed = true; } - - Multiplayer.LogDebug(() => $"Reconciled {response.Items.Count(item => item.NetId != 0)}/{packet.Items.Count} items for {player.Username}"); - if (packet.IsFinalBatch) - player.InventoryReconciliationComplete = !player.InventoryReconciliationFailed; - return response; - } - - private static bool IsAllowedReconciliationState(ItemState state, InventoryItemSpec spec) => - NetworkedItem.IsCarriedState(state) || - state == ItemState.Dropped && spec.IsEssential; - - private static bool IsReconciliationCandidate(NetworkedItem item) - { - if (item?.Item == null || item.NetId != 0 || IsLocalShopItem(item)) - return false; - - var inventory = Inventory.Instance; - var storage = StorageController.Instance; - return item.Item.IsEssential() || item.Item.IsGrabbed() || - inventory != null && inventory.Contains(item.gameObject, true) || - storage?.StorageInventory != null && storage.StorageInventory.ContainsItem(item.Item) || - storage?.StorageItemContainers != null && storage.StorageItemContainers.ContainsItem(item.Item); } private NetworkedItem GetFromCache(string prefabName) @@ -1118,8 +710,13 @@ private NetworkedItem GetFromCache(string prefabName) return null; } - private void SendToCache(NetworkedItem netItem) + internal void SendToCache(NetworkedItem netItem) { + // Cancel the old creation generation even if this object is immediately + // reused for another Create with the same network ID. + pendingGadgetCreates.Remove(netItem); + netItem.IsPendingInitialization = false; + netItem.DetachFromContainer(); string prefabName = netItem?.Item?.InventorySpecs?.itemPrefabName; //NetworkLifecycle.Instance.Client.LogDebug(() => $"Caching Spawned Item: {prefabName ?? ""}"); @@ -1173,7 +770,7 @@ public bool DoNotCreateItem(Type itemType) return false; } - private static bool IsLocalShopItem(NetworkedItem item) => + internal static bool IsLocalShopItem(NetworkedItem item) => item != null && item.TryGetComponent(out _); [UsedImplicitly] diff --git a/Multiplayer/Components/Networking/World/ShopPurchaseCoordinator.cs b/Multiplayer/Components/Networking/World/ShopPurchaseCoordinator.cs index 4aaff899..2548395c 100644 --- a/Multiplayer/Components/Networking/World/ShopPurchaseCoordinator.cs +++ b/Multiplayer/Components/Networking/World/ShopPurchaseCoordinator.cs @@ -3,9 +3,12 @@ using DV.Shops; using DV.Utils; using Multiplayer.Networking.Data; +using Multiplayer.Networking.Data.Items; +using Multiplayer.Components.SaveGame; using Multiplayer.Networking.Packets.Common; using System; using System.Collections.Generic; +using System.Collections; using System.Linq; using UnityEngine; @@ -30,6 +33,8 @@ internal static class ShopPurchaseCoordinator private static readonly Dictionary> pendingOwners = []; private static readonly Dictionary scaledAllowances = []; private static bool applyingClientApproval; + private static bool stockRecountPending; + private static int sessionGeneration; public static ShopPurchase[] Capture(CashRegisterWithModules register) => register.registerModules .OfType() @@ -93,6 +98,8 @@ public static void ResetSessionState() { pendingOwners.Clear(); scaledAllowances.Clear(); + stockRecountPending = false; + sessionGeneration++; } public static void AssignPurchasedObject(GameObject purchasedObject) @@ -281,17 +288,79 @@ public static void ScaleShopLimits(GlobalShopController controller) public static void RecountExistingShopItems(GlobalShopController controller) { - var counts = SingletonBehaviour.Instance.GetAllStorageItems() - .Select(item => item?.GetComponent()) + if (controller == null || !NetworkLifecycle.Instance.IsHost()) + return; + + var players = NetworkLifecycle.Instance.Server.ServerPlayers.ToArray(); + var stored = NetworkedSaveGameManager.Instance.GetSavedPlayerInventories() + .Where(inventory => !players.Any(player => player.Guid == inventory.Guid && + (player.InventoryReconciliationComplete || player.Peer == NetworkLifecycle.Instance.Server.SelfPeer))).ToArray(); + + // Until a returning player's complete inventory is reconciled, its saved + // inventory reserves the stock. Partial live proxies are the same items. + var partialInventoryItems = players.Where(player => stored.Any(inventory => inventory.Guid == player.Guid)) + .SelectMany(player => player.ReconciledInventoryItems).ToHashSet(); + var networkedItems = NetworkedItem.GetAll().Where(item => item != null && item.NetId != 0).ToArray(); + var ignoredItems = networkedItems.Where(item => item.IsPendingReconciliation || partialInventoryItems.Contains(item)) + .Select(item => item.Item).ToHashSet(); + var physicalItems = SingletonBehaviour.Instance.GetAllStorageItems() + .Concat(networkedItems.Select(item => item.Item)).Where(item => item != null && !ignoredItems.Contains(item)) + .Distinct().Select(item => item.GetComponent()) .Where(restocker => restocker != null && restocker.restockOnItemDestroyed) - .Select(restocker => restocker.GetComponent()?.ItemPrefabName) - .Where(prefabName => !string.IsNullOrEmpty(prefabName)) - .GroupBy(prefabName => prefabName) - .ToDictionary(group => group.Key, group => group.Count()); + .Select(restocker => restocker.GetComponent()?.ItemPrefabName); + var counts = CountStockReservations(physicalItems, stored.SelectMany(inventory => inventory.Items), + pendingOwners.Select(entry => new KeyValuePair(entry.Key, entry.Value.Count))); + bool changed = false; foreach (ShopItemData data in controller.shopItemsData) - data.purchasedItems = counts.TryGetValue(data.item.ItemPrefabName, out int count) ? count : 0; + { + int count = counts.TryGetValue(data.item.ItemPrefabName, out int total) ? total : 0; + changed |= data.purchasedItems != count; + data.purchasedItems = count; + } controller.Fire_GlobalShopDataChanged(); + if (changed) + NetworkLifecycle.Instance.Server.SendShopAction(CreateStockSnapshot()); + } + + internal static Dictionary CountStockReservations(IEnumerable physicalItems, + IEnumerable storedItems, IEnumerable> pendingPurchases) + { + var counts = physicalItems.Concat(storedItems + .Where(item => item.State?.Value(ShopRestocker.RESTOCK_SHOP_KEY) == true) + .Select(item => item.ItemPrefabName)) + .Where(name => !string.IsNullOrEmpty(name)).GroupBy(name => name) + .ToDictionary(group => group.Key, group => group.Count()); + foreach (var purchase in pendingPurchases) + counts[purchase.Key] = (counts.TryGetValue(purchase.Key, out int count) ? count : 0) + purchase.Value; + return counts; + } + + internal static void SuppressStorageTransitionRestock(GameObject item) + { + // The saved record retains the native Restock flag. Destroying a proxy + // while moving it to/from that record is not consumption of the item. + var restocker = item?.GetComponent(); + if (restocker != null) + restocker.restockOnItemDestroyed = false; + } + + internal static void RequestStockRecount() + { + if (stockRecountPending || !NetworkLifecycle.Instance.IsHost()) + return; + stockRecountPending = true; + CoroutineManager.Instance.StartCoroutine(RecountAfterDestroyedObjects(sessionGeneration)); + } + + private static IEnumerator RecountAfterDestroyedObjects(int generation) + { + yield return null; + if (generation != sessionGeneration) + yield break; + stockRecountPending = false; + if (NetworkLifecycle.Instance.IsHost()) + RecountExistingShopItems(GlobalShopController.Instance); } } diff --git a/Multiplayer/Components/SaveGame/NetworkedSaveGameManager.cs b/Multiplayer/Components/SaveGame/NetworkedSaveGameManager.cs index d7930923..875225e2 100644 --- a/Multiplayer/Components/SaveGame/NetworkedSaveGameManager.cs +++ b/Multiplayer/Components/SaveGame/NetworkedSaveGameManager.cs @@ -203,6 +203,7 @@ public void ResetPlayerInventory(Guid guid) // Absence means first-time provisioning on the next connection. Keep the // identity/timestamp metadata so administrative lookup remains available. playerData.Remove(INVENTORY_KEY); + ShopPurchaseCoordinator.RequestStockRecount(); } private static void UpdatePlayerMetadata(JObject playerData, ServerPlayer player) diff --git a/Multiplayer/Networking/Data/Items/ItemUpdateData.cs b/Multiplayer/Networking/Data/Items/ItemUpdateData.cs index fe89216c..2a1ce934 100644 --- a/Multiplayer/Networking/Data/Items/ItemUpdateData.cs +++ b/Multiplayer/Networking/Data/Items/ItemUpdateData.cs @@ -29,6 +29,7 @@ public enum ItemUpdateType : byte public ushort ItemNetId { get; set; } public byte OwnerPlayerId { get; set; } public bool BelongsToPlayer { get; set; } + public bool RestockOnDestroy { get; set; } public string PrefabName { get; set; } public ItemState ItemState { get; set; } public bool Active { get; set; } @@ -39,6 +40,8 @@ public enum ItemUpdateType : byte public int InventorySlotIndex { get; set; } = -1; public int ContainerSlotIndex { get; set; } = -1; public string ContainerId { get; set; } + // Identity of this item's container, distinct from the container holding it. + public string ContainerIdentity { get; set; } public bool InLockedSlot { get; set; } public bool IsDropped { get; set; } public ushort CarNetId { get; set; } @@ -65,7 +68,9 @@ public void Serialize(NetDataWriter writer) { writer.Put(OwnerPlayerId); writer.Put(BelongsToPlayer); + writer.Put(RestockOnDestroy); writer.Put(PrefabName); + writer.Put(ContainerIdentity ?? string.Empty); } else if (UpdateType.HasFlag(ItemUpdateType.Ownership)) { @@ -140,7 +145,9 @@ public void Deserialize(NetDataReader reader) { OwnerPlayerId = reader.GetByte(); BelongsToPlayer = reader.GetBool(); + RestockOnDestroy = reader.GetBool(); PrefabName = reader.GetString(); + ContainerIdentity = reader.GetString(); } else if (UpdateType.HasFlag(ItemUpdateType.Ownership)) { diff --git a/Multiplayer/Networking/Data/ServerPlayer.cs b/Multiplayer/Networking/Data/ServerPlayer.cs index e42f4669..38b66be1 100644 --- a/Multiplayer/Networking/Data/ServerPlayer.cs +++ b/Multiplayer/Networking/Data/ServerPlayer.cs @@ -50,6 +50,7 @@ public void Dispose() public bool CustomizationSnapshotSent { get; set; } internal bool InventoryReconciliationComplete { get; set; } internal bool InventoryReconciliationFailed { get; set; } + internal HashSet ReconciledInventoryItems { get; } = []; public HashSet ProcessedCustomizationActionIds { get; } = []; public PlayerTrackingData TrackingData { get; set; } diff --git a/Multiplayer/Networking/Managers/Client/NetworkClient.cs b/Multiplayer/Networking/Managers/Client/NetworkClient.cs index 30c2f85d..4867b3e7 100644 --- a/Multiplayer/Networking/Managers/Client/NetworkClient.cs +++ b/Multiplayer/Networking/Managers/Client/NetworkClient.cs @@ -79,17 +79,8 @@ public class NetworkClient : NetworkManager internal uint trainSetsToSpawn = uint.MaxValue; internal uint trainSetsSpawned = 0; internal bool railwayStateLoaded = false; - private bool customizerStateLoaded; - private bool customizationSnapshotApplied; - private bool applyingCustomizationSnapshot; - private bool customizationActionDrainRunning; - private bool customizationSnapshotTimedOut; - private CustomizationStateData pendingCustomizationState; - private readonly List pendingCustomizationSnapshotPlacements = []; - private readonly List pendingCustomizationSnapshotRelationships = []; - private readonly Dictionary> pendingCustomizationSnapshotHoleStates = []; - private readonly List pendingCustomizationActions = []; - private readonly List appliedActionsSinceSnapshotTimeout = []; + private ClientCustomizationSynchronization clientCustomizationSynchronization; + private ClientCustomizationSynchronization CustomizationSynchronization => clientCustomizationSynchronization ??= new(this); private bool itemReconciliationComplete; private uint nextCustomizationActionId = 1; private uint nextShopRequestId = 1; @@ -159,16 +150,7 @@ public void Start(string address, int port, string password, bool isSinglePlayer public override void Stop() { Log("Stopping client"); - pendingCustomizationState = null; - pendingCustomizationSnapshotPlacements.Clear(); - pendingCustomizationSnapshotRelationships.Clear(); - pendingCustomizationSnapshotHoleStates.Clear(); - pendingCustomizationActions.Clear(); - appliedActionsSinceSnapshotTimeout.Clear(); - customizationSnapshotApplied = false; - customizationSnapshotTimedOut = false; - customizerStateLoaded = false; - CustomizationStateManager.ClearPendingLocalState(); + CustomizationSynchronization.Reset(); if (!isAlsoHost && originalSession != null) { @@ -209,7 +191,6 @@ protected override void Subscribe() netPacketProcessor.SubscribeReusable(OnClientboundSaveGameDataPacket); netPacketProcessor.SubscribeReusable(OnClientboundRailwayStatePacket); - // General Sync netPacketProcessor.SubscribeNetSerializable(OnClientboundRpcResponsePacket); netPacketProcessor.SubscribeReusable(OnClientboundTickSyncPacket); @@ -218,7 +199,6 @@ protected override void Subscribe() netPacketProcessor.SubscribeReusable(OnCommonChangeJunctionPacket); netPacketProcessor.SubscribeReusable(OnCommonRotateTurntablePacket); - // Player Management netPacketProcessor.SubscribeReusable(OnClientboundPlayerJoinedPacket); netPacketProcessor.SubscribeReusable(OnClientboundPlayerDisconnectPacket); @@ -226,7 +206,6 @@ protected override void Subscribe() netPacketProcessor.SubscribeReusable(OnClientboundPlayerPositionPacket); netPacketProcessor.SubscribeReusable(OnClientboundPlayerPreferencesUpdatePacket); - // Train Sync netPacketProcessor.SubscribeReusable(OnClientboundSpawnTrainSetPacket); netPacketProcessor.SubscribeReusable(OnClientboundDestroyTrainCarPacket); @@ -275,7 +254,7 @@ protected override void Subscribe() // World Sync netPacketProcessor.SubscribeNetSerializable(OnCommonItemChangePacket); netPacketProcessor.SubscribeNetSerializable(OnClientboundItemReconciliationPacket); - netPacketProcessor.SubscribeNetSerializable(OnClientboundCustomizationStatePacket); + netPacketProcessor.SubscribeNetSerializable(CustomizationSynchronization.ReceiveSnapshot); SubscribeCustomizationActions(); netPacketProcessor.SubscribeReusable(OnCommonPitStopInteractionPacket); netPacketProcessor.SubscribeNetSerializable(OnCommonPitStopPlugInteractionPacket); @@ -405,46 +384,12 @@ private IEnumerator SyncWorldState() * ReadyForCustomizers */ - customizerStateLoaded = false; - customizationSnapshotApplied = false; - applyingCustomizationSnapshot = false; - customizationActionDrainRunning = false; - customizationSnapshotTimedOut = false; - pendingCustomizationState = null; - pendingCustomizationSnapshotPlacements.Clear(); - pendingCustomizationSnapshotRelationships.Clear(); - pendingCustomizationSnapshotHoleStates.Clear(); - pendingCustomizationActions.Clear(); - appliedActionsSinceSnapshotTimeout.Clear(); - CustomizationStateManager.ClearPendingLocalState(); + CustomizationSynchronization.Reset(forLoading: true); Log("Requesting customization state"); SendLoadStateUpdate(PlayerLoadingState.ReadyForCustomizers); displayLoadingInfo.OnLoadingStatusChanged("Syncing customizations", false, ((float)LoadingState / (float)PlayerLoadingState.Complete) * 100); - // The host may need to stream customized train-car interiors before taking - // the complete world snapshot. Do not mistake that preparation for a lost - // packet on low-latency connections where RPC_Timeout is very small. - float customizationDeadline = Time.time + Mathf.Max(RPC_Timeout, 5f); - while (!customizerStateLoaded) - { - if (pendingCustomizationState == null) - { - if (Time.time >= customizationDeadline) - { - LogWarning("Timed out waiting for customization state; continuing while retaining the late-snapshot path"); - customizationSnapshotTimedOut = true; - customizationSnapshotApplied = true; - customizerStateLoaded = true; - StartCustomizationActionDrain(); - break; - } - yield return null; - continue; - } - - yield return ApplyPendingCustomizationStates(); - customizerStateLoaded = true; - } + yield return CustomizationSynchronization.LoadInitialState(RPC_Timeout); /* * ReadyForItems @@ -476,7 +421,6 @@ private IEnumerator SyncWorldState() yield return new WaitForSeconds(0.25f); - /* * ReadyForTiles */ @@ -1384,12 +1328,10 @@ private void OnClientboundPitStopBulkUpdatePacket(ClientboundPitStopBulkUpdatePa netPitStop.ProcessBulkUpdate(packet); } - private void OnCommonItemChangePacket(CommonItemChangePacket packet) { //LogDebug(() => $"OnCommonItemChangePacket({packet?.Items?.Count})"); - //LogDebug(() => //{ // string debug = ""; @@ -1434,208 +1376,23 @@ private void OnClientboundItemReconciliationPacket(ClientboundItemReconciliation } } - private void OnClientboundCustomizationStatePacket(ClientboundCustomizationStatePacket packet) - { - if (packet?.State == null) - { - LogWarning("Received an invalid customization state packet"); - return; - } - - pendingCustomizationState = packet.State; - // Pause live mutation application immediately. If the timeout path already - // released actions, they are replayed after this older baseline is applied. - customizationSnapshotApplied = false; - if (customizerStateLoaded && !applyingCustomizationSnapshot) - CoroutineManager.Instance.StartCoroutine(ApplyPendingCustomizationStates()); - } - private void SubscribeCustomizationActions() { - netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); - netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); - netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); - netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); - netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); - netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); - netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); - netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); - netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); - netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); - netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); - netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); - netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); - netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); - netPacketProcessor.SubscribeNetSerializable(OnCustomizationPacket); - } - - private void OnCustomizationPacket(T packet) - where T : CustomizationActionPacket, INetSerializable, new() - { - if (packet == null) - return; - - // LiteNetLib reuses the INetSerializable instance registered for this - // subscription. Keep a value copy because the action can remain queued - // while later packets are deserialized into that same instance. - pendingCustomizationActions.Add(packet.Copy()); - StartCustomizationActionDrain(); - } - - private IEnumerator ApplyPendingCustomizationStates() - { - if (applyingCustomizationSnapshot) - yield break; - - applyingCustomizationSnapshot = true; - try - { - while (pendingCustomizationState != null) - { - CustomizationStateData state = pendingCustomizationState; - pendingCustomizationState = null; - pendingCustomizationSnapshotPlacements.Clear(); - pendingCustomizationSnapshotRelationships.Clear(); - pendingCustomizationSnapshotHoleStates.Clear(); - yield return CustomizationStateManager.ApplyCurrentStateWhenReady(state, - pendingCustomizationSnapshotPlacements, pendingCustomizationSnapshotRelationships, - pendingCustomizationSnapshotHoleStates); - - if (customizationSnapshotTimedOut) - { - var capturedLocalActions = state.ProcessedOriginActionIds.ToHashSet(); - pendingCustomizationActions.InsertRange(0, appliedActionsSinceSnapshotTimeout.Where( - action => !capturedLocalActions.Contains(action.OriginActionId))); - appliedActionsSinceSnapshotTimeout.Clear(); - customizationSnapshotTimedOut = false; - } - - customizationSnapshotApplied = true; - Log($"Customization state loaded ({state.Gadgets.Count} gadgets, {state.Holes.Count} holes)"); - } - } - finally - { - applyingCustomizationSnapshot = false; - } - - StartCustomizationActionDrain(); - } - - private void StartCustomizationActionDrain() - { - if (customizationActionDrainRunning || - pendingCustomizationSnapshotPlacements.Count == 0 && - pendingCustomizationSnapshotRelationships.Count == 0 && - pendingCustomizationSnapshotHoleStates.Count == 0 && - pendingCustomizationActions.Count == 0) - return; - - CoroutineManager.Instance.StartCoroutine(DrainPendingCustomizationActions()); - } - - private IEnumerator DrainPendingCustomizationActions() - { - customizationActionDrainRunning = true; - try - { - while (IsRunning && (pendingCustomizationSnapshotPlacements.Count > 0 || - pendingCustomizationSnapshotRelationships.Count > 0 || - pendingCustomizationSnapshotHoleStates.Count > 0 || pendingCustomizationActions.Count > 0)) - { - if (!customizationSnapshotApplied || applyingCustomizationSnapshot) - { - yield return null; - continue; - } - - bool appliedAny = false; - for (int index = 0; index < pendingCustomizationSnapshotPlacements.Count; index++) - { - var placement = pendingCustomizationSnapshotPlacements[index]; - if (!CustomizationStateManager.IsSnapshotPlacementReady(placement, out _)) - continue; - - pendingCustomizationSnapshotPlacements.RemoveAt(index--); - // The initial object-state pass already applied or queued these - // values. If the item did not exist, its reliable Create carries - // a newer full state. Reapplying this older baseline here could - // roll back changes received while the target was unavailable. - CustomizationStateManager.ApplySnapshotPlacement(placement, applyTrackedValues: false); - appliedAny = true; - } - - foreach (string targetKey in pendingCustomizationSnapshotHoleStates.Keys.ToArray()) - { - if (!CustomizationStateManager.ApplySnapshotHoleState(targetKey, - pendingCustomizationSnapshotHoleStates[targetKey])) - continue; - - pendingCustomizationSnapshotHoleStates.Remove(targetKey); - appliedAny = true; - } - - for (int index = 0; index < pendingCustomizationSnapshotRelationships.Count; index++) - { - var action = pendingCustomizationSnapshotRelationships[index]; - bool blockedByEarlierDependency = pendingCustomizationSnapshotRelationships.Take(index) - .Any(earlier => CustomizationStateManager.ActionsConflict(earlier, action)); - bool blockedByPlacement = pendingCustomizationSnapshotPlacements - .Any(placement => CustomizationStateManager.SnapshotPlacementConflicts(placement, action)); - bool blockedByHoleState = pendingCustomizationSnapshotHoleStates.Keys - .Any(targetKey => CustomizationStateManager.SnapshotHoleStateConflicts(targetKey, action)); - if (blockedByEarlierDependency || blockedByPlacement || blockedByHoleState || - !CustomizationStateManager.IsActionReady(action, out _)) - continue; - - pendingCustomizationSnapshotRelationships.RemoveAt(index--); - try - { - CustomizationStateManager.ApplyAction(action); - } - catch (Exception exception) - { - LogError($"Failed to apply deferred snapshot relationship {action.GetType().Name}: {exception}"); - } - appliedAny = true; - } - - for (int index = 0; index < pendingCustomizationActions.Count; index++) - { - var action = pendingCustomizationActions[index]; - bool blockedByEarlierDependency = pendingCustomizationActions.Take(index) - .Any(earlier => CustomizationStateManager.ActionsConflict(earlier, action)); - bool blockedBySnapshotPlacement = pendingCustomizationSnapshotPlacements - .Any(placement => CustomizationStateManager.SnapshotPlacementConflicts(placement, action)); - bool blockedBySnapshotRelationship = pendingCustomizationSnapshotRelationships - .Any(relationship => CustomizationStateManager.ActionsConflict(relationship, action)); - bool blockedBySnapshotHoleState = pendingCustomizationSnapshotHoleStates.Keys - .Any(targetKey => CustomizationStateManager.SnapshotHoleStateConflicts(targetKey, action)); - if (blockedByEarlierDependency || blockedBySnapshotPlacement || - blockedBySnapshotRelationship || blockedBySnapshotHoleState || - !CustomizationStateManager.IsActionReady(action, out _)) - continue; - - pendingCustomizationActions.RemoveAt(index--); - try - { - CustomizationStateManager.ApplyAction(action); - } - catch (Exception exception) - { - LogError($"Failed to apply deferred customization action {action.GetType().Name}: {exception}"); - } - appliedAny = true; - } - - if (!appliedAny) - yield return null; - } - } - finally - { - customizationActionDrainRunning = false; - } + netPacketProcessor.SubscribeNetSerializable(CustomizationSynchronization.ReceiveAction); + netPacketProcessor.SubscribeNetSerializable(CustomizationSynchronization.ReceiveAction); + netPacketProcessor.SubscribeNetSerializable(CustomizationSynchronization.ReceiveAction); + netPacketProcessor.SubscribeNetSerializable(CustomizationSynchronization.ReceiveAction); + netPacketProcessor.SubscribeNetSerializable(CustomizationSynchronization.ReceiveAction); + netPacketProcessor.SubscribeNetSerializable(CustomizationSynchronization.ReceiveAction); + netPacketProcessor.SubscribeNetSerializable(CustomizationSynchronization.ReceiveAction); + netPacketProcessor.SubscribeNetSerializable(CustomizationSynchronization.ReceiveAction); + netPacketProcessor.SubscribeNetSerializable(CustomizationSynchronization.ReceiveAction); + netPacketProcessor.SubscribeNetSerializable(CustomizationSynchronization.ReceiveAction); + netPacketProcessor.SubscribeNetSerializable(CustomizationSynchronization.ReceiveAction); + netPacketProcessor.SubscribeNetSerializable(CustomizationSynchronization.ReceiveAction); + netPacketProcessor.SubscribeNetSerializable(CustomizationSynchronization.ReceiveAction); + netPacketProcessor.SubscribeNetSerializable(CustomizationSynchronization.ReceiveAction); + netPacketProcessor.SubscribeNetSerializable(CustomizationSynchronization.ReceiveAction); } private void OnCommonPaintThemePacket(CommonPaintThemePacket packet) @@ -1791,7 +1548,6 @@ private void OnCommonGenericSwitchStatePacket(CommonGenericSwitchStatePacket pac SendNetSerializablePacket(serverPeer, packet, deliveryMethod); } - #region Mod Packets public void SendExternalPacketToServer(T packet, bool reliable) where T : class, IPacket, new() { @@ -1899,7 +1655,6 @@ public void SendCouplerInteraction(CouplerInteractionType flags, Coupler coupler }, DeliveryMethod.ReliableOrdered); } - //public void SendTrainCouple(Coupler coupler, Coupler otherCoupler, bool playAudio, bool viaChainInteraction) //{ // ushort couplerNetId = coupler.train.GetNetId(); @@ -2228,12 +1983,7 @@ internal void SendCustomizationAction(T packet) if (nextCustomizationActionId == 0) nextCustomizationActionId = 1; - // Ordinary actions are not echoed to their sender. Preserve locally - // originated mutations so a snapshot that arrives after the loading - // timeout cannot erase them. Replacement actions are server-echoed after - // their authoritative replacement item id has been assigned. - if (customizationSnapshotTimedOut && packet is not ReplaceSpoolPacket and not ReplaceDuctTapePacket) - appliedActionsSinceSnapshotTimeout.Add(packet.Copy()); + CustomizationSynchronization.RecordLocalAction(packet); SendNetSerializablePacketToServer(packet, DeliveryMethod.ReliableOrdered); } @@ -2258,7 +2008,6 @@ public void SendCashRegisterAction(ushort netId, CashRegisterAction action, doub ); } - public uint SendShopPurchase(ushort registerNetId, double amount, string[] itemPrefabNames, int[] itemAmounts) { uint requestId = nextShopRequestId++; diff --git a/Multiplayer/Networking/Managers/Server/NetworkServer.cs b/Multiplayer/Networking/Managers/Server/NetworkServer.cs index 41f6c5d2..4a4aad5d 100644 --- a/Multiplayer/Networking/Managers/Server/NetworkServer.cs +++ b/Multiplayer/Networking/Managers/Server/NetworkServer.cs @@ -106,8 +106,8 @@ public ITransportPeer SelfPeer public ChatManager ChatManager => _chatManager; private uint lastTick; - private readonly List pendingHostCustomizationActions = []; - private bool hostCustomizationDrainRunning; + private HostCustomizationSynchronization hostCustomizationSynchronization; + private HostCustomizationSynchronization CustomizationSynchronization => hostCustomizationSynchronization ??= new(this); public NetworkServer(IDifficulty difficulty, Settings settings, bool singlePlayer, LobbyServerData serverData) : base(settings) { @@ -155,8 +155,7 @@ public override void Stop() { Log($"Stopping server..."); WorldStreamingInit.LoadingFinished -= OnLoaded; - pendingHostCustomizationActions.Clear(); - hostCustomizationDrainRunning = false; + CustomizationSynchronization.Reset(); if (lobbyServerManager != null) { @@ -2221,8 +2220,11 @@ private void OnServerboundItemReconciliationPacket(ServerboundItemReconciliation return; } - var response = NetworkedItemManager.Instance.ReconcileClientItems(packet, player); - SendNetSerializablePacket(peer, response, DeliveryMethod.ReliableOrdered); + // The packet processor reuses the packet's list. Own the batch before + // yielding for native item initialization. + NetworkedItemManager.Instance.EnqueueClientItemReconciliation( + packet.Items.ToArray(), packet.IsFinalBatch, player, + response => SendNetSerializablePacket(peer, response, DeliveryMethod.ReliableOrdered)); } internal void SendCustomizationAction(T packet) @@ -2258,92 +2260,8 @@ private void OnCustomizationPacket(T packet, ITransportPeer peer) // subscription. Keep a value copy because the action can remain queued // while later packets are deserialized into that same instance. var copy = (T)packet.Copy(); - pendingHostCustomizationActions.Add(new PendingHostCustomizationAction(copy, player, peer, - (action, origin, echoOrigin) => RelayCustomizationAction((T)action, origin, echoOrigin))); - StartHostCustomizationDrain(); - } - - private void StartHostCustomizationDrain() - { - if (hostCustomizationDrainRunning || pendingHostCustomizationActions.Count == 0) - return; - - CoroutineManager.Instance.StartCoroutine(DrainPendingHostCustomizationActions()); - } - - private IEnumerator DrainPendingHostCustomizationActions() - { - hostCustomizationDrainRunning = true; - try - { - while (IsRunning && pendingHostCustomizationActions.Count > 0) - { - bool appliedAny = false; - for (int index = 0; index < pendingHostCustomizationActions.Count; index++) - { - var pending = pendingHostCustomizationActions[index]; - if (!peerToPlayer.TryGetValue(pending.Peer, out var connectedPlayer) || - connectedPlayer != pending.Player) - { - pendingHostCustomizationActions.RemoveAt(index--); - continue; - } - - bool blockedByEarlierDependency = pendingHostCustomizationActions.Take(index) - .Any(earlier => CustomizationStateManager.ActionsConflict(earlier.Packet, pending.Packet)); - if (blockedByEarlierDependency || - !CustomizationStateManager.IsActionReady(pending.Packet, out _)) - continue; - - pendingHostCustomizationActions.RemoveAt(index--); - bool applied = false; - try - { - applied = CustomizationStateManager.ProcessActionAsHost(pending.Packet, pending.Player); - } - catch (Exception exception) - { - LogError($"Failed to apply customization action {pending.Packet.GetType().Name} from " + - $"{pending.Player.Username}: {exception}"); - } - - if (pending.Packet.OriginActionId != 0) - pending.Player.ProcessedCustomizationActionIds.Add(pending.Packet.OriginActionId); - - if (applied) - { - bool echoSender = pending.Packet is ReplaceSpoolPacket or ReplaceDuctTapePacket; - pending.Relay(pending.Packet, pending.Peer, echoSender); - } - appliedAny = true; - } - - if (!appliedAny) - yield return null; - } - } - finally - { - hostCustomizationDrainRunning = false; - } - } - - private sealed class PendingHostCustomizationAction - { - public readonly ICustomizationActionPacket Packet; - public readonly ServerPlayer Player; - public readonly ITransportPeer Peer; - - public readonly Action Relay; - - public PendingHostCustomizationAction(ICustomizationActionPacket packet, ServerPlayer player, - ITransportPeer peer, Action relay) - { - Packet = packet; - Player = player; - Peer = peer; - Relay = relay; - } + CustomizationSynchronization.Enqueue(copy, player, peer, + (action, origin, echoOrigin) => RelayCustomizationAction((T)action, origin, echoOrigin)); } private void RelayCustomizationAction(T packet, ITransportPeer origin = null, bool echoOrigin = false) diff --git a/Multiplayer/Networking/Packets/Common/Customization/LoadSpoolPacket.cs b/Multiplayer/Networking/Packets/Common/Customization/LoadSpoolPacket.cs index 6fd978d0..f73b3a95 100644 --- a/Multiplayer/Networking/Packets/Common/Customization/LoadSpoolPacket.cs +++ b/Multiplayer/Networking/Packets/Common/Customization/LoadSpoolPacket.cs @@ -6,14 +6,20 @@ internal sealed class LoadSpoolPacket : CustomizationActionPacket, INetSerializa { public ushort ToolItemNetId; public ushort SpoolItemNetId; + public bool HasRemainingUnits; + public int RemainingUnits; public void Serialize(NetDataWriter writer) { writer.Put(OriginActionId); writer.Put(ToolItemNetId); writer.Put(SpoolItemNetId); + writer.Put(HasRemainingUnits); + if (HasRemainingUnits) writer.Put(RemainingUnits); } public void Deserialize(NetDataReader reader) { OriginActionId = reader.GetUInt(); ToolItemNetId = reader.GetUShort(); SpoolItemNetId = reader.GetUShort(); + HasRemainingUnits = reader.GetBool(); + RemainingUnits = HasRemainingUnits ? reader.GetInt() : 0; } } diff --git a/Multiplayer/Patches/SaveGame/StorageSerializerPatch.cs b/Multiplayer/Patches/SaveGame/StorageSerializerPatch.cs new file mode 100644 index 00000000..cd2067db --- /dev/null +++ b/Multiplayer/Patches/SaveGame/StorageSerializerPatch.cs @@ -0,0 +1,48 @@ +using DV.CabControls; +using HarmonyLib; +using Multiplayer.Components.Networking; +using Multiplayer.Components.Networking.World; +using System.Collections.Generic; +using System.Linq; +using System.Reflection.Emit; + +namespace Multiplayer.Patches.SaveGame; + +[HarmonyPatch(typeof(StorageSerializer), nameof(StorageSerializer.SaveStorage))] +internal static class StorageSerializerPatch +{ + // Native container membership is needed even for remote carried items. Their + // persistence belongs to the player's saved inventory, not the world storage. + private static List ExcludeRemoteCarriedContents(List items) + { + if (items == null || NetworkLifecycle.Instance?.IsHost() != true) + return items; + return items.Where(item => !IsRemoteCarriedContent(item)).ToList(); + } + + private static bool IsRemoteCarriedContent(ItemBase item) + { + var root = item; + var visited = new HashSet(); + while (root != null && root.InContainer != null && visited.Add(root)) + root = root.InContainer.GetComponent(); + return root != null && root != item && + NetworkedItem.TryGetNetworkedItem(root, out var networkedRoot) && + networkedRoot.OwnerPlayerId != 0 && + networkedRoot.OwnerPlayerId != NetworkLifecycle.Instance.Server.SelfId && + networkedRoot.CurrentState is ItemState.InHand or ItemState.InInventory; + } + + [HarmonyTranspiler] + private static IEnumerable Transpiler(IEnumerable instructions) + { + var getItems = AccessTools.Method(typeof(StorageBase), nameof(StorageBase.GetStorageItemList)); + var filter = AccessTools.Method(typeof(StorageSerializerPatch), nameof(ExcludeRemoteCarriedContents)); + foreach (var instruction in instructions) + { + yield return instruction; + if (instruction.Calls(getItems)) + yield return new CodeInstruction(OpCodes.Call, filter); + } + } +} diff --git a/Multiplayer/Patches/World/GadgetRelationshipPatch.cs b/Multiplayer/Patches/World/GadgetRelationshipPatch.cs index 7a21a17b..ace16e27 100644 --- a/Multiplayer/Patches/World/GadgetRelationshipPatch.cs +++ b/Multiplayer/Patches/World/GadgetRelationshipPatch.cs @@ -148,7 +148,8 @@ private static void SpoolLoaded(ItemMagazine __instance, UnityEngine.GameObject !NetworkedItem.TryGetNetworkedItem(item.GetComponent(), out var spoolNet) || spoolNet.NetId == 0) return; CustomizationStateManager.SendAction(new LoadSpoolPacket - { ToolItemNetId = toolNet.NetId, SpoolItemNetId = spoolNet.NetId }); + { ToolItemNetId = toolNet.NetId, SpoolItemNetId = spoolNet.NetId, + HasRemainingUnits = true, RemainingUnits = tool.remainingUnits }); } [HarmonyPatch(typeof(ItemSnapPointBase), nameof(ItemSnapPointBase.SnapItem), new[] { typeof(ItemBase), typeof(bool) }), HarmonyPostfix] diff --git a/Multiplayer/Patches/World/ShopPatch.cs b/Multiplayer/Patches/World/ShopPatch.cs index cf2b11f5..5b905d77 100644 --- a/Multiplayer/Patches/World/ShopPatch.cs +++ b/Multiplayer/Patches/World/ShopPatch.cs @@ -37,8 +37,13 @@ private static bool AddItemToInstantiationQueue(InventoryItemSpec boughtItemSpec [HarmonyPrefix] [HarmonyPatch(nameof(GlobalShopController.Restock))] - private static void Restock_Prefix(GlobalShopController __instance, string itemPrefabName, out int __state) => + private static bool Restock_Prefix(GlobalShopController __instance, string itemPrefabName, out int __state) + { __state = __instance.GetShopItemData(itemPrefabName)?.purchasedItems ?? 0; + // The host relays native restocks. A client's proxy destruction must not + // also decrement that client's copy of the authoritative stock count. + return NetworkLifecycle.Instance?.IsClientRunning != true || NetworkLifecycle.Instance.IsHost(); + } [HarmonyPostfix] [HarmonyPatch(nameof(GlobalShopController.Restock))] From 1f5793299627ec4ddcda51835759f6d4bf6cace9 Mon Sep 17 00:00:00 2001 From: Patrick van Halm Date: Sun, 13 Sep 2026 18:37:53 +0200 Subject: [PATCH 09/14] Harden gadget and inventory synchronization --- .../World/HostCustomizationSynchronization.cs | 26 +++- .../World/InventorySynchronization.cs | 124 +++++++++++++++--- .../World/NetworkedCashRegisterWithModules.cs | 9 +- .../Networking/World/NetworkedItem.cs | 28 ++-- .../Networking/World/NetworkedItemManager.cs | 17 ++- Multiplayer/Multiplayer.csproj | 2 +- Multiplayer/Networking/Data/ServerPlayer.cs | 1 + .../Managers/Client/NetworkClient.cs | 78 +++++++++-- .../Managers/Server/NetworkServer.cs | 29 +++- .../ClientboundItemReconciliationPacket.cs | 3 + .../ServerboundItemReconciliationPacket.cs | 3 + .../Patches/World/Items/FlashlightPatch.cs | 18 ++- 12 files changed, 278 insertions(+), 60 deletions(-) diff --git a/Multiplayer/Components/Networking/World/HostCustomizationSynchronization.cs b/Multiplayer/Components/Networking/World/HostCustomizationSynchronization.cs index f8063896..21c59122 100644 --- a/Multiplayer/Components/Networking/World/HostCustomizationSynchronization.cs +++ b/Multiplayer/Components/Networking/World/HostCustomizationSynchronization.cs @@ -7,12 +7,14 @@ using Multiplayer.Networking.Managers.Server; using Multiplayer.Networking.Packets.Common.Customization; using Multiplayer.Networking.TransportLayers; +using UnityEngine; namespace Multiplayer.Components.Networking.World; // Orders native customization operations; NetworkServer retains packet routing. internal sealed class HostCustomizationSynchronization { + private const float ActionReadyTimeoutSeconds = 10f; private readonly NetworkServer server; private readonly List pendingHostCustomizationActions = []; private bool hostCustomizationDrainRunning; @@ -57,9 +59,28 @@ private IEnumerator DrainPendingHostCustomizationActions() bool blockedByEarlierDependency = pendingHostCustomizationActions.Take(index) .Any(earlier => CustomizationStateManager.ActionsConflict(earlier.Packet, pending.Packet)); - if (blockedByEarlierDependency || - !CustomizationStateManager.IsActionReady(pending.Packet, out _)) + if (blockedByEarlierDependency) + { + pending.UnreadySince = -1f; + continue; + } + + if (!CustomizationStateManager.IsActionReady(pending.Packet, out _)) + { + if (pending.UnreadySince < 0f) + pending.UnreadySince = Time.realtimeSinceStartup; + else if (Time.realtimeSinceStartup - pending.UnreadySince >= ActionReadyTimeoutSeconds) + { + pendingHostCustomizationActions.RemoveAt(index--); + if (pending.Packet.OriginActionId != 0) + pending.Player.ProcessedCustomizationActionIds.Add(pending.Packet.OriginActionId); + server.LogWarning($"Discarding customization action {pending.Packet.GetType().Name} from " + + $"{pending.Player.Username} after it remained unready for {ActionReadyTimeoutSeconds:F0}s"); + server.ResynchronizeCustomization(pending.Player, pending.Peer); + appliedAny = true; + } continue; + } pendingHostCustomizationActions.RemoveAt(index--); bool applied = false; @@ -99,6 +120,7 @@ private sealed class PendingHostCustomizationAction public readonly ICustomizationActionPacket Packet; public readonly ServerPlayer Player; public readonly ITransportPeer Peer; + public float UnreadySince = -1f; public readonly Action Relay; diff --git a/Multiplayer/Components/Networking/World/InventorySynchronization.cs b/Multiplayer/Components/Networking/World/InventorySynchronization.cs index c99598d1..07a0ebf3 100644 --- a/Multiplayer/Components/Networking/World/InventorySynchronization.cs +++ b/Multiplayer/Components/Networking/World/InventorySynchronization.cs @@ -23,17 +23,32 @@ namespace Multiplayer.Components.Networking.World; internal sealed class InventorySynchronization { private readonly Dictionary pendingItemReconciliation = []; + private bool currentReconciliationFailed; + private bool clientReconciliationInProgress; + private byte clientReconciliationAttempt; + private IReadOnlyList activeReconciliationRequests = []; private readonly Queue reconciliationBatches = new(); + private readonly Dictionary> completedReconciliation = []; private bool drainingReconciliationBatches; private readonly NetworkedItemManager manager; internal InventorySynchronization(NetworkedItemManager manager) => this.manager = manager; + internal void ResetClientReconciliation() + { + pendingItemReconciliation.Clear(); + currentReconciliationFailed = false; + clientReconciliationInProgress = false; + activeReconciliationRequests = []; + } + internal void PlayerDisconnected(ServerPlayer disconnectedPlayer) { if (disconnectedPlayer == null || !NetworkLifecycle.Instance.IsHost()) return; + completedReconciliation.Remove(disconnectedPlayer); + byte hostPlayerId = NetworkLifecycle.Instance.Server?.SelfId ?? 0; ServerPlayer hostPlayer = null; if (hostPlayerId != 0 && disconnectedPlayer.PlayerId != hostPlayerId) @@ -96,11 +111,10 @@ private static HashSet GetCarriedInventoryItems(ServerPlayer play var allItems = NetworkedItem.GetAll() .Where(item => item != null && item.NetId != 0) .ToArray(); - foreach (var item in allItems) - item.RefreshPersistenceState(); + var persistenceStates = allItems.ToDictionary(item => item, item => item.RefreshPersistenceState()); var selected = allItems .Where(item => item.OwnerPlayerId == player.PlayerId && - item.CurrentState is ItemState.InHand or ItemState.InInventory) + persistenceStates[item] is ItemState.InHand or ItemState.InInventory) .ToHashSet(); bool added; @@ -115,7 +129,7 @@ private static HashSet GetCarriedInventoryItems(ServerPlayer play // them there. Ownership of the root decides whose inventory is saved. foreach (var item in allItems) { - if (selected.Contains(item) || item.CurrentState != ItemState.InContainer || + if (selected.Contains(item) || persistenceStates[item] != ItemState.InContainer || string.IsNullOrEmpty(item.ContainerId) || !selectedContainerIds.Contains(item.ContainerId)) continue; selected.Add(item); @@ -160,12 +174,13 @@ private static bool TryCreateSaveData(IEnumerable items, state ??= []; item.GetComponent()?.OnItemSaveDataRequested(state); + ItemState persistenceState = item.RefreshPersistenceState(); result.Add(new PlayerItemSaveData { NetId = 0, ItemPrefabName = item.Item.InventorySpecs.ItemPrefabName, BelongsToPlayer = item.Item.InventorySpecs.BelongsToPlayer, - IsGrabbed = item.CurrentState == ItemState.InHand, + IsGrabbed = persistenceState == ItemState.InHand, State = state ?? [], InventorySlotIndex = item.InventorySlotIndex, ContainerSlotIndex = item.ContainerSlotIndex, @@ -320,11 +335,15 @@ public IEnumerator RecoverStoredInventory(SavedPlayerInventory stored, completed?.Invoke(true, created.Count, containerCount, null); } - internal IReadOnlyList CreateItemReconciliationRequests() + internal IReadOnlyList CreateItemReconciliationRequests(byte attempt) { + if (clientReconciliationInProgress && attempt == clientReconciliationAttempt) + return activeReconciliationRequests; + pendingItemReconciliation.Clear(); + currentReconciliationFailed = false; var packets = new List(); - var packet = new ServerboundItemReconciliationPacket(); + var packet = new ServerboundItemReconciliationPacket { Attempt = attempt }; int localId = 1; foreach (var item in NetworkedItem.GetAll()) @@ -332,7 +351,7 @@ internal IReadOnlyList CreateItemReconcilia if (packet.Items.Count >= ItemReconciliationProtocol.MaxItems) { packets.Add(packet); - packet = new ServerboundItemReconciliationPacket(); + packet = new ServerboundItemReconciliationPacket { Attempt = attempt }; } if (!IsReconciliationCandidate(item)) @@ -351,14 +370,24 @@ internal IReadOnlyList CreateItemReconcilia localId++; } + packet.Attempt = attempt; packet.IsFinalBatch = true; packets.Add(packet); + clientReconciliationAttempt = attempt; + clientReconciliationInProgress = true; + activeReconciliationRequests = packets; Multiplayer.LogDebug(() => $"Prepared {localId - 1} local items in {packets.Count} batch(es) for authoritative reconciliation"); return packets; } - internal bool ApplyItemReconciliation(ClientboundItemReconciliationPacket packet) + internal bool ApplyItemReconciliation(ClientboundItemReconciliationPacket packet, byte attempt, + out bool succeeded) { + succeeded = false; + if (packet == null || packet.Attempt != attempt) + return false; + + bool rejected = false; foreach (var result in packet?.Items ?? []) { if (!pendingItemReconciliation.TryGetValue(result.LocalId, out var localItem)) @@ -370,6 +399,8 @@ internal bool ApplyItemReconciliation(ClientboundItemReconciliationPacket packet pendingItemReconciliation.Remove(result.LocalId); if (result.NetId == 0) { + rejected = true; + currentReconciliationFailed = true; Multiplayer.LogWarning($"Host rejected reconciliation for local item {result.LocalId} ({localItem?.name})"); continue; } @@ -401,22 +432,23 @@ internal bool ApplyItemReconciliation(ClientboundItemReconciliationPacket packet foreach (var unresolved in pendingItemReconciliation) Multiplayer.LogWarning($"Host did not return reconciliation data for local item {unresolved.Key} ({unresolved.Value?.name})"); + rejected |= currentReconciliationFailed || pendingItemReconciliation.Count > 0; pendingItemReconciliation.Clear(); - manager.ClientInitialised = true; + clientReconciliationInProgress = false; + activeReconciliationRequests = []; + succeeded = !rejected; + if (succeeded) + manager.ClientInitialised = true; return true; } - internal void ContinueAfterItemReconciliationTimeout() - { - // Allow ordinary item updates to resume, but retain the local-ID mapping so - // a delayed reliable response can still assign authoritative IDs. - manager.ClientInitialised = true; - } - internal void EnqueueClientItemReconciliation(ItemReconciliationRequest[] requests, bool isFinalBatch, - ServerPlayer player, Action completed) + byte attempt, ServerPlayer player, Action completed) { - reconciliationBatches.Enqueue(ReconcileClientItems(requests, isFinalBatch, player, completed)); + if (TryReplayCompletedReconciliation(requests, isFinalBatch, attempt, player, completed)) + return; + + reconciliationBatches.Enqueue(ReconcileClientItems(requests, isFinalBatch, attempt, player, completed)); if (!drainingReconciliationBatches) CoroutineManager.Instance.StartCoroutine(DrainReconciliationBatches()); } @@ -438,12 +470,13 @@ private IEnumerator DrainReconciliationBatches() } private IEnumerator ReconcileClientItems( - ItemReconciliationRequest[] requests, bool isFinalBatch, + ItemReconciliationRequest[] requests, bool isFinalBatch, byte attempt, ServerPlayer player, Action completed) { var response = new ClientboundItemReconciliationPacket { IsFinalBatch = isFinalBatch, + Attempt = attempt, }; if (!NetworkLifecycle.Instance.IsHost() || requests == null || player == null) yield break; @@ -451,6 +484,29 @@ private IEnumerator ReconcileClientItems( initialPlayer != player) yield break; + // A duplicate can have been queued while the original batch was still + // initializing. Recheck here after earlier queued work has completed. + if (TryReplayCompletedReconciliation(requests, isFinalBatch, attempt, player, completed)) + yield break; + + if (player.InventoryReconciliationAttempt < 0 && attempt != 0 || + player.InventoryReconciliationComplete || + attempt < player.InventoryReconciliationAttempt || + player.InventoryReconciliationAttempt >= 0 && attempt > player.InventoryReconciliationAttempt + 1) + { + Multiplayer.LogWarning($"Disconnecting {player.Username} after out-of-order item reconciliation attempt {attempt}"); + NetworkLifecycle.Instance.Server.KickPlayer(player); + yield break; + } + + if (attempt != player.InventoryReconciliationAttempt) + { + player.InventoryReconciliationAttempt = attempt; + player.InventoryReconciliationFailed = false; + player.InventoryReconciliationComplete = false; + completedReconciliation[player] = []; + } + var created = new List<(ItemReconciliationRequest Request, NetworkedItem Item)>(); foreach (var request in requests) { @@ -534,7 +590,11 @@ private IEnumerator ReconcileClientItems( snapshot.ItemNetId = item.NetId; snapshot.Player = player.PlayerId; snapshot.OwnerPlayerId = player.PlayerId; - item.ReceiveSnapshot(snapshot); + // This snapshot was constructed from the inventory state the host + // supplied during login. Seed even normally host-authoritative + // values (notably flashlight battery charge) before the host takes + // authority over subsequent updates. + item.ReceiveSnapshot(snapshot, acceptServerAuthoritativeValues: true); item.IsPendingReconciliation = false; player.KnownItems[item] = NetworkLifecycle.Instance.Tick; player.NearbyItems[item] = Time.time; @@ -563,9 +623,31 @@ private IEnumerator ReconcileClientItems( player.ReconciledInventoryItems.Clear(); ShopPurchaseCoordinator.RequestStockRecount(); } + if (!completedReconciliation.TryGetValue(player, out var responses)) + completedReconciliation[player] = responses = []; + responses.Add(response); completed(response); } + private bool TryReplayCompletedReconciliation(ItemReconciliationRequest[] requests, bool isFinalBatch, + byte attempt, ServerPlayer player, Action completed) + { + if (player == null || attempt != player.InventoryReconciliationAttempt || + !completedReconciliation.TryGetValue(player, out var completedBatches)) + return false; + + var replay = completedBatches.FirstOrDefault(batch => + batch.IsFinalBatch == isFinalBatch && + batch.Items.Select(item => item.LocalId).SequenceEqual( + (requests ?? []).Select(item => item.LocalId))); + if (replay == null) + return false; + + Multiplayer.LogDebug(() => $"Replaying completed reconciliation attempt {attempt} for {player.Username}"); + completed(replay); + return true; + } + private static bool IsAllowedReconciliationState(ItemState state, InventoryItemSpec spec) => NetworkedItem.IsCarriedState(state) || state == ItemState.Dropped && spec.IsEssential; diff --git a/Multiplayer/Components/Networking/World/NetworkedCashRegisterWithModules.cs b/Multiplayer/Components/Networking/World/NetworkedCashRegisterWithModules.cs index f6cddc7f..2c34bb05 100644 --- a/Multiplayer/Components/Networking/World/NetworkedCashRegisterWithModules.cs +++ b/Multiplayer/Components/Networking/World/NetworkedCashRegisterWithModules.cs @@ -302,7 +302,14 @@ public void Server_ProcessShopPurchase(ServerPlayer player, CommonShopPacket pac totalCost += data.basePrice * amounts[i]; } - if (double.IsNaN(packet.Amount) || double.IsInfinity(packet.Amount) || packet.Amount + 0.001 < totalCost) + if (double.IsNaN(packet.Amount) || double.IsInfinity(packet.Amount) || packet.Amount + 0.001 < totalCost || + Inventory.Instance.PlayerMoney + 0.001 < totalCost) + { + SendShopRejection(player, packet.RequestId, ShopAction.RejectFunds); + return; + } + + if (!Inventory.Instance.RemoveMoney(totalCost)) { SendShopRejection(player, packet.RequestId, ShopAction.RejectFunds); return; diff --git a/Multiplayer/Components/Networking/World/NetworkedItem.cs b/Multiplayer/Components/Networking/World/NetworkedItem.cs index 2c2ecf2d..c1dc5eeb 100644 --- a/Multiplayer/Components/Networking/World/NetworkedItem.cs +++ b/Multiplayer/Components/Networking/World/NetworkedItem.cs @@ -436,7 +436,7 @@ public ItemUpdateData GetSnapshot() return snapshot; } - public void ReceiveSnapshot(ItemUpdateData snapshot) + public void ReceiveSnapshot(ItemUpdateData snapshot, bool acceptServerAuthoritativeValues = false) { if (snapshot == null || snapshot.UpdateType == ItemUpdateData.ItemUpdateType.None) return; @@ -448,10 +448,10 @@ public void ReceiveSnapshot(ItemUpdateData snapshot) return; } - ApplySnapshot(snapshot); + ApplySnapshot(snapshot, acceptServerAuthoritativeValues); } - private void ApplySnapshot(ItemUpdateData snapshot) + private void ApplySnapshot(ItemUpdateData snapshot, bool acceptServerAuthoritativeValues = false) { // Shop clients instantiate the raw prefab, whose local flag can differ from // the host's purchased instance. Apply this before dropped-state handling so @@ -547,7 +547,7 @@ private void ApplySnapshot(ItemUpdateData snapshot) if (trackedItem != null && snapshot.States != null) { - ApplyTrackedValues(snapshot.States); + ApplyTrackedValues(snapshot.States, acceptServerAuthoritativeValues); if (trackedItem is GadgetItem gadgetItem && gadgetItem.Gadget is AlternatingController alternating) @@ -688,13 +688,21 @@ internal void PublishDeferredCreate() internal ItemState RefreshPersistenceState() { - lastState = GetItemState(); + ItemState currentState = GetItemState(); + if (currentState != lastState) + stateDirty = true; // Remote carried objects are inactive host-side proxies. Their layout was // supplied by the owning client and cannot be rediscovered through the // host's Inventory; doing so replaces valid slots/container IDs with -1. if (ShouldObserveLocalLayout()) - CaptureCurrentLayout(lastState); - return lastState; + { + if (CaptureCurrentLayout(currentState)) + { + stateDirty = true; + forceStateSnapshot = true; + } + } + return currentState; } public ItemUpdateData CreateCurrentUpdateData(ItemUpdateData.ItemUpdateType updateType) @@ -773,7 +781,8 @@ private ItemState ObserveState(ItemState currentState) } - private void ApplyTrackedValues(Dictionary newValues) + private void ApplyTrackedValues(Dictionary newValues, + bool acceptServerAuthoritativeValues = false) { Multiplayer.LogDebug(() => $"NetworkedItem.ApplyTrackedValues() itemNetId: {NetId}, item name: {name}. Null checks"); @@ -788,7 +797,8 @@ private void ApplyTrackedValues(Dictionary newValues) var trackedValue = trackedValues.Find(tv => ((dynamic)tv).Key == newValue.Key); if (trackedValue != null) { - if (!NetworkLifecycle.Instance.IsHost() || !((dynamic)trackedValue).ServerAuthoritative) + if (acceptServerAuthoritativeValues || !NetworkLifecycle.Instance.IsHost() || + !((dynamic)trackedValue).ServerAuthoritative) { try { diff --git a/Multiplayer/Components/Networking/World/NetworkedItemManager.cs b/Multiplayer/Components/Networking/World/NetworkedItemManager.cs index b0c46545..3df31b0a 100644 --- a/Multiplayer/Components/Networking/World/NetworkedItemManager.cs +++ b/Multiplayer/Components/Networking/World/NetworkedItemManager.cs @@ -665,20 +665,19 @@ public void CacheWorldItems() } ClientInitialised = false; + InventorySynchronization.ResetClientReconciliation(); } - internal IReadOnlyList CreateItemReconciliationRequests() => - InventorySynchronization.CreateItemReconciliationRequests(); + internal IReadOnlyList CreateItemReconciliationRequests(byte attempt) => + InventorySynchronization.CreateItemReconciliationRequests(attempt); - internal bool ApplyItemReconciliation(ClientboundItemReconciliationPacket packet) => - InventorySynchronization.ApplyItemReconciliation(packet); - - internal void ContinueAfterItemReconciliationTimeout() => - InventorySynchronization.ContinueAfterItemReconciliationTimeout(); + internal bool ApplyItemReconciliation(ClientboundItemReconciliationPacket packet, byte attempt, + out bool succeeded) => + InventorySynchronization.ApplyItemReconciliation(packet, attempt, out succeeded); internal void EnqueueClientItemReconciliation(ItemReconciliationRequest[] requests, bool isFinalBatch, - ServerPlayer player, Action completed) => - InventorySynchronization.EnqueueClientItemReconciliation(requests, isFinalBatch, player, completed); + byte attempt, ServerPlayer player, Action completed) => + InventorySynchronization.EnqueueClientItemReconciliation(requests, isFinalBatch, attempt, player, completed); internal static void RestoreContainerMemberships() { diff --git a/Multiplayer/Multiplayer.csproj b/Multiplayer/Multiplayer.csproj index ee7f908d..1070f654 100644 --- a/Multiplayer/Multiplayer.csproj +++ b/Multiplayer/Multiplayer.csproj @@ -3,7 +3,7 @@ net48 latest Multiplayer - 0.1.16.0 + 0.1.17.0 diff --git a/Multiplayer/Networking/Data/ServerPlayer.cs b/Multiplayer/Networking/Data/ServerPlayer.cs index 38b66be1..0216aa26 100644 --- a/Multiplayer/Networking/Data/ServerPlayer.cs +++ b/Multiplayer/Networking/Data/ServerPlayer.cs @@ -50,6 +50,7 @@ public void Dispose() public bool CustomizationSnapshotSent { get; set; } internal bool InventoryReconciliationComplete { get; set; } internal bool InventoryReconciliationFailed { get; set; } + internal int InventoryReconciliationAttempt { get; set; } = -1; internal HashSet ReconciledInventoryItems { get; } = []; public HashSet ProcessedCustomizationActionIds { get; } = []; diff --git a/Multiplayer/Networking/Managers/Client/NetworkClient.cs b/Multiplayer/Networking/Managers/Client/NetworkClient.cs index 2f6a762b..75c9d7c6 100644 --- a/Multiplayer/Networking/Managers/Client/NetworkClient.cs +++ b/Multiplayer/Networking/Managers/Client/NetworkClient.cs @@ -85,6 +85,8 @@ public class NetworkClient : NetworkManager private ClientCustomizationSynchronization clientCustomizationSynchronization; private ClientCustomizationSynchronization CustomizationSynchronization => clientCustomizationSynchronization ??= new(this); private bool itemReconciliationComplete; + private bool itemReconciliationSucceeded; + private byte itemReconciliationAttempt; private uint nextCustomizationActionId = 1; private uint nextShopRequestId = 1; internal bool noCarTimeout = false; @@ -406,21 +408,46 @@ private IEnumerator SyncWorldState() * ReadyForItems */ - itemReconciliationComplete = false; Log("Reconciling local items"); SendLoadStateUpdate(PlayerLoadingState.ReadyForItems); - foreach (var reconciliationPacket in NetworkedItemManager.Instance.CreateItemReconciliationRequests()) - SendNetSerializablePacketToServer(reconciliationPacket, DeliveryMethod.ReliableOrdered); displayLoadingInfo.OnLoadingStatusChanged("Syncing items", false, ((float)LoadingState / (float)PlayerLoadingState.Complete) * 100); - float reconciliationDeadline = Time.time + Mathf.Max(RPC_Timeout, 5f); - while (!itemReconciliationComplete && Time.time < reconciliationDeadline) - yield return null; - if (!itemReconciliationComplete) + itemReconciliationAttempt = 0; + for (int reconciliationTry = 0; reconciliationTry < 2; reconciliationTry++) { - LogWarning("Timed out waiting for item reconciliation; continuing in degraded state"); - NetworkedItemManager.Instance.ContinueAfterItemReconciliationTimeout(); - itemReconciliationComplete = true; + itemReconciliationComplete = false; + itemReconciliationSucceeded = false; + foreach (var reconciliationPacket in NetworkedItemManager.Instance.CreateItemReconciliationRequests( + itemReconciliationAttempt)) + SendNetSerializablePacketToServer(reconciliationPacket, DeliveryMethod.ReliableOrdered); + + float reconciliationDeadline = Time.time + Mathf.Max(RPC_Timeout, 5f); + while (IsRunning && !itemReconciliationComplete && Time.time < reconciliationDeadline) + yield return null; + + if (!IsRunning) + yield break; + if (itemReconciliationComplete && itemReconciliationSucceeded) + break; + + if (reconciliationTry == 0) + { + LogWarning(itemReconciliationComplete + ? "Item reconciliation failed; retrying once" + : "Timed out waiting for item reconciliation; retrying once"); + // A timeout does not prove the host failed. Resend the same attempt + // so it can replay its authoritative result without duplicating the + // inventory. An explicit rejection starts a clean second attempt. + if (itemReconciliationComplete) + itemReconciliationAttempt++; + continue; + } + + LogError(itemReconciliationComplete + ? "Item reconciliation failed twice; aborting connection" + : "Item reconciliation timed out twice; aborting connection"); + Stop(); + yield break; } /* @@ -714,7 +741,28 @@ private void OnClientboundWeatherPacket(ClientboundWeatherPacket packet) if (LoadingState < PlayerLoadingState.Complete) Log("Received weather state"); - WeatherDriver.Instance.LoadSaveData(JObject.FromObject(packet), Globals.G.GameParams.WeatherEditorAlwaysAllowed); + // BatteryConsumer measures usage from changes in the game's time-of-day. + // Periodic network correction can move that clock slightly backwards; + // vanilla interprets the negative delta as midnight wraparound and drains + // almost a full day of flashlight charge. Restart active consumers around + // the correction so their baseline is the newly synchronized time. + var activeFlashlightConsumers = Object.FindObjectsOfType() + .Select(flashlight => flashlight.batteryConsumer) + .Where(consumer => consumer != null && consumer.isConsuming) + .ToArray(); + foreach (var consumer in activeFlashlightConsumers) + consumer.TogglePowerConsumption(false); + + try + { + WeatherDriver.Instance.LoadSaveData(JObject.FromObject(packet), Globals.G.GameParams.WeatherEditorAlwaysAllowed); + } + finally + { + foreach (var consumer in activeFlashlightConsumers) + if (consumer != null && consumer.gameObject.activeInHierarchy) + consumer.TogglePowerConsumption(true); + } } private void OnClientboundRemoveLoadingScreen(ClientboundRemoveLoadingScreenPacket packet) @@ -1525,10 +1573,14 @@ private void OnCommonItemChangePacket(CommonItemChangePacket packet) private void OnClientboundItemReconciliationPacket(ClientboundItemReconciliationPacket packet) { - if (NetworkedItemManager.Instance.ApplyItemReconciliation(packet)) + if (NetworkedItemManager.Instance.ApplyItemReconciliation(packet, itemReconciliationAttempt, + out bool succeeded)) { itemReconciliationComplete = true; - Log($"Item reconciliation complete ({packet?.Items?.Count ?? 0} items in final batch)"); + itemReconciliationSucceeded = succeeded; + Log(succeeded + ? $"Item reconciliation complete ({packet?.Items?.Count ?? 0} items in final batch)" + : $"Item reconciliation attempt {itemReconciliationAttempt + 1} failed"); } } diff --git a/Multiplayer/Networking/Managers/Server/NetworkServer.cs b/Multiplayer/Networking/Managers/Server/NetworkServer.cs index 7d2932f7..95986f4c 100644 --- a/Multiplayer/Networking/Managers/Server/NetworkServer.cs +++ b/Multiplayer/Networking/Managers/Server/NetworkServer.cs @@ -2352,11 +2352,27 @@ private void OnServerboundItemReconciliationPacket(ServerboundItemReconciliation return; } + if (packet.Attempt > 1) + { + LogWarning($"Disconnecting {player.Username} after invalid item reconciliation attempt {packet.Attempt}"); + KickPlayer(player); + return; + } + // The packet processor reuses the packet's list. Own the batch before // yielding for native item initialization. + byte reconciliationAttempt = packet.Attempt; NetworkedItemManager.Instance.EnqueueClientItemReconciliation( - packet.Items.ToArray(), packet.IsFinalBatch, player, - response => SendNetSerializablePacket(peer, response, DeliveryMethod.ReliableOrdered)); + packet.Items.ToArray(), packet.IsFinalBatch, reconciliationAttempt, player, + response => + { + SendNetSerializablePacket(peer, response, DeliveryMethod.ReliableOrdered); + if (response.IsFinalBatch && !player.InventoryReconciliationComplete && reconciliationAttempt >= 1) + { + LogWarning($"Disconnecting {player.Username} after two failed item reconciliation attempts"); + KickPlayer(player); + } + }); } internal void SendCustomizationAction(T packet) @@ -2492,6 +2508,15 @@ private IEnumerator SendInitialCustomizationStateWhenReady(ServerPlayer player, } } + internal void ResynchronizeCustomization(ServerPlayer player, ITransportPeer peer) + { + if (player == null || peer == null || !player.CustomizationSnapshotSent) + return; + + player.CustomizationSnapshotSent = false; + CoroutineManager.Instance.StartCoroutine(SendInitialCustomizationStateWhenReady(player, peer)); + } + private void EnsureCustomizationItemsKnown(ICustomizationActionPacket packet, ServerPlayer player) { // Replacement actions construct (or reuse) their replacement object locally diff --git a/Multiplayer/Networking/Packets/Clientbound/ClientboundItemReconciliationPacket.cs b/Multiplayer/Networking/Packets/Clientbound/ClientboundItemReconciliationPacket.cs index 608aef75..600ec863 100644 --- a/Multiplayer/Networking/Packets/Clientbound/ClientboundItemReconciliationPacket.cs +++ b/Multiplayer/Networking/Packets/Clientbound/ClientboundItemReconciliationPacket.cs @@ -9,12 +9,14 @@ internal sealed class ClientboundItemReconciliationPacket : INetSerializable { public List Items = []; public bool IsFinalBatch; + public byte Attempt; public void Serialize(NetDataWriter writer) { if (Items.Count > ItemReconciliationProtocol.MaxItems) throw new System.InvalidOperationException($"Too many item reconciliation results: {Items.Count}"); writer.Put(IsFinalBatch); + writer.Put(Attempt); writer.Put(Items.Count); for (int i = 0; i < Items.Count; i++) { @@ -30,6 +32,7 @@ public void Deserialize(NetDataReader reader) { Items.Clear(); IsFinalBatch = reader.GetBool(); + Attempt = reader.GetByte(); int count = reader.GetInt(); if (count < 0 || count > ItemReconciliationProtocol.MaxItems) throw new System.InvalidOperationException($"Invalid item reconciliation result count: {count}"); diff --git a/Multiplayer/Networking/Packets/Serverbound/ServerboundItemReconciliationPacket.cs b/Multiplayer/Networking/Packets/Serverbound/ServerboundItemReconciliationPacket.cs index ef1d94ff..74c44f4c 100644 --- a/Multiplayer/Networking/Packets/Serverbound/ServerboundItemReconciliationPacket.cs +++ b/Multiplayer/Networking/Packets/Serverbound/ServerboundItemReconciliationPacket.cs @@ -9,12 +9,14 @@ internal sealed class ServerboundItemReconciliationPacket : INetSerializable { public List Items = []; public bool IsFinalBatch; + public byte Attempt; public void Serialize(NetDataWriter writer) { if (Items.Count > ItemReconciliationProtocol.MaxItems) throw new System.InvalidOperationException($"Too many item reconciliation requests: {Items.Count}"); writer.Put(IsFinalBatch); + writer.Put(Attempt); writer.Put(Items.Count); foreach (var item in Items) { @@ -28,6 +30,7 @@ public void Deserialize(NetDataReader reader) { Items.Clear(); IsFinalBatch = reader.GetBool(); + Attempt = reader.GetByte(); int count = reader.GetInt(); if (count < 0 || count > ItemReconciliationProtocol.MaxItems) throw new System.InvalidOperationException($"Invalid item reconciliation count: {count}"); diff --git a/Multiplayer/Patches/World/Items/FlashlightPatch.cs b/Multiplayer/Patches/World/Items/FlashlightPatch.cs index 3a5f7131..35c63a4d 100644 --- a/Multiplayer/Patches/World/Items/FlashlightPatch.cs +++ b/Multiplayer/Patches/World/Items/FlashlightPatch.cs @@ -1,4 +1,5 @@ using HarmonyLib; +using Multiplayer.Components.Networking; using Multiplayer.Components.Networking.World; using Multiplayer.Utils; using System; @@ -58,8 +59,9 @@ static void Postfix(FlashlightItem __instance) __instance.battery.currentPower = value; //set the value __instance.battery.UpdatePower(0f); //process a delta of 0 to force an update }, - (current, last) => Math.Abs(current - last) >= 1.0f, //Don't communicate updates for changes less than 1f - true //This parameter is driven by the server: true + (current, last) => LocallyOwns(networkedItem) && + (Math.Abs(current - last) >= 1.0f || current != last && (current == 0f || current == 100f)), + serverAuthoritative: false ); networkedItem.RegisterTrackedValue( @@ -78,4 +80,16 @@ static void Postfix(FlashlightItem __instance) networkedItem.FinaliseTrackedValues(); } + + private static bool LocallyOwns(NetworkedItem item) + { + var lifecycle = NetworkLifecycle.Instance; + if (lifecycle == null) + return false; + + byte localPlayerId = lifecycle.IsHost() + ? lifecycle.Server?.SelfId ?? 0 + : lifecycle.Client?.PlayerId ?? 0; + return localPlayerId != 0 && item.OwnerPlayerId == localPlayerId; + } } From 74c1b1b6a78d6dfefd5312612e1320f7847135f4 Mon Sep 17 00:00:00 2001 From: Patrick van Halm Date: Sun, 13 Sep 2026 18:43:38 +0200 Subject: [PATCH 10/14] Fix snapshot recovery edge cases --- .../ClientCustomizationSynchronization.cs | 36 +++++++++---------- .../World/InventorySynchronization.cs | 29 +++++++-------- 2 files changed, 31 insertions(+), 34 deletions(-) diff --git a/Multiplayer/Components/Networking/World/ClientCustomizationSynchronization.cs b/Multiplayer/Components/Networking/World/ClientCustomizationSynchronization.cs index 1ea33239..47ac7c66 100644 --- a/Multiplayer/Components/Networking/World/ClientCustomizationSynchronization.cs +++ b/Multiplayer/Components/Networking/World/ClientCustomizationSynchronization.cs @@ -21,13 +21,12 @@ internal sealed class ClientCustomizationSynchronization private bool customizationSnapshotApplied; private bool applyingCustomizationSnapshot; private bool customizationActionDrainRunning; - private bool customizationSnapshotTimedOut; private CustomizationStateData pendingCustomizationState; private readonly List pendingCustomizationSnapshotPlacements = []; private readonly List pendingCustomizationSnapshotRelationships = []; private readonly Dictionary> pendingCustomizationSnapshotHoleStates = []; private readonly List pendingCustomizationActions = []; - private readonly List appliedActionsSinceSnapshotTimeout = []; + private readonly List unacknowledgedLocalActions = []; internal void Reset(bool forLoading = false) { @@ -38,13 +37,12 @@ internal void Reset(bool forLoading = false) applyingCustomizationSnapshot = false; customizationActionDrainRunning = false; } - customizationSnapshotTimedOut = false; pendingCustomizationState = null; pendingCustomizationSnapshotPlacements.Clear(); pendingCustomizationSnapshotRelationships.Clear(); pendingCustomizationSnapshotHoleStates.Clear(); pendingCustomizationActions.Clear(); - appliedActionsSinceSnapshotTimeout.Clear(); + unacknowledgedLocalActions.Clear(); CustomizationStateManager.ClearPendingLocalState(); } @@ -61,7 +59,6 @@ internal IEnumerator LoadInitialState(float timeout) if (Time.time >= customizationDeadline) { client.LogWarning("Timed out waiting for customization state; continuing while retaining the late-snapshot path"); - customizationSnapshotTimedOut = true; customizationSnapshotApplied = true; customizerStateLoaded = true; StartCustomizationActionDrain(); @@ -79,12 +76,13 @@ internal IEnumerator LoadInitialState(float timeout) internal void RecordLocalAction(ICustomizationActionPacket packet) { - // Ordinary actions are not echoed to their sender. Preserve locally - // originated mutations so a snapshot that arrives after the loading - // timeout cannot erase them. Replacement actions are server-echoed after - // their authoritative replacement item id has been assigned. - if (customizationSnapshotTimedOut && packet is not ReplaceSpoolPacket and not ReplaceDuctTapePacket) - appliedActionsSinceSnapshotTimeout.Add(packet.Copy()); + // Ordinary actions are not echoed to their sender. Retain them until a + // host snapshot reports that it processed their origin id. This also + // protects edits made after a recovery snapshot was captured but before + // that older baseline reaches the client. Replacement actions are + // server-echoed after their authoritative replacement item id is assigned. + if (packet is not ReplaceSpoolPacket and not ReplaceDuctTapePacket) + unacknowledgedLocalActions.Add(packet.Copy()); } @@ -136,15 +134,13 @@ private IEnumerator ApplyPendingCustomizationStates() pendingCustomizationSnapshotPlacements, pendingCustomizationSnapshotRelationships, pendingCustomizationSnapshotHoleStates); - if (customizationSnapshotTimedOut) - { - var capturedLocalActions = state.ProcessedOriginActionIds.ToHashSet(); - pendingCustomizationActions.InsertRange(0, appliedActionsSinceSnapshotTimeout.Where( - action => !capturedLocalActions.Contains(action.OriginActionId))); - appliedActionsSinceSnapshotTimeout.Clear(); - customizationSnapshotTimedOut = false; - } - + var capturedLocalActions = state.ProcessedOriginActionIds.ToHashSet(); + var actionsNewerThanSnapshot = unacknowledgedLocalActions + .Where(action => !capturedLocalActions.Contains(action.OriginActionId)) + .ToList(); + pendingCustomizationActions.InsertRange(0, actionsNewerThanSnapshot.Select(action => action.Copy())); + unacknowledgedLocalActions.Clear(); + unacknowledgedLocalActions.AddRange(actionsNewerThanSnapshot); customizationSnapshotApplied = true; client.Log($"Customization state loaded ({state.Gadgets.Count} gadgets, {state.Holes.Count} holes)"); } diff --git a/Multiplayer/Components/Networking/World/InventorySynchronization.cs b/Multiplayer/Components/Networking/World/InventorySynchronization.cs index 07a0ebf3..f0792fb2 100644 --- a/Multiplayer/Components/Networking/World/InventorySynchronization.cs +++ b/Multiplayer/Components/Networking/World/InventorySynchronization.cs @@ -160,17 +160,16 @@ private static bool TryCreateSaveData(IEnumerable items, } catch (Exception exception) { - // Some inactive remote proxies (notably Flashlight) cannot run - // every native save listener. Preserve the item and its layout - // with default custom state instead of discarding the complete - // inventory snapshot. + // An empty replacement state can erase a previously valid native + // state (for example flashlight charge). Mark the whole capture + // incomplete so callers retain the last known-good inventory. Multiplayer.LogWarning($"Could not save custom item state for {item.NetId} ({item.name}); " + - $"using default state: {exception.Message}"); - state = []; + $"preserving the previous inventory snapshot: {exception.Message}"); + saveData = []; + return false; } - // Preserve native stock bookkeeping even if a different save listener - // on an inactive proxy failed (for example the flashlight listener). + // Preserve native stock bookkeeping for items without ItemSaveData. state ??= []; item.GetComponent()?.OnItemSaveDataRequested(state); @@ -507,9 +506,11 @@ private IEnumerator ReconcileClientItems( completedReconciliation[player] = []; } - var created = new List<(ItemReconciliationRequest Request, NetworkedItem Item)>(); + var created = new List<(ItemReconciliationRequest Request, NetworkedItem Item, int ResponseIndex)>(); foreach (var request in requests) { + int responseIndex = response.Items.Count; + response.Items.Add(default); var snapshot = request.Snapshot; GameObject gameObject = null; try @@ -530,7 +531,7 @@ private IEnumerator ReconcileClientItems( networkedItem.IsPendingReconciliation = true; player.ReconciledInventoryItems.Add(networkedItem); networkedItem.DeferInitialCreate(); - created.Add((request, networkedItem)); + created.Add((request, networkedItem, responseIndex)); continue; } } @@ -552,7 +553,7 @@ private IEnumerator ReconcileClientItems( } } - response.Items.Add(new ItemReconciliationResult(request.LocalId, 0, null)); + response.Items[responseIndex] = new ItemReconciliationResult(request.LocalId, 0, null); player.InventoryReconciliationFailed = true; } @@ -598,8 +599,8 @@ private IEnumerator ReconcileClientItems( item.IsPendingReconciliation = false; player.KnownItems[item] = NetworkLifecycle.Instance.Tick; player.NearbyItems[item] = Time.time; - response.Items.Add(new ItemReconciliationResult(request.LocalId, item.NetId, - item.CreateCurrentUpdateData(ItemUpdateData.ItemUpdateType.Create))); + response.Items[entry.ResponseIndex] = new ItemReconciliationResult(request.LocalId, item.NetId, + item.CreateCurrentUpdateData(ItemUpdateData.ItemUpdateType.Create)); } catch (Exception exception) { @@ -609,7 +610,7 @@ private IEnumerator ReconcileClientItems( ShopPurchaseCoordinator.SuppressStorageTransitionRestock(item.gameObject); UnityEngine.Object.Destroy(item.gameObject); } - response.Items.Add(new ItemReconciliationResult(request.LocalId, 0, null)); + response.Items[entry.ResponseIndex] = new ItemReconciliationResult(request.LocalId, 0, null); player.InventoryReconciliationFailed = true; } } From 13c210373980aef1e30d3a6e66de0f5fde6f01e5 Mon Sep 17 00:00:00 2001 From: Patrick van Halm Date: Sun, 13 Sep 2026 18:50:28 +0200 Subject: [PATCH 11/14] Fix customization recovery races --- .../ClientCustomizationSynchronization.cs | 9 +++- .../World/InventorySynchronization.cs | 45 ++++++++++++------- .../Managers/Server/NetworkServer.cs | 13 ++++++ 3 files changed, 49 insertions(+), 18 deletions(-) diff --git a/Multiplayer/Components/Networking/World/ClientCustomizationSynchronization.cs b/Multiplayer/Components/Networking/World/ClientCustomizationSynchronization.cs index 47ac7c66..7320d219 100644 --- a/Multiplayer/Components/Networking/World/ClientCustomizationSynchronization.cs +++ b/Multiplayer/Components/Networking/World/ClientCustomizationSynchronization.cs @@ -95,8 +95,13 @@ internal void ReceiveSnapshot(ClientboundCustomizationStatePacket packet) } pendingCustomizationState = packet.State; - // Pause live mutation application immediately. If the timeout path already - // released actions, they are replayed after this older baseline is applied. + // Reliable server-to-client ordering guarantees that actions already in + // this queue were sent before this snapshot was captured. The snapshot + // supersedes them; actions received while it is being applied arrive + // afterwards and remain queued. + pendingCustomizationActions.Clear(); + // Pause live mutation application immediately. Local actions newer than + // this baseline are replayed after it is applied. customizationSnapshotApplied = false; if (customizerStateLoaded && !applyingCustomizationSnapshot) CoroutineManager.Instance.StartCoroutine(ApplyPendingCustomizationStates()); diff --git a/Multiplayer/Components/Networking/World/InventorySynchronization.cs b/Multiplayer/Components/Networking/World/InventorySynchronization.cs index f0792fb2..67d613c0 100644 --- a/Multiplayer/Components/Networking/World/InventorySynchronization.cs +++ b/Multiplayer/Components/Networking/World/InventorySynchronization.cs @@ -153,23 +153,10 @@ private static bool TryCreateSaveData(IEnumerable items, return false; } - JObject state = null; - try - { - state = item.GetComponent()?.SaveItemData()?.DeepClone() as JObject; - } - catch (Exception exception) - { - // An empty replacement state can erase a previously valid native - // state (for example flashlight charge). Mark the whole capture - // incomplete so callers retain the last known-good inventory. - Multiplayer.LogWarning($"Could not save custom item state for {item.NetId} ({item.name}); " + - $"preserving the previous inventory snapshot: {exception.Message}"); - saveData = []; - return false; - } + JObject state = SaveItemState(item); - // Preserve native stock bookkeeping for items without ItemSaveData. + // Ensure native stock bookkeeping is present even when ItemSaveData + // has no registered restocker listener. state ??= []; item.GetComponent()?.OnItemSaveDataRequested(state); @@ -192,6 +179,32 @@ private static bool TryCreateSaveData(IEnumerable items, return true; } + private static JObject SaveItemState(NetworkedItem item) + { + var itemSaveData = item.GetComponent(); + if (itemSaveData == null) + return null; + + try + { + return itemSaveData.SaveItemData()?.DeepClone() as JObject; + } + catch (Exception exception) + { + // SaveItemData mutates this publicized state object in place. Keep all + // fields written before a listener failed and add the flashlight's + // battery state explicitly because its native listener writes that + // after consulting controls which may be unavailable on an inactive + // remote proxy. + itemSaveData.saveData ??= []; + var flashlight = item.GetComponent(); + flashlight?.battery?.SaveState(itemSaveData.saveData); + Multiplayer.LogWarning($"Could not save all custom item state for {item.NetId} ({item.name}); " + + $"preserving the available state: {exception.Message}"); + return itemSaveData.saveData.DeepClone() as JObject; + } + } + public IEnumerator RecoverStoredInventory(SavedPlayerInventory stored, Action completed) { diff --git a/Multiplayer/Networking/Managers/Server/NetworkServer.cs b/Multiplayer/Networking/Managers/Server/NetworkServer.cs index 95986f4c..7ea0df97 100644 --- a/Multiplayer/Networking/Managers/Server/NetworkServer.cs +++ b/Multiplayer/Networking/Managers/Server/NetworkServer.cs @@ -2415,6 +2415,7 @@ private void OnCustomizationPacket(T packet, ITransportPeer peer) private void RelayCustomizationAction(T packet, ITransportPeer origin = null, bool echoOrigin = false) where T : CustomizationActionPacket, INetSerializable, new() { + bool sentToOrigin = false; foreach (var player in CustomizationStateManager.GetCustomizationRecipients(packet).ToArray()) { if (player.Peer == null || player.Peer == SelfPeer || (!echoOrigin && player.Peer == origin)) @@ -2422,6 +2423,18 @@ private void RelayCustomizationAction(T packet, ITransportPeer origin = null, EnsureCustomizationItemsKnown(packet, player); SendNetSerializablePacket(player.Peer, packet, DeliveryMethod.ReliableOrdered); + sentToOrigin |= player.Peer == origin; + } + + // Replacement actions assign their canonical item id on the host. Their + // acknowledgement must reach the sender even while a recovery snapshot + // temporarily excludes that player from ordinary live-action routing. + if (echoOrigin && !sentToOrigin && origin != null && origin != SelfPeer && + TryGetServerPlayer(origin, out var originPlayer) && + originPlayer.LoadingState >= PlayerLoadingState.ReadyForCustomizers) + { + EnsureCustomizationItemsKnown(packet, originPlayer); + SendNetSerializablePacket(origin, packet, DeliveryMethod.ReliableOrdered); } } From 689cc622250c0b0c75a70a85f8d1bc1fd7d0c33a Mon Sep 17 00:00:00 2001 From: Patrick van Halm Date: Sun, 13 Sep 2026 18:54:59 +0200 Subject: [PATCH 12/14] Harden vanilla item state persistence --- .../World/InventorySynchronization.cs | 107 +++++++++++++++++- 1 file changed, 101 insertions(+), 6 deletions(-) diff --git a/Multiplayer/Components/Networking/World/InventorySynchronization.cs b/Multiplayer/Components/Networking/World/InventorySynchronization.cs index 67d613c0..6093b1df 100644 --- a/Multiplayer/Components/Networking/World/InventorySynchronization.cs +++ b/Multiplayer/Components/Networking/World/InventorySynchronization.cs @@ -4,8 +4,12 @@ using System.Linq; using DV; using DV.Common; +using DV.Customization.Gadgets; +using DV.Customization.Gadgets.Implementations; using DV.InventorySystem; +using DV.Items; using DV.Shops; +using DV.TimeKeeping; using DV.Utils; using Multiplayer.Components.SaveGame; using Multiplayer.Networking.Data; @@ -192,19 +196,110 @@ private static JObject SaveItemState(NetworkedItem item) catch (Exception exception) { // SaveItemData mutates this publicized state object in place. Keep all - // fields written before a listener failed and add the flashlight's - // battery state explicitly because its native listener writes that - // after consulting controls which may be unavailable on an inactive - // remote proxy. + // fields written before a listener failed, then retry each vanilla + // subscriber independently. Several subscribers consult controls or + // streamed components that may be unavailable on an inactive proxy. itemSaveData.saveData ??= []; - var flashlight = item.GetComponent(); - flashlight?.battery?.SaveState(itemSaveData.saveData); + SaveVanillaItemState(item, itemSaveData.saveData, + (component, state) => component.OnItemSaveDataRequested(state)); + SaveVanillaItemState(item, itemSaveData.saveData, + (component, state) => component.OnItemSaveDataRequested(state)); + SaveVanillaItemState(item, itemSaveData.saveData, + (component, state) => component.OnSaveDataRequested(state)); + SaveVanillaItemState(item, itemSaveData.saveData, + (component, state) => component.OnItemSaveDataRequested(state)); + SaveVanillaItemState(item, itemSaveData.saveData, + (component, state) => component.OnItemSaveDataRequested(state)); + foreach (FlashlightItem flashlight in item.GetComponents()) + SaveBatteryState(item, flashlight.battery, itemSaveData.saveData); + SaveVanillaItemState(item, itemSaveData.saveData, + (component, state) => component.OnItemSaveDataRequested(state)); + SaveVanillaItemState(item, itemSaveData.saveData, + (component, state) => component.OnItemSaveDataRequested(state)); + SaveVanillaItemState(item, itemSaveData.saveData, + (component, state) => component.OnItemSaveDataRequested(state)); + SaveVanillaItemState(item, itemSaveData.saveData, + (component, state) => component.OnSaveDataRequested(state)); + SaveVanillaItemState(item, itemSaveData.saveData, + (component, state) => component.OnItemSaveDataRequested(state)); + SaveSnappableItemState(item, itemSaveData.saveData); + SaveVanillaItemState(item, itemSaveData.saveData, + (component, state) => component.OnItemSaveDataRequested(state)); + SaveVanillaItemState(item, itemSaveData.saveData, + (component, state) => component.OnItemSaveDataRequestedInternal(state)); + SaveVanillaItemState(item, itemSaveData.saveData, + (component, state) => component.OnSaveDataRequested(state)); + SaveVanillaItemState(item, itemSaveData.saveData, + (component, state) => component.OnItemSaveDataRequested(state)); + SaveVanillaItemState(item, itemSaveData.saveData, + (component, state) => component.OnItemSaveDataRequested(state)); + foreach (LocomotiveRemoteController remote in item.GetComponents()) + SaveBatteryState(item, remote.battery, itemSaveData.saveData); Multiplayer.LogWarning($"Could not save all custom item state for {item.NetId} ({item.name}); " + $"preserving the available state: {exception.Message}"); return itemSaveData.saveData.DeepClone() as JObject; } } + private static void SaveVanillaItemState(NetworkedItem item, JObject state, + Func saveState) where T : Component + { + foreach (T component in item.GetComponents()) + { + try + { + saveState(component, state); + } + catch (Exception exception) + { + Multiplayer.LogWarning($"Could not save {typeof(T).Name} state for {item.NetId} ({item.name}); " + + $"preserving other item state: {exception.Message}"); + } + } + } + + private static void SaveBatteryState(NetworkedItem item, Battery battery, JObject state) + { + if (battery == null) + return; + try + { + battery.SaveState(state); + } + catch (Exception exception) + { + Multiplayer.LogWarning($"Could not save battery state for {item.NetId} ({item.name}); " + + $"preserving other item state: {exception.Message}"); + } + } + + private static void SaveSnappableItemState(NetworkedItem item, JObject state) + { + foreach (SnappableItem snappable in item.GetComponents()) + { + foreach (SnappableItem.SpecialSnappedBehaviour behaviour in snappable.specialSnappedBehaviours ?? []) + { + try + { + switch (behaviour) + { + case SnappableItem.SpecialSnappedBehaviourCoupler coupler: + coupler.OnItemSaveDataRequested(state); + break; + case SnappableItem.SpecialSnappedBehaviourGadgetSnapPoint gadgetSnapPoint: + gadgetSnapPoint.OnItemSaveDataRequested(state); + break; + } + } + catch (Exception exception) + { + Multiplayer.LogWarning($"Could not save snapped-item state for {item.NetId} ({item.name}); " + + $"preserving other item state: {exception.Message}"); + } + } + } + } + public IEnumerator RecoverStoredInventory(SavedPlayerInventory stored, Action completed) { From 88833b548aae3ff30d575e881c6bb45b0cff1717 Mon Sep 17 00:00:00 2001 From: Patrick van Halm Date: Sun, 13 Sep 2026 19:39:10 +0200 Subject: [PATCH 13/14] Harden customization recovery and connection compatibility --- .../ClientCustomizationSynchronization.cs | 99 +- .../CustomizationStateManager.Mutations.cs | 564 +++++++++ .../CustomizationStateManager.Snapshots.cs | 578 +++++++++ .../World/CustomizationStateManager.cs | 1107 +---------------- .../World/HostCustomizationSynchronization.cs | 9 + .../World/InventorySynchronization.cs | 125 +- .../Networking/World/NetworkedItemManager.cs | 7 +- .../Networking/World/SnapshotActionQueue.cs | 79 ++ .../SaveGame/ItemStateTransaction.cs | 32 + .../SaveGame/NativeItemPersistence.cs | 125 ++ Multiplayer/Networking/ConnectionProtocol.cs | 23 + .../Customization/CustomizationStateData.cs | 10 +- Multiplayer/Networking/Data/ServerPlayer.cs | 3 + .../Managers/Client/NetworkClient.cs | 1 + .../Networking/Managers/NetworkManager.cs | 2 +- .../Managers/Server/NetworkServer.cs | 49 +- ...tboundCustomizationAcknowledgmentPacket.cs | 29 + .../Common/Customization/LoadSpoolPacket.cs | 1 + .../Patches/SaveGame/ItemSaveDataPatch.cs | 33 + info.json | 2 +- 20 files changed, 1643 insertions(+), 1235 deletions(-) create mode 100644 Multiplayer/Components/Networking/World/CustomizationStateManager.Mutations.cs create mode 100644 Multiplayer/Components/Networking/World/CustomizationStateManager.Snapshots.cs create mode 100644 Multiplayer/Components/Networking/World/SnapshotActionQueue.cs create mode 100644 Multiplayer/Components/SaveGame/ItemStateTransaction.cs create mode 100644 Multiplayer/Components/SaveGame/NativeItemPersistence.cs create mode 100644 Multiplayer/Networking/ConnectionProtocol.cs create mode 100644 Multiplayer/Networking/Packets/Clientbound/ClientboundCustomizationAcknowledgmentPacket.cs create mode 100644 Multiplayer/Patches/SaveGame/ItemSaveDataPatch.cs diff --git a/Multiplayer/Components/Networking/World/ClientCustomizationSynchronization.cs b/Multiplayer/Components/Networking/World/ClientCustomizationSynchronization.cs index 7320d219..ccfee411 100644 --- a/Multiplayer/Components/Networking/World/ClientCustomizationSynchronization.cs +++ b/Multiplayer/Components/Networking/World/ClientCustomizationSynchronization.cs @@ -22,11 +22,16 @@ internal sealed class ClientCustomizationSynchronization private bool applyingCustomizationSnapshot; private bool customizationActionDrainRunning; private CustomizationStateData pendingCustomizationState; + private CustomizationStateData applyingCustomizationState; private readonly List pendingCustomizationSnapshotPlacements = []; private readonly List pendingCustomizationSnapshotRelationships = []; private readonly Dictionary> pendingCustomizationSnapshotHoleStates = []; - private readonly List pendingCustomizationActions = []; - private readonly List unacknowledgedLocalActions = []; + private readonly SnapshotActionQueue actionQueue = new( + action => action.OriginActionId, + action => action is ReplaceSpoolPacket or ReplaceDuctTapePacket or LoadSpoolPacket or DropEmptySpoolPacket, + action => action.Copy()); + private List pendingCustomizationActions => actionQueue.Pending; + private readonly Dictionary localReplacements = []; internal void Reset(bool forLoading = false) { @@ -38,11 +43,12 @@ internal void Reset(bool forLoading = false) customizationActionDrainRunning = false; } pendingCustomizationState = null; + applyingCustomizationState = null; pendingCustomizationSnapshotPlacements.Clear(); pendingCustomizationSnapshotRelationships.Clear(); pendingCustomizationSnapshotHoleStates.Clear(); - pendingCustomizationActions.Clear(); - unacknowledgedLocalActions.Clear(); + actionQueue.Reset(); + localReplacements.Clear(); CustomizationStateManager.ClearPendingLocalState(); } @@ -76,13 +82,19 @@ internal IEnumerator LoadInitialState(float timeout) internal void RecordLocalAction(ICustomizationActionPacket packet) { - // Ordinary actions are not echoed to their sender. Retain them until a - // host snapshot reports that it processed their origin id. This also - // protects edits made after a recovery snapshot was captured but before - // that older baseline reaches the client. Replacement actions are - // server-echoed after their authoritative replacement item id is assigned. - if (packet is not ReplaceSpoolPacket and not ReplaceDuctTapePacket) - unacknowledgedLocalActions.Add(packet.Copy()); + // Retain local edits until acknowledged by a host batch or snapshot. + // Edits needed by an older in-progress snapshot survive until replay + // completes. Replacement echoes separately bind authoritative item ids. + actionQueue.RecordLocal(packet); + // Preserve the native magazine while this edit is unacknowledged or + // needed by an older snapshot. Replaying a replacement/drop can destroy + // a newer reel, and the baseline can reference a reel destroyed locally. + ushort toolId = GetSpoolToolId(packet); + if (toolId != 0) + PreserveLocalMagazine(toolId); + NetworkedItem replacement = CustomizationStateManager.GetLocalReplacement(packet); + if (replacement != null) + localReplacements[packet.OriginActionId] = replacement; } @@ -95,28 +107,64 @@ internal void ReceiveSnapshot(ClientboundCustomizationStatePacket packet) } pendingCustomizationState = packet.State; - // Reliable server-to-client ordering guarantees that actions already in - // this queue were sent before this snapshot was captured. The snapshot - // supersedes them; actions received while it is being applied arrive - // afterwards and remain queued. - pendingCustomizationActions.Clear(); + actionQueue.ReceiveSnapshot(packet.State.ProcessedOriginActionIds); + foreach (ushort toolId in actionQueue.LocalActions.Select(GetSpoolToolId).Where(id => id != 0).Distinct()) + PreserveLocalMagazine(toolId); // Pause live mutation application immediately. Local actions newer than // this baseline are replayed after it is applied. customizationSnapshotApplied = false; + // Item packets are normally drained on a tick, whereas this snapshot + // starts a coroutine immediately. Flush preceding reliable Creates now: + // a refresh must not replace an existing reel after we have loaded it + // into the tool from this baseline. + NetworkedItemManager.Instance.ProcessReceived(); if (customizerStateLoaded && !applyingCustomizationSnapshot) CoroutineManager.Instance.StartCoroutine(ApplyPendingCustomizationStates()); } + internal void ReceiveAcknowledgment(ClientboundCustomizationAcknowledgmentPacket packet) => + actionQueue.Acknowledge(packet.ActionIds, + applyingCustomizationSnapshot || pendingCustomizationState != null); + + private static ushort GetSpoolToolId(ICustomizationActionPacket action) => action switch + { + ReplaceSpoolPacket packet => packet.ToolItemNetId, + LoadSpoolPacket packet => packet.ToolItemNetId, + DropEmptySpoolPacket packet => packet.ToolItemNetId, + _ => 0, + }; + + private void PreserveLocalMagazine(ushort toolId) + { + pendingCustomizationState?.MagazineStates.RemoveAll(packet => packet.ToolItemNetId == toolId); + applyingCustomizationState?.MagazineStates.RemoveAll(packet => packet.ToolItemNetId == toolId); + pendingCustomizationSnapshotRelationships.RemoveAll(action => + action is LoadSpoolPacket packet && packet.ToolItemNetId == toolId); + } + internal void ReceiveAction(T packet) where T : CustomizationActionPacket, INetSerializable, new() { if (packet == null) return; + byte owner = packet switch + { + ReplaceSpoolPacket spool => spool.OwnerPlayerId, + ReplaceDuctTapePacket tape => tape.OwnerPlayerId, + _ => 0, + }; + if (owner == client.PlayerId && localReplacements.TryGetValue(packet.OriginActionId, out var replacement) && + CustomizationStateManager.BindLocalReplacement(packet, replacement)) + { + localReplacements.Remove(packet.OriginActionId); + return; + } + // LiteNetLib reuses the INetSerializable instance registered for this // subscription. Keep a value copy because the action can remain queued // while later packets are deserialized into that same instance. - pendingCustomizationActions.Add(packet.Copy()); + actionQueue.Receive(packet); StartCustomizationActionDrain(); } @@ -131,27 +179,26 @@ private IEnumerator ApplyPendingCustomizationStates() while (pendingCustomizationState != null) { CustomizationStateData state = pendingCustomizationState; + applyingCustomizationState = state; + int generation = actionQueue.Generation; pendingCustomizationState = null; pendingCustomizationSnapshotPlacements.Clear(); pendingCustomizationSnapshotRelationships.Clear(); pendingCustomizationSnapshotHoleStates.Clear(); yield return CustomizationStateManager.ApplyCurrentStateWhenReady(state, pendingCustomizationSnapshotPlacements, pendingCustomizationSnapshotRelationships, - pendingCustomizationSnapshotHoleStates); - - var capturedLocalActions = state.ProcessedOriginActionIds.ToHashSet(); - var actionsNewerThanSnapshot = unacknowledgedLocalActions - .Where(action => !capturedLocalActions.Contains(action.OriginActionId)) - .ToList(); - pendingCustomizationActions.InsertRange(0, actionsNewerThanSnapshot.Select(action => action.Copy())); - unacknowledgedLocalActions.Clear(); - unacknowledgedLocalActions.AddRange(actionsNewerThanSnapshot); + pendingCustomizationSnapshotHoleStates, + () => generation != actionQueue.Generation); + + if (!actionQueue.CompleteSnapshot(generation)) + continue; customizationSnapshotApplied = true; client.Log($"Customization state loaded ({state.Gadgets.Count} gadgets, {state.Holes.Count} holes)"); } } finally { + applyingCustomizationState = null; applyingCustomizationSnapshot = false; } diff --git a/Multiplayer/Components/Networking/World/CustomizationStateManager.Mutations.cs b/Multiplayer/Components/Networking/World/CustomizationStateManager.Mutations.cs new file mode 100644 index 00000000..0fe852a7 --- /dev/null +++ b/Multiplayer/Components/Networking/World/CustomizationStateManager.Mutations.cs @@ -0,0 +1,564 @@ +using DV.Customization.Gadgets; +using DV.Customization.Gadgets.Implementations; +using DV.InventorySystem; +using Multiplayer.Networking.Packets.Common.Customization; +using System; +using System.Linq; +using UnityEngine; + +namespace Multiplayer.Components.Networking.World; + +public static partial class CustomizationStateManager +{ + private static void ApplyPlacement(PlaceGadgetPacket packet) + { + if (!NetworkedItem.TryGet(packet.ItemNetId, out var item) || + item.GetTrackedItem() is not GadgetItem gadgetItem || + !CustomizationRef.TryResolve(TargetOf(packet.TargetKey), out var target)) + return; + + GadgetItem.Place(target, packet.Position, packet.Rotation, gadgetItem, colliderForPlacementData: null); + } + + private static void ApplyRemoval(RemoveGadgetPacket packet) + { + if (!NetworkedItem.TryGet(packet.ItemNetId, out var item)) + return; + + var removedItem = item.GetTrackedItem()?.Gadget?.Remove(packet.ReparentToTrainCar); + if (removedItem != null) + { + if (packet.ReparentToTrainCar) + { + // Gadget remover: native removal reparents the item to the train car, + // where it must remain visible until somebody actually picks it up. + item.gameObject.SetActive(true); + item.MarkAsSynchronized(); + } + else + { + // Direct hand removal is relayed before its resulting ownership + // update. Conceal the remote copy until InHand/InInventory arrives. + item.gameObject.SetActive(false); + } + } + } + + private static void ApplyHoleAction(AddHolePacket packet) + { + if (CustomizationRef.TryResolve(TargetOf(packet.TargetKey), out var target)) + target.AddHole(packet.Position, packet.Normal); + } + + private static void ApplyHoleAction(MoveHolePacket packet) + { + if (CustomizationRef.TryResolve(TargetOf(packet.TargetKey), out var target) && + target.FindHole(packet.PreviousPosition, out var movedHole)) + target.MoveHole(movedHole, packet.Position, packet.Normal); + } + + private static void ApplyHoleAction(RemoveHolePacket packet) + { + if (CustomizationRef.TryResolve(TargetOf(packet.TargetKey), out var target) && + target.FindHole(packet.PreviousPosition, out var removedHole)) + target.RemoveHole(removedHole); + } + + private static void ApplyMountAction(MountGadgetPacket packet) + { + if (!TryGadget(packet.MountItemNetId, out var owner)) return; + var mounts = owner.GetComponents(); + if (IsValidIndex(packet.MountIndex, mounts.Length) && TryGadget(packet.MountedItemNetId, out var mounted)) + mounts[packet.MountIndex].MountGadget(mounted); + } + + private static void ApplyMountAction(UnmountGadgetPacket packet) + { + if (!TryGadget(packet.MountItemNetId, out var owner)) return; + var mounts = owner.GetComponents(); + if (IsValidIndex(packet.MountIndex, mounts.Length)) + mounts[packet.MountIndex].UnmountGadget(); + } + + private static void ApplyWireAction(WireGadgetsPacket packet) => + ApplyWireAction(packet.FirstItemNetId, packet.SecondItemNetId, packet.FirstPortIndex, + packet.SecondPortIndex, unwire: false); + + private static void ApplyWireAction(UnwireGadgetsPacket packet) => + ApplyWireAction(packet.FirstItemNetId, packet.SecondItemNetId, packet.FirstPortIndex, + packet.SecondPortIndex, unwire: true); + + private static void ApplyWireAction(ushort firstItemNetId, ushort secondItemNetId, + int firstPortIndex, int secondPortIndex, bool unwire) + { + if (!TryGadget(firstItemNetId, out var first) || !TryGadget(secondItemNetId, out var second) || + !IsValidIndex(firstPortIndex, first.WireLinkPorts.Count) || + !IsValidIndex(secondPortIndex, second.WireLinkPorts.Count)) + return; + + var firstPort = first.WireLinkPorts[firstPortIndex]; + var secondPort = second.WireLinkPorts[secondPortIndex]; + if (!unwire) + { + if (GadgetWiringModule.WireLinkPort.AreWired(firstPort, secondPort)) + return; + + // The relationship packet is authoritative. A mono port can retain a + // stale local counterpart after placement/LOD reconstruction, causing + // native Wire() to fail silently. Clear only mono ports; multi ports + // (such as an alternating controller) must retain their other lights. + ClearStaleMonoWireLinks(firstPort); + ClearStaleMonoWireLinks(secondPort); + + bool wired = GadgetWiringModule.WireLinkPort.Wire(firstPort, secondPort); + if (!wired || !GadgetWiringModule.WireLinkPort.AreWired(firstPort, secondPort)) + { + Multiplayer.LogWarning($"Could not apply authoritative wire {firstItemNetId}:{firstPortIndex} -> " + + $"{secondItemNetId}:{secondPortIndex}; first={first.GetType().Name}, " + + $"second={second.GetType().Name}, sameTarget={first.Custom != null && first.Custom == second.Custom}"); + } + else + { + Multiplayer.LogDebug(() => $"Wired gadget {firstItemNetId}:{firstPortIndex} to " + + $"{secondItemNetId}:{secondPortIndex}"); + } + } + else + { + if (!GadgetWiringModule.WireLinkPort.AreWired(firstPort, secondPort)) + return; + + if (!GadgetWiringModule.WireLinkPort.Unwire(firstPort, secondPort)) + Multiplayer.LogWarning($"Could not unwire gadget {firstItemNetId}:{firstPortIndex} from " + + $"{secondItemNetId}:{secondPortIndex}"); + } + } + + private static void ClearStaleMonoWireLinks(GadgetWiringModule.WireLinkPort port) + { + Type type = port?.GetType(); + while (type != null) + { + if (type.IsGenericType && + type.GetGenericTypeDefinition() == typeof(GadgetWiringModule.WireLinkPortMono<>)) + { + GadgetWiringModule.WireLinkPort.Unwire(port); + return; + } + + type = type.BaseType; + } + } + + private static void ApplySpoolAction(ReplaceSpoolPacket packet) + { + // A newer snapshot/Create may already have supplied the canonical reel. + // Replaying its earlier replacement must not eject a subsequently loaded reel. + if (packet.ReplacementSpoolItemNetId != 0 && NetworkedItem.TryGet(packet.ReplacementSpoolItemNetId, out _)) + return; + if (NetworkedItem.TryGet(packet.ToolItemNetId, out var toolItem)) + ApplySpoolReplacement(toolItem, toolItem.GetTrackedItem(), packet); + } + + private static void ApplySpoolAction(DropEmptySpoolPacket packet) + { + if (NetworkedItem.TryGet(packet.ToolItemNetId, out var toolItem)) + DropEmptySpool(toolItem.GetTrackedItem(), packet); + } + + private static void ApplySpoolAction(LoadSpoolPacket packet) + { + if (NetworkedItem.TryGet(packet.ToolItemNetId, out var toolItem) && + toolItem.GetTrackedItem() is { magazine: not null } tool) + { + if (packet.SpoolItemNetId == 0) + { + var current = tool.magazine.items?.FirstOrDefault(); + if (current != null) + { + if (!tool.magazine.RemoveItem(0, true, true)) + throw new InvalidOperationException($"Could not clear magazine on tool {toolItem.NetId}"); + NetworkedItem.TryGetNetworkedItem(current.GetComponent(), out var removed); + // Preserve the physical reel; an empty baseline does not + // authorize destroying an item that may still exist elsewhere. + FinalizeDroppedSpool(removed, current); + } + tool.currentSpool = null; + } + else if (NetworkedItem.TryGet(packet.SpoolItemNetId, out var spoolItem)) + LoadSpool(toolItem, tool, spoolItem); + else + return; + if (packet.HasRemainingUnits) + { + // Native loading restores AMMO after magazine callbacks have run. + tool.OnAfterMagazineDataLoaded(new Newtonsoft.Json.Linq.JObject { ["AMMO"] = packet.RemainingUnits }); + toolItem.MarkAsSynchronized(); + } + } + } + + private static void LoadSpool(NetworkedItem toolItem, GadgetSolderingTool tool, NetworkedItem spoolItem) + { + var magazineItems = tool.magazine.items; + var current = magazineItems != null && magazineItems.Length > 0 ? magazineItems[0] : null; + NetworkedItem currentItem = null; + if (current != null) + NetworkedItem.TryGetNetworkedItem(current.GetComponent(), out currentItem); + + if (currentItem == spoolItem) + { + FinalizeContainedSpool(spoolItem); + return; + } + + if (current != null) + { + var currentAmmo = current.GetComponent(); + if (currentItem == null || currentAmmo == null) + { + Multiplayer.LogWarning($"Could not reconcile reel load for soldering tool {toolItem.NetId}: " + + $"contained item hasNetworkIdentity={currentItem != null}, isMagazineAmmo={currentAmmo != null}"); + return; + } + + // Replace a stale local representation through the native magazine. + // Relabelling a spent reel as full changes both its model and resource. + if (!tool.magazine.RemoveItem(0, true, true)) + return; + currentItem.SuppressDestroySync(); + Inventory.Instance.DestroyItem(current); + } + + EnsureInWorldStorage(spoolItem.Item); + if (tool.magazine.AddItem(spoolItem.gameObject, 0)) + FinalizeContainedSpool(spoolItem); + else + Multiplayer.LogWarning($"Could not load reel {spoolItem.NetId} into soldering tool {toolItem.NetId}"); + } + + private static void ApplySpoolReplacement(NetworkedItem toolItem, GadgetSolderingTool tool, ReplaceSpoolPacket packet) + { + if (tool?.magazine == null) + return; + + // The base game changes the physical full reel into its empty reel model on + // the first soldering tick, while the wire resource remains available in + // remainingUnits. Magazine.AddItem sees isSpent and temporarily sets -1, so + // preserve the actual resource count across the model replacement. + int remainingUnits = tool.remainingUnits; + + var magazineItems = tool.magazine.items; + var current = magazineItems != null && magazineItems.Length > 0 ? magazineItems[0] : null; + var currentAmmo = current?.GetComponent(); + NetworkedItem currentItem = null; + if (current != null) + NetworkedItem.TryGetNetworkedItem(current.GetComponent(), out currentItem); + byte replacementOwner = packet.OwnerPlayerId != 0 + ? packet.OwnerPlayerId + : currentItem?.OwnerPlayerId ?? toolItem?.OwnerPlayerId ?? 0; + bool belongsToPlayer = currentItem?.Item?.InventorySpecs?.BelongsToPlayer ?? true; + GameObject spentObject = currentAmmo != null && currentAmmo.isSpent ? current : null; + + if (spentObject == null) + { + if (current != null) + { + if (NetworkedItem.TryGetNetworkedItem(current.GetComponent(), out var oldSpool)) + oldSpool.SuppressDestroySync(); + try + { + tool.ignoreMagazineDataChange = true; + tool.magazine.RemoveItem(0, true, true); + Inventory.Instance.DestroyItem(current); + } + finally + { + tool.ignoreMagazineDataChange = false; + } + } + + spentObject = UnityEngine.Object.Instantiate( + tool.emptyCoilItemPrefab, + tool.reelInteractionPoint.transform.position, + tool.reelInteractionPoint.transform.rotation); + tool.UpdateEmptySpoolItemParams(spentObject); + tool.magazine.AddItem(spentObject, 0); + } + + var spentItemBase = spentObject.GetComponent(); + if (!NetworkedItem.TryGetNetworkedItem(spentItemBase, out var spentItem)) + return; + + if (packet.ReplacementSpoolItemNetId == 0 && NetworkLifecycle.Instance.IsHost()) + packet.ReplacementSpoolItemNetId = spentItem.NetId; + else if (packet.ReplacementSpoolItemNetId != 0) + spentItem.NetId = packet.ReplacementSpoolItemNetId; + + spentItem.SetOwner(replacementOwner); + if (spentItem.Item?.InventorySpecs != null) + spentItem.Item.InventorySpecs.BelongsToPlayer = belongsToPlayer; + spentItem.MarkAsSynchronized(); + tool.currentSpool = spentObject.GetComponent(); + tool.remainingUnits = remainingUnits; + tool.OnUnitsChanged(); + } + + private static void DropEmptySpool(GadgetSolderingTool tool, DropEmptySpoolPacket packet) + { + if (tool?.magazine == null) + { + Multiplayer.LogWarning($"Cannot apply empty-reel ejection: tool={tool != null}, magazine={tool?.magazine != null}"); + return; + } + + var magazineItems = tool.magazine.items; + var current = magazineItems != null && magazineItems.Length > 0 ? magazineItems[0] : null; + NetworkedItem currentItem = null; + if (current != null) + NetworkedItem.TryGetNetworkedItem(current.GetComponent(), out currentItem); + + NetworkedItem spoolItem = currentItem != null && currentItem.NetId == packet.SpoolItemNetId + ? currentItem + : null; + if (spoolItem == null) + NetworkedItem.TryGet(packet.SpoolItemNetId, out spoolItem); + if (spoolItem == null && currentItem != null) + { + Multiplayer.LogWarning($"Recover empty-reel identity for soldering tool {packet.ToolItemNetId}: " + + $"binding contained reel {currentItem.NetId} to packet reel {packet.SpoolItemNetId}"); + currentItem.NetId = packet.SpoolItemNetId; + spoolItem = currentItem; + } + + if (spoolItem == null) + { + Multiplayer.LogWarning($"Cannot apply empty-reel ejection for tool {packet.ToolItemNetId}: " + + $"reel {packet.SpoolItemNetId} is unavailable and magazine slot 0 has no networked reel"); + return; + } + + if (currentItem != spoolItem) + { + Multiplayer.LogWarning($"Reconcile empty-reel ejection for soldering tool {packet.ToolItemNetId}: replacing stale reel " + + $"{currentItem?.NetId ?? 0} with authoritative reel {spoolItem.NetId}"); + if (current != null) + { + if (!tool.magazine.RemoveItem(0, true, true)) + return; + + // This object represents the full reel that was logically consumed. + // Let its normal host-side destroy sync remove any phantom copy on + // peers that missed the physical full-to-empty replacement. + Inventory.Instance.DestroyItem(current); + } + + EnsureInWorldStorage(spoolItem.Item); + if (!tool.magazine.AddItem(spoolItem.gameObject, 0)) + { + Multiplayer.LogWarning($"Could not place authoritative reel {spoolItem.NetId} in soldering tool {packet.ToolItemNetId} before ejection"); + return; + } + } + + var spoolObject = spoolItem.gameObject; + var spoolAmmo = spoolObject.GetComponent(); + if (spoolAmmo == null) + { + Multiplayer.LogWarning($"Cannot apply empty-reel ejection: item {spoolItem.NetId} is not magazine ammunition"); + return; + } + + // The action and synchronized remainingUnits are authoritative. A peer may + // still have the full-reel prefab/flag after a missed replacement or load. + spoolAmmo.isSpent = true; + tool.currentSpool = spoolAmmo; + if (!tool.HasEjectableSpool) + { + tool.remainingUnits = -1; + tool.OnUnitsChanged(); + } + + tool.DropEmptySpool(); + if (tool.HasEjectableSpool) + { + Multiplayer.LogWarning($"Empty-spool ejection did not remove reel {spoolItem?.NetId ?? 0} from the soldering tool"); + return; + } + + spoolObject.SetActive(true); + if (packet.HasWorldTransform) + { + spoolObject.transform.SetPositionAndRotation( + packet.Position + WorldMover.currentMove, + packet.Rotation); + } + FinalizeDroppedSpool(spoolItem, spoolObject); + } + + private static void FinalizeContainedSpool(NetworkedItem spoolItem) + { + spoolItem.MarkAsSynchronized(); + } + + private static void FinalizeDroppedSpool(NetworkedItem spoolItem, GameObject spoolObject) + { + spoolObject.SetActive(true); + var spoolItemBase = spoolObject.GetComponent(); + EnsureInWorldStorage(spoolItemBase); + spoolItem?.MarkAsSynchronized(); + } + + private static void EnsureInWorldStorage(DV.CabControls.ItemBase item) + { + var storage = StorageController.Instance; + if (storage != null && item != null && item.BelongsToPlayer() && !storage.AnyStorageContains(item)) + storage.AddItemToWorldStorage(item); + } + + private static void ApplyDuctTapeReplacement(ReplaceDuctTapePacket packet) + { + NetworkedItem.TryGet(packet.ConsumedItemNetId, out var oldItem); + var oldTape = oldItem?.GetTrackedItem(); + byte replacementOwner = packet.OwnerPlayerId != 0 ? packet.OwnerPlayerId : oldItem?.OwnerPlayerId ?? 0; + bool belongsToPlayer = oldItem?.Item?.InventorySpecs?.BelongsToPlayer ?? true; + Vector3 position = packet.HasWorldTransform ? packet.Position + WorldMover.currentMove : oldItem?.transform.position ?? Vector3.zero; + Quaternion rotation = packet.HasWorldTransform ? packet.Rotation : oldItem?.transform.rotation ?? Quaternion.identity; + + // The originating client records the exact object produced by the native + // replacement path. Do not infer identity from position: inactive inventory + // and cache entries can legitimately share the same transform. + NetworkedItem replacementItem = null; + if (packet.ReplacementItemNetId != 0) + NetworkedItem.TryGet(packet.ReplacementItemNetId, out replacementItem); + + if (replacementItem == null) + { + pendingLocalDuctTapeReplacements.TryGetValue(packet.ConsumedItemNetId, out replacementItem); + if (replacementItem == null || replacementItem.NetId != 0) + replacementItem = null; + else + pendingLocalDuctTapeReplacements.Remove(packet.ConsumedItemNetId); + } + else + { + pendingLocalDuctTapeReplacements.Remove(packet.ConsumedItemNetId); + } + bool existingReplacement = replacementItem != null; + + if (replacementItem == null) + { + if (oldTape?.emptyTapeItemPrefab == null) + { + Multiplayer.LogWarning($"Cannot replace consumed duct tape {packet.ConsumedItemNetId}: empty-tape prefab unavailable"); + return; + } + + var replacementObject = UnityEngine.Object.Instantiate(oldTape.emptyTapeItemPrefab, position, rotation); + var itemBase = replacementObject.GetComponent(); + if (!NetworkedItem.TryGetNetworkedItem(itemBase, out replacementItem)) + replacementItem = replacementObject.AddComponent(); + replacementObject.GetComponent().ignoreDistanceFromSpawnPosition = true; + } + + if (oldItem != null) + { + oldItem.SuppressDestroySync(); + oldItem.gameObject.SetActive(false); + Inventory.Instance.DestroyItem(oldItem.gameObject); + } + + if (packet.ReplacementItemNetId == 0 && NetworkLifecycle.Instance.IsHost()) + packet.ReplacementItemNetId = replacementItem.NetId; + else if (packet.ReplacementItemNetId != 0) + replacementItem.NetId = packet.ReplacementItemNetId; + + replacementItem.SetOwner(replacementOwner); + if (replacementItem.Item?.InventorySpecs != null) + replacementItem.Item.InventorySpecs.BelongsToPlayer = belongsToPlayer; + if (!existingReplacement) + { + replacementItem.transform.SetPositionAndRotation(position, rotation); + // A newly instantiated inactive item never reaches Start(), so finish + // registration now. Otherwise every subsequent drop/hand snapshot is + // queued indefinitely on the host. + replacementItem.FinaliseTrackedValues(); + replacementItem.gameObject.SetActive(false); + } + replacementItem.MarkAsSynchronized(); + } + + private static void ApplySnapAction(UnsnapItemPacket packet) + { + if (!TryGadget(packet.GadgetItemNetId, out var owner)) return; + var snapPoints = GetSnapPoints(owner); + if (IsValidIndex(packet.SnapPointIndex, snapPoints.Length) && + !snapPoints[packet.SnapPointIndex].UnsnapItem(forced: true)) + Multiplayer.LogWarning($"Could not unsnap item {packet.SnappedItemNetId} from gadget {packet.GadgetItemNetId} point {packet.SnapPointIndex}"); + } + + private static void ApplySnapAction(SnapItemPacket packet) + { + if (!TryGadget(packet.GadgetItemNetId, out var owner)) return; + var snapPoints = GetSnapPoints(owner); + if (!IsValidIndex(packet.SnapPointIndex, snapPoints.Length)) return; + if (!NetworkedItem.TryGet(packet.SnappedItemNetId, out var snappedItem)) + { + Multiplayer.LogWarning($"Cannot snap item {packet.SnappedItemNetId}: networked item was not found"); + return; + } + + var snapPoint = snapPoints[packet.SnapPointIndex]; + bool snapSucceeded; + if (snapPoint is SnapPointGadgetSliding) + { + var anchor = snappedItem.Item.SnappableItem?.GetAnchor(snapPoint.SnapPointType); + if (anchor == null) + { + Multiplayer.LogWarning($"Could not restore sliding snap for item {packet.SnappedItemNetId}: anchor {snapPoint.SnapPointType} was not found"); + return; + } + + // The sliding override performs local physics queries even for a forced + // snap. Prefer the authoritative anchor offset. Older/incomplete state + // can derive the same offset from the synchronized snapped item pose. + if (packet.HasAnchorPosition) + { + anchor.localPosition = packet.AnchorPosition; + } + else + { + var target = snapPoint.snapPointTarget != null ? snapPoint.snapPointTarget : snapPoint.transform; + anchor.localPosition = snappedItem.transform.InverseTransformPoint(target.position); + Multiplayer.LogWarning($"Sliding snap {packet.GadgetItemNetId}:{packet.SnapPointIndex} for item {packet.SnappedItemNetId} " + + $"had no anchor offset; derived {anchor.localPosition} from synchronized transforms"); + } + + // Keep the rigidbody kinematic while applying an authoritative sliding + // relationship. Native FinalizeSnap also leaves snapped items kinematic. + snapSucceeded = global::Multiplayer.Patches.World.GadgetRelationshipPatch.SnapItemBaseAuthoritative( + snapPoint, snappedItem.Item, forced: true); + } + else + { + // Match the native save loader for ordinary snap points. + if (snappedItem.Item?.ItemRigidbody != null && snappedItem.Item.ItemRigidbody.isKinematic) + snappedItem.Item.ItemRigidbody.isKinematic = false; + snapSucceeded = snapPoint.SnapItem(snappedItem.Item, forced: true); + } + + if (!snapSucceeded) + { + var snappable = snappedItem.Item?.SnappableItem; + Multiplayer.LogWarning($"Could not snap item {packet.SnappedItemNetId} to gadget {packet.GadgetItemNetId} point {packet.SnapPointIndex}: " + + $"pointType={snapPoint.SnapPointType}, allowedTypes={snappable?.AllowedSnapPointTypes}, occupied={snapPoint.SnappedItem != null}"); + } + else + { + if (snappedItem.Item?.ItemRigidbody != null) + snappedItem.Item.ItemRigidbody.isKinematic = true; + snappedItem.MarkAsSynchronized(); + Multiplayer.LogDebug(() => $"Snapped item {packet.SnappedItemNetId} to gadget {packet.GadgetItemNetId} point {packet.SnapPointIndex}"); + } + } +} diff --git a/Multiplayer/Components/Networking/World/CustomizationStateManager.Snapshots.cs b/Multiplayer/Components/Networking/World/CustomizationStateManager.Snapshots.cs new file mode 100644 index 00000000..fe055307 --- /dev/null +++ b/Multiplayer/Components/Networking/World/CustomizationStateManager.Snapshots.cs @@ -0,0 +1,578 @@ +using DV.Customization; +using DV.Customization.Gadgets; +using DV.Customization.Gadgets.Implementations; +using Multiplayer.Networking.Data.Customization; +using Multiplayer.Networking.Data.Items; +using Multiplayer.Networking.Packets.Common.Customization; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +namespace Multiplayer.Components.Networking.World; + +public static partial class CustomizationStateManager +{ + public static CustomizationStateData CaptureCurrentState() + { + var state = new CustomizationStateData(); + + foreach (var item in NetworkedItem.GetAll()) + CaptureItem(state, item); + + // Native OnGadgetWired appends subscribers. Replaying in that same order + // restores the alternating sequence without assigning its private list. + state.Wires = state.Wires.OrderBy(GetWireReplayOrder).ToList(); + + CaptureFreeHoles(state); + LogSnapshot("captured", state); + return state; + } + + internal static IEnumerator ApplyCurrentStateWhenReady(CustomizationStateData state, + ICollection deferredPlacements = null, + ICollection deferredRelationships = null, + IDictionary> deferredHoleStates = null, + Func isSuperseded = null) + { + if (isSuperseded?.Invoke() == true) + yield break; + LogSnapshot("applying", state); + + const int maxItemInitializationFrames = 600; + int itemInitializationFrames = 0; + bool loggedWait = false; + bool itemsReady = AreSnapshotItemsReady(state, out string waitingFor); + while (!itemsReady && itemInitializationFrames < maxItemInitializationFrames) + { + if (!loggedWait) + { + Multiplayer.LogDebug(() => $"Waiting to apply customization snapshot until {waitingFor} is initialized"); + loggedWait = true; + } + yield return null; + if (isSuperseded?.Invoke() == true) + yield break; + itemInitializationFrames++; + itemsReady = AreSnapshotItemsReady(state, out waitingFor); + } + + if (!itemsReady) + Multiplayer.LogWarning($"Customization snapshot timed out waiting for {waitingFor}; continuing with available items"); + else if (loggedWait) + Multiplayer.LogDebug(() => "Customization snapshot items initialized; continuing reconstruction"); + + using (CustomizationSyncScope.Remote()) + { + ResetRelationships(); + ReconcileGadgetPlacements(state.Gadgets, deferredPlacements); + } + + // NetworkedItem registration can finish before SnappableItem.Initialize in + // the same Unity frame. Wait for the item's compatible anchor before + // restoring snap relationships; SnapItem otherwise returns false once and + // leaves items such as shovels lying at their captured world position. + const int maxSnapInitializationFrames = 120; + int snapInitializationFrames = 0; + bool loggedSnapWait = false; + bool snapRelationshipsReady = AreSnapRelationshipsReady(state.SnappedItems, out string snapWaitingFor); + while (!snapRelationshipsReady && snapInitializationFrames < maxSnapInitializationFrames) + { + if (!loggedSnapWait) + { + Multiplayer.LogDebug(() => $"Waiting to apply customization snapshot until {snapWaitingFor}"); + loggedSnapWait = true; + } + yield return null; + if (isSuperseded?.Invoke() == true) + yield break; + snapInitializationFrames++; + snapRelationshipsReady = AreSnapRelationshipsReady(state.SnappedItems, out snapWaitingFor); + } + + if (!snapRelationshipsReady) + Multiplayer.LogWarning($"Customization snapshot timed out waiting for {snapWaitingFor}; deferring unavailable relationships"); + else if (loggedSnapWait) + Multiplayer.LogDebug(() => $"Customization snap relationships ready after {snapInitializationFrames} frame(s)"); + + using (CustomizationSyncScope.Remote()) + { + ApplyRelationships(GetSnapshotRelationships(state), deferredRelationships); + ApplyFreeHoles(state.CustomizationKeys, state.Holes, deferredHoleStates); + ApplyTrackedValues(state.Gadgets); + } + + Multiplayer.LogDebug(() => "Customization snapshot application completed"); + } + + public static IEnumerable GetReferencedItemIds(CustomizationStateData state) + { + if (state == null) + return []; + + return state.Gadgets.Select(gadget => gadget.ItemNetId) + .Concat(GetSnapshotRelationships(state).SelectMany(GetActionItemIds)) + .Where(itemNetId => itemNetId != 0) + .Distinct(); + } + + public static HashSet GetItemsRequiringDroppedCreate(CustomizationStateData state) => + state.MagazineStates.Select(packet => packet.SpoolItemNetId) + .Concat(state.SnappedItems.Select(packet => packet.SnappedItemNetId)) + .Where(itemNetId => itemNetId != 0) + .ToHashSet(); + + private static void CaptureItem(CustomizationStateData state, NetworkedItem item) + { + if (item == null) + return; + + CaptureMagazineState(state, item); + + var gadget = item.GetTrackedItem()?.Gadget; + if (gadget == null || !gadget.IsLinked || !CustomizationRef.TryCreate(gadget.Custom, out var target)) + return; + + CapturePlacement(state, item, gadget, target); + CaptureMounts(state, item, gadget); + CaptureSnappedItems(state, item, gadget); + CaptureWires(state, item, gadget); + } + + private static void CaptureMagazineState(CustomizationStateData state, NetworkedItem toolItem) + { + var tool = toolItem.GetTrackedItem(); + var magazineItems = tool?.magazine?.items; + if (tool == null || toolItem.NetId == 0 || magazineItems == null || magazineItems.Length == 0) + return; + + ushort spoolId = 0; + if (magazineItems[0] != null) + { + var spoolItemBase = magazineItems[0].GetComponent(); + if (!NetworkedItem.TryGetNetworkedItem(spoolItemBase, out var spoolItem) || spoolItem.NetId == 0) + return; // An unidentified occupied slot is not an empty magazine. + spoolId = spoolItem.NetId; + } + + state.MagazineStates.Add(new LoadSpoolPacket + { ToolItemNetId = toolItem.NetId, SpoolItemNetId = spoolId, + HasRemainingUnits = true, RemainingUnits = tool.remainingUnits }); + } + + private static void CapturePlacement( + CustomizationStateData state, + NetworkedItem item, + GadgetBase gadget, + CustomizationRefData target) + { + var fullState = item.CreateUpdateData(ItemUpdateData.ItemUpdateType.FullSync); + state.Gadgets.Add(new GadgetPlacementData + { + ItemNetId = item.NetId, + SentTick = NetworkLifecycle.Instance.SynchronizedTick, + Target = target, + LocalPosition = gadget.transform.localPosition, + LocalRotation = gadget.transform.localRotation, + TrackedValues = fullState?.States ?? new Dictionary(), + }); + + Multiplayer.LogDebug(() => $"Customization snapshot gadget={item.NetId} type={gadget.GetType().Name} " + + $"target={target.IdentificationKey} pos={gadget.transform.localPosition} " + + $"solder={gadget.SolderingProgressUnits} tracked={fullState?.States?.Count ?? 0}"); + } + + private static void CaptureMounts(CustomizationStateData state, NetworkedItem ownerItem, GadgetBase owner) + { + var mounts = owner.GetComponents(); + for (int index = 0; index < mounts.Length; index++) + { + if (mounts[index].MountedGadget != null && TryItemId(mounts[index].MountedGadget, out var mountedId)) + state.Mounts.Add(new MountGadgetPacket + { MountItemNetId = ownerItem.NetId, MountedItemNetId = mountedId, MountIndex = index }); + } + } + + private static void CaptureSnappedItems(CustomizationStateData state, NetworkedItem ownerItem, GadgetBase owner) + { + var snapPoints = GetSnapPoints(owner); + for (int index = 0; index < snapPoints.Length; index++) + { + var snappedItem = snapPoints[index].SnappedItem; + if (snappedItem == null || !NetworkedItem.TryGetNetId(snappedItem, out var snappedId)) + continue; + + bool hasAnchorPosition = false; + Vector3 anchorPosition = Vector3.zero; + if (snappedItem.SnappableItem != null) + { + var anchor = snappedItem.SnappableItem.GetAnchor(snapPoints[index].SnapPointType); + if (anchor != null) + { + hasAnchorPosition = true; + anchorPosition = anchor.localPosition; + } + } + + state.SnappedItems.Add(new SnapItemPacket { GadgetItemNetId = ownerItem.NetId, + SnappedItemNetId = snappedId, SnapPointIndex = index, + HasAnchorPosition = hasAnchorPosition, AnchorPosition = anchorPosition }); + Multiplayer.LogDebug(() => $"Customization snapshot snap owner={ownerItem.NetId} item={snappedId} " + + $"point={snapPoints[index].GetType().Name}:{snapPoints[index].SnapPointType} " + + $"anchor={hasAnchorPosition} offset={anchorPosition}"); + } + } + + private static void CaptureWires(CustomizationStateData state, NetworkedItem ownerItem, GadgetBase owner) + { + for (int portIndex = 0; portIndex < owner.WireLinkPorts.Count; portIndex++) + { + var links = new List(); + owner.WireLinkPorts[portIndex].GetLinks(links); + + foreach (var link in links) + { + if (!TryItemId(link.owner, out var linkedId) || ownerItem.NetId >= linkedId) + continue; + + int linkedPortIndex = link.owner.WireLinkPorts.ToList().IndexOf(link); + if (linkedPortIndex >= 0) + state.Wires.Add(new WireGadgetsPacket { FirstItemNetId = ownerItem.NetId, + SecondItemNetId = linkedId, FirstPortIndex = portIndex, SecondPortIndex = linkedPortIndex }); + } + } + } + + private static (int ControllerId, int SubscriberIndex) GetWireReplayOrder(WireGadgetsPacket wire) + { + if (TryGadget(wire.FirstItemNetId, out var first) && TryGadget(wire.SecondItemNetId, out var second)) + { + if (first is AlternatingController a && + first.WireLinkPorts[wire.FirstPortIndex] is GadgetWiringModule.WireLinkPortMulti) + return (wire.FirstItemNetId, a.subscribers.IndexOf(second)); + if (second is AlternatingController b && + second.WireLinkPorts[wire.SecondPortIndex] is GadgetWiringModule.WireLinkPortMulti) + return (wire.SecondItemNetId, b.subscribers.IndexOf(first)); + } + return (int.MaxValue, 0); + } + + private static void CaptureFreeHoles(CustomizationStateData state) + { + var customizations = Resources.FindObjectsOfTypeAll() + .Where(customization => customization != null && customization.gameObject.scene.IsValid()); + + foreach (var customization in customizations) + { + if (!CustomizationRef.TryCreate(customization, out var target)) + continue; + + if (!state.CustomizationKeys.Contains(target.IdentificationKey)) + state.CustomizationKeys.Add(target.IdentificationKey); + + foreach (var hole in customization.Holes) + { + state.Holes.Add(new CustomizationHoleData + { + Target = target, + LocalPosition = hole.transform.localPosition, + LocalNormal = hole.transform.localRotation * Vector3.forward, + }); + } + } + } + + private static bool AreSnapshotItemsReady(CustomizationStateData state, out string waitingFor) + { + foreach (ushort itemNetId in GetReferencedItemIds(state)) + { + if (!NetworkedItem.TryGet(itemNetId, out var item) || !item.IsReadyForSnapshots) + { + waitingFor = $"network item {itemNetId}"; + return false; + } + } + + foreach (var placement in state.Gadgets) + { + if (!NetworkedItem.TryGet(placement.ItemNetId, out var item) || !item.IsReadyForSnapshots) + { + waitingFor = $"gadget item {placement.ItemNetId}"; + return false; + } + + var gadgetItem = item.GetTrackedItem(); + if (gadgetItem?.Gadget == null || gadgetItem.Item == null) + { + waitingFor = $"GadgetItem {placement.ItemNetId} Start"; + return false; + } + } + + waitingFor = null; + return true; + } + + private static bool AreSnapRelationshipsReady(IEnumerable relationships, out string waitingFor) + { + foreach (var relationship in relationships) + { + if (!IsSnapActionReady(relationship, out waitingFor)) + return false; + } + + waitingFor = null; + return true; + } + + private static void ResetRelationships() + { + foreach (var item in NetworkedItem.GetAll()) + { + var gadget = item?.GetTrackedItem()?.Gadget; + if (gadget == null) + continue; + + try + { + foreach (var mount in gadget.GetComponents()) + if (mount.MountedGadget != null) + mount.UnmountGadget(); + + foreach (var port in gadget.WireLinkPorts) + GadgetWiringModule.WireLinkPort.Unwire(port); + + foreach (var snapPoint in GetSnapPoints(gadget)) + if (snapPoint.SnappedItem != null) + snapPoint.UnsnapItem(forced: true); + } + catch (Exception exception) + { + Multiplayer.LogError($"Failed to reset relationships for snapshot gadget {item.NetId}: {exception}"); + } + } + } + + private static void ReconcileGadgetPlacements(IEnumerable placements, + ICollection deferredPlacements) + { + var desiredPlacements = placements + .GroupBy(placement => placement.ItemNetId) + .ToDictionary(group => group.Key, group => group.Last()); + + foreach (var item in NetworkedItem.GetAll()) + { + var gadgetItem = item?.GetTrackedItem(); + var gadget = gadgetItem?.Gadget; + if (gadget == null || !gadget.IsLinked) + continue; + + if (desiredPlacements.TryGetValue(item.NetId, out var placement)) + { + // A timed-out target is not evidence that the placement is stale. + // Preserve the existing link and let a later snapshot reconcile it. + if (!CustomizationRef.TryResolve(placement.Target, out var target)) + continue; + + if (gadget.Custom == target) + { + gadget.transform.SetParent(target.GetParentingTransform(), false); + gadget.transform.localPosition = placement.LocalPosition; + gadget.transform.localRotation = placement.LocalRotation; + gadget.gameObject.SetActive(true); + desiredPlacements.Remove(item.NetId); + continue; + } + } + + try + { + if (gadget.Remove(reparentToTrainCar: false) == null) + { + desiredPlacements.Remove(item.NetId); + Multiplayer.LogWarning($"Could not remove stale snapshot gadget {item.NetId}; preserving its current placement"); + continue; + } + + item.gameObject.SetActive(false); + item.MarkAsSynchronized(); + } + catch (Exception exception) + { + desiredPlacements.Remove(item.NetId); + Multiplayer.LogError($"Failed to remove stale snapshot gadget {item.NetId}: {exception}"); + } + } + + PlaceGadgets(desiredPlacements.Values, deferredPlacements); + } + + private static void PlaceGadgets(IEnumerable placements, + ICollection deferredPlacements) + { + foreach (var placement in placements) + { + if (!IsSnapshotPlacementReady(placement, out string waitingFor)) + { + deferredPlacements?.Add(placement); + Multiplayer.LogDebug(() => $"Deferring snapshot gadget {placement.ItemNetId} until {waitingFor}"); + continue; + } + + ApplySnapshotPlacement(placement, applyTrackedValues: false); + } + } + + private static void ApplyRelationships(IEnumerable relationships, + ICollection deferredRelationships) + { + foreach (var relationship in relationships) + { + string waitingFor = null; + bool blockedByEarlierDependency = deferredRelationships?.Any( + earlier => ActionsConflict(earlier, relationship)) == true; + if (blockedByEarlierDependency || !IsActionReady(relationship, out waitingFor)) + { + deferredRelationships?.Add(relationship); + Multiplayer.LogDebug(() => $"Deferring snapshot relationship {relationship.GetType().Name} " + + $"until {waitingFor ?? "an earlier dependency"}"); + continue; + } + + try + { + ApplyAction(relationship); + } + catch (Exception exception) + { + Multiplayer.LogError($"Failed to apply snapshot relationship {relationship.GetType().Name}: {exception}"); + } + } + } + + internal static bool IsSnapshotPlacementReady(GadgetPlacementData placement, out string waitingFor) + { + if (!NetworkedItem.TryGet(placement.ItemNetId, out var item) || !item.IsReadyForSnapshots) + { + waitingFor = $"gadget item {placement.ItemNetId}"; + return false; + } + + var gadgetItem = item.GetTrackedItem(); + if (gadgetItem?.Gadget == null || gadgetItem.Item == null) + { + waitingFor = $"GadgetItem {placement.ItemNetId} Start"; + return false; + } + + if (!CustomizationRef.TryResolve(placement.Target, out _)) + { + waitingFor = $"customization target '{placement.Target.IdentificationKey}'"; + return false; + } + + waitingFor = null; + return true; + } + + internal static void ApplySnapshotPlacement(GadgetPlacementData placement, bool applyTrackedValues = true) + { + if (!NetworkedItem.TryGet(placement.ItemNetId, out var item) || + !CustomizationRef.TryResolve(placement.Target, out var target)) + return; + + var gadgetItem = item.GetTrackedItem(); + using (CustomizationSyncScope.Remote()) + { + try + { + var gadget = gadgetItem.Gadget; + if (gadget.IsLinked && gadget.Custom != target && gadget.Remove(reparentToTrainCar: false) == null) + { + Multiplayer.LogWarning($"Could not remove stale snapshot gadget {placement.ItemNetId}; preserving its current placement"); + return; + } + + if (gadget.IsLinked && gadget.Custom == target) + { + gadget.transform.SetParent(target.GetParentingTransform(), false); + gadget.transform.localPosition = placement.LocalPosition; + gadget.transform.localRotation = placement.LocalRotation; + gadget.gameObject.SetActive(true); + } + else + { + GadgetItem.Place(target, placement.LocalPosition, placement.LocalRotation, gadgetItem, + colliderForPlacementData: null); + } + + if (applyTrackedValues) + ApplyTrackedValues([placement]); + } + catch (Exception exception) + { + Multiplayer.LogError($"Failed to place snapshot gadget {placement.ItemNetId} on " + + $"'{placement.Target.IdentificationKey}': {exception}"); + } + } + } + + private static void ApplyFreeHoles(IEnumerable customizationKeys, + IEnumerable holes, + IDictionary> deferredHoleStates) + { + var holesByTarget = holes + .Where(hole => !string.IsNullOrEmpty(hole.Target.IdentificationKey)) + .GroupBy(hole => hole.Target.IdentificationKey) + .ToDictionary(group => group.Key, group => group.ToList()); + var targetKeys = customizationKeys + .Concat(holesByTarget.Keys) + .Where(key => !string.IsNullOrEmpty(key)) + .Distinct(); + + foreach (string key in targetKeys) + { + List targetHoles = holesByTarget.TryGetValue(key, out var captured) + ? captured + : []; + if (!ApplySnapshotHoleState(key, targetHoles)) + { + deferredHoleStates?[key] = targetHoles; + Multiplayer.LogDebug(() => $"Deferring snapshot holes until customization target '{key}' is initialized"); + } + } + } + + internal static bool ApplySnapshotHoleState(string targetKey, + IEnumerable holes) + { + if (!Customization.TryGetFromIdentificationKey(targetKey, out var target)) + return false; + + using (CustomizationSyncScope.Remote()) + { + target.ClearHoles(); + foreach (var hole in holes ?? []) + target.AddHole(hole.LocalPosition, hole.LocalNormal); + } + return true; + } + + private static void ApplyTrackedValues(IEnumerable placements) + { + foreach (var placement in placements) + { + if (!NetworkedItem.TryGet(placement.ItemNetId, out var item)) + continue; + + item.ReceiveSnapshot(new ItemUpdateData + { + UpdateType = ItemUpdateData.ItemUpdateType.ObjectState, + ItemNetId = placement.ItemNetId, + SentTick = placement.SentTick, + States = placement.TrackedValues, + }); + } + } +} diff --git a/Multiplayer/Components/Networking/World/CustomizationStateManager.cs b/Multiplayer/Components/Networking/World/CustomizationStateManager.cs index a88fe161..ffe31aa0 100644 --- a/Multiplayer/Components/Networking/World/CustomizationStateManager.cs +++ b/Multiplayer/Components/Networking/World/CustomizationStateManager.cs @@ -17,7 +17,7 @@ namespace Multiplayer.Components.Networking.World; -public static class CustomizationStateManager +public static partial class CustomizationStateManager { private static readonly Dictionary pendingLocalDuctTapeReplacements = []; @@ -29,108 +29,46 @@ public static void RegisterPendingDuctTapeReplacement(ushort consumedItemNetId, public static void ClearPendingLocalState() => pendingLocalDuctTapeReplacements.Clear(); - public static CustomizationStateData CaptureCurrentState() + internal static NetworkedItem GetLocalReplacement(ICustomizationActionPacket packet) { - var state = new CustomizationStateData(); - - foreach (var item in NetworkedItem.GetAll()) - CaptureItem(state, item); - - // Native OnGadgetWired appends subscribers. Replaying in that same order - // restores the alternating sequence without assigning its private list. - state.Wires = state.Wires.OrderBy(GetWireReplayOrder).ToList(); - - CaptureFreeHoles(state); - LogSnapshot("captured", state); - return state; - } - - internal static IEnumerator ApplyCurrentStateWhenReady(CustomizationStateData state, - ICollection deferredPlacements = null, - ICollection deferredRelationships = null, - IDictionary> deferredHoleStates = null) - { - LogSnapshot("applying", state); - - const int maxItemInitializationFrames = 600; - int itemInitializationFrames = 0; - bool loggedWait = false; - bool itemsReady = AreSnapshotItemsReady(state, out string waitingFor); - while (!itemsReady && itemInitializationFrames < maxItemInitializationFrames) - { - if (!loggedWait) - { - Multiplayer.LogDebug(() => $"Waiting to apply customization snapshot until {waitingFor} is initialized"); - loggedWait = true; - } - yield return null; - itemInitializationFrames++; - itemsReady = AreSnapshotItemsReady(state, out waitingFor); - } - - if (!itemsReady) - Multiplayer.LogWarning($"Customization snapshot timed out waiting for {waitingFor}; continuing with available items"); - else if (loggedWait) - Multiplayer.LogDebug(() => "Customization snapshot items initialized; continuing reconstruction"); - - using (CustomizationSyncScope.Remote()) + if (packet is ReplaceDuctTapePacket tape) { - ResetRelationships(); - ReconcileGadgetPlacements(state.Gadgets, deferredPlacements); + pendingLocalDuctTapeReplacements.TryGetValue(tape.ConsumedItemNetId, out var replacement); + return replacement; } - - // NetworkedItem registration can finish before SnappableItem.Initialize in - // the same Unity frame. Wait for the item's compatible anchor before - // restoring snap relationships; SnapItem otherwise returns false once and - // leaves items such as shovels lying at their captured world position. - const int maxSnapInitializationFrames = 120; - int snapInitializationFrames = 0; - bool loggedSnapWait = false; - bool snapRelationshipsReady = AreSnapRelationshipsReady(state.SnappedItems, out string snapWaitingFor); - while (!snapRelationshipsReady && snapInitializationFrames < maxSnapInitializationFrames) + if (packet is ReplaceSpoolPacket spool && NetworkedItem.TryGet(spool.ToolItemNetId, out var toolItem)) { - if (!loggedSnapWait) - { - Multiplayer.LogDebug(() => $"Waiting to apply customization snapshot until {snapWaitingFor}"); - loggedSnapWait = true; - } - yield return null; - snapInitializationFrames++; - snapRelationshipsReady = AreSnapRelationshipsReady(state.SnappedItems, out snapWaitingFor); + var items = toolItem.GetTrackedItem()?.magazine?.items; + var spentObject = items != null && items.Length > 0 ? items[0] : null; + if (spentObject != null && NetworkedItem.TryGetNetworkedItem(spentObject.GetComponent(), out var replacement)) + return replacement; } - - if (!snapRelationshipsReady) - Multiplayer.LogWarning($"Customization snapshot timed out waiting for {snapWaitingFor}; deferring unavailable relationships"); - else if (loggedSnapWait) - Multiplayer.LogDebug(() => $"Customization snap relationships ready after {snapInitializationFrames} frame(s)"); - - using (CustomizationSyncScope.Remote()) - { - ApplyRelationships(GetSnapshotRelationships(state), deferredRelationships); - ApplyFreeHoles(state.CustomizationKeys, state.Holes, deferredHoleStates); - ApplyTrackedValues(state.Gadgets); - } - - Multiplayer.LogDebug(() => "Customization snapshot application completed"); + return null; } - public static IEnumerable GetReferencedItemIds(CustomizationStateData state) + internal static bool BindLocalReplacement(ICustomizationActionPacket packet, NetworkedItem replacement) { - if (state == null) - return []; + ushort netId = packet switch + { + ReplaceSpoolPacket spool => spool.ReplacementSpoolItemNetId, + ReplaceDuctTapePacket tape => tape.ReplacementItemNetId, + _ => 0, + }; + if (replacement == null || netId == 0) + return false; - return state.Gadgets.Select(gadget => gadget.ItemNetId) - .Concat(GetSnapshotRelationships(state).SelectMany(GetActionItemIds)) - .Where(itemNetId => itemNetId != 0) - .Distinct(); + // The native local mutation already happened. Bind its exact object even + // during snapshot application, without replacing a newer reel in the tool. + if (NetworkedItem.TryGet(netId, out var duplicate) && duplicate != replacement) + NetworkedItemManager.Instance.SendToCache(duplicate); + replacement.NetId = netId; + replacement.SetOwner(NetworkLifecycle.Instance.Client.PlayerId); + replacement.MarkAsSynchronized(); + if (packet is ReplaceDuctTapePacket ductTape) + pendingLocalDuctTapeReplacements.Remove(ductTape.ConsumedItemNetId); + return true; } - public static HashSet GetItemsRequiringDroppedCreate(CustomizationStateData state) => - state.LoadedSpools.Select(packet => packet.SpoolItemNetId) - .Concat(state.SnappedItems.Select(packet => packet.SnappedItemNetId)) - .Where(itemNetId => itemNetId != 0) - .ToHashSet(); - internal static void SendAction(T packet) where T : CustomizationActionPacket, INetSerializable, new() { @@ -228,6 +166,10 @@ private static bool WasActionApplied(ICustomizationActionPacket packet) return NetworkedItem.TryGet(action.ToolItemNetId, out var dropToolItem) && dropToolItem.GetTrackedItem() is { HasEjectableSpool: false }; case LoadSpoolPacket action: + if (action.SpoolItemNetId == 0) + return NetworkedItem.TryGet(action.ToolItemNetId, out var emptyToolItem) && + emptyToolItem.GetTrackedItem()?.magazine is { } emptyMagazine && + emptyMagazine.items?.FirstOrDefault() == null; return NetworkedItem.TryGet(action.ToolItemNetId, out var loadToolItem) && loadToolItem.GetTrackedItem()?.magazine?.items?.FirstOrDefault() is GameObject loaded && NetworkedItem.TryGetNetworkedItem(loaded.GetComponent(), out var loadedItem) && @@ -385,453 +327,6 @@ internal static bool TryGetSnapPointIndex(GadgetBase owner, ItemSnapPointBase sn return index >= 0; } - private static void CaptureItem(CustomizationStateData state, NetworkedItem item) - { - if (item == null) - return; - - CaptureLoadedSpool(state, item); - - var gadget = item.GetTrackedItem()?.Gadget; - if (gadget == null || !gadget.IsLinked || !CustomizationRef.TryCreate(gadget.Custom, out var target)) - return; - - CapturePlacement(state, item, gadget, target); - CaptureMounts(state, item, gadget); - CaptureSnappedItems(state, item, gadget); - CaptureWires(state, item, gadget); - } - - private static void CaptureLoadedSpool(CustomizationStateData state, NetworkedItem toolItem) - { - var tool = toolItem.GetTrackedItem(); - var magazineItems = tool?.magazine?.items; - if (magazineItems == null || magazineItems.Length == 0 || magazineItems[0] == null) - return; - - var spoolItemBase = magazineItems[0].GetComponent(); - if (!NetworkedItem.TryGetNetworkedItem(spoolItemBase, out var spoolItem)) - return; - - state.LoadedSpools.Add(new LoadSpoolPacket - { ToolItemNetId = toolItem.NetId, SpoolItemNetId = spoolItem.NetId, - HasRemainingUnits = true, RemainingUnits = tool.remainingUnits }); - } - - private static void CapturePlacement( - CustomizationStateData state, - NetworkedItem item, - GadgetBase gadget, - CustomizationRefData target) - { - var fullState = item.CreateUpdateData(ItemUpdateData.ItemUpdateType.FullSync); - state.Gadgets.Add(new GadgetPlacementData - { - ItemNetId = item.NetId, - SentTick = NetworkLifecycle.Instance.SynchronizedTick, - Target = target, - LocalPosition = gadget.transform.localPosition, - LocalRotation = gadget.transform.localRotation, - TrackedValues = fullState?.States ?? new Dictionary(), - }); - - Multiplayer.LogDebug(() => $"Customization snapshot gadget={item.NetId} type={gadget.GetType().Name} " + - $"target={target.IdentificationKey} pos={gadget.transform.localPosition} " + - $"solder={gadget.SolderingProgressUnits} tracked={fullState?.States?.Count ?? 0}"); - } - - private static void CaptureMounts(CustomizationStateData state, NetworkedItem ownerItem, GadgetBase owner) - { - var mounts = owner.GetComponents(); - for (int index = 0; index < mounts.Length; index++) - { - if (mounts[index].MountedGadget != null && TryItemId(mounts[index].MountedGadget, out var mountedId)) - state.Mounts.Add(new MountGadgetPacket - { MountItemNetId = ownerItem.NetId, MountedItemNetId = mountedId, MountIndex = index }); - } - } - - private static void CaptureSnappedItems(CustomizationStateData state, NetworkedItem ownerItem, GadgetBase owner) - { - var snapPoints = GetSnapPoints(owner); - for (int index = 0; index < snapPoints.Length; index++) - { - var snappedItem = snapPoints[index].SnappedItem; - if (snappedItem == null || !NetworkedItem.TryGetNetId(snappedItem, out var snappedId)) - continue; - - bool hasAnchorPosition = false; - Vector3 anchorPosition = Vector3.zero; - if (snappedItem.SnappableItem != null) - { - var anchor = snappedItem.SnappableItem.GetAnchor(snapPoints[index].SnapPointType); - if (anchor != null) - { - hasAnchorPosition = true; - anchorPosition = anchor.localPosition; - } - } - - state.SnappedItems.Add(new SnapItemPacket { GadgetItemNetId = ownerItem.NetId, - SnappedItemNetId = snappedId, SnapPointIndex = index, - HasAnchorPosition = hasAnchorPosition, AnchorPosition = anchorPosition }); - Multiplayer.LogDebug(() => $"Customization snapshot snap owner={ownerItem.NetId} item={snappedId} " + - $"point={snapPoints[index].GetType().Name}:{snapPoints[index].SnapPointType} " + - $"anchor={hasAnchorPosition} offset={anchorPosition}"); - } - } - - private static void CaptureWires(CustomizationStateData state, NetworkedItem ownerItem, GadgetBase owner) - { - for (int portIndex = 0; portIndex < owner.WireLinkPorts.Count; portIndex++) - { - var links = new List(); - owner.WireLinkPorts[portIndex].GetLinks(links); - - foreach (var link in links) - { - if (!TryItemId(link.owner, out var linkedId) || ownerItem.NetId >= linkedId) - continue; - - int linkedPortIndex = link.owner.WireLinkPorts.ToList().IndexOf(link); - if (linkedPortIndex >= 0) - state.Wires.Add(new WireGadgetsPacket { FirstItemNetId = ownerItem.NetId, - SecondItemNetId = linkedId, FirstPortIndex = portIndex, SecondPortIndex = linkedPortIndex }); - } - } - } - - private static (int ControllerId, int SubscriberIndex) GetWireReplayOrder(WireGadgetsPacket wire) - { - if (TryGadget(wire.FirstItemNetId, out var first) && TryGadget(wire.SecondItemNetId, out var second)) - { - if (first is AlternatingController a && - first.WireLinkPorts[wire.FirstPortIndex] is GadgetWiringModule.WireLinkPortMulti) - return (wire.FirstItemNetId, a.subscribers.IndexOf(second)); - if (second is AlternatingController b && - second.WireLinkPorts[wire.SecondPortIndex] is GadgetWiringModule.WireLinkPortMulti) - return (wire.SecondItemNetId, b.subscribers.IndexOf(first)); - } - return (int.MaxValue, 0); - } - - private static void CaptureFreeHoles(CustomizationStateData state) - { - var customizations = Resources.FindObjectsOfTypeAll() - .Where(customization => customization != null && customization.gameObject.scene.IsValid()); - - foreach (var customization in customizations) - { - if (!CustomizationRef.TryCreate(customization, out var target)) - continue; - - if (!state.CustomizationKeys.Contains(target.IdentificationKey)) - state.CustomizationKeys.Add(target.IdentificationKey); - - foreach (var hole in customization.Holes) - { - state.Holes.Add(new CustomizationHoleData - { - Target = target, - LocalPosition = hole.transform.localPosition, - LocalNormal = hole.transform.localRotation * Vector3.forward, - }); - } - } - } - - private static bool AreSnapshotItemsReady(CustomizationStateData state, out string waitingFor) - { - foreach (ushort itemNetId in GetReferencedItemIds(state)) - { - if (!NetworkedItem.TryGet(itemNetId, out var item) || !item.IsReadyForSnapshots) - { - waitingFor = $"network item {itemNetId}"; - return false; - } - } - - foreach (var placement in state.Gadgets) - { - if (!NetworkedItem.TryGet(placement.ItemNetId, out var item) || !item.IsReadyForSnapshots) - { - waitingFor = $"gadget item {placement.ItemNetId}"; - return false; - } - - var gadgetItem = item.GetTrackedItem(); - if (gadgetItem?.Gadget == null || gadgetItem.Item == null) - { - waitingFor = $"GadgetItem {placement.ItemNetId} Start"; - return false; - } - } - - waitingFor = null; - return true; - } - - private static bool AreSnapRelationshipsReady(IEnumerable relationships, out string waitingFor) - { - foreach (var relationship in relationships) - { - if (!IsSnapActionReady(relationship, out waitingFor)) - return false; - } - - waitingFor = null; - return true; - } - - private static void ResetRelationships() - { - foreach (var item in NetworkedItem.GetAll()) - { - var gadget = item?.GetTrackedItem()?.Gadget; - if (gadget == null) - continue; - - try - { - foreach (var mount in gadget.GetComponents()) - if (mount.MountedGadget != null) - mount.UnmountGadget(); - - foreach (var port in gadget.WireLinkPorts) - GadgetWiringModule.WireLinkPort.Unwire(port); - - foreach (var snapPoint in GetSnapPoints(gadget)) - if (snapPoint.SnappedItem != null) - snapPoint.UnsnapItem(forced: true); - } - catch (Exception exception) - { - Multiplayer.LogError($"Failed to reset relationships for snapshot gadget {item.NetId}: {exception}"); - } - } - } - - private static void ReconcileGadgetPlacements(IEnumerable placements, - ICollection deferredPlacements) - { - var desiredPlacements = placements - .GroupBy(placement => placement.ItemNetId) - .ToDictionary(group => group.Key, group => group.Last()); - - foreach (var item in NetworkedItem.GetAll()) - { - var gadgetItem = item?.GetTrackedItem(); - var gadget = gadgetItem?.Gadget; - if (gadget == null || !gadget.IsLinked) - continue; - - if (desiredPlacements.TryGetValue(item.NetId, out var placement)) - { - // A timed-out target is not evidence that the placement is stale. - // Preserve the existing link and let a later snapshot reconcile it. - if (!CustomizationRef.TryResolve(placement.Target, out var target)) - continue; - - if (gadget.Custom == target) - { - gadget.transform.SetParent(target.GetParentingTransform(), false); - gadget.transform.localPosition = placement.LocalPosition; - gadget.transform.localRotation = placement.LocalRotation; - gadget.gameObject.SetActive(true); - desiredPlacements.Remove(item.NetId); - continue; - } - } - - try - { - if (gadget.Remove(reparentToTrainCar: false) == null) - { - desiredPlacements.Remove(item.NetId); - Multiplayer.LogWarning($"Could not remove stale snapshot gadget {item.NetId}; preserving its current placement"); - continue; - } - - item.gameObject.SetActive(false); - item.MarkAsSynchronized(); - } - catch (Exception exception) - { - desiredPlacements.Remove(item.NetId); - Multiplayer.LogError($"Failed to remove stale snapshot gadget {item.NetId}: {exception}"); - } - } - - PlaceGadgets(desiredPlacements.Values, deferredPlacements); - } - - private static void PlaceGadgets(IEnumerable placements, - ICollection deferredPlacements) - { - foreach (var placement in placements) - { - if (!IsSnapshotPlacementReady(placement, out string waitingFor)) - { - deferredPlacements?.Add(placement); - Multiplayer.LogDebug(() => $"Deferring snapshot gadget {placement.ItemNetId} until {waitingFor}"); - continue; - } - - ApplySnapshotPlacement(placement, applyTrackedValues: false); - } - } - - private static void ApplyRelationships(IEnumerable relationships, - ICollection deferredRelationships) - { - foreach (var relationship in relationships) - { - string waitingFor = null; - bool blockedByEarlierDependency = deferredRelationships?.Any( - earlier => ActionsConflict(earlier, relationship)) == true; - if (blockedByEarlierDependency || !IsActionReady(relationship, out waitingFor)) - { - deferredRelationships?.Add(relationship); - Multiplayer.LogDebug(() => $"Deferring snapshot relationship {relationship.GetType().Name} " + - $"until {waitingFor ?? "an earlier dependency"}"); - continue; - } - - try - { - ApplyAction(relationship); - } - catch (Exception exception) - { - Multiplayer.LogError($"Failed to apply snapshot relationship {relationship.GetType().Name}: {exception}"); - } - } - } - - internal static bool IsSnapshotPlacementReady(GadgetPlacementData placement, out string waitingFor) - { - if (!NetworkedItem.TryGet(placement.ItemNetId, out var item) || !item.IsReadyForSnapshots) - { - waitingFor = $"gadget item {placement.ItemNetId}"; - return false; - } - - var gadgetItem = item.GetTrackedItem(); - if (gadgetItem?.Gadget == null || gadgetItem.Item == null) - { - waitingFor = $"GadgetItem {placement.ItemNetId} Start"; - return false; - } - - if (!CustomizationRef.TryResolve(placement.Target, out _)) - { - waitingFor = $"customization target '{placement.Target.IdentificationKey}'"; - return false; - } - - waitingFor = null; - return true; - } - - internal static void ApplySnapshotPlacement(GadgetPlacementData placement, bool applyTrackedValues = true) - { - if (!NetworkedItem.TryGet(placement.ItemNetId, out var item) || - !CustomizationRef.TryResolve(placement.Target, out var target)) - return; - - var gadgetItem = item.GetTrackedItem(); - using (CustomizationSyncScope.Remote()) - { - try - { - var gadget = gadgetItem.Gadget; - if (gadget.IsLinked && gadget.Custom != target && gadget.Remove(reparentToTrainCar: false) == null) - { - Multiplayer.LogWarning($"Could not remove stale snapshot gadget {placement.ItemNetId}; preserving its current placement"); - return; - } - - if (gadget.IsLinked && gadget.Custom == target) - { - gadget.transform.SetParent(target.GetParentingTransform(), false); - gadget.transform.localPosition = placement.LocalPosition; - gadget.transform.localRotation = placement.LocalRotation; - gadget.gameObject.SetActive(true); - } - else - { - GadgetItem.Place(target, placement.LocalPosition, placement.LocalRotation, gadgetItem, - colliderForPlacementData: null); - } - - if (applyTrackedValues) - ApplyTrackedValues([placement]); - } - catch (Exception exception) - { - Multiplayer.LogError($"Failed to place snapshot gadget {placement.ItemNetId} on " + - $"'{placement.Target.IdentificationKey}': {exception}"); - } - } - } - - private static void ApplyFreeHoles(IEnumerable customizationKeys, - IEnumerable holes, - IDictionary> deferredHoleStates) - { - var holesByTarget = holes - .Where(hole => !string.IsNullOrEmpty(hole.Target.IdentificationKey)) - .GroupBy(hole => hole.Target.IdentificationKey) - .ToDictionary(group => group.Key, group => group.ToList()); - var targetKeys = customizationKeys - .Concat(holesByTarget.Keys) - .Where(key => !string.IsNullOrEmpty(key)) - .Distinct(); - - foreach (string key in targetKeys) - { - List targetHoles = holesByTarget.TryGetValue(key, out var captured) - ? captured - : []; - if (!ApplySnapshotHoleState(key, targetHoles)) - { - deferredHoleStates?[key] = targetHoles; - Multiplayer.LogDebug(() => $"Deferring snapshot holes until customization target '{key}' is initialized"); - } - } - } - - internal static bool ApplySnapshotHoleState(string targetKey, - IEnumerable holes) - { - if (!Customization.TryGetFromIdentificationKey(targetKey, out var target)) - return false; - - using (CustomizationSyncScope.Remote()) - { - target.ClearHoles(); - foreach (var hole in holes ?? []) - target.AddHole(hole.LocalPosition, hole.LocalNormal); - } - return true; - } - - private static void ApplyTrackedValues(IEnumerable placements) - { - foreach (var placement in placements) - { - if (!NetworkedItem.TryGet(placement.ItemNetId, out var item)) - continue; - - item.ReceiveSnapshot(new ItemUpdateData - { - UpdateType = ItemUpdateData.ItemUpdateType.ObjectState, - ItemNetId = placement.ItemNetId, - SentTick = placement.SentTick, - States = placement.TrackedValues, - }); - } - } internal static bool IsActionReady(ICustomizationActionPacket packet, out string waitingFor) { @@ -1019,538 +514,6 @@ private static bool IsSnapActionReady(UnsnapItemPacket packet, out string waitin return true; } - private static void ApplyPlacement(PlaceGadgetPacket packet) - { - if (!NetworkedItem.TryGet(packet.ItemNetId, out var item) || - item.GetTrackedItem() is not GadgetItem gadgetItem || - !CustomizationRef.TryResolve(TargetOf(packet.TargetKey), out var target)) - return; - - GadgetItem.Place(target, packet.Position, packet.Rotation, gadgetItem, colliderForPlacementData: null); - } - - private static void ApplyRemoval(RemoveGadgetPacket packet) - { - if (!NetworkedItem.TryGet(packet.ItemNetId, out var item)) - return; - - var removedItem = item.GetTrackedItem()?.Gadget?.Remove(packet.ReparentToTrainCar); - if (removedItem != null) - { - if (packet.ReparentToTrainCar) - { - // Gadget remover: native removal reparents the item to the train car, - // where it must remain visible until somebody actually picks it up. - item.gameObject.SetActive(true); - item.MarkAsSynchronized(); - } - else - { - // Direct hand removal is relayed before its resulting ownership - // update. Conceal the remote copy until InHand/InInventory arrives. - item.gameObject.SetActive(false); - } - } - } - - private static void ApplyHoleAction(AddHolePacket packet) - { - if (CustomizationRef.TryResolve(TargetOf(packet.TargetKey), out var target)) - target.AddHole(packet.Position, packet.Normal); - } - - private static void ApplyHoleAction(MoveHolePacket packet) - { - if (CustomizationRef.TryResolve(TargetOf(packet.TargetKey), out var target) && - target.FindHole(packet.PreviousPosition, out var movedHole)) - target.MoveHole(movedHole, packet.Position, packet.Normal); - } - - private static void ApplyHoleAction(RemoveHolePacket packet) - { - if (CustomizationRef.TryResolve(TargetOf(packet.TargetKey), out var target) && - target.FindHole(packet.PreviousPosition, out var removedHole)) - target.RemoveHole(removedHole); - } - - private static void ApplyMountAction(MountGadgetPacket packet) - { - if (!TryGadget(packet.MountItemNetId, out var owner)) return; - var mounts = owner.GetComponents(); - if (IsValidIndex(packet.MountIndex, mounts.Length) && TryGadget(packet.MountedItemNetId, out var mounted)) - mounts[packet.MountIndex].MountGadget(mounted); - } - - private static void ApplyMountAction(UnmountGadgetPacket packet) - { - if (!TryGadget(packet.MountItemNetId, out var owner)) return; - var mounts = owner.GetComponents(); - if (IsValidIndex(packet.MountIndex, mounts.Length)) - mounts[packet.MountIndex].UnmountGadget(); - } - - private static void ApplyWireAction(WireGadgetsPacket packet) => - ApplyWireAction(packet.FirstItemNetId, packet.SecondItemNetId, packet.FirstPortIndex, - packet.SecondPortIndex, unwire: false); - - private static void ApplyWireAction(UnwireGadgetsPacket packet) => - ApplyWireAction(packet.FirstItemNetId, packet.SecondItemNetId, packet.FirstPortIndex, - packet.SecondPortIndex, unwire: true); - - private static void ApplyWireAction(ushort firstItemNetId, ushort secondItemNetId, - int firstPortIndex, int secondPortIndex, bool unwire) - { - if (!TryGadget(firstItemNetId, out var first) || !TryGadget(secondItemNetId, out var second) || - !IsValidIndex(firstPortIndex, first.WireLinkPorts.Count) || - !IsValidIndex(secondPortIndex, second.WireLinkPorts.Count)) - return; - - var firstPort = first.WireLinkPorts[firstPortIndex]; - var secondPort = second.WireLinkPorts[secondPortIndex]; - if (!unwire) - { - if (GadgetWiringModule.WireLinkPort.AreWired(firstPort, secondPort)) - return; - - // The relationship packet is authoritative. A mono port can retain a - // stale local counterpart after placement/LOD reconstruction, causing - // native Wire() to fail silently. Clear only mono ports; multi ports - // (such as an alternating controller) must retain their other lights. - ClearStaleMonoWireLinks(firstPort); - ClearStaleMonoWireLinks(secondPort); - - bool wired = GadgetWiringModule.WireLinkPort.Wire(firstPort, secondPort); - if (!wired || !GadgetWiringModule.WireLinkPort.AreWired(firstPort, secondPort)) - { - Multiplayer.LogWarning($"Could not apply authoritative wire {firstItemNetId}:{firstPortIndex} -> " + - $"{secondItemNetId}:{secondPortIndex}; first={first.GetType().Name}, " + - $"second={second.GetType().Name}, sameTarget={first.Custom != null && first.Custom == second.Custom}"); - } - else - { - Multiplayer.LogDebug(() => $"Wired gadget {firstItemNetId}:{firstPortIndex} to " + - $"{secondItemNetId}:{secondPortIndex}"); - } - } - else - { - if (!GadgetWiringModule.WireLinkPort.AreWired(firstPort, secondPort)) - return; - - if (!GadgetWiringModule.WireLinkPort.Unwire(firstPort, secondPort)) - Multiplayer.LogWarning($"Could not unwire gadget {firstItemNetId}:{firstPortIndex} from " + - $"{secondItemNetId}:{secondPortIndex}"); - } - } - - private static void ClearStaleMonoWireLinks(GadgetWiringModule.WireLinkPort port) - { - Type type = port?.GetType(); - while (type != null) - { - if (type.IsGenericType && - type.GetGenericTypeDefinition() == typeof(GadgetWiringModule.WireLinkPortMono<>)) - { - GadgetWiringModule.WireLinkPort.Unwire(port); - return; - } - - type = type.BaseType; - } - } - - private static void ApplySpoolAction(ReplaceSpoolPacket packet) - { - if (NetworkedItem.TryGet(packet.ToolItemNetId, out var toolItem)) - ApplySpoolReplacement(toolItem, toolItem.GetTrackedItem(), packet); - } - - private static void ApplySpoolAction(DropEmptySpoolPacket packet) - { - if (NetworkedItem.TryGet(packet.ToolItemNetId, out var toolItem)) - DropEmptySpool(toolItem.GetTrackedItem(), packet); - } - - private static void ApplySpoolAction(LoadSpoolPacket packet) - { - if (NetworkedItem.TryGet(packet.ToolItemNetId, out var toolItem) && - toolItem.GetTrackedItem() is { magazine: not null } tool && - NetworkedItem.TryGet(packet.SpoolItemNetId, out var spoolItem)) - { - LoadSpool(toolItem, tool, spoolItem); - if (packet.HasRemainingUnits) - { - // Native loading restores AMMO after magazine callbacks have run. - tool.OnAfterMagazineDataLoaded(new Newtonsoft.Json.Linq.JObject { ["AMMO"] = packet.RemainingUnits }); - toolItem.MarkAsSynchronized(); - } - } - } - - private static void LoadSpool(NetworkedItem toolItem, GadgetSolderingTool tool, NetworkedItem spoolItem) - { - var magazineItems = tool.magazine.items; - var current = magazineItems != null && magazineItems.Length > 0 ? magazineItems[0] : null; - NetworkedItem currentItem = null; - if (current != null) - NetworkedItem.TryGetNetworkedItem(current.GetComponent(), out currentItem); - - if (currentItem == spoolItem) - { - FinalizeContainedSpool(spoolItem); - return; - } - - if (current != null) - { - var currentAmmo = current.GetComponent(); - if (currentItem == null || currentAmmo == null) - { - Multiplayer.LogWarning($"Could not reconcile reel load for soldering tool {toolItem.NetId}: " + - $"contained item hasNetworkIdentity={currentItem != null}, isMagazineAmmo={currentAmmo != null}"); - return; - } - - // Replace a stale local representation through the native magazine. - // Relabelling a spent reel as full changes both its model and resource. - if (!tool.magazine.RemoveItem(0, true, true)) - return; - currentItem.SuppressDestroySync(); - Inventory.Instance.DestroyItem(current); - } - - EnsureInWorldStorage(spoolItem.Item); - if (tool.magazine.AddItem(spoolItem.gameObject, 0)) - FinalizeContainedSpool(spoolItem); - else - Multiplayer.LogWarning($"Could not load reel {spoolItem.NetId} into soldering tool {toolItem.NetId}"); - } - - private static void ApplySpoolReplacement(NetworkedItem toolItem, GadgetSolderingTool tool, ReplaceSpoolPacket packet) - { - if (tool?.magazine == null) - return; - - // The base game changes the physical full reel into its empty reel model on - // the first soldering tick, while the wire resource remains available in - // remainingUnits. Magazine.AddItem sees isSpent and temporarily sets -1, so - // preserve the actual resource count across the model replacement. - int remainingUnits = tool.remainingUnits; - - var magazineItems = tool.magazine.items; - var current = magazineItems != null && magazineItems.Length > 0 ? magazineItems[0] : null; - var currentAmmo = current?.GetComponent(); - NetworkedItem currentItem = null; - if (current != null) - NetworkedItem.TryGetNetworkedItem(current.GetComponent(), out currentItem); - byte replacementOwner = packet.OwnerPlayerId != 0 - ? packet.OwnerPlayerId - : currentItem?.OwnerPlayerId ?? toolItem?.OwnerPlayerId ?? 0; - bool belongsToPlayer = currentItem?.Item?.InventorySpecs?.BelongsToPlayer ?? true; - GameObject spentObject = currentAmmo != null && currentAmmo.isSpent ? current : null; - - if (spentObject == null) - { - if (current != null) - { - if (NetworkedItem.TryGetNetworkedItem(current.GetComponent(), out var oldSpool)) - oldSpool.SuppressDestroySync(); - try - { - tool.ignoreMagazineDataChange = true; - tool.magazine.RemoveItem(0, true, true); - Inventory.Instance.DestroyItem(current); - } - finally - { - tool.ignoreMagazineDataChange = false; - } - } - - spentObject = UnityEngine.Object.Instantiate( - tool.emptyCoilItemPrefab, - tool.reelInteractionPoint.transform.position, - tool.reelInteractionPoint.transform.rotation); - tool.UpdateEmptySpoolItemParams(spentObject); - tool.magazine.AddItem(spentObject, 0); - } - - var spentItemBase = spentObject.GetComponent(); - if (!NetworkedItem.TryGetNetworkedItem(spentItemBase, out var spentItem)) - return; - - if (packet.ReplacementSpoolItemNetId == 0 && NetworkLifecycle.Instance.IsHost()) - packet.ReplacementSpoolItemNetId = spentItem.NetId; - else if (packet.ReplacementSpoolItemNetId != 0) - spentItem.NetId = packet.ReplacementSpoolItemNetId; - - spentItem.SetOwner(replacementOwner); - if (spentItem.Item?.InventorySpecs != null) - spentItem.Item.InventorySpecs.BelongsToPlayer = belongsToPlayer; - spentItem.MarkAsSynchronized(); - tool.currentSpool = spentObject.GetComponent(); - tool.remainingUnits = remainingUnits; - tool.OnUnitsChanged(); - } - - private static void DropEmptySpool(GadgetSolderingTool tool, DropEmptySpoolPacket packet) - { - if (tool?.magazine == null) - { - Multiplayer.LogWarning($"Cannot apply empty-reel ejection: tool={tool != null}, magazine={tool?.magazine != null}"); - return; - } - - var magazineItems = tool.magazine.items; - var current = magazineItems != null && magazineItems.Length > 0 ? magazineItems[0] : null; - NetworkedItem currentItem = null; - if (current != null) - NetworkedItem.TryGetNetworkedItem(current.GetComponent(), out currentItem); - - NetworkedItem spoolItem = currentItem != null && currentItem.NetId == packet.SpoolItemNetId - ? currentItem - : null; - if (spoolItem == null) - NetworkedItem.TryGet(packet.SpoolItemNetId, out spoolItem); - if (spoolItem == null && currentItem != null) - { - Multiplayer.LogWarning($"Recover empty-reel identity for soldering tool {packet.ToolItemNetId}: " + - $"binding contained reel {currentItem.NetId} to packet reel {packet.SpoolItemNetId}"); - currentItem.NetId = packet.SpoolItemNetId; - spoolItem = currentItem; - } - - if (spoolItem == null) - { - Multiplayer.LogWarning($"Cannot apply empty-reel ejection for tool {packet.ToolItemNetId}: " + - $"reel {packet.SpoolItemNetId} is unavailable and magazine slot 0 has no networked reel"); - return; - } - - if (currentItem != spoolItem) - { - Multiplayer.LogWarning($"Reconcile empty-reel ejection for soldering tool {packet.ToolItemNetId}: replacing stale reel " + - $"{currentItem?.NetId ?? 0} with authoritative reel {spoolItem.NetId}"); - if (current != null) - { - if (!tool.magazine.RemoveItem(0, true, true)) - return; - - // This object represents the full reel that was logically consumed. - // Let its normal host-side destroy sync remove any phantom copy on - // peers that missed the physical full-to-empty replacement. - Inventory.Instance.DestroyItem(current); - } - - EnsureInWorldStorage(spoolItem.Item); - if (!tool.magazine.AddItem(spoolItem.gameObject, 0)) - { - Multiplayer.LogWarning($"Could not place authoritative reel {spoolItem.NetId} in soldering tool {packet.ToolItemNetId} before ejection"); - return; - } - } - - var spoolObject = spoolItem.gameObject; - var spoolAmmo = spoolObject.GetComponent(); - if (spoolAmmo == null) - { - Multiplayer.LogWarning($"Cannot apply empty-reel ejection: item {spoolItem.NetId} is not magazine ammunition"); - return; - } - - // The action and synchronized remainingUnits are authoritative. A peer may - // still have the full-reel prefab/flag after a missed replacement or load. - spoolAmmo.isSpent = true; - tool.currentSpool = spoolAmmo; - if (!tool.HasEjectableSpool) - { - tool.remainingUnits = -1; - tool.OnUnitsChanged(); - } - - tool.DropEmptySpool(); - if (tool.HasEjectableSpool) - { - Multiplayer.LogWarning($"Empty-spool ejection did not remove reel {spoolItem?.NetId ?? 0} from the soldering tool"); - return; - } - - spoolObject.SetActive(true); - if (packet.HasWorldTransform) - { - spoolObject.transform.SetPositionAndRotation( - packet.Position + WorldMover.currentMove, - packet.Rotation); - } - FinalizeDroppedSpool(spoolItem, spoolObject); - } - - private static void FinalizeContainedSpool(NetworkedItem spoolItem) - { - spoolItem.MarkAsSynchronized(); - } - - private static void FinalizeDroppedSpool(NetworkedItem spoolItem, GameObject spoolObject) - { - spoolObject.SetActive(true); - var spoolItemBase = spoolObject.GetComponent(); - EnsureInWorldStorage(spoolItemBase); - spoolItem?.MarkAsSynchronized(); - } - - private static void EnsureInWorldStorage(DV.CabControls.ItemBase item) - { - var storage = StorageController.Instance; - if (storage != null && item != null && item.BelongsToPlayer() && !storage.AnyStorageContains(item)) - storage.AddItemToWorldStorage(item); - } - - private static void ApplyDuctTapeReplacement(ReplaceDuctTapePacket packet) - { - NetworkedItem.TryGet(packet.ConsumedItemNetId, out var oldItem); - var oldTape = oldItem?.GetTrackedItem(); - byte replacementOwner = packet.OwnerPlayerId != 0 ? packet.OwnerPlayerId : oldItem?.OwnerPlayerId ?? 0; - bool belongsToPlayer = oldItem?.Item?.InventorySpecs?.BelongsToPlayer ?? true; - Vector3 position = packet.HasWorldTransform ? packet.Position + WorldMover.currentMove : oldItem?.transform.position ?? Vector3.zero; - Quaternion rotation = packet.HasWorldTransform ? packet.Rotation : oldItem?.transform.rotation ?? Quaternion.identity; - - // The originating client records the exact object produced by the native - // replacement path. Do not infer identity from position: inactive inventory - // and cache entries can legitimately share the same transform. - NetworkedItem replacementItem = null; - if (packet.ReplacementItemNetId != 0) - NetworkedItem.TryGet(packet.ReplacementItemNetId, out replacementItem); - - if (replacementItem == null) - { - pendingLocalDuctTapeReplacements.TryGetValue(packet.ConsumedItemNetId, out replacementItem); - if (replacementItem == null || replacementItem.NetId != 0) - replacementItem = null; - else - pendingLocalDuctTapeReplacements.Remove(packet.ConsumedItemNetId); - } - else - { - pendingLocalDuctTapeReplacements.Remove(packet.ConsumedItemNetId); - } - bool existingReplacement = replacementItem != null; - - if (replacementItem == null) - { - if (oldTape?.emptyTapeItemPrefab == null) - { - Multiplayer.LogWarning($"Cannot replace consumed duct tape {packet.ConsumedItemNetId}: empty-tape prefab unavailable"); - return; - } - - var replacementObject = UnityEngine.Object.Instantiate(oldTape.emptyTapeItemPrefab, position, rotation); - var itemBase = replacementObject.GetComponent(); - if (!NetworkedItem.TryGetNetworkedItem(itemBase, out replacementItem)) - replacementItem = replacementObject.AddComponent(); - replacementObject.GetComponent().ignoreDistanceFromSpawnPosition = true; - } - - if (oldItem != null) - { - oldItem.SuppressDestroySync(); - oldItem.gameObject.SetActive(false); - Inventory.Instance.DestroyItem(oldItem.gameObject); - } - - if (packet.ReplacementItemNetId == 0 && NetworkLifecycle.Instance.IsHost()) - packet.ReplacementItemNetId = replacementItem.NetId; - else if (packet.ReplacementItemNetId != 0) - replacementItem.NetId = packet.ReplacementItemNetId; - - replacementItem.SetOwner(replacementOwner); - if (replacementItem.Item?.InventorySpecs != null) - replacementItem.Item.InventorySpecs.BelongsToPlayer = belongsToPlayer; - if (!existingReplacement) - { - replacementItem.transform.SetPositionAndRotation(position, rotation); - // A newly instantiated inactive item never reaches Start(), so finish - // registration now. Otherwise every subsequent drop/hand snapshot is - // queued indefinitely on the host. - replacementItem.FinaliseTrackedValues(); - replacementItem.gameObject.SetActive(false); - } - replacementItem.MarkAsSynchronized(); - } - - private static void ApplySnapAction(UnsnapItemPacket packet) - { - if (!TryGadget(packet.GadgetItemNetId, out var owner)) return; - var snapPoints = GetSnapPoints(owner); - if (IsValidIndex(packet.SnapPointIndex, snapPoints.Length) && - !snapPoints[packet.SnapPointIndex].UnsnapItem(forced: true)) - Multiplayer.LogWarning($"Could not unsnap item {packet.SnappedItemNetId} from gadget {packet.GadgetItemNetId} point {packet.SnapPointIndex}"); - } - - private static void ApplySnapAction(SnapItemPacket packet) - { - if (!TryGadget(packet.GadgetItemNetId, out var owner)) return; - var snapPoints = GetSnapPoints(owner); - if (!IsValidIndex(packet.SnapPointIndex, snapPoints.Length)) return; - if (!NetworkedItem.TryGet(packet.SnappedItemNetId, out var snappedItem)) - { - Multiplayer.LogWarning($"Cannot snap item {packet.SnappedItemNetId}: networked item was not found"); - return; - } - - var snapPoint = snapPoints[packet.SnapPointIndex]; - bool snapSucceeded; - if (snapPoint is SnapPointGadgetSliding) - { - var anchor = snappedItem.Item.SnappableItem?.GetAnchor(snapPoint.SnapPointType); - if (anchor == null) - { - Multiplayer.LogWarning($"Could not restore sliding snap for item {packet.SnappedItemNetId}: anchor {snapPoint.SnapPointType} was not found"); - return; - } - - // The sliding override performs local physics queries even for a forced - // snap. Prefer the authoritative anchor offset. Older/incomplete state - // can derive the same offset from the synchronized snapped item pose. - if (packet.HasAnchorPosition) - { - anchor.localPosition = packet.AnchorPosition; - } - else - { - var target = snapPoint.snapPointTarget != null ? snapPoint.snapPointTarget : snapPoint.transform; - anchor.localPosition = snappedItem.transform.InverseTransformPoint(target.position); - Multiplayer.LogWarning($"Sliding snap {packet.GadgetItemNetId}:{packet.SnapPointIndex} for item {packet.SnappedItemNetId} " + - $"had no anchor offset; derived {anchor.localPosition} from synchronized transforms"); - } - - // Keep the rigidbody kinematic while applying an authoritative sliding - // relationship. Native FinalizeSnap also leaves snapped items kinematic. - snapSucceeded = global::Multiplayer.Patches.World.GadgetRelationshipPatch.SnapItemBaseAuthoritative( - snapPoint, snappedItem.Item, forced: true); - } - else - { - // Match the native save loader for ordinary snap points. - if (snappedItem.Item?.ItemRigidbody != null && snappedItem.Item.ItemRigidbody.isKinematic) - snappedItem.Item.ItemRigidbody.isKinematic = false; - snapSucceeded = snapPoint.SnapItem(snappedItem.Item, forced: true); - } - - if (!snapSucceeded) - { - var snappable = snappedItem.Item?.SnappableItem; - Multiplayer.LogWarning($"Could not snap item {packet.SnappedItemNetId} to gadget {packet.GadgetItemNetId} point {packet.SnapPointIndex}: " + - $"pointType={snapPoint.SnapPointType}, allowedTypes={snappable?.AllowedSnapPointTypes}, occupied={snapPoint.SnappedItem != null}"); - } - else - { - if (snappedItem.Item?.ItemRigidbody != null) - snappedItem.Item.ItemRigidbody.isKinematic = true; - snappedItem.MarkAsSynchronized(); - Multiplayer.LogDebug(() => $"Snapped item {packet.SnappedItemNetId} to gadget {packet.GadgetItemNetId} point {packet.SnapPointIndex}"); - } - } - private static CustomizationRefData TargetOf(string targetKey) { return new CustomizationRefData @@ -1562,7 +525,7 @@ private static CustomizationRefData TargetOf(string targetKey) private static IEnumerable GetSnapshotRelationships(CustomizationStateData state) => state.Mounts.Cast() .Concat(state.Wires) - .Concat(state.LoadedSpools) + .Concat(state.MagazineStates) .Concat(state.SnappedItems); private static bool TryItemId(GadgetBase gadget, out ushort id) diff --git a/Multiplayer/Components/Networking/World/HostCustomizationSynchronization.cs b/Multiplayer/Components/Networking/World/HostCustomizationSynchronization.cs index 21c59122..79a9ac6b 100644 --- a/Multiplayer/Components/Networking/World/HostCustomizationSynchronization.cs +++ b/Multiplayer/Components/Networking/World/HostCustomizationSynchronization.cs @@ -77,6 +77,7 @@ private IEnumerator DrainPendingHostCustomizationActions() server.LogWarning($"Discarding customization action {pending.Packet.GetType().Name} from " + $"{pending.Player.Username} after it remained unready for {ActionReadyTimeoutSeconds:F0}s"); server.ResynchronizeCustomization(pending.Player, pending.Peer); + server.FlushCustomizationAcknowledgments(pending.Player, pending.Peer); appliedAny = true; } continue; @@ -102,7 +103,15 @@ private IEnumerator DrainPendingHostCustomizationActions() bool echoSender = pending.Packet is ReplaceSpoolPacket or ReplaceDuctTapePacket; pending.Relay(pending.Packet, pending.Peer, echoSender); } + else + { + // The sender already performed this mutation locally. + // Include its processed id in recovery so it is not + // replayed when the authoritative baseline arrives. + server.ResynchronizeCustomization(pending.Player, pending.Peer); + } appliedAny = true; + server.FlushCustomizationAcknowledgments(pending.Player, pending.Peer); } if (!appliedAny) diff --git a/Multiplayer/Components/Networking/World/InventorySynchronization.cs b/Multiplayer/Components/Networking/World/InventorySynchronization.cs index 6093b1df..1a1e3de9 100644 --- a/Multiplayer/Components/Networking/World/InventorySynchronization.cs +++ b/Multiplayer/Components/Networking/World/InventorySynchronization.cs @@ -4,12 +4,8 @@ using System.Linq; using DV; using DV.Common; -using DV.Customization.Gadgets; -using DV.Customization.Gadgets.Implementations; using DV.InventorySystem; -using DV.Items; using DV.Shops; -using DV.TimeKeeping; using DV.Utils; using Multiplayer.Components.SaveGame; using Multiplayer.Networking.Data; @@ -157,11 +153,10 @@ private static bool TryCreateSaveData(IEnumerable items, return false; } - JObject state = SaveItemState(item); + JObject state = NativeItemPersistence.Capture(item); // Ensure native stock bookkeeping is present even when ItemSaveData // has no registered restocker listener. - state ??= []; item.GetComponent()?.OnItemSaveDataRequested(state); ItemState persistenceState = item.RefreshPersistenceState(); @@ -171,7 +166,7 @@ private static bool TryCreateSaveData(IEnumerable items, ItemPrefabName = item.Item.InventorySpecs.ItemPrefabName, BelongsToPlayer = item.Item.InventorySpecs.BelongsToPlayer, IsGrabbed = persistenceState == ItemState.InHand, - State = state ?? [], + State = state, InventorySlotIndex = item.InventorySlotIndex, ContainerSlotIndex = item.ContainerSlotIndex, ContainerId = item.ContainerId, @@ -183,122 +178,6 @@ private static bool TryCreateSaveData(IEnumerable items, return true; } - private static JObject SaveItemState(NetworkedItem item) - { - var itemSaveData = item.GetComponent(); - if (itemSaveData == null) - return null; - - try - { - return itemSaveData.SaveItemData()?.DeepClone() as JObject; - } - catch (Exception exception) - { - // SaveItemData mutates this publicized state object in place. Keep all - // fields written before a listener failed, then retry each vanilla - // subscriber independently. Several subscribers consult controls or - // streamed components that may be unavailable on an inactive proxy. - itemSaveData.saveData ??= []; - SaveVanillaItemState(item, itemSaveData.saveData, - (component, state) => component.OnItemSaveDataRequested(state)); - SaveVanillaItemState(item, itemSaveData.saveData, - (component, state) => component.OnItemSaveDataRequested(state)); - SaveVanillaItemState(item, itemSaveData.saveData, - (component, state) => component.OnSaveDataRequested(state)); - SaveVanillaItemState(item, itemSaveData.saveData, - (component, state) => component.OnItemSaveDataRequested(state)); - SaveVanillaItemState(item, itemSaveData.saveData, - (component, state) => component.OnItemSaveDataRequested(state)); - foreach (FlashlightItem flashlight in item.GetComponents()) - SaveBatteryState(item, flashlight.battery, itemSaveData.saveData); - SaveVanillaItemState(item, itemSaveData.saveData, - (component, state) => component.OnItemSaveDataRequested(state)); - SaveVanillaItemState(item, itemSaveData.saveData, - (component, state) => component.OnItemSaveDataRequested(state)); - SaveVanillaItemState(item, itemSaveData.saveData, - (component, state) => component.OnItemSaveDataRequested(state)); - SaveVanillaItemState(item, itemSaveData.saveData, - (component, state) => component.OnSaveDataRequested(state)); - SaveVanillaItemState(item, itemSaveData.saveData, - (component, state) => component.OnItemSaveDataRequested(state)); - SaveSnappableItemState(item, itemSaveData.saveData); - SaveVanillaItemState(item, itemSaveData.saveData, - (component, state) => component.OnItemSaveDataRequested(state)); - SaveVanillaItemState(item, itemSaveData.saveData, - (component, state) => component.OnItemSaveDataRequestedInternal(state)); - SaveVanillaItemState(item, itemSaveData.saveData, - (component, state) => component.OnSaveDataRequested(state)); - SaveVanillaItemState(item, itemSaveData.saveData, - (component, state) => component.OnItemSaveDataRequested(state)); - SaveVanillaItemState(item, itemSaveData.saveData, - (component, state) => component.OnItemSaveDataRequested(state)); - foreach (LocomotiveRemoteController remote in item.GetComponents()) - SaveBatteryState(item, remote.battery, itemSaveData.saveData); - Multiplayer.LogWarning($"Could not save all custom item state for {item.NetId} ({item.name}); " + - $"preserving the available state: {exception.Message}"); - return itemSaveData.saveData.DeepClone() as JObject; - } - } - - private static void SaveVanillaItemState(NetworkedItem item, JObject state, - Func saveState) where T : Component - { - foreach (T component in item.GetComponents()) - { - try - { - saveState(component, state); - } - catch (Exception exception) - { - Multiplayer.LogWarning($"Could not save {typeof(T).Name} state for {item.NetId} ({item.name}); " + - $"preserving other item state: {exception.Message}"); - } - } - } - - private static void SaveBatteryState(NetworkedItem item, Battery battery, JObject state) - { - if (battery == null) - return; - try - { - battery.SaveState(state); - } - catch (Exception exception) - { - Multiplayer.LogWarning($"Could not save battery state for {item.NetId} ({item.name}); " + - $"preserving other item state: {exception.Message}"); - } - } - - private static void SaveSnappableItemState(NetworkedItem item, JObject state) - { - foreach (SnappableItem snappable in item.GetComponents()) - { - foreach (SnappableItem.SpecialSnappedBehaviour behaviour in snappable.specialSnappedBehaviours ?? []) - { - try - { - switch (behaviour) - { - case SnappableItem.SpecialSnappedBehaviourCoupler coupler: - coupler.OnItemSaveDataRequested(state); - break; - case SnappableItem.SpecialSnappedBehaviourGadgetSnapPoint gadgetSnapPoint: - gadgetSnapPoint.OnItemSaveDataRequested(state); - break; - } - } - catch (Exception exception) - { - Multiplayer.LogWarning($"Could not save snapped-item state for {item.NetId} ({item.name}); " + - $"preserving other item state: {exception.Message}"); - } - } - } - } public IEnumerator RecoverStoredInventory(SavedPlayerInventory stored, Action completed) diff --git a/Multiplayer/Components/Networking/World/NetworkedItemManager.cs b/Multiplayer/Components/Networking/World/NetworkedItemManager.cs index 3df31b0a..1cd1ad59 100644 --- a/Multiplayer/Components/Networking/World/NetworkedItemManager.cs +++ b/Multiplayer/Components/Networking/World/NetworkedItemManager.cs @@ -154,7 +154,7 @@ private void Common_OnTick(uint tick) } } - private void ProcessReceived() + internal void ProcessReceived() { while (ReceivedSnapshots.Count > 0) { @@ -593,7 +593,7 @@ private IEnumerator ApplyGadgetCreateAfterStart(NetworkedItem item, ItemUpdateDa } public void SendInitialItems(ServerPlayer player, IEnumerable itemNetIds, - ISet forceDropped = null) + ISet forceDropped = null, ISet refreshItems = null) { if (player == null || itemNetIds == null || !NetworkLifecycle.Instance.IsHost()) return; @@ -602,7 +602,8 @@ public void SendInitialItems(ServerPlayer player, IEnumerable itemNetIds uint tick = NetworkLifecycle.Instance.Tick; foreach (ushort itemNetId in itemNetIds.Distinct()) { - if (!NetworkedItem.TryGet(itemNetId, out var item) || player.KnownItems.ContainsKey(item)) + if (!NetworkedItem.TryGet(itemNetId, out var item) || + player.KnownItems.ContainsKey(item) && refreshItems?.Contains(itemNetId) != true) continue; var snapshot = item.CreateCurrentUpdateData(ItemUpdateData.ItemUpdateType.Create); diff --git a/Multiplayer/Components/Networking/World/SnapshotActionQueue.cs b/Multiplayer/Components/Networking/World/SnapshotActionQueue.cs new file mode 100644 index 00000000..55d60354 --- /dev/null +++ b/Multiplayer/Components/Networking/World/SnapshotActionQueue.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Multiplayer.Components.Networking.World; + +// Tracks snapshot supersession independently of Unity's coroutine scheduling. +internal sealed class SnapshotActionQueue +{ + private readonly Func originId; + private readonly Func skipLocalReplay; + private readonly Func copy; + private readonly List localActions = []; + private readonly HashSet acknowledged = []; + public IEnumerable LocalActions => localActions; + public List Pending { get; } = []; + public int Generation { get; private set; } + + public SnapshotActionQueue(Func originId, Func skipLocalReplay, Func copy) + { + this.originId = originId; + this.skipLocalReplay = skipLocalReplay; + this.copy = copy; + } + + public void Reset() + { + Generation++; + Pending.Clear(); + localActions.Clear(); + acknowledged.Clear(); + } + + public void RecordLocal(T action) + { + localActions.Add(copy(action)); + } + + public void Receive(T action) => Pending.Add(copy(action)); + + public void Acknowledge(IEnumerable ids, bool applyingSnapshot) + { + acknowledged.UnionWith(ids); + // An older baseline may still need these local edits replayed over it. + if (!applyingSnapshot) + PruneAcknowledged(); + } + + private void PruneAcknowledged() + { + localActions.RemoveAll(action => acknowledged.Contains(originId(action))); + acknowledged.Clear(); + } + + public void ReceiveSnapshot(IEnumerable processedOriginIds) + { + Generation++; + // Consume acknowledgments on receipt, even if this baseline is superseded + // before application. The server clears its acknowledgment set after send. + // Earlier reliable acknowledgments are also included in this newer + // baseline, even if its predecessor was still being applied. + acknowledged.UnionWith(processedOriginIds); + PruneAcknowledged(); + // Origin acknowledgments bind identity on receipt. For other peers the + // server supplies current replacement objects before this baseline, so + // no earlier physical mutation should be replayed over its newer state. + Pending.Clear(); + } + + public bool CompleteSnapshot(int generation) + { + if (generation != Generation) + return false; + + Pending.InsertRange(0, localActions.Where(action => !skipLocalReplay(action)).Select(copy)); + PruneAcknowledged(); + return true; + } +} diff --git a/Multiplayer/Components/SaveGame/ItemStateTransaction.cs b/Multiplayer/Components/SaveGame/ItemStateTransaction.cs new file mode 100644 index 00000000..0083bf1e --- /dev/null +++ b/Multiplayer/Components/SaveGame/ItemStateTransaction.cs @@ -0,0 +1,32 @@ +using System; +using Newtonsoft.Json.Linq; + +namespace Multiplayer.Components.SaveGame; + +internal static class ItemStateTransaction +{ + internal static bool TryApply(JObject state, Action save, out Exception failure) + { + var candidate = (JObject)state.DeepClone(); + try + { + save(candidate); + } + catch (Exception exception) + { + failure = exception; + return false; + } + + Replace(state, candidate); + failure = null; + return true; + } + + internal static void Replace(JObject target, JObject source) + { + target.RemoveAll(); + foreach (JProperty property in source.Properties()) + target.Add(property.Name, property.Value.DeepClone()); + } +} diff --git a/Multiplayer/Components/SaveGame/NativeItemPersistence.cs b/Multiplayer/Components/SaveGame/NativeItemPersistence.cs new file mode 100644 index 00000000..90509fca --- /dev/null +++ b/Multiplayer/Components/SaveGame/NativeItemPersistence.cs @@ -0,0 +1,125 @@ +using System; +using DV.Customization.Gadgets; +using DV.JObjectExtstensions; +using Multiplayer.Components.Networking.World; +using Newtonsoft.Json.Linq; + +namespace Multiplayer.Components.SaveGame; + +// The native event supplies the actual subscribers, including other mods and +// nested snap behaviours. Publicized APIs provide all component access. +internal static class NativeItemPersistence +{ + [ThreadStatic] private static CaptureContext currentCapture; + + private sealed class CaptureContext + { + public string Description; + } + + internal static JObject Capture(NetworkedItem item) + { + var save = item.GetComponent(); + if (save == null) + return new JObject(); + + JObject previous = (JObject)(save.saveData?.DeepClone() ?? new JObject()); + var context = new CaptureContext { Description = $"{item.NetId} ({item.name})" }; + CaptureContext outerCapture = currentCapture; + currentCapture = context; + save.saveData = (JObject)previous.DeepClone(); + try + { + save.SaveItemData(); + } + catch (Exception exception) + { + // Covers failures outside the intercepted delegate call as well. + save.saveData = previous; + ReportFailure(context, "ItemSaveData", exception); + } + finally + { + currentCapture = outerCapture; + } + + return (JObject)save.saveData.DeepClone(); + } + + // Called from the patched native delegate invocation. Outside an inventory + // capture preserve native multicast semantics, including exception propagation. + internal static JObject InvokeSaveListeners(ItemSaveData.ItemSaveDataRequestedDelegate listeners, JObject state) + { + CaptureContext context = currentCapture; + if (context == null) + return listeners(state); + + foreach (ItemSaveData.ItemSaveDataRequestedDelegate listener in listeners.GetInvocationList()) + { + GadgetItem gadget = listener.Target as GadgetItem; + bool nativeGadgetWriter = gadget != null && listener == + (ItemSaveData.ItemSaveDataRequestedDelegate)gadget.OnItemSaveDataRequestedInternal; + if (nativeGadgetWriter && state["gadgetData"] == null && gadget.gadgetData.HasValues) + state["gadgetData"] = gadget.gadgetData.DeepClone(); + + bool succeeded = ItemStateTransaction.TryApply(state, candidate => + { + if (nativeGadgetWriter) + SaveGadget(gadget, candidate); + else + listener(candidate); // Native SaveItemData ignores the return value. + }, out Exception failure); + + if (!succeeded) + { + ReportFailure(context, listener.Target?.GetType().Name ?? "static listener", failure); + // These native handlers write charge after consulting controls. + // Preserve current charge independently when those controls fail. + Battery battery = listener.Target switch + { + FlashlightItem flashlight => flashlight.battery, + LocomotiveRemoteController remote => remote.battery, + _ => null, + }; + if (battery != null && !ItemStateTransaction.TryApply(state, battery.SaveState, out Exception batteryFailure)) + ReportFailure(context, "Battery", batteryFailure); + } + } + return state; + } + + private static void SaveGadget(GadgetItem item, JObject state) + { + // The game's wrapper clears gadgetData and swallows serialization errors. + // Call its publicized operations directly so a failed write can be rejected. + JObject previous = (JObject)item.gadgetData.DeepClone(); + var candidate = new JObject(); + bool succeeded = false; + try + { + item.Gadget.SaveDataRequested(candidate); + state.SetJObject("gadgetData", candidate); + item.OnItemSaveDataRequested(state); + var target = item.Gadget.Custom; + state.SetString("placedOn", target != null ? target.GetIdentificationKey() : string.Empty); + if (target != null) + { + state.SetVector3("position", item.Gadget.transform.localPosition); + state.SetVector3("rotation", item.Gadget.transform.localEulerAngles); + } + ItemStateTransaction.Replace(item.gadgetData, candidate); + succeeded = true; + } + finally + { + if (!succeeded) + ItemStateTransaction.Replace(item.gadgetData, previous); + } + } + + private static void ReportFailure(CaptureContext context, string component, Exception exception) + { + Multiplayer.LogWarning($"Could not save {component} state for {context.Description}; " + + $"retaining the previous state for this callback: {exception.Message}"); + } +} diff --git a/Multiplayer/Networking/ConnectionProtocol.cs b/Multiplayer/Networking/ConnectionProtocol.cs new file mode 100644 index 00000000..9da78a23 --- /dev/null +++ b/Multiplayer/Networking/ConnectionProtocol.cs @@ -0,0 +1,23 @@ +using LiteNetLib.Utils; + +namespace Multiplayer.Networking; + +// Fixed connection preamble, independent of login packet serialization. +internal static class ConnectionProtocol +{ + private const ulong Magic = 0x44564D50434F4E4E; + internal const ushort Version = 1; // Increment for incompatible packet layout changes. + + internal static NetDataWriter Wrap(NetDataWriter login) + { + var writer = new NetDataWriter(); + writer.Put(Magic); + writer.Put(Version); + writer.Put(login.Data, 0, login.Length); + return writer; + } + + internal static bool TryRead(NetDataReader reader) => + reader.AvailableBytes >= sizeof(ulong) + sizeof(ushort) && + reader.GetULong() == Magic && reader.GetUShort() == Version; +} diff --git a/Multiplayer/Networking/Data/Customization/CustomizationStateData.cs b/Multiplayer/Networking/Data/Customization/CustomizationStateData.cs index 37de0dac..da3c481b 100644 --- a/Multiplayer/Networking/Data/Customization/CustomizationStateData.cs +++ b/Multiplayer/Networking/Data/Customization/CustomizationStateData.cs @@ -94,7 +94,7 @@ public sealed class CustomizationStateData public List Gadgets = new(); internal List Mounts = new(); internal List Wires = new(); - internal List LoadedSpools = new(); + internal List MagazineStates = new(); internal List SnappedItems = new(); public List Holes = new(); @@ -102,7 +102,7 @@ public void Serialize(NetDataWriter writer) { ValidateCount(CustomizationKeys.Count, MaxTargets, nameof(CustomizationKeys)); ValidateCount(Gadgets.Count, MaxGadgets, nameof(Gadgets)); - ValidateCount(Mounts.Count + Wires.Count + LoadedSpools.Count + SnappedItems.Count, + ValidateCount(Mounts.Count + Wires.Count + MagazineStates.Count + SnappedItems.Count, MaxRelationships, "relationships"); ValidateCount(Holes.Count, MaxHoles, nameof(Holes)); ValidateCount(ProcessedOriginActionIds.Count, MaxProcessedActions, nameof(ProcessedOriginActionIds)); @@ -121,7 +121,7 @@ public void Serialize(NetDataWriter writer) WritePackets(writer, Mounts); WritePackets(writer, Wires); - WritePackets(writer, LoadedSpools); + WritePackets(writer, MagazineStates); WritePackets(writer, SnappedItems); writer.Put(Holes.Count); @@ -147,9 +147,9 @@ public static CustomizationStateData Deserialize(NetDataReader reader) ReadPackets(reader, result.Mounts); ReadPackets(reader, result.Wires); - ReadPackets(reader, result.LoadedSpools); + ReadPackets(reader, result.MagazineStates); ReadPackets(reader, result.SnappedItems); - ValidateCount(result.Mounts.Count + result.Wires.Count + result.LoadedSpools.Count + + ValidateCount(result.Mounts.Count + result.Wires.Count + result.MagazineStates.Count + result.SnappedItems.Count, MaxRelationships, "relationships"); int holeCount = ReadCount(reader, MaxHoles, nameof(Holes)); diff --git a/Multiplayer/Networking/Data/ServerPlayer.cs b/Multiplayer/Networking/Data/ServerPlayer.cs index 0216aa26..8f8397b5 100644 --- a/Multiplayer/Networking/Data/ServerPlayer.cs +++ b/Multiplayer/Networking/Data/ServerPlayer.cs @@ -53,6 +53,9 @@ public void Dispose() internal int InventoryReconciliationAttempt { get; set; } = -1; internal HashSet ReconciledInventoryItems { get; } = []; public HashSet ProcessedCustomizationActionIds { get; } = []; + // Actions provision these objects without an ordinary Create packet. Recovery + // must supply their current state before superseding queued replacements. + public HashSet CustomizationReplacementItems { get; } = []; public PlayerTrackingData TrackingData { get; set; } public PlayerPostureFlags Posture { get; set; } // already exists — keep diff --git a/Multiplayer/Networking/Managers/Client/NetworkClient.cs b/Multiplayer/Networking/Managers/Client/NetworkClient.cs index 75c9d7c6..e356bbd4 100644 --- a/Multiplayer/Networking/Managers/Client/NetworkClient.cs +++ b/Multiplayer/Networking/Managers/Client/NetworkClient.cs @@ -268,6 +268,7 @@ protected override void Subscribe() netPacketProcessor.SubscribeNetSerializable(OnCommonItemChangePacket); netPacketProcessor.SubscribeNetSerializable(OnClientboundItemReconciliationPacket); netPacketProcessor.SubscribeNetSerializable(CustomizationSynchronization.ReceiveSnapshot); + netPacketProcessor.SubscribeNetSerializable(CustomizationSynchronization.ReceiveAcknowledgment); SubscribeCustomizationActions(); netPacketProcessor.SubscribeReusable(OnCommonPitStopInteractionPacket); netPacketProcessor.SubscribeNetSerializable(OnCommonPitStopPlugInteractionPacket); diff --git a/Multiplayer/Networking/Managers/NetworkManager.cs b/Multiplayer/Networking/Managers/NetworkManager.cs index a1372dd6..f274a47f 100644 --- a/Multiplayer/Networking/Managers/NetworkManager.cs +++ b/Multiplayer/Networking/Managers/NetworkManager.cs @@ -103,7 +103,7 @@ public virtual bool Start(int port) protected virtual ITransportPeer Connect(string address, int port, NetDataWriter netDataWriter) { - return transport.Connect(address, port, netDataWriter); + return transport.Connect(address, port, ConnectionProtocol.Wrap(netDataWriter)); } diff --git a/Multiplayer/Networking/Managers/Server/NetworkServer.cs b/Multiplayer/Networking/Managers/Server/NetworkServer.cs index 7ea0df97..5f3f98c3 100644 --- a/Multiplayer/Networking/Managers/Server/NetworkServer.cs +++ b/Multiplayer/Networking/Managers/Server/NetworkServer.cs @@ -416,6 +416,13 @@ public override void OnNetworkLatencyUpdate(ITransportPeer peer, int latency) public override void OnConnectionRequest(NetDataReader requestData, IConnectionRequest request) { LogDebug(() => $"NetworkServer OnConnectionRequest"); + if (!ConnectionProtocol.TryRead(requestData)) + { + LogWarning("Rejected connection with a missing or incompatible multiplayer protocol header"); + // Do not send a version-dependent login response to an unknown peer. + request.Reject(); + return; + } netPacketProcessor.ReadAllPackets(requestData, request); } @@ -2438,7 +2445,20 @@ private void RelayCustomizationAction(T packet, ITransportPeer origin = null, } } - private IEnumerator SendInitialCustomizationStateWhenReady(ServerPlayer player, ITransportPeer peer) + internal void FlushCustomizationAcknowledgments(ServerPlayer player, ITransportPeer peer) + { + while (player.ProcessedCustomizationActionIds.Count >= ClientboundCustomizationAcknowledgmentPacket.BatchSize) + { + var ids = player.ProcessedCustomizationActionIds + .Take(ClientboundCustomizationAcknowledgmentPacket.BatchSize).ToArray(); + SendNetSerializablePacket(peer, new ClientboundCustomizationAcknowledgmentPacket { ActionIds = ids }, + DeliveryMethod.ReliableOrdered); + player.ProcessedCustomizationActionIds.ExceptWith(ids); + } + } + + private IEnumerator SendInitialCustomizationStateWhenReady(ServerPlayer player, ITransportPeer peer, + bool restoreLiveRoutingOnFailure = false) { // Installed gadget items remain in this storage even while their train-car // interior is streamed out. Use it as a second source in addition to the @@ -2463,6 +2483,7 @@ private IEnumerator SendInitialCustomizationStateWhenReady(ServerPlayer player, (car.HasAttachedCustomizations || carsWithStoredGadgets.Contains(car.TrainCar))) .ToList(); + bool snapshotSent = false; try { foreach (var car in pinnedCars) @@ -2500,21 +2521,37 @@ private IEnumerator SendInitialCustomizationStateWhenReady(ServerPlayer player, var customizationState = CustomizationStateManager.CaptureCurrentState(); customizationState.ProcessedOriginActionIds.AddRange(player.ProcessedCustomizationActionIds); + // Queued replacements may be superseded along with their later + // drop/load actions. Supply the current objects, including loose + // reels and tape absent from customization relationships. Owners + // already bound their native objects through the ordered ack; + // recreating those would detach real carried items. + var replacementIds = player.CustomizationReplacementItems + .Where(item => item != null && item.NetId != 0 && item.OwnerPlayerId != player.PlayerId) + .Select(item => item.NetId).ToHashSet(); NetworkedItemManager.Instance.SendInitialItems(player, - CustomizationStateManager.GetReferencedItemIds(customizationState), - CustomizationStateManager.GetItemsRequiringDroppedCreate(customizationState)); + CustomizationStateManager.GetReferencedItemIds(customizationState).Concat(replacementIds), + CustomizationStateManager.GetItemsRequiringDroppedCreate(customizationState), replacementIds); SendNetSerializablePacket(peer, new ClientboundCustomizationStatePacket { State = customizationState, }, DeliveryMethod.ReliableOrdered); player.CustomizationSnapshotSent = true; + snapshotSent = true; player.ProcessedCustomizationActionIds.Clear(); + player.CustomizationReplacementItems.Clear(); LogDebug(() => $"Sent join customization snapshot to {player.Username} after loading {pinnedCars.Count} customized train-car interiors"); } finally { + if (!snapshotSent && restoreLiveRoutingOnFailure && + TryGetServerPlayer(peer, out var connectedPlayer) && connectedPlayer == player) + { + player.CustomizationSnapshotSent = true; + LogWarning($"Customization recovery snapshot failed for {player.Username}; restored live-action routing"); + } foreach (var car in pinnedCars) if (car != null) car.EndInteriorNetworkSync(); @@ -2527,7 +2564,8 @@ internal void ResynchronizeCustomization(ServerPlayer player, ITransportPeer pee return; player.CustomizationSnapshotSent = false; - CoroutineManager.Instance.StartCoroutine(SendInitialCustomizationStateWhenReady(player, peer)); + CoroutineManager.Instance.StartCoroutine(SendInitialCustomizationStateWhenReady(player, peer, + restoreLiveRoutingOnFailure: true)); } private void EnsureCustomizationItemsKnown(ICustomizationActionPacket packet, ServerPlayer player) @@ -2558,7 +2596,10 @@ private void EnsureCustomizationItemsKnown(ICustomizationActionPacket packet, Se _ => 0, }; if (createsReplacementLocally && NetworkedItem.TryGet(replacementId, out NetworkedItem replacementItem)) + { player.KnownItems[replacementItem] = NetworkLifecycle.Instance.Tick; + player.CustomizationReplacementItems.Add(replacementItem); + } } private void OnCommonCashRegisterWithModulesActionPacket(CommonCashRegisterWithModulesActionPacket packet, ITransportPeer peer) diff --git a/Multiplayer/Networking/Packets/Clientbound/ClientboundCustomizationAcknowledgmentPacket.cs b/Multiplayer/Networking/Packets/Clientbound/ClientboundCustomizationAcknowledgmentPacket.cs new file mode 100644 index 00000000..1d502b19 --- /dev/null +++ b/Multiplayer/Networking/Packets/Clientbound/ClientboundCustomizationAcknowledgmentPacket.cs @@ -0,0 +1,29 @@ +using System; +using LiteNetLib.Utils; + +namespace Multiplayer.Networking.Packets.Clientbound; + +internal sealed class ClientboundCustomizationAcknowledgmentPacket : INetSerializable +{ + internal const int BatchSize = 256; + public uint[] ActionIds = []; + + public void Serialize(NetDataWriter writer) + { + if (ActionIds.Length > BatchSize) + throw new InvalidOperationException("Customization acknowledgment batch is too large"); + writer.Put((ushort)ActionIds.Length); + foreach (uint id in ActionIds) + writer.Put(id); + } + + public void Deserialize(NetDataReader reader) + { + int count = reader.GetUShort(); + if (count > BatchSize) + throw new InvalidOperationException("Customization acknowledgment batch is too large"); + ActionIds = new uint[count]; + for (int index = 0; index < count; index++) + ActionIds[index] = reader.GetUInt(); + } +} diff --git a/Multiplayer/Networking/Packets/Common/Customization/LoadSpoolPacket.cs b/Multiplayer/Networking/Packets/Common/Customization/LoadSpoolPacket.cs index f73b3a95..b8258e6d 100644 --- a/Multiplayer/Networking/Packets/Common/Customization/LoadSpoolPacket.cs +++ b/Multiplayer/Networking/Packets/Common/Customization/LoadSpoolPacket.cs @@ -5,6 +5,7 @@ namespace Multiplayer.Networking.Packets.Common.Customization; internal sealed class LoadSpoolPacket : CustomizationActionPacket, INetSerializable { public ushort ToolItemNetId; + // Zero explicitly restores an empty magazine in a customization snapshot. public ushort SpoolItemNetId; public bool HasRemainingUnits; public int RemainingUnits; diff --git a/Multiplayer/Patches/SaveGame/ItemSaveDataPatch.cs b/Multiplayer/Patches/SaveGame/ItemSaveDataPatch.cs new file mode 100644 index 00000000..3a0eeaec --- /dev/null +++ b/Multiplayer/Patches/SaveGame/ItemSaveDataPatch.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Reflection.Emit; +using HarmonyLib; +using Multiplayer.Components.SaveGame; + +namespace Multiplayer.Patches.SaveGame; + +[HarmonyPatch(typeof(ItemSaveData), nameof(ItemSaveData.SaveItemData))] +internal static class ItemSaveDataPatch +{ + [HarmonyTranspiler] + private static IEnumerable Transpiler(IEnumerable instructions) + { + var invoke = AccessTools.Method(typeof(ItemSaveData.ItemSaveDataRequestedDelegate), "Invoke"); + var isolatedInvoke = AccessTools.Method(typeof(NativeItemPersistence), nameof(NativeItemPersistence.InvokeSaveListeners)); + int replacements = 0; + foreach (CodeInstruction instruction in instructions) + { + if (instruction.Calls(invoke)) + { + // Both calls consume the existing delegate and JObject arguments. + // Retain branch labels and exception markers on the instruction. + instruction.opcode = OpCodes.Call; + instruction.operand = isolatedInvoke; + replacements++; + } + yield return instruction; + } + if (replacements != 1) + throw new InvalidOperationException($"Expected one ItemSaveData callback invocation, found {replacements}"); + } +} diff --git a/info.json b/info.json index 22aaa378..8952a537 100644 --- a/info.json +++ b/info.json @@ -1,6 +1,6 @@ { "Id": "Multiplayer", - "Version": "0.1.16.0", + "Version": "0.1.17.0", "DisplayName": "Multiplayer", "Author": "Insprill, Macka, Morm", "EntryMethod": "Multiplayer.Multiplayer.Load", From 1d8dba25194bfdaa5522aca776d34a9021900279 Mon Sep 17 00:00:00 2001 From: Patrick van Halm Date: Sun, 13 Sep 2026 22:36:32 +0200 Subject: [PATCH 14/14] Fix item ownership persistence and reconnect synchronization --- Multiplayer/Components/IdMonoBehaviour.cs | 23 ++++-- .../Networking/Train/NetworkedTrainCar.cs | 72 +++++++++-------- .../CustomizationStateManager.Mutations.cs | 9 ++- .../World/InventorySynchronization.cs | 30 +++++-- .../Networking/World/NetworkedItem.cs | 78 ++++++++++++++++--- .../Networking/World/NetworkedItemManager.cs | 18 ++++- .../World/NetworkedPitStopStation.cs | 6 +- .../World/NetworkedPluggableObject.cs | 16 ++-- .../World/ShopPurchaseCoordinator.cs | 37 ++------- .../SaveGame/NativeItemPersistence.cs | 31 ++++++++ .../Managers/Client/NetworkClient.cs | 70 ++++++++++------- .../Managers/Server/LobbyServerManager.cs | 12 +-- .../Patches/SaveGame/ItemSaveDataPatch.cs | 21 +++++ .../World/CashRegisterWithModulesPatch.cs | 6 +- .../World/Items/CustomizationToolPatch.cs | 1 + 15 files changed, 289 insertions(+), 141 deletions(-) diff --git a/Multiplayer/Components/IdMonoBehaviour.cs b/Multiplayer/Components/IdMonoBehaviour.cs index 8e26582f..6a7d1c58 100644 --- a/Multiplayer/Components/IdMonoBehaviour.cs +++ b/Multiplayer/Components/IdMonoBehaviour.cs @@ -19,8 +19,6 @@ public T NetId { set { if (_netId.Equals(value)) return; - if ((_netId as dynamic).CompareTo(default(T)) != 0) - idPool.ReleaseId(_netId); Register(value); } } @@ -29,7 +27,7 @@ public T NetId { protected static bool Get(T netId, out IdMonoBehaviour obj) { - if (indexToObject.TryGetValue(netId, out obj)) + if (indexToObject.TryGetValue(netId, out obj) && obj != null) return true; obj = null; if ((netId as dynamic).CompareTo(default(T)) != 0) @@ -39,7 +37,7 @@ protected static bool Get(T netId, out IdMonoBehaviour obj) protected static bool TryGet(T netId, out IdMonoBehaviour obj) { - if (indexToObject.TryGetValue(netId, out obj)) + if (indexToObject.TryGetValue(netId, out obj) && obj != null) return true; obj = null; @@ -55,13 +53,26 @@ protected virtual void Awake() public void Register(T id) { + if (_netId.Equals(id) && indexToObject.TryGetValue(id, out var existing) && ReferenceEquals(existing, this)) + return; + Unregister(); _netId = id; - indexToObject[id] = this; + if (!id.Equals(default(T))) + indexToObject[id] = this; + } + + private void Unregister() + { + if (!_netId.Equals(default(T)) && indexToObject.TryGetValue(_netId, out var current) && ReferenceEquals(current, this)) + { + indexToObject.Remove(_netId); + idPool.ReleaseId(_netId); + } } protected virtual void OnDestroy() { - idPool.ReleaseId(NetId); + Unregister(); if (!UnloadWatcher.isUnloading) return; idPool.Reset(); diff --git a/Multiplayer/Components/Networking/Train/NetworkedTrainCar.cs b/Multiplayer/Components/Networking/Train/NetworkedTrainCar.cs index 804217af..c480de5a 100644 --- a/Multiplayer/Components/Networking/Train/NetworkedTrainCar.cs +++ b/Multiplayer/Components/Networking/Train/NetworkedTrainCar.cs @@ -488,50 +488,40 @@ protected void Start() NetworkLifecycle.Instance?.Client.SendTrainSyncRequest(NetId); } + private Coroutine interiorHookRoutine; + private void OnTrainCarInteriorLoaded(GameObject interior) { - Multiplayer.LogDebug(() => $"OnTrainCarInteriorLoaded() {CurrentID}, interior is null: {interior == null}"); - - StartCoroutine(WaitForInterior()); + // Streaming can unload the cab while controls are still initializing. + // Cancel work for the previous instance before binding the new one. + OnTrainCarInteriorUnloaded(interior); + if (interior != null) + interiorHookRoutine = StartCoroutine(WaitForInterior(interior)); } - private IEnumerator WaitForInterior() + private IEnumerator WaitForInterior(GameObject interior) { - float time = Time.time; - interiorControlsManager = null; - - yield return new WaitUntil - ( - () => - { - return TrainCar?.loadedInterior != null || Time.time - time > INTERIOR_CONTROLS_TIMEOUT; - } - ); - - yield return new WaitForFixedUpdate(); - - if (TrainCar?.loadedInterior == null) + float deadline = Time.time + INTERIOR_CONTROLS_TIMEOUT; + InteriorControlsManager controls = null; + while (interior != null && !interior.TryGetComponent(out controls) && Time.time < deadline) + yield return null; + if (interior == null) + yield break; + if (controls == null) { - Multiplayer.LogError($"TrainCar {CurrentID} failed to load an interior"); + Multiplayer.LogWarning($"TrainCar {CurrentID} interior has no controls manager"); yield break; } - - time = Time.time; - - yield return new WaitUntil - ( - () => - { - return TrainCar.loadedInterior.TryGetComponent(out interiorControlsManager) || Time.time - time > INTERIOR_CONTROLS_TIMEOUT; - } - ); - - CoroutineManager.Instance.StartCoroutine(HookControls(interiorControlsManager)); + interiorControlsManager = controls; + yield return HookControls(controls); + interiorHookRoutine = null; } private IEnumerator HookControls(InteriorControlsManager interiorControlsManager) { - yield return new WaitUntil(() => interiorControlsManager.Initialized); + yield return new WaitUntil(() => interiorControlsManager == null || interiorControlsManager.Initialized); + if (interiorControlsManager == null) + yield break; Multiplayer.LogDebug(() => $"HookControls() Hooking controls for car {CurrentID}, found {interiorControlsManager?.controls?.Count} controls"); @@ -541,6 +531,11 @@ private IEnumerator HookControls(InteriorControlsManager interiorControlsManager var control = kvp.Value; var key = kvp.Key; + // Controls without an override port (e.g. the handbrake) use their + // dedicated synchronization path, not control-port authority. + if (control.overridableBaseControl == null) + continue; + float timeOut = Time.time; yield return new WaitUntil ( @@ -606,6 +601,11 @@ private IEnumerator HookControls(InteriorControlsManager interiorControlsManager private void OnTrainCarInteriorUnloaded(GameObject interior) { Multiplayer.LogDebug(() => $"OnTrainCarInteriorUnloaded() {CurrentID}"); + if (interiorHookRoutine != null) + { + StopCoroutine(interiorHookRoutine); + interiorHookRoutine = null; + } foreach (var control in controlToPortNetId.Keys) { @@ -634,6 +634,7 @@ private void OnTrainCarInteriorUnloaded(GameObject interior) kvp.Key.ValueChanged -= kvp.Value; } + scrollableControlDelegates.Clear(); portNetIdToControl.Clear(); controlToPortNetId.Clear(); @@ -647,6 +648,13 @@ public void OnDisable() if (UnloadWatcher.isQuitting) return; + OnTrainCarInteriorUnloaded(null); + if (TrainCar != null) + { + TrainCar.InteriorLoaded -= OnTrainCarInteriorLoaded; + TrainCar.InteriorAboutToBeUnloaded -= OnTrainCarInteriorUnloaded; + } + //Clean dictionaries trainCarsToNetworkedTrainCars.Remove(TrainCar); trainCarIdToNetworkedTrainCars.Remove(CurrentID); diff --git a/Multiplayer/Components/Networking/World/CustomizationStateManager.Mutations.cs b/Multiplayer/Components/Networking/World/CustomizationStateManager.Mutations.cs index 0fe852a7..d4670ea4 100644 --- a/Multiplayer/Components/Networking/World/CustomizationStateManager.Mutations.cs +++ b/Multiplayer/Components/Networking/World/CustomizationStateManager.Mutations.cs @@ -257,6 +257,9 @@ private static void ApplySpoolReplacement(NetworkedItem toolItem, GadgetSolderin byte replacementOwner = packet.OwnerPlayerId != 0 ? packet.OwnerPlayerId : currentItem?.OwnerPlayerId ?? toolItem?.OwnerPlayerId ?? 0; + Guid? replacementUniqueId = replacementOwner == currentItem?.OwnerPlayerId + ? currentItem.OwnerUniqueId + : replacementOwner == toolItem?.OwnerPlayerId ? toolItem.OwnerUniqueId : null; bool belongsToPlayer = currentItem?.Item?.InventorySpecs?.BelongsToPlayer ?? true; GameObject spentObject = currentAmmo != null && currentAmmo.isSpent ? current : null; @@ -295,7 +298,7 @@ private static void ApplySpoolReplacement(NetworkedItem toolItem, GadgetSolderin else if (packet.ReplacementSpoolItemNetId != 0) spentItem.NetId = packet.ReplacementSpoolItemNetId; - spentItem.SetOwner(replacementOwner); + spentItem.InheritOwner(replacementOwner, replacementUniqueId); if (spentItem.Item?.InventorySpecs != null) spentItem.Item.InventorySpecs.BelongsToPlayer = belongsToPlayer; spentItem.MarkAsSynchronized(); @@ -421,6 +424,8 @@ private static void ApplyDuctTapeReplacement(ReplaceDuctTapePacket packet) NetworkedItem.TryGet(packet.ConsumedItemNetId, out var oldItem); var oldTape = oldItem?.GetTrackedItem(); byte replacementOwner = packet.OwnerPlayerId != 0 ? packet.OwnerPlayerId : oldItem?.OwnerPlayerId ?? 0; + Guid? replacementUniqueId = replacementOwner == oldItem?.OwnerPlayerId + ? oldItem.OwnerUniqueId : null; bool belongsToPlayer = oldItem?.Item?.InventorySpecs?.BelongsToPlayer ?? true; Vector3 position = packet.HasWorldTransform ? packet.Position + WorldMover.currentMove : oldItem?.transform.position ?? Vector3.zero; Quaternion rotation = packet.HasWorldTransform ? packet.Rotation : oldItem?.transform.rotation ?? Quaternion.identity; @@ -473,7 +478,7 @@ private static void ApplyDuctTapeReplacement(ReplaceDuctTapePacket packet) else if (packet.ReplacementItemNetId != 0) replacementItem.NetId = packet.ReplacementItemNetId; - replacementItem.SetOwner(replacementOwner); + replacementItem.InheritOwner(replacementOwner, replacementUniqueId); if (replacementItem.Item?.InventorySpecs != null) replacementItem.Item.InventorySpecs.BelongsToPlayer = belongsToPlayer; if (!existingReplacement) diff --git a/Multiplayer/Components/Networking/World/InventorySynchronization.cs b/Multiplayer/Components/Networking/World/InventorySynchronization.cs index 1a1e3de9..eb7aec75 100644 --- a/Multiplayer/Components/Networking/World/InventorySynchronization.cs +++ b/Multiplayer/Components/Networking/World/InventorySynchronization.cs @@ -42,6 +42,23 @@ internal void ResetClientReconciliation() activeReconciliationRequests = []; } + internal void PlayerConnected(ServerPlayer player) + { + byte hostId = NetworkLifecycle.Instance.Server.SelfId; + int restoredItems = 0; + foreach (var item in NetworkedItem.GetAll().ToArray()) + { + if (item == null || item.OwnerUniqueId != player.Guid) + continue; + if (item.OwnerPlayerId == hostId && item.RefreshPersistenceState() is ItemState.Dropped or ItemState.Thrown) + { + item.SetSynchronizationOwner(player.PlayerId, true); + restoredItems++; + } + } + Multiplayer.LogDebug(() => $"Restored {restoredItems} world-item synchronization owners for player {player.PlayerId}"); + } + internal void PlayerDisconnected(ServerPlayer disconnectedPlayer) { if (disconnectedPlayer == null || !NetworkLifecycle.Instance.IsHost()) @@ -50,10 +67,6 @@ internal void PlayerDisconnected(ServerPlayer disconnectedPlayer) completedReconciliation.Remove(disconnectedPlayer); byte hostPlayerId = NetworkLifecycle.Instance.Server?.SelfId ?? 0; - ServerPlayer hostPlayer = null; - if (hostPlayerId != 0 && disconnectedPlayer.PlayerId != hostPlayerId) - NetworkLifecycle.Instance.Server.TryGetServerPlayer(hostPlayerId, out hostPlayer); - ShopPurchaseCoordinator.TransferPendingOwnership(disconnectedPlayer, hostPlayer); if (hostPlayerId == 0 || disconnectedPlayer.PlayerId == hostPlayerId) return; @@ -91,7 +104,7 @@ internal void PlayerDisconnected(ServerPlayer disconnectedPlayer) } preservedItems++; - item.SetOwner(hostPlayerId, true); + item.AssignHostFallback(disconnectedPlayer, hostPlayerId); transferredItems++; } @@ -299,7 +312,12 @@ public IEnumerator RecoverStoredInventory(SavedPlayerInventory stored, int containerCount = created.Count(entry => entry.Item.GetComponent() != null); foreach (var entry in created) { - entry.Item.SetOwner(hostPlayerId); + // Recovery moves items to Lost and Found; it is not a purchase or pickup. + // Older inventory saves still identify the owner through their record. + Guid owner = NativeItemPersistence.TryReadOwner(entry.Record.State, out var savedOwner) + ? savedOwner : stored.Guid; + entry.Item.RestoreOwnerUniqueId(owner); + entry.Item.SetSynchronizationOwner(hostPlayerId); if (!string.IsNullOrEmpty(entry.Record.ContainerId)) continue; diff --git a/Multiplayer/Components/Networking/World/NetworkedItem.cs b/Multiplayer/Components/Networking/World/NetworkedItem.cs index c1dc5eeb..43e4cadb 100644 --- a/Multiplayer/Components/Networking/World/NetworkedItem.cs +++ b/Multiplayer/Components/Networking/World/NetworkedItem.cs @@ -5,6 +5,7 @@ using DV.Customization.Gadgets; using DV.Customization.Gadgets.Implementations; using Multiplayer.Components.Networking.Train; +using Multiplayer.Components.SaveGame; using Multiplayer.Networking.Data; using Multiplayer.Networking.Data.Items; using Multiplayer.Utils; @@ -120,19 +121,63 @@ public static bool TryGetNetId(ItemBase item, out ushort netID) private Quaternion thrownRotation; private Vector3 throwDirection; - //Handle ownership - public byte OwnerPlayerId { get; private set; } // 0 means no owner + // Connected player responsible for replication; 0 means unassigned. + public byte OwnerPlayerId { get; private set; } + + // Host-maintained stable owner, persisted in native item save data. + // A temporary replication fallback must not change this identity. + internal Guid? OwnerUniqueId { get; private set; } public void SetOwner(byte playerId, bool markDirty = false) + { + // Resolve even when the byte ID is unchanged: picking up an item being + // simulated by the host transfers its stable ownership to the host. + var server = NetworkLifecycle.Instance?.Server; + if (playerId == 0) + OwnerUniqueId = null; + else if (server != null && server.TryGetServerPlayer(playerId, out var player)) + OwnerUniqueId = player.Guid; + else if (OwnerPlayerId != playerId) + OwnerUniqueId = null; + + SetSynchronizationOwner(playerId, markDirty); + } + + internal void SetSynchronizationOwner(byte playerId, bool markDirty = false) { if (OwnerPlayerId == playerId) return; - OwnerPlayerId = playerId; if (markDirty) ownerDirty = true; } + internal void AssignHostFallback(ServerPlayer previousOwner, byte hostPlayerId) + { + // The disconnected player has already left the server's byte-ID lookup. + // Seed older/uninitialized items without replacing an existing identity. + OwnerUniqueId ??= previousOwner.Guid; + SetSynchronizationOwner(hostPlayerId, markDirty: true); + } + + internal void RestoreOwnerUniqueId(Guid ownerUniqueId) + { + if (!NetworkLifecycle.Instance.IsHost() || ownerUniqueId == Guid.Empty) + return; + OwnerUniqueId = ownerUniqueId; + var server = NetworkLifecycle.Instance.Server; + // This also handles items streamed in after their owner has reconnected. + var owner = server.ServerPlayers.FirstOrDefault(player => player.Guid == ownerUniqueId); + SetSynchronizationOwner(owner?.PlayerId ?? server.SelfId, markDirty: true); + } + + internal void InheritOwner(byte playerId, Guid? ownerUniqueId) + { + SetOwner(playerId); + if (NetworkLifecycle.Instance.IsHost() && ownerUniqueId.HasValue) + OwnerUniqueId = ownerUniqueId; + } + protected override bool IsIdServerAuthoritative => true; protected override void Awake() @@ -220,6 +265,11 @@ private bool Register() forceStateSnapshot = false; initialised = true; + // Inactive prefabs may receive native save data before ItemBase.Awake + // adds this component. Restore it after default registration as well. + var savedItem = GetComponent(); + if (savedItem != null) + NativeItemPersistence.RestoreOwner(savedItem); return true; } catch (Exception ex) @@ -237,6 +287,8 @@ private void OnUngrabbed(ControlImplBase obj) private void OnGrabbed(ControlImplBase obj) { + if (NetworkLifecycle.Instance.IsClientRunning && !NetworkLifecycle.Instance.IsProcessingPacket) + SetOwner(NetworkLifecycle.Instance.Client.PlayerId, markDirty: true); //Multiplayer.LogDebug(() => $"NetworkedItem.OnGrabbed() NetID: {NetId}, {name}"); stateDirty = true; } @@ -392,7 +444,9 @@ public ItemUpdateData GetSnapshot() if (stateDirty && NetworkLifecycle.Instance.IsClientRunning) { byte localPlayerId = NetworkLifecycle.Instance.Client?.PlayerId ?? 0; - bool isHeldByLocalPlayer = IsCarriedState(currentState); + bool isHeldByLocalPlayer = IsCarriedState(currentState) && + (Item.IsGrabbed() || Inventory.Instance.Contains(gameObject, false) || + !IsCarriedState(lastState) && ShouldObserveLocalLayout()); if (isHeldByLocalPlayer) SetOwner(localPlayerId, markDirty: true); @@ -483,6 +537,7 @@ private void ApplySnapshot(ItemUpdateData snapshot, bool acceptServerAuthoritati DetachFromContainer(); } pendingContainerRestore = snapshot.ItemState == ItemState.InContainer; + ItemState previousState = lastState; lastState = snapshot.ItemState; Multiplayer.Log($"NetworkedItem.ApplySnapshot() netId: {snapshot?.ItemNetId}, ItemUpdateType: {snapshot?.UpdateType}, ItemState: {snapshot?.ItemState}, Active state: {gameObject.activeInHierarchy}"); @@ -490,7 +545,7 @@ private void ApplySnapshot(ItemUpdateData snapshot, bool acceptServerAuthoritati { case ItemState.Dropped: case ItemState.Thrown: - HandleDroppedOrThrownState(snapshot); + HandleDroppedOrThrownState(snapshot, previousState); break; case ItemState.InHand: @@ -525,8 +580,13 @@ private void ApplySnapshot(ItemUpdateData snapshot, bool acceptServerAuthoritati if (snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.Ownership) || snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.Create)) { - // Ownership-only updates must not activate or relocate the item. - SetOwner(snapshot.OwnerPlayerId); + // Packets carry replication authority, not the stable owner GUID. + // Genuine remote pickups establish ownership in the host's validated + // receive path; echoed fallback updates must preserve that identity. + if (snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.Create) && OwnerUniqueId == null) + SetOwner(snapshot.OwnerPlayerId); + else + SetSynchronizationOwner(snapshot.OwnerPlayerId); } if (snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.Create) || @@ -824,7 +884,7 @@ private void ApplyTrackedValues(Dictionary newValues, #region Item State Update Handlers - private void HandleDroppedOrThrownState(ItemUpdateData snapshot) + private void HandleDroppedOrThrownState(ItemUpdateData snapshot, ItemState previousState) { //resolve attachment if (Item.IsSnapped) @@ -847,7 +907,7 @@ private void HandleDroppedOrThrownState(ItemUpdateData snapshot) // Installed gadgets can retain stale rigidbody velocity while their backing // item is hidden. Reset it on a remote drop so removal does not launch the // item through the cab when physics resumes. - if (snapshot.ItemState == ItemState.Dropped && Item.ItemRigidbody != null) + if (snapshot.ItemState == ItemState.Dropped && previousState == ItemState.InstalledGadget && Item.ItemRigidbody != null) { TrainCar parentCar = Item.GetComponentInParent(); Item.ItemRigidbody.velocity = parentCar?.rb != null ? parentCar.rb.velocity : Vector3.zero; diff --git a/Multiplayer/Components/Networking/World/NetworkedItemManager.cs b/Multiplayer/Components/Networking/World/NetworkedItemManager.cs index 1cd1ad59..e824670f 100644 --- a/Multiplayer/Components/Networking/World/NetworkedItemManager.cs +++ b/Multiplayer/Components/Networking/World/NetworkedItemManager.cs @@ -63,6 +63,7 @@ protected override void Awake() return; NetworkLifecycle.Instance.Server.PlayerDisconnected += PlayerDisconnected; + NetworkLifecycle.Instance.Server.PlayerConnected += PlayerConnected; try { @@ -77,6 +78,8 @@ protected override void Awake() private InventorySynchronization inventorySynchronization; private InventorySynchronization InventorySynchronization => inventorySynchronization ??= new(this); + private void PlayerConnected(ServerPlayer player) => InventorySynchronization.PlayerConnected(player); + private void PlayerDisconnected(ServerPlayer player) => InventorySynchronization.PlayerDisconnected(player); public bool TryCaptureCarriedInventory(ServerPlayer player, out IReadOnlyList items) => @@ -100,7 +103,10 @@ protected override void OnDestroy() NetworkLifecycle.Instance.OnTick -= Common_OnTick; if (NetworkLifecycle.Instance.Server != null) + { NetworkLifecycle.Instance.Server.PlayerDisconnected -= PlayerDisconnected; + NetworkLifecycle.Instance.Server.PlayerConnected -= PlayerConnected; + } } public void AddDirtyItemSnapshot(NetworkedItem netItem, ItemUpdateData snapshot) @@ -360,9 +366,13 @@ private void ProcessReceivedAsHost(ItemUpdateData snapshot, ServerPlayer player) // have the sender's area loaded, so its physical item position is not a // reliable basis for ownership or reach validation. snapshot.Player = player.PlayerId; - snapshot.OwnerPlayerId = NetworkedItem.IsCarriedState(snapshot.ItemState) - ? player.PlayerId - : netItem.OwnerPlayerId; + if (changesItemState && NetworkedItem.IsCarriedState(snapshot.ItemState)) + { + netItem.SetOwner(player.PlayerId, markDirty: true); + // State-only pickups must propagate their authority change too. + snapshot.UpdateType |= ItemUpdateData.ItemUpdateType.Ownership; + } + snapshot.OwnerPlayerId = netItem.OwnerPlayerId; NetworkLifecycle.Instance.Server.LogWarning($"NetworkedItemManager.ProcessReceivedAsHost() ItemNetId: {snapshot.ItemNetId}, snapshot type: {snapshot.UpdateType}"); netItem.ReceiveSnapshot(snapshot); netItem.MarkRemoteUpdateAccepted(); @@ -712,6 +722,8 @@ private NetworkedItem GetFromCache(string prefabName) internal void SendToCache(NetworkedItem netItem) { + if (netItem == null || netItem.Item == null) + return; // Cancel the old creation generation even if this object is immediately // reused for another Create with the same network ID. pendingGadgetCreates.Remove(netItem); diff --git a/Multiplayer/Components/Networking/World/NetworkedPitStopStation.cs b/Multiplayer/Components/Networking/World/NetworkedPitStopStation.cs index ba386c7d..09d94868 100644 --- a/Multiplayer/Components/Networking/World/NetworkedPitStopStation.cs +++ b/Multiplayer/Components/Networking/World/NetworkedPitStopStation.cs @@ -156,7 +156,7 @@ protected override void Awake() protected void OnDisable() { - if (!NetworkLifecycle.Instance.IsHost()) + if (NetworkLifecycle.Instance == null || !NetworkLifecycle.Instance.IsHost()) Refreshed = false; } @@ -164,7 +164,7 @@ protected override void OnDestroy() { pitStopStationToNetworkedPitStopStation.Remove(Station); - if (NetworkLifecycle.Instance.IsHost()) + if (NetworkLifecycle.Instance != null && NetworkLifecycle.Instance.Server != null && NetworkLifecycle.Instance.IsHost()) { foreach (var kvp in resourceStartStopDelegates) { @@ -199,7 +199,7 @@ protected override void OnDestroy() carSelectorGrab.Ungrabbed -= CarSelectorUnGrabbed; } - if (Station?.pitstop != null) + if (Station != null && Station.pitstop != null) { Station.pitstop.CarSelected -= CarSelected; } diff --git a/Multiplayer/Components/Networking/World/NetworkedPluggableObject.cs b/Multiplayer/Components/Networking/World/NetworkedPluggableObject.cs index 26b82a5d..237d1544 100644 --- a/Multiplayer/Components/Networking/World/NetworkedPluggableObject.cs +++ b/Multiplayer/Components/Networking/World/NetworkedPluggableObject.cs @@ -111,7 +111,7 @@ protected IEnumerator Start() protected void OnDisable() { - if (!NetworkLifecycle.Instance.IsHost()) + if (NetworkLifecycle.Instance == null || !NetworkLifecycle.Instance.IsHost()) Refreshed = false; } @@ -128,14 +128,14 @@ protected override void OnDestroy() plugToNetworkedPluggable.Remove(PluggableObject); } - if (PluggableObject?.controlBase != null && handlersInitialised) + if (PluggableObject != null && PluggableObject.controlBase != null && handlersInitialised) { PluggableObject.controlBase.Grabbed -= OnGrabbed; PluggableObject.controlBase.Ungrabbed -= OnUngrabbed; PluggableObject.PluggedIn -= OnPluggedIn; } - if (NetworkLifecycle.Instance.IsHost()) + if (NetworkLifecycle.Instance != null && NetworkLifecycle.Instance.Server != null && NetworkLifecycle.Instance.IsHost()) { NetworkLifecycle.Instance.Server.PlayerDisconnected -= OnPlayerDisconnected; } @@ -565,20 +565,14 @@ public void InitPitStop(NetworkedPitStopStation netPitStop) if (NetId == 0) base.Awake(); - if (plugToStation.TryGetValue(this, out _)) - { - Multiplayer.LogWarning($"Lookup cache 'plugToStation' already contains NetworkedPitStopStation \"{netPitStop?.StationName}\", skipping Init"); - return; - } - Station = netPitStop; - plugToStation.Add(this, netPitStop); + plugToStation[this] = netPitStop; if (PluggableObject == null) PluggableObject = GetComponent(); if (PluggableObject != null) - plugToNetworkedPluggable.Add(PluggableObject, this); + plugToNetworkedPluggable[PluggableObject] = this; } public void DropPlug() diff --git a/Multiplayer/Components/Networking/World/ShopPurchaseCoordinator.cs b/Multiplayer/Components/Networking/World/ShopPurchaseCoordinator.cs index 2548395c..e6b9b723 100644 --- a/Multiplayer/Components/Networking/World/ShopPurchaseCoordinator.cs +++ b/Multiplayer/Components/Networking/World/ShopPurchaseCoordinator.cs @@ -30,7 +30,7 @@ public ShopPurchase(string prefabName, int amount, float unitPrice) internal static class ShopPurchaseCoordinator { - private static readonly Dictionary> pendingOwners = []; + private static readonly Dictionary> pendingOwners = []; private static readonly Dictionary scaledAllowances = []; private static bool applyingClientApproval; private static bool stockRecountPending; @@ -62,35 +62,10 @@ public static void QueueOwnership(IEnumerable purchases, ServerPla foreach (var purchase in purchases) { if (!pendingOwners.TryGetValue(purchase.PrefabName, out var owners)) - pendingOwners[purchase.PrefabName] = owners = new Queue(); + pendingOwners[purchase.PrefabName] = owners = new Queue(); for (int i = 0; i < purchase.Amount; i++) - owners.Enqueue(buyer); - } - } - - public static void TransferPendingOwnership(ServerPlayer from, ServerPlayer to) - { - if (from == null) - return; - - foreach (string prefabName in pendingOwners.Keys.ToArray()) - { - var owners = pendingOwners[prefabName]; - var replacements = new Queue(owners.Count); - while (owners.Count > 0) - { - ServerPlayer owner = owners.Dequeue(); - if (owner == from) - owner = to; - if (owner != null) - replacements.Enqueue(owner); - } - - if (replacements.Count == 0) - pendingOwners.Remove(prefabName); - else - pendingOwners[prefabName] = replacements; + owners.Enqueue(buyer.Guid); } } @@ -113,12 +88,12 @@ public static void AssignPurchasedObject(GameObject purchasedObject) if (!pendingOwners.TryGetValue(prefabName, out var owners) || owners.Count == 0) return; - ServerPlayer buyer = owners.Dequeue(); + Guid buyer = owners.Dequeue(); if (owners.Count == 0) pendingOwners.Remove(prefabName); - item.SetOwner(buyer.PlayerId); - Multiplayer.LogDebug(() => $"Assigned purchased item {item.NetId} ({prefabName}) to {buyer.Username}"); + item.RestoreOwnerUniqueId(buyer); + Multiplayer.LogDebug(() => $"Assigned purchased item {item.NetId} ({prefabName}) to owner {buyer}"); } public static bool SuppressClientInstantiation(InventoryItemSpec itemSpec, int amountBought) diff --git a/Multiplayer/Components/SaveGame/NativeItemPersistence.cs b/Multiplayer/Components/SaveGame/NativeItemPersistence.cs index 90509fca..1500b2a0 100644 --- a/Multiplayer/Components/SaveGame/NativeItemPersistence.cs +++ b/Multiplayer/Components/SaveGame/NativeItemPersistence.cs @@ -2,6 +2,7 @@ using DV.Customization.Gadgets; using DV.JObjectExtstensions; using Multiplayer.Components.Networking.World; +using Multiplayer.Components.Networking; using Newtonsoft.Json.Linq; namespace Multiplayer.Components.SaveGame; @@ -10,6 +11,34 @@ namespace Multiplayer.Components.SaveGame; // nested snap behaviours. Publicized APIs provide all component access. internal static class NativeItemPersistence { + internal const string OwnerUniqueIdKey = "multiplayer.ownerUniqueId"; + + internal static void WriteOwner(ItemSaveData save, JObject state) + { + if (state == null || NetworkLifecycle.Instance == null || !NetworkLifecycle.Instance.IsHost() || + !save.TryGetComponent(out var item)) + return; + if (item.OwnerUniqueId is Guid owner && owner != Guid.Empty) + state[OwnerUniqueIdKey] = owner.ToString("D"); + else + state.Remove(OwnerUniqueIdKey); + } + + internal static bool TryReadOwner(JObject state, out Guid owner) + { + owner = Guid.Empty; + return state?[OwnerUniqueIdKey]?.Type == JTokenType.String && + Guid.TryParse((string)state[OwnerUniqueIdKey], out owner) && owner != Guid.Empty; + } + + internal static void RestoreOwner(ItemSaveData save) + { + if (NetworkLifecycle.Instance != null && NetworkLifecycle.Instance.IsHost() && + TryReadOwner(save.saveData, out var owner) && + save.TryGetComponent(out var item)) + item.RestoreOwnerUniqueId(owner); + } + [ThreadStatic] private static CaptureContext currentCapture; private sealed class CaptureContext @@ -43,6 +72,8 @@ internal static JObject Capture(NetworkedItem item) currentCapture = outerCapture; } + // Even a failing native callback must not roll back the current owner. + WriteOwner(save, save.saveData); return (JObject)save.saveData.DeepClone(); } diff --git a/Multiplayer/Networking/Managers/Client/NetworkClient.cs b/Multiplayer/Networking/Managers/Client/NetworkClient.cs index e356bbd4..527706be 100644 --- a/Multiplayer/Networking/Managers/Client/NetworkClient.cs +++ b/Multiplayer/Networking/Managers/Client/NetworkClient.cs @@ -84,6 +84,9 @@ public class NetworkClient : NetworkManager internal bool railwayStateLoaded = false; private ClientCustomizationSynchronization clientCustomizationSynchronization; private ClientCustomizationSynchronization CustomizationSynchronization => clientCustomizationSynchronization ??= new(this); + private Coroutine worldSyncRoutine; + private bool worldSyncStarted; + private bool awaitingWorldLoad; private bool itemReconciliationComplete; private bool itemReconciliationSucceeded; private byte itemReconciliationAttempt; @@ -117,10 +120,7 @@ public NetworkClient(Settings settings, bool singlePlayer) : base(settings) isSinglePlayer = singlePlayer; ClientPlayerManager = new ClientPlayerManager(); - WorldStreamingInit.LoadingFinished += () => - { - NetworkedPlayer.CaptureItemAnchorOffset(); - }; + WorldStreamingInit.LoadingFinished += OnWorldLoadingFinished; Username = Multiplayer.Settings.GetUserName(); characterModelId = Multiplayer.Settings.CharacterId; @@ -162,6 +162,13 @@ public void Start(string address, int port, string password, bool isSinglePlayer public override void Stop() { Log("Stopping client"); + WorldStreamingInit.LoadingFinished -= OnWorldLoadingFinished; + awaitingWorldLoad = false; + if (worldSyncRoutine != null) + { + CoroutineManager.Instance.Stop(worldSyncRoutine); + worldSyncRoutine = null; + } CustomizationSynchronization.Reset(); if (!isAlsoHost && originalSession != null) @@ -318,7 +325,27 @@ internal void CancelReadyBlock(string modName) { readyBlocks.Remove(modName); DisplayLoadingInfo displayLoadingInfo = Object.FindObjectOfType(); - displayLoadingInfo?.OnLoadingStatusChanged($"Mod {modName} loaded", false, 100); + if (displayLoadingInfo != null) displayLoadingInfo.OnLoadingStatusChanged($"Mod {modName} loaded", false, 100); + } + } + + private void OnWorldLoadingFinished() + { + if (!IsRunning) + return; + NetworkedPlayer.CaptureItemAnchorOffset(); + if (!awaitingWorldLoad || worldSyncStarted) + return; + awaitingWorldLoad = false; + worldSyncStarted = true; + worldSyncRoutine = CoroutineManager.Instance.StartCoroutine(SyncWorldState()); + var playerTracker = WorldMover.Instance.playerTracker; + if (playerTracker != null && playerTracker.GetComponent() == null) + { + if (VRManager.IsVREnabled()) + playerTracker.gameObject.AddComponent(); + else + playerTracker.gameObject.AddComponent(); } } @@ -344,7 +371,7 @@ private IEnumerator SyncWorldState() Log($"Waiting for ready-blocks..."); DisplayLoadingInfo displayLoadingInfo = Object.FindObjectOfType(); foreach (string modName in readyBlocks) - displayLoadingInfo?.OnLoadingStatusChanged($"Waiting for mod {modName} to load", false, ((float)LoadingState / (float)PlayerLoadingState.Complete) * 100); + if (displayLoadingInfo != null) displayLoadingInfo.OnLoadingStatusChanged($"Waiting for mod {modName} to load", false, ((float)LoadingState / (float)PlayerLoadingState.Complete) * 100); while (readyBlocks.Count > 0) yield return null; @@ -356,7 +383,7 @@ private IEnumerator SyncWorldState() Log("Syncing world state"); SendLoadStateUpdate(PlayerLoadingState.ReadyForWorldState); - displayLoadingInfo.OnLoadingStatusChanged(Locale.LOADING_INFO__SYNC_WORLD_STATE, false, ((float)LoadingState / (float)PlayerLoadingState.Complete) * 100); + if (displayLoadingInfo != null) displayLoadingInfo.OnLoadingStatusChanged(Locale.LOADING_INFO__SYNC_WORLD_STATE, false, ((float)LoadingState / (float)PlayerLoadingState.Complete) * 100); while (!railwayStateLoaded) yield return null; @@ -368,7 +395,7 @@ private IEnumerator SyncWorldState() Log("Requesting cars"); SendLoadStateUpdate(PlayerLoadingState.ReadyForTrainSets); - displayLoadingInfo.OnLoadingStatusChanged("Syncing rolling stock", false, ((float)LoadingState / (float)PlayerLoadingState.Complete) * 100); + if (displayLoadingInfo != null) displayLoadingInfo.OnLoadingStatusChanged("Syncing rolling stock", false, ((float)LoadingState / (float)PlayerLoadingState.Complete) * 100); uint lastLoggedSets = 0; @@ -401,7 +428,7 @@ private IEnumerator SyncWorldState() CustomizationSynchronization.Reset(forLoading: true); Log("Requesting customization state"); SendLoadStateUpdate(PlayerLoadingState.ReadyForCustomizers); - displayLoadingInfo.OnLoadingStatusChanged("Syncing customizations", false, ((float)LoadingState / (float)PlayerLoadingState.Complete) * 100); + if (displayLoadingInfo != null) displayLoadingInfo.OnLoadingStatusChanged("Syncing customizations", false, ((float)LoadingState / (float)PlayerLoadingState.Complete) * 100); yield return CustomizationSynchronization.LoadInitialState(RPC_Timeout); @@ -411,7 +438,7 @@ private IEnumerator SyncWorldState() Log("Reconciling local items"); SendLoadStateUpdate(PlayerLoadingState.ReadyForItems); - displayLoadingInfo.OnLoadingStatusChanged("Syncing items", false, ((float)LoadingState / (float)PlayerLoadingState.Complete) * 100); + if (displayLoadingInfo != null) displayLoadingInfo.OnLoadingStatusChanged("Syncing items", false, ((float)LoadingState / (float)PlayerLoadingState.Complete) * 100); itemReconciliationAttempt = 0; for (int reconciliationTry = 0; reconciliationTry < 2; reconciliationTry++) @@ -470,7 +497,7 @@ private IEnumerator SyncWorldState() yield return new WaitForSeconds(0.5f); SendLoadStateUpdate(PlayerLoadingState.Complete); - displayLoadingInfo.OnLoadingStatusChanged("Complete", false, ((float)LoadingState / (float)PlayerLoadingState.Complete) * 100); + if (displayLoadingInfo != null) displayLoadingInfo.OnLoadingStatusChanged("Complete", false, ((float)LoadingState / (float)PlayerLoadingState.Complete) * 100); yield return new WaitForSeconds(0.25f); // Start culling player models @@ -562,24 +589,7 @@ private void OnClientboundLoginResponsePacket(ClientboundLoginResponsePacket pac // Request Game Params and Save Game Data SendLoadStateUpdate(PlayerLoadingState.ReadyForGameData); - WorldStreamingInit.LoadingFinished += () => - { - LogDebug(() => "Loading finished, beginning sync"); - CoroutineManager.Instance.StartCoroutine(SyncWorldState()); - - if (WorldMover.Instance.playerTracker != null) - { - var tracker = WorldMover.Instance.playerTracker.GetComponent(); - if (tracker == null) - { - LogWarning($"LocalPlayerTracker not found, adding {(VRManager.IsVREnabled() ? "" : "non")}VR tracker."); - if (VRManager.IsVREnabled()) - tracker = WorldMover.Instance.playerTracker.gameObject.AddComponent(); - else - tracker = WorldMover.Instance.playerTracker.gameObject.AddComponent(); - } - } - }; + awaitingWorldLoad = true; return; } @@ -697,7 +707,7 @@ private void OnClientboundServerLoadingPacket(ClientboundServerLoadingPacket pac return; } - displayLoadingInfo.OnLoadingStatusChanged(Locale.LOADING_INFO__WAIT_FOR_SERVER, false, 100); + if (displayLoadingInfo != null) displayLoadingInfo.OnLoadingStatusChanged(Locale.LOADING_INFO__WAIT_FOR_SERVER, false, 100); } private void OnClientboundGameParamsPacket(ClientboundGameParamsPacket packet) diff --git a/Multiplayer/Networking/Managers/Server/LobbyServerManager.cs b/Multiplayer/Networking/Managers/Server/LobbyServerManager.cs index b342c7b7..dce134cc 100644 --- a/Multiplayer/Networking/Managers/Server/LobbyServerManager.cs +++ b/Multiplayer/Networking/Managers/Server/LobbyServerManager.cs @@ -144,11 +144,13 @@ public void OnDestroy() Multiplayer.Log($"LobbyServerManager OnDestroy()"); sendUpdates = false; StopAllCoroutines(); - StartCoroutine(RemoveFromLobbyServer($"{Multiplayer.Settings.LobbyServerAddress}/{ENDPOINT_REMOVE_SERVER}")); - - - lobby?.SetJoinable(false); - lobby?.Leave(); + // Normal server Stop removes the listing while this component is active. + // OnDestroy may run after both the coroutine runner and Steam are gone. + if (Steamworks.SteamClient.IsValid) + { + lobby?.SetJoinable(false); + lobby?.Leave(); + } discoveryManager?.Stop(); } diff --git a/Multiplayer/Patches/SaveGame/ItemSaveDataPatch.cs b/Multiplayer/Patches/SaveGame/ItemSaveDataPatch.cs index 3a0eeaec..bb2bfb96 100644 --- a/Multiplayer/Patches/SaveGame/ItemSaveDataPatch.cs +++ b/Multiplayer/Patches/SaveGame/ItemSaveDataPatch.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Reflection.Emit; using HarmonyLib; +using Newtonsoft.Json.Linq; using Multiplayer.Components.SaveGame; namespace Multiplayer.Patches.SaveGame; @@ -9,6 +10,10 @@ namespace Multiplayer.Patches.SaveGame; [HarmonyPatch(typeof(ItemSaveData), nameof(ItemSaveData.SaveItemData))] internal static class ItemSaveDataPatch { + [HarmonyPostfix] + private static void SaveOwner(ItemSaveData __instance, JObject __result) => + NativeItemPersistence.WriteOwner(__instance, __result); + [HarmonyTranspiler] private static IEnumerable Transpiler(IEnumerable instructions) { @@ -31,3 +36,19 @@ private static IEnumerable Transpiler(IEnumerable + NativeItemPersistence.RestoreOwner(__instance); + + [HarmonyPostfix] + [HarmonyPatch(nameof(ItemSaveData.PostLoadItemData))] + private static void RestoreOwnerAfterInitialization(ItemSaveData __instance) => + NativeItemPersistence.RestoreOwner(__instance); +} diff --git a/Multiplayer/Patches/World/CashRegisterWithModulesPatch.cs b/Multiplayer/Patches/World/CashRegisterWithModulesPatch.cs index 31728332..50a2ce9a 100644 --- a/Multiplayer/Patches/World/CashRegisterWithModulesPatch.cs +++ b/Multiplayer/Patches/World/CashRegisterWithModulesPatch.cs @@ -16,7 +16,7 @@ public class CashRegisterWithModulesPatch private static bool OnDisable(CashRegisterWithModules __instance) { //Multiplayer.LogDebug(() => $"CashRegisterWithModules.OnDisable({__instance.GetObjectPath()})"); - if (__instance == null) + if (__instance == null || NetworkLifecycle.Instance == null) return true; if (ShopPurchaseCoordinator.IsShopRegister(__instance)) @@ -62,7 +62,7 @@ private static bool OnBuyPressed(CashRegisterWithModules __instance) [HarmonyPatch(nameof(CashRegisterWithModules.OnBuyPressed))] private static void OnBuyPressed_Postfix(CashRegisterWithModules __instance) { - if (!NetworkLifecycle.Instance.IsHost()) + if (NetworkLifecycle.Instance == null || !NetworkLifecycle.Instance.IsServerRunning || !NetworkLifecycle.Instance.IsHost()) return; if (!NetworkedCashRegisterWithModules.TryGet(__instance, out var netCashRegister)) @@ -145,7 +145,7 @@ private static bool Cancel(CashRegisterWithModules __instance) private static void Cancel_Postfix(CashRegisterWithModules __instance) { //Multiplayer.LogWarning($"CashRegisterWithModules.Cancel_Postfix({__instance.GetObjectPath()})"); - if (!NetworkLifecycle.Instance.IsHost()) + if (NetworkLifecycle.Instance == null || !NetworkLifecycle.Instance.IsServerRunning || !NetworkLifecycle.Instance.IsHost()) return; if (!NetworkedCashRegisterWithModules.TryGet(__instance, out var netCashRegister)) diff --git a/Multiplayer/Patches/World/Items/CustomizationToolPatch.cs b/Multiplayer/Patches/World/Items/CustomizationToolPatch.cs index 1b021f52..87a4952b 100644 --- a/Multiplayer/Patches/World/Items/CustomizationToolPatch.cs +++ b/Multiplayer/Patches/World/Items/CustomizationToolPatch.cs @@ -53,6 +53,7 @@ private static void AfterConsumeDuctTape(DuctTapeReplacementState __state) return; __state.OldItem.SuppressDestroySync(); + replacementItem.InheritOwner(__state.OldItem.OwnerPlayerId, __state.OldItem.OwnerUniqueId); replacementItem.MarkAsSynchronized(); CustomizationStateManager.RegisterPendingDuctTapeReplacement(__state.OldItem.NetId, replacementItem); CustomizationStateManager.SendAction(new ReplaceDuctTapePacket