Skip to content

Commit a30b642

Browse files
HandyS11claude
andcommitted
feat(storage): inline per-device reachability (relay + renderer + EN/FR)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e870ce4 commit a30b642

8 files changed

Lines changed: 224 additions & 8 deletions

File tree

src/RustPlusBot.Features.StorageMonitors/Hosting/StorageMonitorsHostedService.cs

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
namespace RustPlusBot.Features.StorageMonitors.Hosting;
88

9-
/// <summary>Runs the storage-monitor pairing loop, the triggered relay loop, and the connection-status relay loop.</summary>
9+
/// <summary>Runs the storage-monitor pairing loop, the triggered relay loop, the connection-status relay loop, and the reachability relay loop.</summary>
1010
/// <param name="eventBus">The in-process event bus.</param>
1111
/// <param name="coordinator">Handles paired storage monitors.</param>
1212
/// <param name="relay">Re-renders storage monitors on trigger/connection changes.</param>
@@ -19,6 +19,7 @@ internal sealed partial class StorageMonitorsHostedService(
1919
{
2020
private readonly CancellationTokenSource _cts = new();
2121
private Task? _pairedLoop;
22+
private Task? _reachabilityLoop;
2223
private Task? _statusLoop;
2324
private Task? _triggeredLoop;
2425

@@ -31,6 +32,7 @@ public Task StartAsync(CancellationToken cancellationToken)
3132
_pairedLoop = Task.Run(() => ConsumePairedAsync(_cts.Token), CancellationToken.None);
3233
_triggeredLoop = Task.Run(() => ConsumeTriggeredAsync(_cts.Token), CancellationToken.None);
3334
_statusLoop = Task.Run(() => ConsumeStatusAsync(_cts.Token), CancellationToken.None);
35+
_reachabilityLoop = Task.Run(() => ConsumeReachabilityChangedAsync(_cts.Token), CancellationToken.None);
3436
return Task.CompletedTask;
3537
}
3638

@@ -40,7 +42,7 @@ public async Task StopAsync(CancellationToken cancellationToken)
4042
await _cts.CancelAsync().ConfigureAwait(false);
4143
foreach (var loop in new[]
4244
{
43-
_pairedLoop, _triggeredLoop, _statusLoop
45+
_pairedLoop, _triggeredLoop, _statusLoop, _reachabilityLoop
4446
}.Where(t => t is not null))
4547
{
4648
try
@@ -122,6 +124,28 @@ private async Task ConsumeStatusAsync(CancellationToken cancellationToken)
122124
}
123125
}
124126

127+
private async Task ConsumeReachabilityChangedAsync(CancellationToken cancellationToken)
128+
{
129+
try
130+
{
131+
await foreach (var evt in eventBus.SubscribeAsync<DeviceReachabilityChangedEvent>(cancellationToken)
132+
.ConfigureAwait(false))
133+
{
134+
await relay.HandleReachabilityChangedAsync(evt, cancellationToken).ConfigureAwait(false);
135+
}
136+
}
137+
catch (OperationCanceledException)
138+
{
139+
// Shutting down.
140+
}
141+
#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host.
142+
catch (Exception ex)
143+
#pragma warning restore CA1031
144+
{
145+
LogReachabilityLoopFaulted(logger, ex);
146+
}
147+
}
148+
125149
[LoggerMessage(Level = LogLevel.Error, Message = "Storage monitor pairing loop faulted.")]
126150
private static partial void LogPairedLoopFaulted(ILogger logger, Exception exception);
127151

@@ -130,4 +154,7 @@ private async Task ConsumeStatusAsync(CancellationToken cancellationToken)
130154

131155
[LoggerMessage(Level = LogLevel.Error, Message = "Storage monitor connection-status relay loop faulted.")]
132156
private static partial void LogStatusLoopFaulted(ILogger logger, Exception exception);
157+
158+
[LoggerMessage(Level = LogLevel.Error, Message = "Storage monitor reachability relay loop faulted.")]
159+
private static partial void LogReachabilityLoopFaulted(ILogger logger, Exception exception);
133160
}

src/RustPlusBot.Features.StorageMonitors/Relaying/StorageMonitorStateRelay.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,42 @@ await RenderAsync(store, monitor, evt.Contents, evt.GuildId, evt.ServerId, cultu
5454
}
5555
}
5656

