Skip to content

Commit def9550

Browse files
HandyS11claude
andcommitted
feat(switches): surface actuation reachability reason + update embed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a30b642 commit def9550

10 files changed

Lines changed: 139 additions & 25 deletions

File tree

src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,28 +79,28 @@ Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(
7979
ulong entityId,
8080
CancellationToken cancellationToken);
8181

82-
/// <summary>Sets a smart switch on/off; returns false when there is no live socket or the call fails.</summary>
82+
/// <summary>Sets a smart switch on/off; returns the reachability reason when the call fails or there is no live socket.</summary>
8383
/// <param name="guildId">The owning guild snowflake.</param>
8484
/// <param name="serverId">The target server id.</param>
8585
/// <param name="entityId">The in-game smart-switch entity id.</param>
8686
/// <param name="value">True to turn on, false to turn off.</param>
8787
/// <param name="cancellationToken">A cancellation token.</param>
88-
/// <returns>True on success; false when unavailable or the call fails.</returns>
89-
Task<bool> SetSmartSwitchAsync(ulong guildId,
88+
/// <returns><see cref="DeviceReachability.Reachable"/> on success; the failure reason otherwise.</returns>
89+
Task<DeviceReachability> SetSmartSwitchAsync(ulong guildId,
9090
Guid serverId,
9191
ulong entityId,
9292
bool value,
9393
CancellationToken cancellationToken);
9494

95-
/// <summary>Strobes a smart switch; returns false when there is no live socket or the call fails.</summary>
95+
/// <summary>Strobes a smart switch; returns the reachability reason when the call fails or there is no live socket.</summary>
9696
/// <param name="guildId">The owning guild snowflake.</param>
9797
/// <param name="serverId">The target server id.</param>
9898
/// <param name="entityId">The in-game smart-switch entity id.</param>
9999
/// <param name="timeoutMs">The in-game strobe duration in milliseconds.</param>
100100
/// <param name="value">The terminal value after strobing.</param>
101101
/// <param name="cancellationToken">A cancellation token.</param>
102-
/// <returns>True on success; false when unavailable or the call fails.</returns>
103-
Task<bool> StrobeSmartSwitchAsync(ulong guildId,
102+
/// <returns><see cref="DeviceReachability.Reachable"/> on success; the failure reason otherwise.</returns>
103+
Task<DeviceReachability> StrobeSmartSwitchAsync(ulong guildId,
104104
Guid serverId,
105105
ulong entityId,
106106
int timeoutMs,

src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,7 @@ public async Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(
288288
}
289289

290290
/// <inheritdoc />
291-
public async Task<bool> SetSmartSwitchAsync(
291+
public async Task<DeviceReachability> SetSmartSwitchAsync(
292292
ulong guildId,
293293
Guid serverId,
294294
ulong entityId,
@@ -297,17 +297,16 @@ public async Task<bool> SetSmartSwitchAsync(
297297
{
298298
if (!_liveSockets.TryGetValue((guildId, serverId), out var live))
299299
{
300-
return false;
300+
return DeviceReachability.NoResponse;
301301
}
302302

303-
var reachability = await live.Connection
303+
return await live.Connection
304304
.SetSmartSwitchValueAsync(entityId, value, _options.HeartbeatTimeout, cancellationToken)
305305
.ConfigureAwait(false);
306-
return reachability == DeviceReachability.Reachable;
307306
}
308307

309308
/// <inheritdoc />
310-
public async Task<bool> StrobeSmartSwitchAsync(
309+
public async Task<DeviceReachability> StrobeSmartSwitchAsync(
311310
ulong guildId,
312311
Guid serverId,
313312
ulong entityId,
@@ -317,13 +316,12 @@ public async Task<bool> StrobeSmartSwitchAsync(
317316
{
318317
if (!_liveSockets.TryGetValue((guildId, serverId), out var live))
319318
{
320-
return false;
319+
return DeviceReachability.NoResponse;
321320
}
322321

323-
var reachability = await live.Connection
322+
return await live.Connection
324323
.StrobeSmartSwitchAsync(entityId, timeoutMs, value, _options.HeartbeatTimeout, cancellationToken)
325324
.ConfigureAwait(false);
326-
return reachability == DeviceReachability.Reachable;
327325
}
328326

329327
/// <inheritdoc />
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using RustPlusBot.Abstractions.Connections;
2+
using RustPlusBot.Localization;
3+
4+
namespace RustPlusBot.Features.Switches.Modules;
5+
6+
/// <summary>Maps a failed actuation's reachability to a localized ephemeral reply.</summary>
7+
internal static class SwitchActuationReply
8+
{
9+
/// <summary>Returns the localized reason text for a non-Reachable actuation result.</summary>
10+
/// <param name="reachability">The device reachability returned by the actuation call.</param>
11+
/// <param name="localizer">The localizer used to resolve the string.</param>
12+
/// <param name="culture">The BCP-47 culture tag for the guild.</param>
13+
public static string Describe(DeviceReachability reachability, ILocalizer localizer, string culture)
14+
{
15+
ArgumentNullException.ThrowIfNull(localizer);
16+
var key = reachability switch
17+
{
18+
DeviceReachability.Removed => "switch.actuation.removed",
19+
DeviceReachability.NoPrivilege => "switch.actuation.noprivilege",
20+
_ => "switch.actuation.noresponse",
21+
};
22+
return localizer.Get(key, culture);
23+
}
24+
}

src/RustPlusBot.Features.Switches/Modules/SwitchComponentModule.cs

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,22 @@
66
using RustPlusBot.Abstractions.Events;
77
using RustPlusBot.Features.Switches.Pairing;
88
using RustPlusBot.Features.Switches.Rendering;
9+
using RustPlusBot.Localization;
910
using RustPlusBot.Persistence.Switches;
11+
using RustPlusBot.Persistence.Workspace;
1012

1113
namespace RustPlusBot.Features.Switches.Modules;
1214

1315
/// <summary>Thin handler for the #switches pairing prompt + control buttons + rename modal. Any guild member.</summary>
1416
/// <param name="scopeFactory">Creates a short-lived DI scope per interaction.</param>
1517
/// <param name="query">Live socket read/control.</param>
1618
/// <param name="eventBus">Publishes a state-changed event to drive an embed refresh.</param>
19+
/// <param name="localizer">Resolves localized strings for actuation failure replies.</param>
1720
public sealed class SwitchComponentModule(
1821
IServiceScopeFactory scopeFactory,
1922
IRustServerQuery query,
20-
IEventBus eventBus) : InteractionModuleBase<SocketInteractionContext>
23+
IEventBus eventBus,
24+
ILocalizer localizer) : InteractionModuleBase<SocketInteractionContext>
2125
{
2226
private const string InvalidControlMessage = "That control wasn't valid.";
2327

@@ -91,13 +95,25 @@ public async Task StrobeAsync(string tail)
9195
}
9296

9397
await DeferAsync(ephemeral: true).ConfigureAwait(false);
94-
var ok = await query
98+
var strobeResult = await query
9599
.StrobeSmartSwitchAsync(Context.Guild.Id, serverId, entityId, timeoutMs: 1000, value: true,
96100
CancellationToken.None)
97101
.ConfigureAwait(false);
98-
if (!ok)
102+
if (strobeResult != DeviceReachability.Reachable)
99103
{
100-
await FollowupAsync("Switch is unreachable right now.", ephemeral: true).ConfigureAwait(false);
104+
var strobeScope = scopeFactory.CreateAsyncScope();
105+
await using (strobeScope.ConfigureAwait(false))
106+
{
107+
var culture = await strobeScope.ServiceProvider.GetRequiredService<IWorkspaceStore>()
108+
.GetCultureAsync(Context.Guild.Id, CancellationToken.None).ConfigureAwait(false);
109+
await eventBus
110+
.PublishAsync(new DeviceReachabilityChangedEvent(Context.Guild.Id, serverId, entityId,
111+
strobeResult))
112+
.ConfigureAwait(false);
113+
await FollowupAsync(SwitchActuationReply.Describe(strobeResult, localizer, culture), ephemeral: true)
114+
.ConfigureAwait(false);
115+
}
116+
101117
return;
102118
}
103119

@@ -170,11 +186,23 @@ private async Task SetAsync(string tail, bool value)
170186
}
171187

172188
await DeferAsync(ephemeral: true).ConfigureAwait(false);
173-
var ok = await query.SetSmartSwitchAsync(Context.Guild.Id, serverId, entityId, value, CancellationToken.None)
189+
var result = await query
190+
.SetSmartSwitchAsync(Context.Guild.Id, serverId, entityId, value, CancellationToken.None)
174191
.ConfigureAwait(false);
175-
if (!ok)
192+
if (result != DeviceReachability.Reachable)
176193
{
177-
await FollowupAsync("Switch is unreachable right now.", ephemeral: true).ConfigureAwait(false);
194+
var setScope = scopeFactory.CreateAsyncScope();
195+
await using (setScope.ConfigureAwait(false))
196+
{
197+
var culture = await setScope.ServiceProvider.GetRequiredService<IWorkspaceStore>()
198+
.GetCultureAsync(Context.Guild.Id, CancellationToken.None).ConfigureAwait(false);
199+
await eventBus
200+
.PublishAsync(new DeviceReachabilityChangedEvent(Context.Guild.Id, serverId, entityId, result))
201+
.ConfigureAwait(false);
202+
await FollowupAsync(SwitchActuationReply.Describe(result, localizer, culture), ephemeral: true)
203+
.ConfigureAwait(false);
204+
}
205+
178206
return;
179207
}
180208

src/RustPlusBot.Localization/Strings.fr.resx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,15 @@
714714
<data name="switch.status.unreachable" xml:space="preserve">
715715
<value>⚠️ Injoignable</value>
716716
</data>
717+
<data name="switch.actuation.noprivilege" xml:space="preserve">
718+
<value>Aucun privilège de construction pour cet interrupteur.</value>
719+
</data>
720+
<data name="switch.actuation.noresponse" xml:space="preserve">
721+
<value>L'interrupteur n'a pas répondu. Réessayez.</value>
722+
</data>
723+
<data name="switch.actuation.removed" xml:space="preserve">
724+
<value>Cet interrupteur a été supprimé en jeu.</value>
725+
</data>
717726
<data name="switch.unreachable.ephemeral" xml:space="preserve">
718727
<value>L'interrupteur est injoignable pour le moment.</value>
719728
</data>

src/RustPlusBot.Localization/Strings.resx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,15 @@
714714
<data name="switch.status.unreachable" xml:space="preserve">
715715
<value>⚠️ Unreachable</value>
716716
</data>
717+
<data name="switch.actuation.noprivilege" xml:space="preserve">
718+
<value>No building privilege for this switch.</value>
719+
</data>
720+
<data name="switch.actuation.noresponse" xml:space="preserve">
721+
<value>The switch didn't respond. Try again.</value>
722+
</data>
723+
<data name="switch.actuation.removed" xml:space="preserve">
724+
<value>This switch was removed in-game.</value>
725+
</data>
717726
<data name="switch.unreachable.ephemeral" xml:space="preserve">
718727
<value>Switch is unreachable right now.</value>
719728
</data>

tests/RustPlusBot.Features.Connections.Tests/SwitchQueryTests.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Microsoft.Extensions.DependencyInjection;
44
using Microsoft.Extensions.Options;
55
using NSubstitute;
6+
using RustPlusBot.Abstractions.Connections;
67
using RustPlusBot.Abstractions.Credentials;
78
using RustPlusBot.Abstractions.Events;
89
using RustPlusBot.Abstractions.Time;
@@ -116,9 +117,9 @@ public async Task SetSmartSwitch_forwards_value_when_connected()
116117
await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token);
117118
await WaitUntilAsync(() => supervisor.HasLiveSocket(10UL, serverId), cts.Token);
118119

119-
var ok = await supervisor.SetSmartSwitchAsync(10UL, serverId, 42UL, value: true, cts.Token);
120+
var result = await supervisor.SetSmartSwitchAsync(10UL, serverId, 42UL, value: true, cts.Token);
120121

121-
Assert.True(ok);
122+
Assert.Equal(DeviceReachability.Reachable, result);
122123
Assert.Contains((42UL, true), source.LastConnection!.SetSwitchCalls);
123124
await supervisor.StopAllAsync();
124125
}
@@ -130,7 +131,8 @@ public async Task SetSmartSwitch_returns_false_when_no_live_socket()
130131
var (provider, supervisor, _) = CreateHarness(source);
131132
await using var disposeProvider = provider;
132133

133-
Assert.False(await supervisor.SetSmartSwitchAsync(10UL, Guid.NewGuid(), 42UL, true, CancellationToken.None));
134+
Assert.Equal(DeviceReachability.NoResponse,
135+
await supervisor.SetSmartSwitchAsync(10UL, Guid.NewGuid(), 42UL, true, CancellationToken.None));
134136
}
135137

136138
[Fact]
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using RustPlusBot.Localization;
2+
3+
namespace RustPlusBot.Features.Switches.Tests.Fakes;
4+
5+
/// <summary>Test double: returns the key unchanged, enabling key-based assertions without depending on real resource strings.</summary>
6+
internal sealed class KeyEchoLocalizer : ILocalizer
7+
{
8+
/// <inheritdoc />
9+
public string Get(string key, string culture) => key;
10+
11+
/// <inheritdoc />
12+
public string Get(string key, string culture, params object[] args) => key;
13+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using RustPlusBot.Abstractions.Connections;
2+
using RustPlusBot.Features.Switches.Modules;
3+
using RustPlusBot.Features.Switches.Tests.Fakes;
4+
using RustPlusBot.Localization;
5+
6+
namespace RustPlusBot.Features.Switches.Tests;
7+
8+
public sealed class SwitchActuationReplyTests
9+
{
10+
[Theory]
11+
[InlineData(DeviceReachability.Removed, "switch.actuation.removed")]
12+
[InlineData(DeviceReachability.NoPrivilege, "switch.actuation.noprivilege")]
13+
[InlineData(DeviceReachability.NoResponse, "switch.actuation.noresponse")]
14+
public void Describe_NonReachable_ReturnsReasonText(DeviceReachability reachability, string expectedKey)
15+
{
16+
// Fake localizer returns the key it is given (same pattern as other Switches.Tests).
17+
var text = SwitchActuationReply.Describe(reachability, new KeyEchoLocalizer(), "en");
18+
Assert.Equal(expectedKey, text);
19+
}
20+
21+
[Theory]
22+
[InlineData(DeviceReachability.Removed, "This switch was removed in-game.")]
23+
[InlineData(DeviceReachability.NoPrivilege, "No building privilege for this switch.")]
24+
[InlineData(DeviceReachability.NoResponse, "The switch didn't respond. Try again.")]
25+
public void Describe_NonReachable_ReturnsCorrectEnglishText(DeviceReachability reachability, string expectedText)
26+
{
27+
// Real localizer for final EN text verification.
28+
var text = SwitchActuationReply.Describe(reachability, new ResxLocalizer(), "en");
29+
Assert.Equal(expectedText, text);
30+
}
31+
}

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(257, EnglishKeys().Count);
45+
Assert.Equal(260, EnglishKeys().Count);
4646
}
4747
}

0 commit comments

Comments
 (0)