Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions RustPlusBot.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<Project Path="src/RustPlusBot.Features.Chat/RustPlusBot.Features.Chat.csproj" />
<Project Path="src/RustPlusBot.Features.Events/RustPlusBot.Features.Events.csproj" />
<Project Path="src/RustPlusBot.Features.Players/RustPlusBot.Features.Players.csproj" />
<Project Path="src/RustPlusBot.Features.StorageMonitors/RustPlusBot.Features.StorageMonitors.csproj" />
<Project Path="src/RustPlusBot.Features.Switches/RustPlusBot.Features.Switches.csproj" />
<Project Path="src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj" />
<Project Path="src/RustPlusBot.Localization/RustPlusBot.Localization.csproj" />
Expand All @@ -24,6 +25,7 @@
<Project Path="tests/RustPlusBot.Features.Map.Tests/RustPlusBot.Features.Map.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Players.Tests/RustPlusBot.Features.Players.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.StorageMonitors.Tests/RustPlusBot.Features.StorageMonitors.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Switches.Tests/RustPlusBot.Features.Switches.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Alarms.Tests/RustPlusBot.Features.Alarms.Tests.csproj" />
Expand Down
12 changes: 12 additions & 0 deletions src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,18 @@ Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(
ulong entityId,
CancellationToken cancellationToken);

/// <summary>Reads a storage monitor's contents for a (guild, server), or null when there is no live socket.</summary>
/// <param name="guildId">The guild snowflake.</param>
/// <param name="serverId">The server id.</param>
/// <param name="entityId">The storage-monitor entity id.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The contents snapshot, or null when unreachable.</returns>
Task<StorageContentsSnapshot?> GetStorageContentsAsync(
ulong guildId,
Guid serverId,
ulong entityId,
CancellationToken cancellationToken);

/// <summary>Sets a smart switch on/off; returns false when there is no live socket or the call fails.</summary>
/// <param name="guildId">The owning guild snowflake.</param>
/// <param name="serverId">The target server id.</param>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace RustPlusBot.Abstractions.Connections;

/// <summary>A point-in-time read of a storage monitor's contents and protection state.</summary>
/// <param name="Capacity">Total slot count (24 = Tool Cupboard, 48 = large box, 12 = small box), or null if unknown.</param>
/// <param name="HasProtection">For a Tool Cupboard: whether decay protection is active. Null for non-TC monitors.</param>
/// <param name="ProtectionExpiry">When decay protection expires (UTC). Only meaningful when <paramref name="HasProtection"/> is true.</param>
/// <param name="Items">The stacks currently inside the monitor.</param>
public sealed record StorageContentsSnapshot(
int? Capacity,
bool? HasProtection,
DateTimeOffset? ProtectionExpiry,
IReadOnlyList<StorageItemSnapshot> Items);
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace RustPlusBot.Abstractions.Connections;

/// <summary>One stack inside a storage monitor: the item id, its quantity, and whether it is a blueprint.</summary>
/// <param name="ItemId">The Rust item id (resolve to a display name via the item-name lookup).</param>
/// <param name="Quantity">The stack quantity.</param>
/// <param name="IsBlueprint">True when the stack is a blueprint rather than the item itself.</param>
public sealed record StorageItemSnapshot(int ItemId, int Quantity, bool IsBlueprint);
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace RustPlusBot.Abstractions.Events;

/// <summary>A storage monitor was paired in-game via FCM; the feature offers an "Add it?" prompt.</summary>
/// <param name="GuildId">The owning Discord guild snowflake.</param>
/// <param name="ServerId">The local Rust server id.</param>
/// <param name="EntityId">The in-game storage-monitor entity id.</param>
public sealed record StorageMonitorPairedEvent(ulong GuildId, Guid ServerId, ulong EntityId);
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using RustPlusBot.Abstractions.Connections;

namespace RustPlusBot.Abstractions.Events;