57+
/// <summary>Handles a per-device reachability change: ignore foreign entities, else persist + re-render.</summary>
58+
/// <param name="evt">The device-reachability-changed event.</param>
59+
/// <param name="cancellationToken">A cancellation token.</param>
60+
/// <returns>A task that completes when the embed has been re-rendered (or the id was ignored).</returns>
61+
public async Task HandleReachabilityChangedAsync(
62+
DeviceReachabilityChangedEvent evt,
63+
CancellationToken cancellationToken)
64+
{
65+
ArgumentNullException.ThrowIfNull(evt);
66+
var scope = scopeFactory.CreateAsyncScope();
67+
await using (scope.ConfigureAwait(false))
68+
{
69+
var store = scope.ServiceProvider.GetRequiredService<IStorageMonitorStore>();
70+
if (!await store.ExistsAsync(evt.GuildId, evt.ServerId, evt.EntityId, cancellationToken)
71+
.ConfigureAwait(false))
72+
{
73+
return; // not a storage monitor this relay manages — ignore.
74+
}
75+
76+
await store.SetReachabilityAsync(evt.GuildId, evt.ServerId, evt.EntityId, evt.Reachability,
77+
cancellationToken)
78+
.ConfigureAwait(false);
79+
var monitor = await store.GetAsync(evt.GuildId, evt.ServerId, evt.EntityId, cancellationToken)
80+
.ConfigureAwait(false);
81+
if (monitor is null)
82+
{
83+
return;
84+
}
85+
86+
var culture = await GetCultureAsync(scope.ServiceProvider, evt.GuildId, cancellationToken)
87+
.ConfigureAwait(false);
88+
await RenderAsync(store, monitor, contents: null, evt.GuildId, evt.ServerId, culture, cancellationToken)
89+
.ConfigureAwait(false);
90+
}
91+
}
92+
5793
/// <summary>Handles a connection-status change: a non-Connected server marks its storage monitor embeds unreachable.</summary>
5894
/// <param name="evt">The connection-status change.</param>
5995
/// <param name="cancellationToken">A cancellation token.</param>

