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/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 41ec9092..6dee3714 100644 --- a/Multiplayer/Components/Networking/Player/NetworkedPlayer.cs +++ b/Multiplayer/Components/Networking/Player/NetworkedPlayer.cs @@ -94,6 +94,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; @@ -281,7 +284,11 @@ protected void Update() return; } - 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, @@ -375,7 +382,7 @@ private void ApplySpineAndHeadRotation() // Side lean is always spinning around the root's global FORWARD axis Quaternion leanOffset = Quaternion.AngleAxis(currentLeanAngle, selfTransform.forward); - // Directly assign the uniform world rotation + // Directly assign the uniform world rotation spineTransform.rotation = leanOffset * currentModelSpineBase; } @@ -413,6 +420,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 e877315f..c480de5a 100644 --- a/Multiplayer/Components/Networking/Train/NetworkedTrainCar.cs +++ b/Multiplayer/Components/Networking/Train/NetworkedTrainCar.cs @@ -47,6 +47,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); @@ -162,6 +164,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; @@ -184,6 +189,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 = []; @@ -482,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"); @@ -535,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 ( @@ -600,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) { @@ -628,6 +634,7 @@ private void OnTrainCarInteriorUnloaded(GameObject interior) kvp.Key.ValueChanged -= kvp.Value; } + scrollableControlDelegates.Clear(); portNetIdToControl.Clear(); controlToPortNetId.Clear(); @@ -641,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); @@ -1186,6 +1200,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/ClientCustomizationSynchronization.cs b/Multiplayer/Components/Networking/World/ClientCustomizationSynchronization.cs new file mode 100644 index 00000000..ccfee411 --- /dev/null +++ b/Multiplayer/Components/Networking/World/ClientCustomizationSynchronization.cs @@ -0,0 +1,324 @@ +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 CustomizationStateData pendingCustomizationState; + private CustomizationStateData applyingCustomizationState; + private readonly List pendingCustomizationSnapshotPlacements = []; + private readonly List pendingCustomizationSnapshotRelationships = []; + private readonly Dictionary> pendingCustomizationSnapshotHoleStates = []; + 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) + { + customizerStateLoaded = false; + customizationSnapshotApplied = false; + if (forLoading) + { + applyingCustomizationSnapshot = false; + customizationActionDrainRunning = false; + } + pendingCustomizationState = null; + applyingCustomizationState = null; + pendingCustomizationSnapshotPlacements.Clear(); + pendingCustomizationSnapshotRelationships.Clear(); + pendingCustomizationSnapshotHoleStates.Clear(); + actionQueue.Reset(); + localReplacements.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"); + customizationSnapshotApplied = true; + customizerStateLoaded = true; + StartCustomizationActionDrain(); + break; + } + yield return null; + continue; + } + + yield return ApplyPendingCustomizationStates(); + customizerStateLoaded = true; + } + + } + + internal void RecordLocalAction(ICustomizationActionPacket packet) + { + // 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; + + } + + internal void ReceiveSnapshot(ClientboundCustomizationStatePacket packet) + { + if (packet?.State == null) + { + client.LogWarning("Received an invalid customization state packet"); + return; + } + + pendingCustomizationState = packet.State; + 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. + actionQueue.Receive(packet); + StartCustomizationActionDrain(); + } + + private IEnumerator ApplyPendingCustomizationStates() + { + if (applyingCustomizationSnapshot) + yield break; + + applyingCustomizationSnapshot = true; + try + { + 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, + () => 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; + } + + 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/CustomizationRef.cs b/Multiplayer/Components/Networking/World/CustomizationRef.cs new file mode 100644 index 00000000..b3c735fb --- /dev/null +++ b/Multiplayer/Components/Networking/World/CustomizationRef.cs @@ -0,0 +1,22 @@ +using DV.Customization; +using Multiplayer.Networking.Data.Customization; + +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; + + reference.IdentificationKey = customization.GetIdentificationKey(); + return !string.IsNullOrEmpty(reference.IdentificationKey); + } + + public static bool TryResolve(CustomizationRefData reference, out Customization customization) + { + return Customization.TryGetFromIdentificationKey(reference.IdentificationKey, out customization); + } +} diff --git a/Multiplayer/Components/Networking/World/CustomizationStateManager.Mutations.cs b/Multiplayer/Components/Networking/World/CustomizationStateManager.Mutations.cs new file mode 100644 index 00000000..d4670ea4 --- /dev/null +++ b/Multiplayer/Components/Networking/World/CustomizationStateManager.Mutations.cs @@ -0,0 +1,569 @@ +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; + 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; + + 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.InheritOwner(replacementOwner, replacementUniqueId); + 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; + 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; + + // 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.InheritOwner(replacementOwner, replacementUniqueId); + 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 new file mode 100644 index 00000000..ffe31aa0 --- /dev/null +++ b/Multiplayer/Components/Networking/World/CustomizationStateManager.cs @@ -0,0 +1,544 @@ +using DV.Customization; +using DV.Customization.Gadgets; +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.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 +{ + 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(); + + internal static NetworkedItem GetLocalReplacement(ICustomizationActionPacket packet) + { + if (packet is ReplaceDuctTapePacket tape) + { + pendingLocalDuctTapeReplacements.TryGetValue(tape.ConsumedItemNetId, out var replacement); + return replacement; + } + if (packet is ReplaceSpoolPacket spool && NetworkedItem.TryGet(spool.ToolItemNetId, out var toolItem)) + { + 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; + } + return null; + } + + internal static bool BindLocalReplacement(ICustomizationActionPacket packet, NetworkedItem replacement) + { + ushort netId = packet switch + { + ReplaceSpoolPacket spool => spool.ReplacementSpoolItemNetId, + ReplaceDuctTapePacket tape => tape.ReplacementItemNetId, + _ => 0, + }; + if (replacement == null || netId == 0) + return false; + + // 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; + } + + internal static void SendAction(T packet) + where T : CustomizationActionPacket, INetSerializable, new() + { + if (NetworkLifecycle.Instance.IsHost()) + NetworkLifecycle.Instance.Server.SendCustomizationAction(packet); + else + NetworkLifecycle.Instance.Client.SendCustomizationAction(packet); + } + + internal static void ApplyAction(ICustomizationActionPacket packet) + { + Multiplayer.LogDebug(() => $"Applying customization action={packet.GetType().Name}"); + + using (CustomizationSyncScope.Remote()) + { + switch (packet) + { + 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; + } + } + } + + internal static bool ProcessActionAsHost(ICustomizationActionPacket 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 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.GetType().Name}; mutation will not be relayed"); + return applied; + } + + private static bool WasActionApplied(ICustomizationActionPacket packet) + { + switch (packet) + { + case PlaceGadgetPacket action: + return TryGadget(action.ItemNetId, out var placed) && placed.IsLinked && + CustomizationRef.TryResolve(TargetOf(action.TargetKey), out var placementTarget) && + placed.Custom == placementTarget; + 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(action.MountIndex, mounts.Length)) + return false; + 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 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) && + 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); + 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; + var unsnapPoints = GetSnapPoints(unsnapOwner); + return IsValidIndex(action.SnapPointIndex, unsnapPoints.Length) && + unsnapPoints[action.SnapPointIndex].SnappedItem == null; + default: + return false; + } + } + + internal static IEnumerable GetCustomizationRecipients(ICustomizationActionPacket 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); + } + + internal static IEnumerable GetActionItemIds(ICustomizationActionPacket packet) => packet switch + { + 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; + + var dependencies = GetActionDependencyKeys(first).ToHashSet(); + return GetActionDependencyKeys(second).Any(dependencies.Contains); + } + + internal static bool SnapshotPlacementConflicts(GadgetPlacementData placement, + ICustomizationActionPacket action) + { + if (placement == null || action == null) + return false; + + return placement.ItemNetId != 0 && GetActionItemIds(action).Contains(placement.ItemNetId) || + !string.IsNullOrEmpty(placement.Target.IdentificationKey) && + placement.Target.IdentificationKey == GetTargetKey(action); + } + + internal static bool SnapshotHoleStateConflicts(string targetKey, ICustomizationActionPacket action) => + action != null && !string.IsNullOrEmpty(targetKey) && targetKey == GetTargetKey(action); + + private static IEnumerable GetActionDependencyKeys(ICustomizationActionPacket packet) + { + if (packet == null) + yield break; + + 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) + { + 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; + } + + + internal static bool IsActionReady(ICustomizationActionPacket packet, out string waitingFor) + { + waitingFor = null; + if (packet == null) + return false; + + string targetKey = GetTargetKey(packet); + if (targetKey != null && !CustomizationRef.TryResolve(TargetOf(targetKey), out _)) + { + waitingFor = $"customization target '{targetKey}'"; + return false; + } + + bool hasLocalDuctTapeReplacement = packet is ReplaceDuctTapePacket tape && + pendingLocalDuctTapeReplacements.TryGetValue(tape.ConsumedItemNetId, out var localReplacement) && + localReplacement != null && localReplacement.NetId == 0; + + 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. + 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. + DropEmptySpoolPacket action => NonZero(action.ToolItemNetId), + _ => 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 is PlaceGadgetPacket placement && + (!NetworkedItem.TryGet(placement.ItemNetId, out var placedItem) || + placedItem.GetTrackedItem() is not { Gadget: not null, Item: not null })) + { + waitingFor = $"gadget item {placement.ItemNetId}"; + return false; + } + + ushort firstGadgetId = packet switch + { + 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; + } + + 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 {secondGadgetId}"; + return false; + } + + if (packet is MountGadgetPacket or UnmountGadgetPacket) + { + int mountIndex = packet is MountGadgetPacket mount ? mount.MountIndex : ((UnmountGadgetPacket)packet).MountIndex; + TryGadget(firstGadgetId, out var owner); + if (!IsValidIndex(mountIndex, owner.GetComponents().Length)) + { + waitingFor = $"mount {mountIndex} on gadget {firstGadgetId}"; + return false; + } + } + + if (packet is WireGadgetsPacket or UnwireGadgetsPacket) + { + 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 {firstPort} on gadget {firstGadgetId}"; + return false; + } + + if (!IsValidIndex(secondPort, second.WireLinkPorts.Count)) + { + waitingFor = $"wire port {secondPort} on gadget {secondGadgetId}"; + 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 is WireGadgetsPacket && + (first.Custom == null || second.Custom == null || first.Custom != second.Custom)) + { + waitingFor = $"gadgets {firstGadgetId} and {secondGadgetId} to share a customization target"; + return false; + } + } + + if (packet is SnapItemPacket snap && !IsSnapActionReady(snap, out waitingFor)) + return false; + if (packet is UnsnapItemPacket unsnap && !IsSnapActionReady(unsnap, out waitingFor)) + return false; + + ushort toolId = packet switch + { + 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 {toolId}"; + return false; + } + } + + return true; + } + + private static bool IsSnapActionReady(SnapItemPacket packet, out string waitingFor) + { + if (!TryGadget(packet.GadgetItemNetId, out var owner)) + { + waitingFor = $"snap-point owner {packet.GadgetItemNetId}"; + return false; + } + + var snapPoints = GetSnapPoints(owner); + if (!IsValidIndex(packet.SnapPointIndex, snapPoints.Length)) + { + waitingFor = $"snap point {packet.SnapPointIndex} on gadget {packet.GadgetItemNetId}"; + return false; + } + + if (!NetworkedItem.TryGet(packet.SnappedItemNetId, out var snappedItem) || + snappedItem.Item?.SnappableItem == null) + { + waitingFor = $"snappable item {packet.SnappedItemNetId}"; + return false; + } + + 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.SnappedItemNetId}"; + return false; + } + + waitingFor = null; + return true; + } + + 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 CustomizationRefData TargetOf(string targetKey) + { + return new CustomizationRefData + { + IdentificationKey = targetKey, + }; + } + + private static IEnumerable GetSnapshotRelationships(CustomizationStateData state) => + state.Mounts.Cast() + .Concat(state.Wires) + .Concat(state.MagazineStates) + .Concat(state.SnappedItems); + + 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 LogSnapshot(string operation, CustomizationStateData state) + { + Multiplayer.LogDebug(() => $"Customization snapshot {operation} targets={state.CustomizationKeys.Count} " + + $"gadgets={state.Gadgets.Count} relationships={GetSnapshotRelationships(state).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..51a85d1a --- /dev/null +++ b/Multiplayer/Components/Networking/World/GadgetTrackedValueRegistry.cs @@ -0,0 +1,323 @@ +using DV.Customization; +using DV.Customization.Gadgets; +using DV.Customization.Gadgets.Implementations; +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace Multiplayer.Components.Networking.World; + +public static class GadgetTrackedValueRegistry +{ + 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); + 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) + { + 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; + } + + 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); + } + + 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) + 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/HostCustomizationSynchronization.cs b/Multiplayer/Components/Networking/World/HostCustomizationSynchronization.cs new file mode 100644 index 00000000..79a9ac6b --- /dev/null +++ b/Multiplayer/Components/Networking/World/HostCustomizationSynchronization.cs @@ -0,0 +1,146 @@ +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; +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; + 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) + { + 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); + server.FlushCustomizationAcknowledgments(pending.Player, pending.Peer); + appliedAny = true; + } + 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); + } + 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) + yield return null; + } + } + finally + { + hostCustomizationDrainRunning = false; + } + } + + private sealed class PendingHostCustomizationAction + { + public readonly ICustomizationActionPacket Packet; + public readonly ServerPlayer Player; + public readonly ITransportPeer Peer; + public float UnreadySince = -1f; + + 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..eb7aec75 --- /dev/null +++ b/Multiplayer/Components/Networking/World/InventorySynchronization.cs @@ -0,0 +1,674 @@ +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 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 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()) + return; + + completedReconciliation.Remove(disconnectedPlayer); + + byte hostPlayerId = NetworkLifecycle.Instance.Server?.SelfId ?? 0; + + 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.AssignHostFallback(disconnectedPlayer, hostPlayerId); + 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(); + var persistenceStates = allItems.ToDictionary(item => item, item => item.RefreshPersistenceState()); + var selected = allItems + .Where(item => item.OwnerPlayerId == player.PlayerId && + persistenceStates[item] 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) || persistenceStates[item] != 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 = NativeItemPersistence.Capture(item); + + // Ensure native stock bookkeeping is present even when ItemSaveData + // has no registered restocker listener. + 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 = persistenceState == 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) + { + // 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; + + 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(byte attempt) + { + if (clientReconciliationInProgress && attempt == clientReconciliationAttempt) + return activeReconciliationRequests; + + pendingItemReconciliation.Clear(); + currentReconciliationFailed = false; + var packets = new List(); + var packet = new ServerboundItemReconciliationPacket { Attempt = attempt }; + int localId = 1; + + foreach (var item in NetworkedItem.GetAll()) + { + if (packet.Items.Count >= ItemReconciliationProtocol.MaxItems) + { + packets.Add(packet); + packet = new ServerboundItemReconciliationPacket { Attempt = attempt }; + } + + 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.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, 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)) + { + Multiplayer.LogWarning($"Received item reconciliation for unknown local item {result.LocalId}"); + continue; + } + + 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; + } + + // 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})"); + rejected |= currentReconciliationFailed || pendingItemReconciliation.Count > 0; + pendingItemReconciliation.Clear(); + clientReconciliationInProgress = false; + activeReconciliationRequests = []; + succeeded = !rejected; + if (succeeded) + manager.ClientInitialised = true; + return true; + } + + internal void EnqueueClientItemReconciliation(ItemReconciliationRequest[] requests, bool isFinalBatch, + byte attempt, ServerPlayer player, Action completed) + { + if (TryReplayCompletedReconciliation(requests, isFinalBatch, attempt, player, completed)) + return; + + reconciliationBatches.Enqueue(ReconcileClientItems(requests, isFinalBatch, attempt, 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, byte attempt, + ServerPlayer player, Action completed) + { + var response = new ClientboundItemReconciliationPacket + { + IsFinalBatch = isFinalBatch, + Attempt = attempt, + }; + if (!NetworkLifecycle.Instance.IsHost() || requests == null || player == null) + yield break; + if (!NetworkLifecycle.Instance.Server.TryGetServerPlayer(player.PlayerId, out var initialPlayer) || + 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, int ResponseIndex)>(); + foreach (var request in requests) + { + int responseIndex = response.Items.Count; + response.Items.Add(default); + 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, responseIndex)); + 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[responseIndex] = 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; + // 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; + response.Items[entry.ResponseIndex] = 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[entry.ResponseIndex] = 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(); + } + 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; + + 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/NetworkedCashRegisterWithModules.cs b/Multiplayer/Components/Networking/World/NetworkedCashRegisterWithModules.cs index 7ffa8226..2c34bb05 100644 --- a/Multiplayer/Components/Networking/World/NetworkedCashRegisterWithModules.cs +++ b/Multiplayer/Components/Networking/World/NetworkedCashRegisterWithModules.cs @@ -78,13 +78,15 @@ public static void InitialiseCashRegisters() bool isBuying; bool isCancelling; bool isAddingCash; + uint pendingShopRequestId; + double pendingShopDepositedCash; public bool IsShopRegister { get; set; } = false; double pendingCashToAdd = 0; #endregion #region Common Variables - CashRegisterWithModules CashRegister; + internal CashRegisterWithModules CashRegister; #endregion #region Unity @@ -140,6 +142,12 @@ private void CullingManager_PlayerEnteredActivationRegion(ServerPlayer serverPla public void Server_ProcessCashRegisterAction(ServerPlayer player, CommonCashRegisterWithModulesActionPacket packet) { + if (IsShopRegister) + { + Multiplayer.LogWarning($"Ignoring cash-register packet for shop register {NetId}"); + return; + } + bool success = false; CashRegisterAction response = CashRegisterAction.RejectGeneric; @@ -235,6 +243,111 @@ public void Server_ProcessCashRegisterAction(ServerPlayer player, CommonCashRegi processingAction = false; } + public void Server_ProcessShopPurchase(ServerPlayer player, CommonShopPacket packet) + { + 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, packet.RequestId, rejection); + return; + } + + string[] prefabNames = packet.ItemPrefabNames ?? []; + int[] amounts = packet.ItemAmounts ?? []; + if (prefabNames.Length == 0 || prefabNames.Length > 64 || prefabNames.Length != amounts.Length) + { + SendShopRejection(player, packet.RequestId, ShopAction.RejectNoItems); + 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, packet.RequestId, 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, packet.RequestId, ShopAction.RejectNoItems); + 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, packet.RequestId, ShopAction.RejectNoItems); + return; + } + + totalCost += data.basePrice * amounts[i]; + } + + 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; + } + + 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.SendShopAction(new CommonShopPacket + { + RegisterNetId = NetId, + RequestId = packet.RequestId, + Action = ShopAction.Approved, + 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, uint requestId, ShopAction action) + { + NetworkLifecycle.Instance.Server.SendShopAction(new CommonShopPacket + { + RegisterNetId = NetId, + RequestId = requestId, + Action = action, + BuyerPlayerId = player.PlayerId + }, [player]); + } + #endregion #region Client @@ -242,6 +355,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 +471,59 @@ public void Client_ProcessCashRegisterAction(CashRegisterAction action, double a } } + public void Client_ProcessShopAction(CommonShopPacket packet) + { + bool isBuyer = NetworkLifecycle.Instance.Client?.PlayerId == packet.BuyerPlayerId; + if (isBuyer && (pendingShopRequestId == 0 || packet.RequestId != pendingShopRequestId)) + { + Multiplayer.LogWarning($"Ignoring stale shop response {packet.RequestId} for {CashRegister.GetObjectPath()} " + + $"(pending {pendingShopRequestId})"); + return; + } + + if (IsShopRegister && packet.Action == ShopAction.Approved) + { + if (isBuyer) + pendingShopRequestId = 0; + ShopPurchaseCoordinator.ApplyApproval(this, packet); + return; + } + + 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 + GlobalShopController.Instance.Fire_GlobalShopDataChanged(); + } + public IEnumerator Buy() { if (isBuying || isCancelling || NetworkLifecycle.Instance.IsProcessingPacket) @@ -369,6 +545,38 @@ 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; + pendingShopDepositedCash = CashRegister.DepositedCash; + + pendingShopRequestId = NetworkLifecycle.Instance.Client.SendShopPurchase(NetId, + pendingShopDepositedCash, + purchases.Select(item => item.PrefabName).ToArray(), + purchases.Select(item => item.Amount).ToArray()); + + // 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); + + 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 140dcd68..43e4cadb 100644 --- a/Multiplayer/Components/Networking/World/NetworkedItem.cs +++ b/Multiplayer/Components/Networking/World/NetworkedItem.cs @@ -2,7 +2,10 @@ using DV.Interaction; using DV.InventorySystem; using DV.Items; +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; @@ -21,7 +24,10 @@ 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 + InLostAndFound, + InContainer } public class NetworkedItem : IdMonoBehaviour @@ -76,11 +82,26 @@ 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 && !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; + 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 + : transform.position; private bool initialised; private bool registrationComplete = false; private Queue pendingSnapshots = new Queue(); @@ -88,31 +109,74 @@ 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 forceStateSnapshot; + private bool ownerDirty; private bool wasThrown; + private bool suppressDestroySync; private Vector3 thrownPosition; private Quaternion thrownRotation; private Vector3 throwDirection; - //Handle ownership - public sbyte OwnerId { get; private set; } = -1; // 0 means no owner - - //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); - // } - // } - //} + // 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; @@ -132,7 +196,9 @@ protected void Start() // Mark registration as complete for items that don't need tracked values if (!registrationComplete && !UsefulItem) - registrationComplete = true; + FinaliseTrackedValues(); + + NetworkedItemManager.Instance.CacheLateLocalWorldDuplicate(this); } public T GetTrackedItem() where T : Component @@ -175,17 +241,35 @@ 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; + Item.ItemInventoryStateChanged += OnItemInventoryStateChanged; //Find special interaction components TryGetComponent(out grabHandler); TryGetComponent(out snappableItem); + TryGetComponent(out itemReparenting); lastState = GetItemState(); stateDirty = false; + 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) @@ -203,10 +287,28 @@ 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; } + 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 @@ -262,7 +364,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(); } @@ -291,22 +394,67 @@ private void MarkValuesClean() public ItemUpdateData GetSnapshot() { + if (IsPendingInitialization || IsPendingReconciliation || pendingContainerRestore) + return null; + ItemUpdateData snapshot; ItemUpdateData.ItemUpdateType updateType = ItemUpdateData.ItemUpdateType.None; - bool hasDirtyVals = HasDirtyValues(); - if (Item == null && Register() == false) return null; - if (!stateDirty && !hasDirtyVals) + 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; - ItemState currentState = GetItemState(); + // 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 && + IsCarriedState(lastState)) + { + stateDirty = false; + forceStateSnapshot = false; + ownerDirty = false; + MarkValuesClean(); + return null; + } + } + + // 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 = IsCarriedState(currentState) && + (Item.IsGrabbed() || Inventory.Instance.Contains(gameObject, false) || + !IsCarriedState(lastState) && ShouldObserveLocalLayout()); + if (isHeldByLocalPlayer) + SetOwner(localPlayerId, markDirty: true); + + } if (!createdDirty) { - if (lastState != currentState) + if (lastState != currentState || forceStateSnapshot) updateType |= ItemUpdateData.ItemUpdateType.ItemState; if (hasDirtyVals) @@ -314,6 +462,9 @@ public ItemUpdateData GetSnapshot() Multiplayer.LogDebug(GetDirtyValuesDebugString); updateType |= ItemUpdateData.ItemUpdateType.ObjectState; } + + if (ownerDirty) + updateType |= ItemUpdateData.ItemUpdateType.Ownership; } else { @@ -330,6 +481,8 @@ public ItemUpdateData GetSnapshot() createdDirty = false; stateDirty = false; + forceStateSnapshot = false; + ownerDirty = false; wasThrown = false; MarkValuesClean(); @@ -337,7 +490,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; @@ -349,20 +502,50 @@ 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 + // the item is restored to the client's world storage list. + 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; + ItemState previousState = lastState; + lastState = snapshot.ItemState; Multiplayer.Log($"NetworkedItem.ApplySnapshot() netId: {snapshot?.ItemNetId}, ItemUpdateType: {snapshot?.UpdateType}, ItemState: {snapshot?.ItemState}, Active state: {gameObject.activeInHierarchy}"); switch (snapshot.ItemState) { case ItemState.Dropped: case ItemState.Thrown: - HandleDroppedOrThrownState(snapshot); + HandleDroppedOrThrownState(snapshot, previousState); break; case ItemState.InHand: @@ -370,14 +553,50 @@ private void ApplySnapshot(ItemUpdateData snapshot) 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; + case ItemState.InstalledGadget: + gameObject.SetActive(false); + break; + + case ItemState.InLostAndFound: + HandleLostAndFoundState(snapshot); + break; + default: throw new Exception($"NetworkedItem.ApplySnapshot() Item state not implemented: {snapshot?.ItemState}"); } + + } + + if (snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.Ownership) || + snapshot.UpdateType.HasFlag(ItemUpdateData.ItemUpdateType.Create)) + { + // 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) || + 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"); @@ -388,7 +607,16 @@ 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) + { + GadgetTrackedValueRegistry.ApplyAlternatingPhase( + alternating, + snapshot.States, + snapshot.SentTick); + } } } @@ -397,6 +625,8 @@ private void ApplySnapshot(ItemUpdateData snapshot) //mark values as clean createdDirty = false; stateDirty = false; + forceStateSnapshot = false; + ownerDirty = false; MarkValuesClean(); return; @@ -451,54 +681,168 @@ public ItemUpdateData CreateUpdateData(ItemUpdateData.ItemUpdateType updateType) { UpdateType = updateType, ItemNetId = NetId, + OwnerPlayerId = this.OwnerPlayerId, + BelongsToPlayer = Item.InventorySpecs.BelongsToPlayer, + RestockOnDestroy = Item.GetComponent()?.restockOnItemDestroyed == true, PrefabName = Item.InventorySpecs.ItemPrefabName, ItemState = lastState, + Active = gameObject.activeSelf, ItemPosition = position, ItemRotation = rotation, ThrowDirection = throwDirection, + Player = IsCarriedState(lastState) + ? OwnerPlayerId + : (byte)0, + InventorySlotIndex = InventorySlotIndex, + ContainerSlotIndex = ContainerSlotIndex, + ContainerId = ContainerId, + ContainerIdentity = Item.GetComponent()?.ContainerId, + InLockedSlot = InLockedSlot, + IsDropped = IsDroppedInInventory, CarNetId = carId, AttachedFront = frontCoupler, 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; + + internal void MarkRemoteUpdateAccepted() + { + LastDirtyTick = NetworkLifecycle.Instance.Tick; + } + + public void MarkAsSynchronized() { - //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)}"); + createdDirty = false; + stateDirty = false; + forceStateSnapshot = false; + ownerDirty = false; + lastState = GetItemState(); + MarkValuesClean(); + } + internal void DeferInitialCreate() + { + createdDirty = false; + stateDirty = false; + forceStateSnapshot = false; + ownerDirty = false; + MarkValuesClean(); + } - if (Item.transform.parent == WorldMover.OriginShiftParent && !wasThrown) + internal void PublishDeferredCreate() + { + lastState = GetItemState(); + CaptureCurrentLayout(lastState); + createdDirty = true; + } + + internal ItemState RefreshPersistenceState() + { + 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()) { - Multiplayer.LogDebug(() => $"GetItemState() NetId: {NetId}, {name}, Parent: {Item.transform.parent} WorldMover: {WorldMover.OriginShiftParent}, wasThrown: {wasThrown}"); - return ItemState.Dropped; + if (CaptureCurrentLayout(currentState)) + { + stateDirty = true; + forceStateSnapshot = true; + } } + return currentState; + } - if (wasThrown) + public ItemUpdateData CreateCurrentUpdateData(ItemUpdateData.ItemUpdateType updateType) + { + lastState = GetItemState(); + if (ShouldObserveLocalLayout()) + CaptureCurrentLayout(lastState); + return CreateUpdateData(updateType); + } + + 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 + // update (for example when ejecting a soldering reel). Their most recent + // client-supplied state is authoritative instead. + if (OwnerPlayerId != 0 && IsCarriedState(lastState)) { - Multiplayer.LogDebug(() => $"GetItemState() NetId: {NetId}, {name}, Parent: {Item.transform.parent} WorldMover: {WorldMover.OriginShiftParent}, wasThrown: {wasThrown}"); - return ItemState.Thrown; + 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.IsGrabbed()) - return ItemState.InHand; + return ObserveState(ItemState.InHand); if (Inventory.Instance.Contains(this.gameObject, false)) - return ItemState.InInventory; + 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); + + //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; } - private void ApplyTrackedValues(Dictionary newValues) + private void ApplyTrackedValues(Dictionary newValues, + bool acceptServerAuthoritativeValues = false) { Multiplayer.LogDebug(() => $"NetworkedItem.ApplyTrackedValues() itemNetId: {NetId}, item name: {name}. Null checks"); @@ -513,7 +857,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 { @@ -539,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) @@ -547,16 +892,27 @@ 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 + // storage and otherwise can pass a null StorageBase into the base game. + var storage = StorageController.Instance; + if (storage != null && Item.BelongsToPlayer() && !storage.StorageWorld.ContainsItem(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 && previousState == ItemState.InstalledGadget && 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) @@ -574,11 +930,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}"); @@ -609,50 +960,208 @@ 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); } - 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. + 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.AnyStorageContains(Item)) + storage.RemoveItemFromStorageItemList(Item); //todo add to player model's hand this.gameObject.SetActive(false); } - #endregion - protected override void OnDestroy() + public static bool IsCarriedState(ItemState state) => + state is ItemState.InHand or ItemState.InInventory or ItemState.InContainer; + + internal void RestoreContainerMembership() { - if (UnloadWatcher.isQuitting) + if (!pendingContainerRestore || IsPendingReconciliation || !IsReadyForSnapshots || + string.IsNullOrEmpty(ContainerId)) return; - if (UnloadWatcher.isUnloading) + var container = Inventory.Instance.ItemContainerRegistry.GetContainer(ContainerId); + if (container == null || ContainerSlotIndex < 0 || ContainerSlotIndex >= container.Capacity) + return; + if (container[ContainerSlotIndex] == gameObject) { - itemBaseToNetworkedItem.Clear(); - base.OnDestroy(); + pendingContainerRestore = false; return; } + if (container[ContainerSlotIndex] != null) + return; - if (NetworkLifecycle.Instance.IsHost()) + 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) + 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) { - var updateData = CreateUpdateData(ItemUpdateData.ItemUpdateType.Destroy); - if (updateData != null) - NetworkedItemManager.Instance.AddDirtyItemSnapshot(this, updateData); + 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) + 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() + { + 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; + Item.ItemInventoryStateChanged -= OnItemInventoryStateChanged; itemBaseToNetworkedItem.Remove(Item); } - else + 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; + } + + if (NetworkLifecycle.Instance.IsHost()) + { + if (suppressDestroySync) + { + NetworkedItemManager.Instance.ForgetItem(this); + } + else + { + var updateData = CreateUpdateData(ItemUpdateData.ItemUpdateType.Destroy); + if (updateData != null) + NetworkedItemManager.Instance.AddDirtyItemSnapshot(this, updateData); + } + } + base.OnDestroy(); } diff --git a/Multiplayer/Components/Networking/World/NetworkedItemManager.cs b/Multiplayer/Components/Networking/World/NetworkedItemManager.cs index 8ac84738..e824670f 100644 --- a/Multiplayer/Components/Networking/World/NetworkedItemManager.cs +++ b/Multiplayer/Components/Networking/World/NetworkedItemManager.cs @@ -9,7 +9,16 @@ using Multiplayer.Utils; 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; +using Multiplayer.Networking.Packets; +using System.Collections; +using Multiplayer.Components.SaveGame; +using Newtonsoft.Json.Linq; +using DV.Common; namespace Multiplayer.Components.Networking.World; @@ -28,11 +37,7 @@ public class NetworkedItemManager : SingletonBehaviour //caches for item snapshots private List DestroyedItems = new(64); - - //Item ownership - //private Dictionary playerInventories = new Dictionary(); - //private Dictionary itemToPlayerMap = new Dictionary(); - + private readonly List AcceptedClientUpdates = new(64); /* * Client @@ -41,7 +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; + internal IReadOnlyDictionary InventoryPrefabs => ItemPrefabs; + internal bool ClientInitialised { get; set; } + private readonly Dictionary> pendingGadgetCreates = []; /* @@ -55,7 +62,8 @@ protected override void Awake() if (!NetworkLifecycle.Instance.IsHost()) return; - //B99 temporary patch NetworkLifecycle.Instance.Server.PlayerDisconnected += PlayerDisconnected; + NetworkLifecycle.Instance.Server.PlayerDisconnected += PlayerDisconnected; + NetworkLifecycle.Instance.Server.PlayerConnected += PlayerConnected; try { @@ -67,10 +75,18 @@ protected override void Awake() } } - private void PlayerDisconnected(uint netID) - { - throw new NotImplementedException(); - } + 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) => + InventorySynchronization.TryCaptureCarriedInventory(player, out items); + + public IEnumerator RecoverStoredInventory(SavedPlayerInventory stored, Action completed) => + InventorySynchronization.RecoverStoredInventory(stored, completed); protected void Start() { @@ -86,12 +102,23 @@ protected override void OnDestroy() return; 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) { DestroyedItems.Add(snapshot); + ForgetItem(netItem); + } + + public void ForgetItem(NetworkedItem netItem) + { + foreach(var player in NetworkLifecycle.Instance.Server.ServerPlayers) { if(player.KnownItems.ContainsKey(netItem)) @@ -120,6 +147,7 @@ public void ReceiveSnapshots(List snapshots, ServerPlayer sender private void Common_OnTick(uint tick) { ProcessReceived(); + RestoreContainerMemberships(); if (NetworkLifecycle.Instance.IsHost()) { @@ -132,7 +160,7 @@ private void Common_OnTick(uint tick) } } - private void ProcessReceived() + internal void ProcessReceived() { while (ReceivedSnapshots.Count > 0) { @@ -181,13 +209,13 @@ private void UpdatePlayerItemLists() foreach (var item in allItems) { - if (item == null) - { - NetworkLifecycle.Instance.Server.LogDebug(() => $"UpdatePlayerItemLists() Null item found in allItems!"); + if (item == null || item.IsPendingReconciliation) + continue; + + if (IsLocalShopItem(item)) 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 +245,19 @@ private void ProcessChanged(uint tick) foreach (var item in NetworkedItem.GetAll()) { + if (item == null || item.NetId == 0 || IsLocalShopItem(item)) + continue; + ItemUpdateData snapshot = item.GetSnapshot(); if (snapshot != null) dirtyItems.Add(snapshot); } + // 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}"); foreach (var player in NetworkLifecycle.Instance.Server.ServerPlayers) @@ -249,21 +285,32 @@ 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(ItemUpdateData.ItemUpdateType.FullSync); + itemUpdates.Add(nearbyItem.CreateUpdateData( + ItemUpdateData.ItemUpdateType.FullSync | + 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 + // 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 +330,7 @@ private void ProcessChanged(uint tick) } DestroyedItems.Clear(); + AcceptedClientUpdates.Clear(); } private void ProcessReceivedAsHost(ItemUpdateData snapshot, ServerPlayer player) @@ -295,15 +343,40 @@ 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 + // 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 = NetworkedItem.IsCarriedState(netItem.CurrentState) && + 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() ItemNetId: {snapshot.ItemNetId}, snapshot type: {snapshot.UpdateType}"); - netItem.ReceiveSnapshot(snapshot); + NetworkLifecycle.Instance.Server.LogWarning( + $"NetworkedItemManager.ProcessReceivedAsHost() Ignoring non-owner drop for item " + + $"{snapshot.ItemNetId}: sender={player.PlayerId}, owner={netItem.OwnerPlayerId}, " + + $"state={snapshot.ItemState}"); + return; } - else + + // 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; + if (changesItemState && NetworkedItem.IsCarriedState(snapshot.ItemState)) { - NetworkLifecycle.Instance.Server.LogWarning($"NetworkedItemManager.ProcessReceivedAsHost() Player action validation failed for ItemNetId: {snapshot.ItemNetId}"); + 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(); + AcceptedClientUpdates.Add(snapshot); } else { @@ -311,51 +384,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 - // 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 @@ -369,6 +397,9 @@ private void ProcessClientChanges(uint tick) foreach (var item in NetworkedItem.GetAll()) { + if (item == null || item.NetId == 0 || IsLocalShopItem(item)) + continue; + ItemUpdateData snapshot = item.GetSnapshot(); if (snapshot != null) { @@ -397,11 +428,15 @@ private void ProcessReceivedAsClient(ItemUpdateData snapshot) } else if (snapshot.UpdateType == ItemUpdateData.ItemUpdateType.Destroy) { - SendToCache(netItem); + if (netItem != null) + SendToCache(netItem); } else if (netItem != null) { - netItem.ReceiveSnapshot(snapshot); + if (pendingGadgetCreates.TryGetValue(netItem, out var pending)) + pending.Enqueue(snapshot); + else + netItem.ReceiveSnapshot(snapshot); } else { @@ -411,7 +446,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) { @@ -419,7 +454,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) { @@ -441,9 +480,161 @@ private void CreateItem(ItemUpdateData snapshot) newItem.gameObject.SetActive(true); newItem.NetId = snapshot.ItemNetId; + // 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 }) + { + var pending = new Queue(); + pending.Enqueue(snapshot); + pendingGadgetCreates[newItem] = pending; + newItem.IsPendingInitialization = true; + CoroutineManager.Instance.StartCoroutine(ApplyGadgetCreateAfterStart(newItem, snapshot, pending)); + return; + } + 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 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 && + pendingGadgetCreates.TryGetValue(item, out var current) && ReferenceEquals(current, pending) && + gadgetItem?.Item == null && + frames++ < maxInitializationFrames) + yield return null; + + if (item == null || item.NetId != snapshot.ItemNetId || + !pendingGadgetCreates.TryGetValue(item, out var active) || !ReferenceEquals(active, pending)) + yield break; + + if (gadgetItem?.Item == null) + { + Multiplayer.LogError($"Gadget {snapshot.ItemNetId} ({snapshot.PrefabName}) did not initialize; create snapshot was not applied"); + SendToCache(item); + yield break; + } + + // 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, + ISet forceDropped = null, ISet refreshItems = null) + { + 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) && refreshItems?.Contains(itemNetId) != true) + 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() { NetworkLifecycle.Instance.Client.LogDebug(() => $"BuildPrefabLookup()"); @@ -466,7 +657,10 @@ 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) && + !StorageController.Instance.StorageItemContainers.ContainsItem(item.Item)) { SendToCache(item); } @@ -481,7 +675,36 @@ public void CacheWorldItems() } } - ClientInitialised = true; + ClientInitialised = false; + InventorySynchronization.ResetClientReconciliation(); + } + + internal IReadOnlyList CreateItemReconciliationRequests(byte attempt) => + InventorySynchronization.CreateItemReconciliationRequests(attempt); + + internal bool ApplyItemReconciliation(ClientboundItemReconciliationPacket packet, byte attempt, + out bool succeeded) => + InventorySynchronization.ApplyItemReconciliation(packet, attempt, out succeeded); + + internal void EnqueueClientItemReconciliation(ItemReconciliationRequest[] requests, bool isFinalBatch, + byte attempt, ServerPlayer player, Action completed) => + InventorySynchronization.EnqueueClientItemReconciliation(requests, isFinalBatch, attempt, player, completed); + + internal static void RestoreContainerMemberships() + { + foreach (var item in NetworkedItem.GetAll().ToArray()) + { + if (item == null) + continue; + try + { + item.RestoreContainerMembership(); + } + catch (Exception exception) + { + Multiplayer.LogError($"Could not restore container membership for item {item.NetId}: {exception}"); + } + } } private NetworkedItem GetFromCache(string prefabName) @@ -497,23 +720,20 @@ private NetworkedItem GetFromCache(string prefabName) return null; } - private void SendToCache(NetworkedItem netItem) + 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); + netItem.IsPendingInitialization = false; + netItem.DetachFromContainer(); string prefabName = netItem?.Item?.InventorySpecs?.itemPrefabName; //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); @@ -529,6 +749,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; @@ -557,6 +782,9 @@ public bool DoNotCreateItem(Type itemType) return false; } + internal static bool IsLocalShopItem(NetworkedItem item) => + item != null && item.TryGetComponent(out _); + [UsedImplicitly] public new static string AllowAutoCreate() { 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 new file mode 100644 index 00000000..e6b9b723 --- /dev/null +++ b/Multiplayer/Components/Networking/World/ShopPurchaseCoordinator.cs @@ -0,0 +1,341 @@ +using DV.CabControls; +using DV.CashRegister; +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; + +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 readonly Dictionary scaledAllowances = []; + private static bool applyingClientApproval; + private static bool stockRecountPending; + private static int sessionGeneration; + + 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.Guid); + } + } + + public static void ResetSessionState() + { + pendingOwners.Clear(); + scaledAllowances.Clear(); + stockRecountPending = false; + sessionGeneration++; + } + + 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; + + string prefabName = item.Item.InventorySpecs.ItemPrefabName; + if (!pendingOwners.TryGetValue(prefabName, out var owners) || owners.Count == 0) + return; + + Guid buyer = owners.Dequeue(); + if (owners.Count == 0) + pendingOwners.Remove(prefabName); + + item.RestoreOwnerUniqueId(buyer); + Multiplayer.LogDebug(() => $"Assigned purchased item {item.NetId} ({prefabName}) to owner {buyer}"); + } + + 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, + CommonShopPacket packet) + { + bool isBuyer = NetworkLifecycle.Instance.Client?.PlayerId == packet.BuyerPlayerId; + if (isBuyer) + { + ApplyApprovedBasket(networkedRegister.CashRegister, packet.ItemPrefabNames, + packet.ItemAmounts, packet.ItemUnitPrices); + networkedRegister.CompleteApprovedShopPurchase(); + return; + } + + ApplyStockDelta(packet.ItemPrefabNames, packet.ItemAmounts); + } + + private static void ApplyApprovedBasket(CashRegisterWithModules register, string[] prefabNames, + int[] amounts, float[] prices) + { + if (prefabNames == null || amounts == null || prices == null || + prefabNames.Length != amounts.Length || prefabNames.Length != prices.Length) + return; + + 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 && 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, + 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 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 + { + 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) + 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; + } + } + + public static void RecountExistingShopItems(GlobalShopController controller) + { + 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); + 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) + { + 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/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..1500b2a0 --- /dev/null +++ b/Multiplayer/Components/SaveGame/NativeItemPersistence.cs @@ -0,0 +1,156 @@ +using System; +using DV.Customization.Gadgets; +using DV.JObjectExtstensions; +using Multiplayer.Components.Networking.World; +using Multiplayer.Components.Networking; +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 +{ + 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 + { + 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; + } + + // Even a failing native callback must not roll back the current owner. + WriteOwner(save, save.saveData); + 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/Components/SaveGame/NetworkedSaveGameManager.cs b/Multiplayer/Components/SaveGame/NetworkedSaveGameManager.cs index 24b0cee7..875225e2 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); + ShopPurchaseCoordinator.RequestStockRecount(); + } + + 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 +237,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/Multiplayer.csproj b/Multiplayer/Multiplayer.csproj index 38898461..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 @@ -14,6 +14,7 @@ + @@ -25,11 +26,11 @@ - + $(NoWarn);CS0436 - + 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 new file mode 100644 index 00000000..da3c481b --- /dev/null +++ b/Multiplayer/Networking/Data/Customization/CustomizationStateData.cs @@ -0,0 +1,194 @@ +using LiteNetLib.Utils; +using Multiplayer.Networking.Serialization; +using System.Collections.Generic; +using UnityEngine; +using Multiplayer.Networking.Packets.Common.Customization; + +namespace Multiplayer.Networking.Data.Customization; + +public struct CustomizationRefData +{ + public string IdentificationKey; + + public void Serialize(NetDataWriter writer) + { + writer.Put(IdentificationKey ?? string.Empty); + } + + public static CustomizationRefData Deserialize(NetDataReader reader) + { + return new CustomizationRefData + { + IdentificationKey = reader.GetString(), + }; + } +} + +public sealed class GadgetPlacementData +{ + public ushort ItemNetId; + public uint SentTick; + public CustomizationRefData Target; + public Vector3 LocalPosition; + public Quaternion LocalRotation; + public Dictionary TrackedValues = new(); + + public void Serialize(NetDataWriter writer) + { + writer.Put(ItemNetId); + writer.Put(SentTick); + Target.Serialize(writer); + Vector3Serializer.Serialize(writer, LocalPosition); + QuaternionSerializer.Serialize(writer, LocalRotation); + TrackedValueSerializer.SerializeDictionary(writer, TrackedValues); + } + + public static GadgetPlacementData Deserialize(NetDataReader reader) + { + return new GadgetPlacementData + { + ItemNetId = reader.GetUShort(), + SentTick = reader.GetUInt(), + Target = CustomizationRefData.Deserialize(reader), + LocalPosition = Vector3Serializer.Deserialize(reader), + LocalRotation = QuaternionSerializer.Deserialize(reader), + TrackedValues = TrackedValueSerializer.DeserializeDictionary(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 +{ + 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(); + internal List Mounts = new(); + internal List Wires = new(); + internal List MagazineStates = 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(Mounts.Count + Wires.Count + MagazineStates.Count + SnappedItems.Count, + MaxRelationships, "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) + gadget.Serialize(writer); + + WritePackets(writer, Mounts); + WritePackets(writer, Wires); + WritePackets(writer, MagazineStates); + WritePackets(writer, SnappedItems); + + writer.Put(Holes.Count); + foreach (var hole in Holes) + hole.Serialize(writer); + } + + public static CustomizationStateData Deserialize(NetDataReader reader) + { + var result = new CustomizationStateData(); + + 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 = ReadCount(reader, MaxGadgets, nameof(Gadgets)); + for (int i = 0; i < gadgetCount; i++) + result.Gadgets.Add(GadgetPlacementData.Deserialize(reader)); + + ReadPackets(reader, result.Mounts); + ReadPackets(reader, result.Wires); + ReadPackets(reader, result.MagazineStates); + ReadPackets(reader, result.SnappedItems); + ValidateCount(result.Mounts.Count + result.Wires.Count + result.MagazineStates.Count + + result.SnappedItems.Count, MaxRelationships, "relationships"); + + int holeCount = ReadCount(reader, MaxHoles, nameof(Holes)); + for (int i = 0; i < holeCount; i++) + result.Holes.Add(CustomizationHoleData.Deserialize(reader)); + + return result; + } + + private static int ReadCount(NetDataReader reader, int maximum, string field) + { + int count = reader.GetInt(); + ValidateCount(count, maximum, 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) + 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 3992481c..2a1ce934 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 { @@ -18,20 +21,33 @@ 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 bool RestockOnDestroy { 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; } public byte Player { get; set; } + 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; } public bool AttachedFront { get; set; } public Dictionary States { get; set; } + public uint SentTick { get; set; } public void Serialize(NetDataWriter writer) { @@ -41,13 +57,29 @@ public void Serialize(NetDataWriter writer) if (UpdateType == ItemUpdateType.Destroy) return; - 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(RestockOnDestroy); writer.Put(PrefabName); + writer.Put(ContainerIdentity ?? string.Empty); + } + 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); @@ -56,28 +88,44 @@ 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) { 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)) { - 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); } } } @@ -91,13 +139,24 @@ public void Deserialize(NetDataReader reader) if (UpdateType == ItemUpdateType.Destroy) return; - ItemState = (ItemState)reader.GetByte(); + SentTick = reader.GetUInt(); if (UpdateType.HasFlag(ItemUpdateType.Create)) + { + OwnerPlayerId = reader.GetByte(); + BelongsToPlayer = reader.GetBool(); + RestockOnDestroy = reader.GetBool(); PrefabName = reader.GetString(); + ContainerIdentity = 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); @@ -110,79 +169,51 @@ 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) { 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)) { 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 static bool IsCarriedState(ItemState state) => + state is ItemState.InHand or ItemState.InInventory or ItemState.InContainer; - 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/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/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/Data/ServerPlayer.cs b/Multiplayer/Networking/Data/ServerPlayer.cs index f8f9eb57..8f8397b5 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; @@ -40,9 +41,21 @@ 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; } + 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 @@ -96,13 +109,15 @@ 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 StorageBase Storage { get; set; } = new StorageBase(); + public IEnumerable OwnedItems => NetworkedItem.GetAll() + .Where(item => item != null && item.OwnerPlayerId == PlayerId) + .Select(item => item.NetId); 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; @@ -112,6 +127,7 @@ public ServerPlayer(ITransportPeer peer, string username, string originalUsernam Username = username; OriginalUsername = originalUsername; Guid = guid; + SteamId = steamId; CharacterId = characterId; IsVR = isVr; @@ -198,37 +214,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 d62734ba..801ec162 100644 --- a/Multiplayer/Networking/Managers/Client/ClientPlayerManager.cs +++ b/Multiplayer/Networking/Managers/Client/ClientPlayerManager.cs @@ -105,10 +105,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 60a953e7..527706be 100644 --- a/Multiplayer/Networking/Managers/Client/NetworkClient.cs +++ b/Multiplayer/Networking/Managers/Client/NetworkClient.cs @@ -27,6 +27,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; @@ -37,6 +38,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; @@ -66,6 +68,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; } @@ -79,6 +82,16 @@ public class NetworkClient : NetworkManager internal uint trainSetsToSpawn = uint.MaxValue; internal uint trainSetsSpawned = 0; 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; + private uint nextCustomizationActionId = 1; + private uint nextShopRequestId = 1; internal bool noCarTimeout = false; private Queue ClientboundTaskUpdateQueue = new(); @@ -107,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; @@ -126,6 +136,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, @@ -134,7 +145,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"); @@ -150,6 +162,15 @@ 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) { LogDebug(() => $"NetworkClient.Stop() destroying session... Original session is Null: {originalSession == null}"); @@ -189,7 +210,6 @@ protected override void Subscribe() netPacketProcessor.SubscribeReusable(OnClientboundSaveGameDataPacket); netPacketProcessor.SubscribeReusable(OnClientboundRailwayStatePacket); - // General Sync netPacketProcessor.SubscribeNetSerializable(OnClientboundRpcResponsePacket); netPacketProcessor.SubscribeReusable(OnClientboundTickSyncPacket); @@ -198,7 +218,6 @@ protected override void Subscribe() netPacketProcessor.SubscribeReusable(OnCommonChangeJunctionPacket); netPacketProcessor.SubscribeReusable(OnCommonRotateTurntablePacket); - // Player Management netPacketProcessor.SubscribeReusable(OnClientboundPlayerJoinedPacket); netPacketProcessor.SubscribeReusable(OnClientboundPlayerDisconnectPacket); @@ -206,7 +225,6 @@ protected override void Subscribe() netPacketProcessor.SubscribeReusable(OnClientboundPlayerPositionPacket); netPacketProcessor.SubscribeReusable(OnClientboundPlayerPreferencesUpdatePacket); - // Train Sync netPacketProcessor.SubscribeReusable(OnClientboundSpawnTrainSetPacket); netPacketProcessor.SubscribeReusable(OnClientboundDestroyTrainCarPacket); @@ -255,10 +273,15 @@ protected override void Subscribe() // World Sync netPacketProcessor.SubscribeNetSerializable(OnCommonItemChangePacket); + netPacketProcessor.SubscribeNetSerializable(OnClientboundItemReconciliationPacket); + netPacketProcessor.SubscribeNetSerializable(CustomizationSynchronization.ReceiveSnapshot); + netPacketProcessor.SubscribeNetSerializable(CustomizationSynchronization.ReceiveAcknowledgment); + SubscribeCustomizationActions(); netPacketProcessor.SubscribeReusable(OnCommonPitStopInteractionPacket); netPacketProcessor.SubscribeNetSerializable(OnCommonPitStopPlugInteractionPacket); netPacketProcessor.SubscribeReusable(OnClientboundPitStopBulkUpdatePacket); netPacketProcessor.SubscribeReusable(OnCommonCashRegisterWithModulesActionPacket); + netPacketProcessor.SubscribeReusable(OnCommonShopPacket); netPacketProcessor.SubscribeReusable(OnCommonGenericSwitchStatePacket); netPacketProcessor.SubscribeReusable(OnCommonChatPacket); @@ -302,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(); } } @@ -328,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; @@ -340,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; @@ -352,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; @@ -382,17 +425,58 @@ private IEnumerator SyncWorldState() * ReadyForCustomizers */ - //TODO: implement - yield return new WaitForSeconds(0.25f); + CustomizationSynchronization.Reset(forLoading: true); + Log("Requesting customization state"); + SendLoadStateUpdate(PlayerLoadingState.ReadyForCustomizers); + if (displayLoadingInfo != null) displayLoadingInfo.OnLoadingStatusChanged("Syncing customizations", false, ((float)LoadingState / (float)PlayerLoadingState.Complete) * 100); + + yield return CustomizationSynchronization.LoadInitialState(RPC_Timeout); /* * ReadyForItems */ - Log($"Train sets spawned, requesting items"); + Log("Reconciling local items"); SendLoadStateUpdate(PlayerLoadingState.ReadyForItems); + if (displayLoadingInfo != null) displayLoadingInfo.OnLoadingStatusChanged("Syncing items", false, ((float)LoadingState / (float)PlayerLoadingState.Complete) * 100); - yield return new WaitForSeconds(0.25f); + itemReconciliationAttempt = 0; + for (int reconciliationTry = 0; reconciliationTry < 2; reconciliationTry++) + { + 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; + } /* * ReadyForJobs @@ -403,7 +487,6 @@ private IEnumerator SyncWorldState() yield return new WaitForSeconds(0.25f); - /* * ReadyForTiles */ @@ -414,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 @@ -488,6 +571,7 @@ private void OnClientboundLoginResponsePacket(ClientboundLoginResponsePacket pac { Log($"Player accepted"); PlayerId = packet.PlayerId; + MaxPlayers = Math.Max(packet.MaxPlayers, 1); if (!string.IsNullOrEmpty(packet.OverrideUsername)) { @@ -505,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; } @@ -575,7 +642,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 @@ -602,7 +669,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) @@ -625,7 +692,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) @@ -639,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) @@ -684,7 +752,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) @@ -1454,12 +1543,10 @@ private void OnClientboundPitStopBulkUpdatePacket(ClientboundPitStopBulkUpdatePa netPitStop.ProcessBulkUpdate(packet); } - private void OnCommonItemChangePacket(CommonItemChangePacket packet) { //LogDebug(() => $"OnCommonItemChangePacket({packet?.Items?.Count})"); - //LogDebug(() => //{ // string debug = ""; @@ -1489,7 +1576,42 @@ 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) + { + if (NetworkedItemManager.Instance.ApplyItemReconciliation(packet, itemReconciliationAttempt, + out bool succeeded)) + { + itemReconciliationComplete = true; + itemReconciliationSucceeded = succeeded; + Log(succeeded + ? $"Item reconciliation complete ({packet?.Items?.Count ?? 0} items in final batch)" + : $"Item reconciliation attempt {itemReconciliationAttempt + 1} failed"); + } + } + + private void SubscribeCustomizationActions() + { + 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) @@ -1597,6 +1719,29 @@ private void OnCommonCashRegisterWithModulesActionPacket(CommonCashRegisterWithM 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) { if (!NetworkedGenericSwitch.TryGet(packet.NetId, out NetworkedGenericSwitch netSwitch)) @@ -1622,7 +1767,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() { @@ -1651,6 +1795,7 @@ public void SendPlayerPosition(PlayerTrackingData trackingData, PlayerPostureFla SendPacketToServer(new ServerboundPlayerPositionPacket { + Tick = NetworkLifecycle.Instance.SynchronizedTick, TrackingData = trackingData, Posture = posture, IsOnCar = isOnCar, @@ -1729,7 +1874,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(); @@ -2066,6 +2210,18 @@ public void SendItemsChangePacket(List items) DeliveryMethod.ReliableOrdered); } + internal void SendCustomizationAction(T packet) + where T : CustomizationActionPacket, INetSerializable, new() + { + packet.OriginActionId = nextCustomizationActionId++; + if (nextCustomizationActionId == 0) + nextCustomizationActionId = 1; + + CustomizationSynchronization.RecordLocalAction(packet); + + SendNetSerializablePacketToServer(packet, DeliveryMethod.ReliableOrdered); + } + public void SendPaintThemeChange(NetworkedTrainCar netTraincar, TrainCarPaint.Target targetArea, uint themeId) { Log($"Sending paint theme change for {netTraincar.CurrentID}"); @@ -2086,6 +2242,24 @@ public void SendCashRegisterAction(ushort netId, CashRegisterAction action, doub ); } + 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/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/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/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/Networking/Managers/Server/NetworkServer.cs b/Multiplayer/Networking/Managers/Server/NetworkServer.cs index ccc80f36..5f3f98c3 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; @@ -10,6 +11,8 @@ using DV.ThingTypes; using DV.Utils; using DV.WeatherSystem; +using DV.UserManagement; +using DV.UserManagement.Data; using Humanizer; using LiteNetLib; using LiteNetLib.Utils; @@ -20,6 +23,7 @@ using Multiplayer.Components.Networking.Jobs; using Multiplayer.Components.Networking.Train; using Multiplayer.Components.Networking.World; +using Multiplayer.Components.SaveGame; using Multiplayer.ModCompatibility; using Multiplayer.Networking.Data; using Multiplayer.Networking.Data.Items; @@ -34,6 +38,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; @@ -103,6 +108,8 @@ public ITransportPeer SelfPeer public ChatManager ChatManager => _chatManager; private uint lastTick; + private HostCustomizationSynchronization hostCustomizationSynchronization; + private HostCustomizationSynchronization CustomizationSynchronization => hostCustomizationSynchronization ??= new(this); public NetworkServer(IDifficulty difficulty, Settings settings, bool singlePlayer, LobbyServerData serverData) : base(settings) { @@ -150,6 +157,7 @@ public override void Stop() { Log($"Stopping server..."); WorldStreamingInit.LoadingFinished -= OnLoaded; + CustomizationSynchronization.Reset(); if (lobbyServerManager != null) { @@ -193,6 +201,7 @@ protected override void Subscribe() netPacketProcessor.SubscribeNetSerializable(OnCommonPitStopPlugInteractionPacket); netPacketProcessor.SubscribeReusable(OnCommonCashRegisterWithModulesActionPacket); + netPacketProcessor.SubscribeReusable(OnCommonShopPacket); netPacketProcessor.SubscribeReusable(OnCommonGenericSwitchStatePacket); @@ -237,6 +246,8 @@ protected override void Subscribe() // Items netPacketProcessor.SubscribeNetSerializable(OnCommonItemChangePacket); + netPacketProcessor.SubscribeNetSerializable(OnServerboundItemReconciliationPacket); + SubscribeCustomizationActions(); } //allow mods to register their own packets @@ -337,13 +348,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); @@ -360,6 +371,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(); } @@ -398,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); } @@ -413,6 +438,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); } } @@ -1170,6 +1198,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!"); @@ -1253,6 +1295,7 @@ private void OnServerboundClientLoginPacket(ServerboundClientLoginPacket packet, overrideUsername, packet.Username, guid, + packet.SteamId, packet.CharacterId, packet.IsVR ); @@ -1264,6 +1307,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, }; @@ -1298,6 +1342,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; @@ -1356,6 +1401,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: @@ -1381,6 +1431,12 @@ private void OnServerboundLoadStateUpdatePacket(ServerboundLoadStateUpdatePacket break; + case PlayerLoadingState.ReadyForCustomizers: + player.CustomizationSnapshotSent = false; + CoroutineManager.Instance.StartCoroutine(SendInitialCustomizationStateWhenReady(player, peer)); + + break; + case PlayerLoadingState.ReadyForItems: // Send Inventory and world items @@ -1439,6 +1495,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, @@ -1464,6 +1521,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, @@ -1483,6 +1541,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) { @@ -1502,6 +1569,7 @@ private void OnServerboundPlayerPositionPacket(ServerboundPlayerPositionPacket p SendPacketToAll(new ClientboundPlayerPositionPacket { PlayerId = player.PlayerId, + Tick = packet.Tick, TrackingData = packet.TrackingData, Posture = packet.Posture, IsOnCar = packet.IsOnCar, @@ -2231,8 +2299,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}\"))"); @@ -2265,7 +2333,273 @@ private void OnCommonItemChangePacket(CommonItemChangePacket packet, ITransportP //); - //NetworkedItemManager.Instance.ReceiveSnapshots(packet.Items, player); + if (packet?.Items == null) + return; + + 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)) + return; + + if (player.LoadingState != PlayerLoadingState.ReadyForItems) + { + LogWarning($"Ignoring item reconciliation from {player.Username} in loading state {player.LoadingState}"); + 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, 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) + where T : CustomizationActionPacket, INetSerializable, new() => + RelayCustomizationAction(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, ITransportPeer peer) + where T : CustomizationActionPacket, INetSerializable, new() + { + if (packet == null || !TryGetServerPlayer(peer, out var player)) + 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. + var copy = (T)packet.Copy(); + CustomizationSynchronization.Enqueue(copy, player, peer, + (action, origin, echoOrigin) => RelayCustomizationAction((T)action, origin, echoOrigin)); + } + + 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)) + continue; + + 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); + } + } + + 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 + // 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(); + + bool snapshotSent = false; + 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); + // 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).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(); + } + } + + internal void ResynchronizeCustomization(ServerPlayer player, ITransportPeer peer) + { + if (player == null || peer == null || !player.CustomizationSnapshotSent) + return; + + player.CustomizationSnapshotSent = false; + CoroutineManager.Instance.StartCoroutine(SendInitialCustomizationStateWhenReady(player, peer, + restoreLiveRoutingOnFailure: true)); + } + + 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 is ReplaceSpoolPacket or ReplaceDuctTapePacket; + IEnumerable itemIds = createsReplacementLocally + ? packet is ReplaceSpoolPacket replacementSpool ? [replacementSpool.ToolItemNetId] + : [((ReplaceDuctTapePacket)packet).ConsumedItemNetId] + : CustomizationStateManager.GetActionItemIds(packet); + 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. + 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; + player.CustomizationReplacementItems.Add(replacementItem); + } } private void OnCommonCashRegisterWithModulesActionPacket(CommonCashRegisterWithModulesActionPacket packet, ITransportPeer peer) @@ -2286,6 +2620,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/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/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..600ec863 --- /dev/null +++ b/Multiplayer/Networking/Packets/Clientbound/ClientboundItemReconciliationPacket.cs @@ -0,0 +1,66 @@ +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 +{ + 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++) + { + 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(); + Attempt = reader.GetByte(); + int count = reader.GetInt(); + if (count < 0 || count > ItemReconciliationProtocol.MaxItems) + throw new System.InvalidOperationException($"Invalid item reconciliation result count: {count}"); + for (int i = 0; i < count; i++) + { + 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)); + } + } +} + +internal readonly struct ItemReconciliationResult +{ + public readonly int LocalId; + public readonly ushort NetId; + public readonly ItemUpdateData Snapshot; + + public ItemReconciliationResult(int localId, ushort netId, ItemUpdateData snapshot) + { + LocalId = localId; + NetId = netId; + Snapshot = snapshot; + } +} 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/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/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/Common/CommonCashRegisterWithModulesActionPacket.cs b/Multiplayer/Networking/Packets/Common/CommonCashRegisterWithModulesActionPacket.cs index 780a0811..e33e46aa 100644 --- a/Multiplayer/Networking/Packets/Common/CommonCashRegisterWithModulesActionPacket.cs +++ b/Multiplayer/Networking/Packets/Common/CommonCashRegisterWithModulesActionPacket.cs @@ -9,8 +9,7 @@ public enum CashRegisterAction : byte SetFunds, RejectGeneric, RejectFunds, - RejectedNoItems, - Approve + RejectedNoItems } public class CommonCashRegisterWithModulesActionPacket { 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/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..b8258e6d --- /dev/null +++ b/Multiplayer/Networking/Packets/Common/Customization/LoadSpoolPacket.cs @@ -0,0 +1,26 @@ +using LiteNetLib.Utils; + +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; + + 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/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/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/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/Networking/Packets/Serverbound/ServerboundItemReconciliationPacket.cs b/Multiplayer/Networking/Packets/Serverbound/ServerboundItemReconciliationPacket.cs new file mode 100644 index 00000000..74c44f4c --- /dev/null +++ b/Multiplayer/Networking/Packets/Serverbound/ServerboundItemReconciliationPacket.cs @@ -0,0 +1,60 @@ +using LiteNetLib.Utils; +using Multiplayer.Networking.Data.Items; +using System.Collections.Generic; +using Multiplayer.Networking.Packets; + +namespace Multiplayer.Networking.Packets.Serverbound; + +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) + { + writer.Put(item.LocalId); + writer.Put(item.BelongsToPlayer); + item.Snapshot.Serialize(writer); + } + } + + 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}"); + 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/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/SaveGame/ItemSaveDataPatch.cs b/Multiplayer/Patches/SaveGame/ItemSaveDataPatch.cs new file mode 100644 index 00000000..bb2bfb96 --- /dev/null +++ b/Multiplayer/Patches/SaveGame/ItemSaveDataPatch.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Reflection.Emit; +using HarmonyLib; +using Newtonsoft.Json.Linq; +using Multiplayer.Components.SaveGame; + +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) + { + 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}"); + } +} + +// LoadItemData stores the native JObject before invoking listeners. Reapply after +// post-load callbacks too, so initialization cannot replace the saved identity. +[HarmonyPatch(typeof(ItemSaveData))] +internal static class ItemOwnerLoadPatch +{ + [HarmonyPostfix] + [HarmonyPatch(nameof(ItemSaveData.LoadItemData))] + private static void RestoreOwner(ItemSaveData __instance) => + NativeItemPersistence.RestoreOwner(__instance); + + [HarmonyPostfix] + [HarmonyPatch(nameof(ItemSaveData.PostLoadItemData))] + private static void RestoreOwnerAfterInitialization(ItemSaveData __instance) => + NativeItemPersistence.RestoreOwner(__instance); +} 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; + } +} 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/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/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..50a2ce9a 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; @@ -15,7 +16,10 @@ 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)) return true; __instance?.StopAllCoroutines(); @@ -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()); @@ -55,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)) @@ -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.SendShopAction(new CommonShopPacket + { + RegisterNetId = networkedRegister.NetId, + Action = ShopAction.Approved, + 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)) @@ -102,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/CustomizationPatch.cs b/Multiplayer/Patches/World/CustomizationPatch.cs new file mode 100644 index 00000000..3ddbb709 --- /dev/null +++ b/Multiplayer/Patches/World/CustomizationPatch.cs @@ -0,0 +1,116 @@ +using DV.Customization; +using DV.Customization.Gadgets; +using HarmonyLib; +using Multiplayer.Components.Networking.World; +using Multiplayer.Networking.Packets.Common.Customization; +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 PlaceGadgetPacket { ItemNetId = id, + TargetKey = target.IdentificationKey, Position = localPos, Rotation = localRot }); + } + + [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 RemoveGadgetPacket { ItemNetId = __state.ItemNetId, + ReparentToTrainCar = __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 && 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 && 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 && TryTarget(__instance, out string targetKey)) + CustomizationStateManager.SendAction(new RemoveHolePacket + { TargetKey = targetKey, PreviousPosition = __state }); + } + + private static bool TryTarget(Customization customization, out string targetKey) + { + targetKey = null; + if (CustomizationSyncScope.IsApplyingRemote || + !CustomizationRef.TryCreate(customization, out var target)) + return false; + targetKey = target.IdentificationKey; + return true; + } + + 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..ace16e27 --- /dev/null +++ b/Multiplayer/Patches/World/GadgetRelationshipPatch.cs @@ -0,0 +1,257 @@ +using DV.Customization.Gadgets; +using DV.Customization.Gadgets.Implementations; +using DV.CabControls; +using HarmonyLib; +using Multiplayer.Components.Networking.World; +using Multiplayer.Networking.Packets.Common.Customization; +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) CustomizationStateManager.SendAction(new MountGadgetPacket + { MountItemNetId = a, MountedItemNetId = b, MountIndex = 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) 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(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(a, b, unwire: true); } + + [HarmonyPatch(typeof(GadgetSolderingTool), nameof(GadgetSolderingTool.DropEmptySpool)), HarmonyPrefix] + 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 (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.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 DropEmptySpoolPacket + { + ToolItemNetId = __state.ToolItemNetId, + SpoolItemNetId = __state.SpoolItemNetId, + Position = __state.Position, + Rotation = __state.Rotation, + HasWorldTransform = 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 ReplaceSpoolPacket + { + ToolItemNetId = __state.ToolItemNetId, + ReplacementSpoolItemNetId = spentSpool?.NetId ?? 0, + OwnerPlayerId = __state.OldSpool?.OwnerPlayerId ?? 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; + CustomizationStateManager.SendAction(new LoadSpoolPacket + { ToolItemNetId = toolNet.NetId, SpoolItemNetId = spoolNet.NetId, + HasRemainingUnits = true, RemainingUnits = tool.remainingUnits }); + } + + [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(__instance, itemToSnap, unsnap: false); } + + [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(__instance, __state, unsnap: true); } + + 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; + + if (unsnap) + { + CustomizationStateManager.SendAction(new UnsnapItemPacket + { GadgetItemNetId = ownerId, SnappedItemNetId = itemId, SnapPointIndex = index }); + return; + } + + var anchor = item.SnappableItem?.GetAnchor(point.SnapPointType); + CustomizationStateManager.SendAction(new SnapItemPacket + { + GadgetItemNetId = ownerId, + SnappedItemNetId = itemId, + SnapPointIndex = index, + HasAnchorPosition = anchor != null, + AnchorPosition = anchor?.localPosition ?? default, + }); + } + + 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; + 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 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 ushort SpoolItemNetId; + public UnityEngine.Vector3 Position; + public UnityEngine.Quaternion Rotation; + } +} diff --git a/Multiplayer/Patches/World/Items/CustomizationToolPatch.cs b/Multiplayer/Patches/World/Items/CustomizationToolPatch.cs new file mode 100644 index 00000000..87a4952b --- /dev/null +++ b/Multiplayer/Patches/World/Items/CustomizationToolPatch.cs @@ -0,0 +1,89 @@ +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.Customization; + +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.InheritOwner(__state.OldItem.OwnerPlayerId, __state.OldItem.OwnerUniqueId); + replacementItem.MarkAsSynchronized(); + CustomizationStateManager.RegisterPendingDuctTapeReplacement(__state.OldItem.NetId, replacementItem); + CustomizationStateManager.SendAction(new ReplaceDuctTapePacket + { + ConsumedItemNetId = __state.OldItem.NetId, + ReplacementItemNetId = replacementItem.NetId, + OwnerPlayerId = __state.OldItem.OwnerPlayerId, + Position = replacementObject.transform.position - WorldMover.currentMove, + Rotation = replacementObject.transform.rotation, + HasWorldTransform = 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/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; + } } 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(); + } +} diff --git a/Multiplayer/Patches/World/Items/ItemBasePatch.cs b/Multiplayer/Patches/World/Items/ItemBasePatch.cs index 9c899ec4..ab9b424c 100644 --- a/Multiplayer/Patches/World/Items/ItemBasePatch.cs +++ b/Multiplayer/Patches/World/Items/ItemBasePatch.cs @@ -13,7 +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(); + __instance.GetOrAddComponent(); //networkedItem.FinaliseTrackedValues(); return; diff --git a/Multiplayer/Patches/World/ShopPatch.cs b/Multiplayer/Patches/World/ShopPatch.cs new file mode 100644 index 00000000..5b905d77 --- /dev/null +++ b/Multiplayer/Patches/World/ShopPatch.cs @@ -0,0 +1,114 @@ +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 System.Reflection.Emit; +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 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))] + 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; + + NetworkLifecycle.Instance.Server.SendShopAction(new CommonShopPacket + { + Action = ShopAction.StockChanged, + 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; + bool ownershipInjected = false; + + foreach (CodeInstruction instruction in instructions) + { + if (!replaced && instruction.Calls(subtraction)) + { + instruction.operand = closestPlayerDelta; + replaced = true; + } + + yield return instruction; + + if (!ownershipInjected && instruction.operand is MethodInfo called && + called.Name == nameof(Object.Instantiate) && called.IsGenericMethod && + called.GetGenericArguments().Length == 1 && called.GetGenericArguments()[0] == typeof(GameObject)) + { + var parameters = called.GetParameters(); + if (parameters.Length == 3 && parameters[1].ParameterType == typeof(Vector3) && + parameters[2].ParameterType == typeof(Quaternion)) + { + yield return new CodeInstruction(OpCodes.Dup); + yield return CodeInstruction.Call(typeof(ShopPurchaseCoordinator), + nameof(ShopPurchaseCoordinator.AssignPurchasedObject)); + ownershipInjected = true; + } + } + } + + if (!replaced) + Multiplayer.LogError("GlobalShopController.InstantiatePurchasedItems distance patch failed"); + if (!ownershipInjected) + Multiplayer.LogError("GlobalShopController.InstantiatePurchasedItems ownership patch failed"); + } +} diff --git a/Multiplayer/Patches/World/StorageControllerPatch.cs b/Multiplayer/Patches/World/StorageControllerPatch.cs index df1e9813..b076a565 100644 --- a/Multiplayer/Patches/World/StorageControllerPatch.cs +++ b/Multiplayer/Patches/World/StorageControllerPatch.cs @@ -1,82 +1,134 @@ using DV.CabControls; using HarmonyLib; +using Multiplayer.Components.Networking; using Multiplayer.Components.Networking.World; -using Multiplayer.Utils; -using System; -using UnityEngine; +using System.Collections; +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(() => + byte localPlayerId = NetworkLifecycle.Instance.Client?.PlayerId ?? 0; + if (localPlayerId == 0) + return new List(); + + var ownedItems = new List(items.Count); + foreach (ItemBase item in items) { - NetworkedItem.TryGetNetworkedItem(item, out NetworkedItem netItem); - return $"StorageController.AddItemToLostAndFound({item.name}) netId: {netItem?.NetId}\r\n{new System.Diagnostics.StackTrace()}"; - }); + if (item == null || !NetworkedItem.TryGetNetworkedItem(item, out var networkedItem)) + continue; + + if (networkedItem.OwnerPlayerId == localPlayerId) + ownedItems.Add(item); + } + + Multiplayer.LogDebug(() => + $"Lost and found filtered {items.Count} items to {ownedItems.Count} for player {localPlayerId}"); + return ownedItems; } - [HarmonyPatch(nameof(StorageController.RemoveItemFromLostAndFound))] - [HarmonyPrefix] - static void RemoveItemFromLostAndFound(StorageController __instance, ItemBase item) + public static void MarkLocalItemInLostAndFound(ItemBase item) { + if (item == null || !IsMultiplayerSession() || + !NetworkedItem.TryGetNetworkedItem(item, out var networkedItem)) + return; - Multiplayer.LogDebug(() => - { - NetworkedItem.TryGetNetworkedItem(item, out NetworkedItem netItem); - return $"StorageController.RemoveItemFromLostAndFound({item.name}) netId: {netItem?.NetId}\r\n{new System.Diagnostics.StackTrace()}"; - }); + byte localPlayerId = NetworkLifecycle.Instance.Client?.PlayerId ?? 0; + if (localPlayerId != 0 && networkedItem.OwnerPlayerId == localPlayerId) + networkedItem.MarkItemStateDirty(); } - [HarmonyPatch(nameof(StorageController.RequestLostAndFoundItemActivation))] - [HarmonyPrefix] - static void RequestLostAndFoundItemActivation(StorageController __instance) + public static void MarkLocalItemsInLostAndFound(StorageController storage) { + if (storage?.StorageLostAndFound == null) + return; - Multiplayer.LogDebug(() => - { - return $"StorageController.RequestLostAndFoundItemActivation()\r\n{new System.Diagnostics.StackTrace()}"; - }); + foreach (var item in storage.StorageLostAndFound.GetStorageItemList()) + MarkLocalItemInLostAndFound(item); } - [HarmonyPatch(nameof(StorageController.MoveItemsFromWorldToLostAndFound))] - [HarmonyPrefix] - static void MoveItemsFromWorldToLostAndFound(StorageController __instance, bool ignoreItemsWithRespawnParents) + public static IEnumerator MarkLocalItemsAfterActivation(StorageController storage) { + int itemCount = storage?.StorageLostAndFound?.GetStorageItemList()?.Count ?? 0; + for (int i = 0; i <= itemCount; i++) + yield return null; - Multiplayer.LogDebug(() => - { - return $"StorageController.MoveItemsFromWorldToLostAndFound({ignoreItemsWithRespawnParents})\r\n{new System.Diagnostics.StackTrace()}"; - }); + MarkLocalItemsInLostAndFound(storage); } - [HarmonyPatch(nameof(StorageController.ForceSummonAllWorldItemsToLostAndFound))] - [HarmonyPrefix] - static void ForceSummonAllWorldItemsToLostAndFound(StorageController __instance) + private static bool IsMultiplayerSession() { + var lifecycle = NetworkLifecycle.Instance; + if (lifecycle == null || !lifecycle.IsClientRunning) + return false; - Multiplayer.LogDebug(() => - { - return $"StorageController.ForceSummonAllWorldItemsToLostAndFound()\r\n{new System.Diagnostics.StackTrace()}"; - }); + return lifecycle.IsHost() + ? lifecycle.Server?.IsSinglePlayer == false + : lifecycle.Client?.MaxPlayers > 1; } - [HarmonyPatch(nameof(StorageController.RequestItemActivation))] - [HarmonyPrefix] - static void RequestItemActivation(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.RequestItemActivation()\r\n{new System.Diagnostics.StackTrace()}"; - }); + yield return instruction; + if (instruction.Calls(getStorageItems)) + yield return new CodeInstruction(OpCodes.Call, filter); + } } +} + +[HarmonyPatch(typeof(StorageController), nameof(StorageController.MoveItemsFromWorldToLostAndFound))] +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))] +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); } -*/ 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",