diff --git a/S1API.Tests/Deliveries/LoadingDockApiTests.cs b/S1API.Tests/Deliveries/LoadingDockApiTests.cs new file mode 100644 index 00000000..37f99c13 --- /dev/null +++ b/S1API.Tests/Deliveries/LoadingDockApiTests.cs @@ -0,0 +1,196 @@ +using System.Reflection; +using S1API.Deliveries; +using S1API.Items; +using S1API.Property; +using S1API.Vehicles; + +#if IL2CPPMELON +using S1Delivery = Il2CppScheduleOne.Delivery; +#elif MONOMELON +using S1Delivery = ScheduleOne.Delivery; +#endif + +namespace S1API.Tests.Deliveries; + +public sealed class LoadingDockApiTests +{ + [Fact] + public void NativeTransitionPatchPointsExistInTargetRuntime() + { + const BindingFlags flags = + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; + + Assert.NotNull(typeof(S1Delivery.LoadingDock).GetMethod( + "SetOccupant", + flags)); + Assert.NotNull(typeof(S1Delivery.LoadingDock).GetMethod( + nameof(S1Delivery.LoadingDock.SetStaticOccupant), + flags)); + Assert.NotNull(typeof(S1Delivery.LoadingDock).GetMethod( + "set_IsAcceptingItems", + flags)); + } + + [Fact] + public void WrapperSurfaceIsReadOnlyAndManaged() + { + Assert.Empty(typeof(LoadingDock).GetConstructors( + BindingFlags.Public | BindingFlags.Instance)); + Assert.All( + typeof(LoadingDock).GetProperties(BindingFlags.Public | BindingFlags.Instance), + property => Assert.Null(property.SetMethod)); + + Assert.Equal(typeof(string), GetProperty(nameof(LoadingDock.GUID)).PropertyType); + Assert.Equal(typeof(string), GetProperty(nameof(LoadingDock.Name)).PropertyType); + Assert.Equal(typeof(PropertyWrapper), GetProperty(nameof(LoadingDock.Property)).PropertyType); + Assert.Equal( + typeof(IReadOnlyList), + GetProperty(nameof(LoadingDock.InputSlots)).PropertyType); + Assert.Equal( + typeof(IReadOnlyList), + GetProperty(nameof(LoadingDock.OutputSlots)).PropertyType); + Assert.Equal(typeof(LandVehicle), GetProperty(nameof(LoadingDock.DynamicOccupant)).PropertyType); + Assert.Equal(typeof(LandVehicle), GetProperty(nameof(LoadingDock.StaticOccupant)).PropertyType); + + Assert.DoesNotContain( + typeof(LoadingDock).GetMembers(BindingFlags.Public | BindingFlags.Instance), + ExposesNativeDeliveryType); + } + + [Fact] + public void PropertyAndDeliveryExposeLoadingDockNavigation() + { + Assert.Equal( + typeof(IReadOnlyList), + typeof(PropertyWrapper).GetProperty(nameof(PropertyWrapper.LoadingDocks))!.PropertyType); + Assert.Equal( + typeof(LoadingDock), + typeof(Delivery).GetProperty(nameof(Delivery.LoadingDock))!.PropertyType); + } + + [Fact] + public void EventsExposeManagedPreviousAndCurrentValues() + { + Assert.Equal( + typeof(Action), + GetEvent(nameof(LoadingDock.DynamicOccupantChanged)).EventHandlerType); + Assert.Equal( + typeof(Action), + GetEvent(nameof(LoadingDock.StaticOccupantChanged)).EventHandlerType); + Assert.Equal( + typeof(Action), + GetEvent(nameof(LoadingDock.AcceptingItemsChanged)).EventHandlerType); + } + + [Fact] + public void ManagedNotificationsSuppressNoOpsAndIsolateSubscribers() + { + var nativeDock = TestObjectFactory.CreateUninitialized(); + var dock = new LoadingDock(nativeDock); + var previous = TestObjectFactory.CreateUninitialized(); + var current = TestObjectFactory.CreateUninitialized(); + int dynamicCalls = 0; + int staticCalls = 0; + int acceptingCalls = 0; + + dock.DynamicOccupantChanged += (_, _) => throw new InvalidOperationException("expected"); + dock.DynamicOccupantChanged += (observedPrevious, observedCurrent) => + { + Assert.Same(previous, observedPrevious); + Assert.Same(current, observedCurrent); + dynamicCalls++; + }; + dock.StaticOccupantChanged += (_, _) => staticCalls++; + dock.AcceptingItemsChanged += (observedPrevious, observedCurrent) => + { + Assert.False(observedPrevious); + Assert.True(observedCurrent); + acceptingCalls++; + }; + + dock.NotifyDynamicOccupantChanged(previous, previous); + dock.NotifyDynamicOccupantChanged(previous, current); + dock.NotifyStaticOccupantChanged(current, current); + dock.NotifyStaticOccupantChanged(previous, current); + dock.NotifyAcceptingItemsChanged(false, false); + dock.NotifyAcceptingItemsChanged(false, true); + + Assert.Equal(1, dynamicCalls); + Assert.Equal(1, staticCalls); + Assert.Equal(1, acceptingCalls); + } + + private static PropertyInfo GetProperty(string name) => + typeof(LoadingDock).GetProperty(name, BindingFlags.Public | BindingFlags.Instance)!; + + private static EventInfo GetEvent(string name) => + typeof(LoadingDock).GetEvent(name, BindingFlags.Public | BindingFlags.Instance)!; + + private static bool ExposesNativeDeliveryType(MemberInfo member) + { + IEnumerable types = member switch + { + PropertyInfo property => new[] { property.PropertyType }, + EventInfo eventInfo when eventInfo.EventHandlerType != null => + new[] { eventInfo.EventHandlerType }, + MethodInfo method => new[] { method.ReturnType } + .Concat(method.GetParameters().Select(parameter => parameter.ParameterType)), + _ => Array.Empty() + }; + + return types + .SelectMany(ExpandType) + .Any(type => type.Namespace?.Contains( + "ScheduleOne.Delivery", + StringComparison.Ordinal) == true); + } + + private static IEnumerable ExpandType(Type root) + { + yield return root; + + if (root.HasElementType && root.GetElementType() is Type elementType) + { + foreach (Type nested in ExpandType(elementType)) + yield return nested; + } + + foreach (Type argument in root.GetGenericArguments()) + { + foreach (Type nested in ExpandType(argument)) + yield return nested; + } + } +} + +internal static class LoadingDockApiCompileFixture +{ + internal static void Observe( + PropertyWrapper property, + Delivery delivery, + LoadingDock dock) + { + IReadOnlyList propertyDocks = property.LoadingDocks; + LoadingDock? selectedDock = delivery.LoadingDock; + IReadOnlyList inputSlots = dock.InputSlots; + IReadOnlyList outputSlots = dock.OutputSlots; + LandVehicle? dynamicOccupant = dock.DynamicOccupant; + LandVehicle? staticOccupant = dock.StaticOccupant; + + Action vehicleHandler = (_, _) => { }; + Action acceptingHandler = (_, _) => { }; + dock.DynamicOccupantChanged += vehicleHandler; + dock.StaticOccupantChanged += vehicleHandler; + dock.AcceptingItemsChanged += acceptingHandler; + dock.DynamicOccupantChanged -= vehicleHandler; + dock.StaticOccupantChanged -= vehicleHandler; + dock.AcceptingItemsChanged -= acceptingHandler; + + _ = propertyDocks; + _ = selectedDock; + _ = inputSlots; + _ = outputSlots; + _ = dynamicOccupant; + _ = staticOccupant; + } +} diff --git a/S1API/Deliveries/Delivery.cs b/S1API/Deliveries/Delivery.cs index d63d5a5a..49ff771e 100644 --- a/S1API/Deliveries/Delivery.cs +++ b/S1API/Deliveries/Delivery.cs @@ -51,6 +51,27 @@ internal Delivery(S1Delivery.DeliveryInstance delivery) /// public int LoadingDockIndex => NativeDelivery.LoadingDockIndex; + /// + /// Gets the selected destination loading dock, or while it is unavailable. + /// + public LoadingDock? LoadingDock + { + get + { + try + { + var loadingDock = NativeDelivery.LoadingDock; + return loadingDock == null + ? null + : global::S1API.Deliveries.LoadingDock.Wrap(loadingDock); + } + catch + { + return null; + } + } + } + /// /// Gets the wrapped destination property, or while it is unavailable. /// diff --git a/S1API/Deliveries/LoadingDock.cs b/S1API/Deliveries/LoadingDock.cs new file mode 100644 index 00000000..f3d5ffd0 --- /dev/null +++ b/S1API/Deliveries/LoadingDock.cs @@ -0,0 +1,245 @@ +#if IL2CPPMELON +using S1Delivery = Il2CppScheduleOne.Delivery; +using S1ItemSlotList = Il2CppSystem.Collections.Generic.List; +#elif MONOMELON +using S1Delivery = ScheduleOne.Delivery; +using S1ItemSlotList = System.Collections.Generic.List; +#endif + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using S1API.Items; +using S1API.Lifecycle; +using S1API.Logging; +using S1API.Property; +using S1API.Vehicles; + +namespace S1API.Deliveries +{ + /// + /// Provides read-only access to a property's native loading dock. + /// + /// + /// Scalar properties reflect the live dock state. Slot collections are immutable + /// snapshots containing live wrappers. Instances are + /// cached for the loaded scene so property and delivery lookups share event subscriptions. + /// Resolve the dock again after a scene or save transition. Events report state observed + /// by the local peer and do not add network replication. + /// + public sealed class LoadingDock + { + private static readonly Log Logger = new Log("LoadingDock"); + private static readonly Dictionary Cache = new Dictionary(); + private static readonly IReadOnlyList EmptySlots = + new ReadOnlyCollection(Array.Empty()); + private static bool _lifecycleHooked; + + internal LoadingDock(S1Delivery.LoadingDock loadingDock) + { + S1LoadingDock = loadingDock ?? throw new ArgumentNullException(nameof(loadingDock)); + } + + /// + /// INTERNAL: Gets the native loading dock represented by this wrapper. + /// + internal S1Delivery.LoadingDock S1LoadingDock { get; } + + /// + /// Gets the stable GUID assigned to this loading dock. + /// + public string GUID => S1LoadingDock.GUID.ToString(); + + /// + /// Gets the display name assigned by the owning property. + /// + public string Name + { + get + { + try + { + return S1LoadingDock.Name ?? string.Empty; + } + catch + { + return S1LoadingDock.gameObject?.name ?? string.Empty; + } + } + } + + /// + /// Gets the owning property, or while it is unavailable. + /// + public PropertyWrapper? Property + { + get + { + try + { + var property = S1LoadingDock.ParentProperty; + return property == null ? null : new PropertyWrapper(property); + } + catch + { + return null; + } + } + } + + /// + /// Gets an immutable snapshot of the dock's input slots. + /// + /// + /// The collection cannot be modified, but each contained slot retains the behavior + /// of the existing API. + /// + public IReadOnlyList InputSlots => + SnapshotSlots(S1LoadingDock.InputSlots); + + /// + /// Gets an immutable snapshot of the dock's output slots. + /// + /// + /// The collection cannot be modified, but each contained slot retains the behavior + /// of the existing API. + /// + public IReadOnlyList OutputSlots => + SnapshotSlots(S1LoadingDock.OutputSlots); + + /// + /// Gets whether the dock currently accepts incoming transit items. + /// + public bool IsAcceptingItems => S1LoadingDock.IsAcceptingItems; + + /// + /// Gets whether the dock has been removed from transit routing. + /// + public bool IsDestroyed => S1LoadingDock.IsDestroyed; + + /// + /// Gets whether a dynamic or static vehicle currently occupies the dock. + /// + public bool IsInUse => S1LoadingDock.IsInUse; + + /// + /// Gets the vehicle detected in the dock, or when none is present. + /// + public LandVehicle? DynamicOccupant => + VehicleRegistry.Wrap(S1LoadingDock.DynamicOccupant); + + /// + /// Gets the delivery vehicle assigned to the dock, or when none is assigned. + /// + public LandVehicle? StaticOccupant => + VehicleRegistry.Wrap(S1LoadingDock.StaticOccupant); + + /// + /// Raised after the dynamically detected vehicle changes. + /// + public event Action? DynamicOccupantChanged; + + /// + /// Raised after the assigned delivery vehicle changes. + /// + public event Action? StaticOccupantChanged; + + /// + /// Raised after the accepting-items state changes. + /// + public event Action? AcceptingItemsChanged; + + internal static LoadingDock Wrap(S1Delivery.LoadingDock native) + { + EnsureLifecycleHook(); + int key = native.GetInstanceID(); + if (!Cache.TryGetValue(key, out LoadingDock? loadingDock) + || loadingDock.S1LoadingDock != native) + { + loadingDock = new LoadingDock(native); + Cache[key] = loadingDock; + } + + return loadingDock; + } + + internal void NotifyDynamicOccupantChanged(LandVehicle? previous, LandVehicle? current) + { + if (ReferenceEquals(previous, current)) + return; + + Invoke(DynamicOccupantChanged, previous, current, nameof(DynamicOccupantChanged)); + } + + internal void NotifyStaticOccupantChanged(LandVehicle? previous, LandVehicle? current) + { + if (ReferenceEquals(previous, current)) + return; + + Invoke(StaticOccupantChanged, previous, current, nameof(StaticOccupantChanged)); + } + + internal void NotifyAcceptingItemsChanged(bool previous, bool current) + { + if (previous == current) + return; + + Invoke(AcceptingItemsChanged, previous, current, nameof(AcceptingItemsChanged)); + } + + private static IReadOnlyList SnapshotSlots(S1ItemSlotList? nativeSlots) + { + if (nativeSlots == null || nativeSlots.Count == 0) + return EmptySlots; + + var slots = new List(nativeSlots.Count); + for (int i = 0; i < nativeSlots.Count; i++) + { + if (nativeSlots[i] != null) + slots.Add(new ItemSlotInstance(nativeSlots[i])); + } + + return slots.Count == 0 + ? EmptySlots + : new ReadOnlyCollection(slots); + } + + private static void Invoke( + Action? handlers, + T previous, + T current, + string eventName) + { + if (handlers == null) + return; + + foreach (Action handler in handlers.GetInvocationList()) + { + try + { + handler(previous, current); + } + catch (Exception ex) + { + try + { + Logger.Warning($"A {eventName} subscriber failed: {ex.Message}"); + } + catch + { + // Logging must not prevent the remaining subscribers from running. + } + } + } + } + + private static void EnsureLifecycleHook() + { + if (_lifecycleHooked) + return; + + GameLifecycle.OnPreSceneChange += Cache.Clear; + _lifecycleHooked = true; + } + } +} diff --git a/S1API/Internal/Deliveries/LoadingDockEventBridge.cs b/S1API/Internal/Deliveries/LoadingDockEventBridge.cs new file mode 100644 index 00000000..59102655 --- /dev/null +++ b/S1API/Internal/Deliveries/LoadingDockEventBridge.cs @@ -0,0 +1,56 @@ +#if IL2CPPMELON +using S1Delivery = Il2CppScheduleOne.Delivery; +using S1Vehicles = Il2CppScheduleOne.Vehicles; +#elif MONOMELON +using S1Delivery = ScheduleOne.Delivery; +using S1Vehicles = ScheduleOne.Vehicles; +#endif + +using S1API.Deliveries; +using S1API.Vehicles; + +namespace S1API.Internal.Deliveries +{ + /// + /// Converts native loading-dock state transitions into managed wrapper events. + /// + internal static class LoadingDockEventBridge + { + internal static void NotifyDynamicOccupantChanged( + S1Delivery.LoadingDock native, + S1Vehicles.LandVehicle? previous, + S1Vehicles.LandVehicle? current) + { + if (previous == current) + return; + + LoadingDock.Wrap(native).NotifyDynamicOccupantChanged( + VehicleRegistry.Wrap(previous), + VehicleRegistry.Wrap(current)); + } + + internal static void NotifyStaticOccupantChanged( + S1Delivery.LoadingDock native, + S1Vehicles.LandVehicle? previous, + S1Vehicles.LandVehicle? current) + { + if (previous == current) + return; + + LoadingDock.Wrap(native).NotifyStaticOccupantChanged( + VehicleRegistry.Wrap(previous), + VehicleRegistry.Wrap(current)); + } + + internal static void NotifyAcceptingItemsChanged( + S1Delivery.LoadingDock native, + bool previous, + bool current) + { + if (previous == current) + return; + + LoadingDock.Wrap(native).NotifyAcceptingItemsChanged(previous, current); + } + } +} diff --git a/S1API/Internal/Patches/LoadingDockPatches.cs b/S1API/Internal/Patches/LoadingDockPatches.cs new file mode 100644 index 00000000..4dad3e69 --- /dev/null +++ b/S1API/Internal/Patches/LoadingDockPatches.cs @@ -0,0 +1,83 @@ +#if IL2CPPMELON +using S1Delivery = Il2CppScheduleOne.Delivery; +using S1Vehicles = Il2CppScheduleOne.Vehicles; +#elif MONOMELON +using S1Delivery = ScheduleOne.Delivery; +using S1Vehicles = ScheduleOne.Vehicles; +#endif + +using HarmonyLib; +using S1API.Internal.Deliveries; + +namespace S1API.Internal.Patches +{ + /// + /// Observes native loading-dock transitions without replacing their behavior. + /// + [HarmonyPatch(typeof(S1Delivery.LoadingDock))] + internal static class LoadingDockPatches + { + [HarmonyPatch("SetOccupant")] + [HarmonyPrefix] + private static void SetOccupantPrefix( + S1Delivery.LoadingDock __instance, + out S1Vehicles.LandVehicle? __state) + { + __state = __instance.DynamicOccupant; + } + + [HarmonyPatch("SetOccupant")] + [HarmonyPostfix] + private static void SetOccupantPostfix( + S1Delivery.LoadingDock __instance, + S1Vehicles.LandVehicle? __state) + { + LoadingDockEventBridge.NotifyDynamicOccupantChanged( + __instance, + __state, + __instance.DynamicOccupant); + } + + [HarmonyPatch(nameof(S1Delivery.LoadingDock.SetStaticOccupant))] + [HarmonyPrefix] + private static void SetStaticOccupantPrefix( + S1Delivery.LoadingDock __instance, + out S1Vehicles.LandVehicle? __state) + { + __state = __instance.StaticOccupant; + } + + [HarmonyPatch(nameof(S1Delivery.LoadingDock.SetStaticOccupant))] + [HarmonyPostfix] + private static void SetStaticOccupantPostfix( + S1Delivery.LoadingDock __instance, + S1Vehicles.LandVehicle? __state) + { + LoadingDockEventBridge.NotifyStaticOccupantChanged( + __instance, + __state, + __instance.StaticOccupant); + } + + [HarmonyPatch(nameof(S1Delivery.LoadingDock.IsAcceptingItems), MethodType.Setter)] + [HarmonyPrefix] + private static void SetAcceptingItemsPrefix( + S1Delivery.LoadingDock __instance, + out bool __state) + { + __state = __instance.IsAcceptingItems; + } + + [HarmonyPatch(nameof(S1Delivery.LoadingDock.IsAcceptingItems), MethodType.Setter)] + [HarmonyPostfix] + private static void SetAcceptingItemsPostfix( + S1Delivery.LoadingDock __instance, + bool __state) + { + LoadingDockEventBridge.NotifyAcceptingItemsChanged( + __instance, + __state, + __instance.IsAcceptingItems); + } + } +} diff --git a/S1API/Property/PropertyWrapper.cs b/S1API/Property/PropertyWrapper.cs index 41a35a68..e1ee8278 100644 --- a/S1API/Property/PropertyWrapper.cs +++ b/S1API/Property/PropertyWrapper.cs @@ -1,6 +1,8 @@ using System.Collections; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Reflection; +using S1API.Deliveries; using S1API.Logging; using UnityEngine; using S1API.Internal.Utils; @@ -20,6 +22,8 @@ namespace S1API.Property public class PropertyWrapper : BaseProperty { private static readonly Log Logger = new Log("PropertyWrapper"); + private static readonly IReadOnlyList EmptyLoadingDocks = + new ReadOnlyCollection(System.Array.Empty()); /// /// A readonly backing field encapsulating the core property instance @@ -140,6 +144,28 @@ public override bool IsPointInside(Vector3 point) public int LoadingDockCount => InnerProperty.LoadingDockCount; + /// + /// Gets an immutable snapshot of the loading docks assigned to this property. + /// + public IReadOnlyList LoadingDocks + { + get + { + var nativeDocks = InnerProperty.LoadingDocks; + if (nativeDocks == null || nativeDocks.Length == 0) + return EmptyLoadingDocks; + + var docks = new List(nativeDocks.Length); + for (int i = 0; i < nativeDocks.Length; i++) + { + if (nativeDocks[i] != null) + docks.Add(LoadingDock.Wrap(nativeDocks[i])); + } + + return new ReadOnlyCollection(docks); + } + } + /// /// Gets the default rotation value for the property. /// diff --git a/S1API/Vehicles/VehicleRegistry.cs b/S1API/Vehicles/VehicleRegistry.cs index 6b8114f2..2f0576dd 100644 --- a/S1API/Vehicles/VehicleRegistry.cs +++ b/S1API/Vehicles/VehicleRegistry.cs @@ -205,7 +205,7 @@ public static void RemoveVehicle(string guidString) { _cache.Remove(gameVehicle); } - private static LandVehicle? Wrap(S1Vehicles.LandVehicle? veh) + internal static LandVehicle? Wrap(S1Vehicles.LandVehicle? veh) { if (veh == null) return null; diff --git a/S1API/docs/delivery-system.md b/S1API/docs/delivery-system.md index fe6090cf..6d55b4a8 100644 --- a/S1API/docs/delivery-system.md +++ b/S1API/docs/delivery-system.md @@ -1,6 +1,6 @@ # Deliveries -`S1API.Deliveries` provides read-only, cross-runtime wrappers around supplier deliveries and their order-history receipts. It is intended for mods that need to observe delivery state without depending on Schedule I's native delivery, vehicle, UI, or networking types. +`S1API.Deliveries` provides read-only, cross-runtime wrappers around supplier deliveries, property loading docks, and order-history receipts. It is intended for mods that need to observe delivery state without depending on Schedule I's native delivery, vehicle, UI, or networking types. This API describes property/shop deliveries managed by the game's delivery app. Supplier dead-drop orders are a separate flow represented by `NPCSupplier.Status`, `MinutesUntilDeadDropReady`, and `OnDeadDropReady`. Customer drop-off points are another separate system documented in [Delivery Location Registry](delivery-location-registry.md). @@ -57,7 +57,8 @@ Registry results reflect the local peer's loaded state. Query after the world ha The delivery surface is deliberately split by concern: -- `Delivery`: A read-only live view of an active supplier delivery. It exposes `Id`, `StoreName`, `DestinationCode`, the wrapped `Destination`, `LoadingDockIndex`, `Status`, `MinutesUntilArrival`, `Items`, `Shop`, and `ActiveVehicle`. `Shop` or `Destination` may be unavailable during world setup, while `ActiveVehicle` is normally unavailable until arrival. +- `Delivery`: A read-only live view of an active supplier delivery. It exposes `Id`, `StoreName`, `DestinationCode`, the wrapped `Destination` and `LoadingDock`, `LoadingDockIndex`, `Status`, `MinutesUntilArrival`, `Items`, `Shop`, and `ActiveVehicle`. `Shop`, `Destination`, or `LoadingDock` may be unavailable during world setup, while `ActiveVehicle` is normally unavailable until arrival. +- `LoadingDock`: A read-only live view of one property dock, including stable identity, transit slots, accepting/destroyed state, and its dynamic and delivery-vehicle occupants. - `DeliveryItem`: An immutable `ItemId` and `Quantity` entry from an order. - `DeliveryReceipt`: An immutable delivery order-details snapshot with the delivery identity, destination, loading dock, and ordered items. `GetHistory()` returns receipts that were recorded in order history. - `DeliveryStatus`: The public delivery lifecycle state. @@ -68,6 +69,36 @@ The delivery surface is deliberately split by concern: The wrappers do not expose Schedule I's native `DeliveryInstance`, delivery shop, or delivery-vehicle component. Native delivery creation and lifecycle transitions remain hidden so mods cannot bypass server authority. Related `Shop`, `PropertyWrapper`, and `LandVehicle` objects use their existing S1API wrappers and retain the capabilities documented by those APIs. +## Loading Docks + +Enumerate all docks owned by a property through `PropertyWrapper.LoadingDocks`, or follow the selected dock directly from an active delivery: + +```csharp +using S1API.Deliveries; + +var deliveries = DeliveryRegistry.GetAll(); +Delivery? delivery = deliveries.Count == 0 ? null : deliveries[0]; +LoadingDock? dock = delivery?.LoadingDock; +if (dock is not null) +{ + MelonLoader.MelonLogger.Msg( + $"{dock.Name} ({dock.GUID}): {dock.OutputSlots.Count} output slots"); + + dock.DynamicOccupantChanged += (previous, current) => + MelonLoader.MelonLogger.Msg( + $"Detected vehicle: {previous?.GUID ?? "none"} -> {current?.GUID ?? "none"}"); + dock.StaticOccupantChanged += (previous, current) => + MelonLoader.MelonLogger.Msg( + $"Delivery vehicle: {previous?.GUID ?? "none"} -> {current?.GUID ?? "none"}"); + dock.AcceptingItemsChanged += (previous, current) => + MelonLoader.MelonLogger.Msg($"Accepting items: {previous} -> {current}"); +} +``` + +`InputSlots` and `OutputSlots` are immutable collection snapshots containing live `ItemSlotInstance` wrappers. `DynamicOccupant` represents a nearby stopped vehicle detected by the dock. `StaticOccupant` represents the delivery vehicle assigned during supplier-delivery arrival. `IsInUse` is true when either occupant exists. + +Dock wrappers are cached for the loaded scene, so a dock reached through a property and an active delivery shares event subscriptions. Resolve wrappers again after a scene or save transition. Events report native state observed by the local peer and do not add a new replication channel. + ## Lifecycle Events `DeliveryRegistry` exposes observation-only lifecycle events: @@ -108,6 +139,8 @@ The initial public API therefore supports observation and lookup only. It does n - construct an arbitrary delivery; - force a delivery status; - assign a native delivery vehicle; +- create, destroy, or mutate a loading dock or its GUID; +- force dock occupancy, accepting state, transit routing, or outline UI; - invoke native delivery UI or network RPCs. Use supplier configuration to define what a custom supplier sells, then use `NPCSupplier` and `DeliveryRegistry` to observe the resulting orders. @@ -118,3 +151,4 @@ Use supplier configuration to define what a custom supplier sells, then use `NPC - [Delivery Location Registry](delivery-location-registry.md) - - +-