src/RustPlusBot.Features.StorageMonitors/Rendering/StorageMonitorEmbedRenderer.cs

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,35 @@ internal sealed class StorageMonitorEmbedRenderer(ILocalizer localizer, IItemNam
2424
string culture)
2525
{
2626
ArgumentNullException.ThrowIfNull(monitor);
27-
var unreachable = contents is null;
27+
var reason = monitor.Reachability;
28+
29+
// Removed and NoPrivilege block controls (device gone or locked); NoResponse is transient — controls stay enabled.
30+
var blocked = reason is DeviceReachability.Removed or DeviceReachability.NoPrivilege;
31+
32+
string? statusKey;
33+
#pragma warning disable IDE0045 // Collapsing to a nested ternary trips RCS1238/S3358 (nested conditional); the if/else chain is intentional.
34+
if (reason == DeviceReachability.Removed)
35+
{
36+
statusKey = "storage.status.removed";
37+
}
38+
else if (reason == DeviceReachability.NoPrivilege)
39+
{
40+
statusKey = "storage.status.noprivilege";
41+
}
42+
else if (reason == DeviceReachability.NoResponse)
43+
{
44+
statusKey = "storage.status.noresponse";
45+
}
46+
else
47+
{
48+
statusKey = contents is null ? "storage.status.unreachable" : null;
49+
}
50+
#pragma warning restore IDE0045
2851

2952
var description = new StringBuilder();
30-
if (unreachable)
53+
if (statusKey is not null)
3154
{
32-
description.Append(localizer.Get("storage.status.unreachable", culture));
55+
description.Append(localizer.Get(statusKey, culture));
3356
}
3457
else
3558
{
@@ -44,12 +67,14 @@ internal sealed class StorageMonitorEmbedRenderer(ILocalizer localizer, IItemNam
4467
.WithFooter(localizer.Get("storage.embed.footer", culture, monitor.EntityId))
4568
.Build();
4669

70+
// Buttons disabled when: blocked (Removed/NoPrivilege), or server is down (Reachable but no contents).
71+
var disableButtons = blocked || (reason == DeviceReachability.Reachable && contents is null);
4772
var tail = $"{monitor.ServerId}:{monitor.EntityId}";
4873
var components = new ComponentBuilder()
4974
.WithButton(localizer.Get("storage.button.refresh", culture),
50-
StorageMonitorComponentIds.RefreshPrefix + tail, ButtonStyle.Primary, disabled: unreachable)
75+
StorageMonitorComponentIds.RefreshPrefix + tail, ButtonStyle.Primary, disabled: disableButtons)
5176
.WithButton(localizer.Get("storage.button.rename", culture),
52-
StorageMonitorComponentIds.RenamePrefix + tail, ButtonStyle.Secondary, disabled: unreachable)
77+
StorageMonitorComponentIds.RenamePrefix + tail, ButtonStyle.Secondary, disabled: disableButtons)
5378
.Build();
5479

5580
return (embed, components);

src/RustPlusBot.Localization/Strings.fr.resx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,6 +756,15 @@
756756
<data name="storage.slots" xml:space="preserve">
757757
<value>{0} / {1} emplacements</value>
758758
</data>
759+
<data name="storage.status.noprivilege" xml:space="preserve">
760+
<value>⛔ Aucun privilège de construction</value>
761+
</data>
762+
<data name="storage.status.noresponse" xml:space="preserve">
763+
<value>⚠️ Aucune réponse</value>
764+
</data>
765+
<data name="storage.status.removed" xml:space="preserve">
766+
<value>❌ Supprimé en jeu</value>
767+
</data>
759768
<data name="storage.status.unreachable" xml:space="preserve">
760769
<value>⚠️ Injoignable</value>
761770
</data>

src/RustPlusBot.Localization/Strings.resx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,6 +756,15 @@
756756
<data name="storage.slots" xml:space="preserve">
757757
<value>{0} / {1} slots</value>
758758
</data>
759+
<data name="storage.status.noprivilege" xml:space="preserve">
760+
<value>⛔ No building privilege</value>
761+
</data>
762+
<data name="storage.status.noresponse" xml:space="preserve">
763+
<value>⚠️ No response</value>
764+
</data>
765+
<data name="storage.status.removed" xml:space="preserve">
766+
<value>❌ Removed in-game</value>
767+
</data>
759768
<data name="storage.status.unreachable" xml:space="preserve">
760769
<value>⚠️ Unreachable</value>
761770
</data>

tests/RustPlusBot.Features.StorageMonitors.Tests/StorageMonitorEmbedRendererTests.cs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,73 @@ public void RenderMonitor_EmptyBox_ShowsEmptyText()
9191
Assert.Contains("Small Box", desc, StringComparison.Ordinal);
9292
}
9393

94+
[Theory]
95+
[InlineData(DeviceReachability.Removed, "Removed in-game")]
96+
[InlineData(DeviceReachability.NoPrivilege, "No building privilege")]
97+
[InlineData(DeviceReachability.NoResponse, "No response")]
98+
public void RenderMonitor_NonReachable_ShowsReasonStatus(DeviceReachability reachability, string expectedText)
99+
{
100+
var renderer = Create(out _);
101+
var monitor = new SmartStorageMonitor
102+
{
103+
Id = Guid.NewGuid(),
104+
ServerId = Guid.NewGuid(),
105+
EntityId = 7UL,
106+
Name = "Box",
107+
Reachability = reachability,
108+
};
109+
110+
var (embed, _) = renderer.RenderMonitor(monitor, contents: null, culture: "en");
111+
112+
Assert.Contains(expectedText, embed.Description ?? string.Empty, StringComparison.Ordinal);
113+
}
114+
115+
[Theory]
116+
[InlineData(DeviceReachability.Removed)]
117+
[InlineData(DeviceReachability.NoPrivilege)]
118+
public void RenderMonitor_BlockedReachability_DisablesButtons(DeviceReachability reachability)
119+
{
120+
var renderer = Create(out _);
121+
var monitor = new SmartStorageMonitor
122+
{
123+
Id = Guid.NewGuid(),
124+
ServerId = Guid.NewGuid(),
125+
EntityId = 7UL,
126+
Name = "Box",
127+
Reachability = reachability,
128+
};
129+
130+
var (_, components) = renderer.RenderMonitor(monitor, contents: null, culture: "en");
131+
132+
var buttons = components.Components.OfType<ActionRowComponent>()
133+
.SelectMany(r => r.Components)
134+
.OfType<ButtonComponent>()
135+
.ToList();
136+
Assert.All(buttons, b => Assert.True(b.IsDisabled));
137+
}
138+
139+
[Fact]
140+
public void RenderMonitor_NoResponse_KeepsButtonsEnabled()
141+
{
142+
var renderer = Create(out _);
143+
var monitor = new SmartStorageMonitor
144+
{
145+
Id = Guid.NewGuid(),
146+
ServerId = Guid.NewGuid(),
147+
EntityId = 7UL,
148+
Name = "Box",
149+
Reachability = DeviceReachability.NoResponse,
150+
};
151+
152+
var (_, components) = renderer.RenderMonitor(monitor, contents: null, culture: "en");
153+
154+
var buttons = components.Components.OfType<ActionRowComponent>()
155+
.SelectMany(r => r.Components)
156+
.OfType<ButtonComponent>()
157+
.ToList();
158+
Assert.False(buttons.All(b => b.IsDisabled));
159+
}
160+
94161
[Fact]
95162
public void RenderPrompt_HasAcceptAndDismissButtons()
96163
{

tests/RustPlusBot.Features.StorageMonitors.Tests/StorageMonitorStateRelayTests.cs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,49 @@ await h.Poster.DidNotReceive().EnsureAsync(Arg.Any<ulong>(), Arg.Any<ulong?>(),
138138
Arg.Any<CancellationToken>());
139139
}
140140