/// <summary>A managed storage monitor's contents were (re)read — on connect-prime or on an in-game change.</summary>
/// <param name="GuildId">The owning Discord guild snowflake.</param>
/// <param name="ServerId">The local Rust server id.</param>
/// <param name="EntityId">The in-game entity id (the discriminant — the feature filters to ids it manages).</param>
/// <param name="Contents">The contents snapshot carried on the read/broadcast.</param>
public sealed record StorageMonitorTriggeredEvent(
ulong GuildId,
Guid ServerId,
ulong EntityId,
StorageContentsSnapshot Contents);
29 changes: 29 additions & 0 deletions src/RustPlusBot.Domain/StorageMonitors/SmartStorageMonitor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
namespace RustPlusBot.Domain.StorageMonitors;

/// <summary>A paired Smart Storage Monitor the bot manages, surviving restarts. Guild- and server-scoped.</summary>
public sealed class SmartStorageMonitor
{
/// <summary>Surrogate primary key.</summary>
public Guid Id { get; set; } = Guid.NewGuid();

/// <summary>The owning Discord guild snowflake.</summary>
public ulong GuildId { get; set; }

/// <summary>The server this monitor belongs to (FK to RustServer, cascade delete).</summary>
public Guid ServerId { get; set; }

/// <summary>The in-game storage-monitor entity id.</summary>
public ulong EntityId { get; set; }

/// <summary>User-facing label; defaults to a generated "Storage Monitor &lt;EntityId&gt;" (the FCM event carries no name).</summary>
public string Name { get; set; } = string.Empty;

/// <summary>The Discord message id of this monitor's embed, or null until first posted.</summary>
public ulong? MessageId { get; set; }

/// <summary>The Discord user who accepted (validated) the pairing.</summary>
public ulong PairedByUserId { get; set; }

/// <summary>When the monitor was accepted (UTC).</summary>
public DateTimeOffset CreatedUtc { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ internal interface IRustServerConnection : IAsyncDisposable
/// <returns>True/false for on/off, or null on failure/timeout.</returns>
Task<bool?> GetSmartDeviceInfoAsync(ulong entityId, TimeSpan timeout, CancellationToken cancellationToken);

/// <summary>Reads a storage monitor's contents, or null on failure/timeout. Also primes the socket's interest so triggers fire for it thereafter.</summary>
/// <param name="entityId">The in-game storage-monitor entity id.</param>
/// <param name="timeout">How long to wait for the response.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The contents snapshot, or null on failure/timeout.</returns>
Task<StorageContentsSnapshot?> GetStorageMonitorInfoAsync(ulong entityId,
TimeSpan timeout,
CancellationToken cancellationToken);

/// <summary>Sets a smart switch on/off; returns true on success, false on failure/timeout.</summary>
/// <param name="entityId">The in-game smart-switch entity id.</param>
/// <param name="value">True to turn on, false to turn off.</param>
Expand Down Expand Up @@ -111,4 +120,7 @@ Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(TimeSpan timeout,

/// <summary>Raised when a managed smart device's state changes in-game; carries the entity id and new state.</summary>
event EventHandler<SmartDeviceTrigger>? SmartDeviceTriggered;

/// <summary>Raised when a managed storage monitor's contents change in-game; carries the entity id and the new contents.</summary>
event EventHandler<StorageMonitorTrigger>? StorageMonitorTriggered;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Globalization;
using System.Linq;
using Microsoft.Extensions.Logging;
using RustPlusApi;
using RustPlusBot.Abstractions.Connections;
Expand Down Expand Up @@ -54,6 +55,11 @@ public Task<bool> PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, Cancella
CancellationToken cancellationToken) =>
Task.FromResult<bool?>(null);

public Task<StorageContentsSnapshot?> GetStorageMonitorInfoAsync(ulong entityId,
TimeSpan timeout,
CancellationToken cancellationToken) =>
Task.FromResult<StorageContentsSnapshot?>(null);

public Task<bool> SetSmartSwitchValueAsync(ulong entityId,
bool value,
TimeSpan timeout,
Expand Down Expand Up @@ -94,6 +100,12 @@ public event EventHandler<SmartDeviceTrigger>? SmartDeviceTriggered
remove { _ = value; }
}

public event EventHandler<StorageMonitorTrigger>? StorageMonitorTriggered
{
add { _ = value; }
remove { _ = value; }
}

public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}

Expand All @@ -118,6 +130,7 @@ public RustPlusServerConnection(string ip, int port, ulong steamId, int playerTo
_rustPlus = new RustPlus(connection);
_rustPlus.OnTeamChatReceived += OnTeamChatReceived;
_rustPlus.OnSmartDeviceTriggered += OnSmartDeviceTriggered;
_rustPlus.OnStorageMonitorTriggered += OnStorageMonitorTriggered;
}

public async Task<SocketConnectOutcome> ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken)
Expand Down Expand Up @@ -348,6 +361,8 @@ public async Task<bool> PromoteToLeaderAsync(ulong steamId,

public event EventHandler<SmartDeviceTrigger>? SmartDeviceTriggered;

public event EventHandler<StorageMonitorTrigger>? StorageMonitorTriggered;

public async Task<bool?> GetSmartDeviceInfoAsync(ulong entityId,
TimeSpan timeout,
CancellationToken cancellationToken)
Expand Down Expand Up @@ -377,6 +392,35 @@ public async Task<bool> PromoteToLeaderAsync(ulong steamId,
}
}

/// <inheritdoc />
public async Task<StorageContentsSnapshot?> GetStorageMonitorInfoAsync(
ulong entityId,
TimeSpan timeout,
CancellationToken cancellationToken)
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(timeout);
try
{
// CONFIRMED (2.0.0-beta.3): GetStorageMonitorInfoAsync(ulong, CancellationToken) returns
// Task<Response<StorageMonitorInfo?>>; the read also primes the entity so OnStorageMonitorTriggered
// fires for it thereafter.
var response = await _rustPlus.GetStorageMonitorInfoAsync(entityId, timeoutCts.Token)
.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
return response is { IsSuccess: true, Data: { } info } ? MapContents(info) : null;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return null;
}
#pragma warning disable CA1031 // Broad catch: a failed/timed-out storage read returns null; the caller treats null as unreachable.
catch (Exception)
#pragma warning restore CA1031
{
return null;
}
}

public async Task<bool> SetSmartSwitchValueAsync(ulong entityId,
bool value,
TimeSpan timeout,
Expand Down Expand Up @@ -558,6 +602,7 @@ public async ValueTask DisposeAsync()
{
_rustPlus.OnTeamChatReceived -= OnTeamChatReceived;
_rustPlus.OnSmartDeviceTriggered -= OnSmartDeviceTriggered;
_rustPlus.OnStorageMonitorTriggered -= OnStorageMonitorTriggered;
try
{
// CONFIRMED: RustPlusSocket implements IAsyncDisposable in 2.0.0-beta.1.
Expand All @@ -575,6 +620,26 @@ public async ValueTask DisposeAsync()
private void OnSmartDeviceTriggered(object? sender, RustPlusApi.Data.Events.SmartDeviceEventArg e) =>
SmartDeviceTriggered?.Invoke(this, new SmartDeviceTrigger(e.Id, e.IsActive));

private void OnStorageMonitorTriggered(object? sender, RustPlusApi.Data.Events.StorageMonitorEventArg e) =>
StorageMonitorTriggered?.Invoke(this, new StorageMonitorTrigger(e.Id, MapContents(e)));

private static StorageContentsSnapshot MapContents(RustPlusApi.Data.Entities.StorageMonitorInfo info)
{
var items = info.Items is null
? (IReadOnlyList<StorageItemSnapshot>)[]
:
[
.. info.Items.Select(i =>
new StorageItemSnapshot(i.Id, i.Quantity ?? 0, i.IsItemBlueprint ?? false))
];

DateTimeOffset? expiry = info.HasProtection == true
? new DateTimeOffset(DateTime.SpecifyKind(info.ProtectionExpiry, DateTimeKind.Utc))
: null;

return new StorageContentsSnapshot(info.Capacity, info.HasProtection, expiry, items);
}

private static void AddMarkers<TMarker>(
List<MapMarkerSnapshot> into,
IReadOnlyDictionary<ulong, TMarker> source,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using RustPlusBot.Abstractions.Connections;

namespace RustPlusBot.Features.Connections.Listening;

/// <summary>An in-game storage-monitor broadcast: the entity id and its current contents.</summary>
/// <param name="EntityId">The in-game entity id.</param>
/// <param name="Contents">The contents snapshot carried on the broadcast.</param>
internal sealed record StorageMonitorTrigger(ulong EntityId, StorageContentsSnapshot Contents);
Loading
Loading