141+
[Fact]
142+
public async Task HandleReachabilityChangedAsync_ForeignEntity_DoesNothing()
143+
{
144+
var h = Create();
145+
h.Store.ExistsAsync(Guild, Server, 99UL, Arg.Any<CancellationToken>()).Returns(false);
146+
147+
await h.Relay.HandleReachabilityChangedAsync(
148+
new DeviceReachabilityChangedEvent(Guild, Server, 99UL, DeviceReachability.Removed),
149+
CancellationToken.None);
150+
151+
await h.Store.DidNotReceive().SetReachabilityAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<ulong>(),
152+
Arg.Any<DeviceReachability>(), Arg.Any<CancellationToken>());
153+
await h.Poster.DidNotReceive().EnsureAsync(Arg.Any<ulong>(), Arg.Any<ulong?>(),
154+
Arg.Any<global::Discord.Embed>(), Arg.Any<global::Discord.MessageComponent>(),
155+
Arg.Any<CancellationToken>());
156+
}
157+
158+
[Fact]
159+
public async Task HandleReachabilityChangedAsync_OwnedEntity_PersistsAndRenders()
160+
{
161+
var h = Create();
162+
h.Store.ExistsAsync(Guild, Server, 42UL, Arg.Any<CancellationToken>()).Returns(true);
163+
h.Store.GetAsync(Guild, Server, 42UL, Arg.Any<CancellationToken>())
164+
.Returns(new SmartStorageMonitor
165+
{
166+
GuildId = Guild,
167+
ServerId = Server,
168+
EntityId = 42UL,
169+
Name = "TC",
170+
MessageId = 900UL,
171+
Reachability = DeviceReachability.Removed,
172+
});
173+
174+
await h.Relay.HandleReachabilityChangedAsync(
175+
new DeviceReachabilityChangedEvent(Guild, Server, 42UL, DeviceReachability.Removed),
176+
CancellationToken.None);
177+
178+
await h.Store.Received(1).SetReachabilityAsync(Guild, Server, 42UL, DeviceReachability.Removed,
179+
Arg.Any<CancellationToken>());
180+
await h.Poster.Received(1).EnsureAsync(555UL, 900UL, Arg.Any<global::Discord.Embed>(),
181+
Arg.Any<global::Discord.MessageComponent>(), Arg.Any<CancellationToken>());
182+
}
183+
141184
private sealed record Harness(
142185
StorageMonitorStateRelay Relay,
143186
IStorageMonitorStore Store,

tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,6 @@ public void English_covers_every_french_key()
4242
[Fact]
4343
public void Catalog_has_expected_key_count()
4444
{
45-
Assert.Equal(254, EnglishKeys().Count);
45+
Assert.Equal(257, EnglishKeys().Count);
4646
}
4747
}

0 commit comments

Comments
 (0)