diff --git a/Directory.Packages.props b/Directory.Packages.props
index 840128b0..4fdf9c9c 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -13,6 +13,8 @@
+
+
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 00000000..e5af93ff
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,29 @@
+RustPlusBot
+Copyright (c) HandyS11 and contributors
+Licensed under the MIT License. See LICENSE for the full license text.
+
+-------------------------------------------------------------------------------
+BUNDLED ASSETS
+-------------------------------------------------------------------------------
+
+Liberation Sans (Regular)
+ File: src/RustPlusBot.Features.Map/Assets/LiberationSans-Regular.ttf
+ Origin: Liberation Fonts project, Copyright (C) 2007 Red Hat, Inc.
+ License: SIL Open Font License, Version 1.1
+ https://scripts.sil.org/OFL
+ Use: Embedded and used solely for rendering text on the map image.
+ The OFL permits embedding and redistribution in software products.
+
+-------------------------------------------------------------------------------
+MAP MARKER / MONUMENT ICONS (future 2b-ii slice)
+-------------------------------------------------------------------------------
+
+The current 2b slice draws all map markers programmatically as glyphs; NO
+third-party image assets are bundled.
+
+When marker and monument icons are added in the 2b-ii slice, they will
+originate as Facepunch / Rust game art. They are used under the same
+companion-app norms that govern the official Rust+ app and established
+reference bots — NOT under the GPL of the third-party repositories that
+merely redistribute them. Attribution for those assets will be added to this
+file at that time.
diff --git a/RustPlusBot.slnx b/RustPlusBot.slnx
index 2df619b4..dbcf73ed 100644
--- a/RustPlusBot.slnx
+++ b/RustPlusBot.slnx
@@ -6,6 +6,7 @@
+
@@ -16,6 +17,7 @@
+
diff --git a/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs b/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs
index fd9a3266..42016780 100644
--- a/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs
+++ b/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs
@@ -31,4 +31,18 @@ public interface IRustServerQuery
/// A cancellation token.
/// True if promoted; false when no live socket or the API fails.
Task PromoteToLeaderAsync(ulong guildId, Guid serverId, ulong steamId, CancellationToken cancellationToken);
+
+ /// Gets the base map image (JPEG bytes), or null when there is no live socket.
+ /// The owning guild snowflake.
+ /// The target server id.
+ /// A cancellation token.
+ /// The base-map JPEG bytes, or null when there is no live socket.
+ Task GetMapImageAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken);
+
+ /// Gets the static map dimensions (for grid rendering), or null when there is no live socket.
+ /// The owning guild snowflake.
+ /// The target server id.
+ /// A cancellation token.
+ /// The map dimensions, or null when there is no live socket.
+ Task GetMapDimensionsAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken);
}
diff --git a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs
index 21a6edc2..8588a1de 100644
--- a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs
+++ b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs
@@ -67,6 +67,12 @@ Task> GetMapMarkersAsync(TimeSpan timeout,
Task> GetMonumentsAsync(TimeSpan timeout,
CancellationToken cancellationToken = default);
+ /// Gets the base map image (JPEG bytes), or null on failure/unavailable.
+ /// How long to wait for the response.
+ /// A cancellation token.
+ /// The base-map JPEG bytes, or null on failure/unavailable.
+ Task GetMapImageAsync(TimeSpan timeout, CancellationToken cancellationToken = default);
+
/// Raised for every in-game team chat line received on this socket.
event EventHandler? TeamMessageReceived;
}
diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs
index 2833d92e..b02b5bdf 100644
--- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs
+++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs
@@ -60,6 +60,9 @@ public Task> GetMonumentsAsync(TimeSpan timeout,
CancellationToken cancellationToken = default) =>
Task.FromResult>([]);
+ public Task GetMapImageAsync(TimeSpan timeout, CancellationToken cancellationToken = default) =>
+ Task.FromResult(null);
+
public event EventHandler? TeamMessageReceived
{
add { _ = value; }
@@ -408,6 +411,30 @@ public async Task> GetMonumentsAsync(
return monuments;
}
+ public async Task GetMapImageAsync(TimeSpan timeout, CancellationToken cancellationToken = default)
+ {
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeoutCts.CancelAfter(timeout);
+ try
+ {
+ // CONFIRMED (2.0.0-beta.1): GetMapAsync -> Response; ServerMap.JpgImage is byte[] (raw JPEG bytes).
+ var response = await _rustPlus.GetMapAsync(timeoutCts.Token).WaitAsync(timeoutCts.Token)
+ .ConfigureAwait(false);
+ return response.IsSuccess ? response.Data?.JpgImage : null;
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ return null;
+ }
+#pragma warning disable CA1031 // Broad catch: any map-query failure maps to null; never surface a token/secret.
+ catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
+#pragma warning restore CA1031
+ {
+ LogQueryFailed(_logger, ex);
+ return null;
+ }
+ }
+
public async ValueTask DisposeAsync()
{
_rustPlus.OnTeamChatReceived -= OnTeamChatReceived;
diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs
index 7c4a91ed..f4d6d9de 100644
--- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs
+++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs
@@ -184,6 +184,33 @@ public async Task PromoteToLeaderAsync(
.ConfigureAwait(false);
}
+ ///
+ public async Task GetMapImageAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken)
+ {
+ if (!_liveSockets.TryGetValue((guildId, serverId), out var live))
+ {
+ return null;
+ }
+
+ return await live.Connection.GetMapImageAsync(_options.HeartbeatTimeout, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ ///
+ public async Task GetMapDimensionsAsync(
+ ulong guildId,
+ Guid serverId,
+ CancellationToken cancellationToken)
+ {
+ if (!_liveSockets.TryGetValue((guildId, serverId), out var live))
+ {
+ return null;
+ }
+
+ return await live.Connection.GetMapDimensionsAsync(_options.HeartbeatTimeout, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
///
public async Task SendAsync(
ulong guildId,
diff --git a/src/RustPlusBot.Features.Map/Assets/LiberationSans-Regular.ttf b/src/RustPlusBot.Features.Map/Assets/LiberationSans-Regular.ttf
new file mode 100644
index 00000000..e6339859
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/LiberationSans-Regular.ttf differ
diff --git a/src/RustPlusBot.Features.Map/Assets/MarkerGlyphs.cs b/src/RustPlusBot.Features.Map/Assets/MarkerGlyphs.cs
new file mode 100644
index 00000000..f900ca42
--- /dev/null
+++ b/src/RustPlusBot.Features.Map/Assets/MarkerGlyphs.cs
@@ -0,0 +1,19 @@
+using RustPlusBot.Features.Connections.Listening;
+using SixLabors.ImageSharp;
+
+namespace RustPlusBot.Features.Map.Assets;
+
+/// Maps a marker kind to a draw glyph (colour + single letter). The keyed registry the map renderer draws from; 2b-ii can swap this for image assets without changing the renderer.
+public static class MarkerGlyphs
+{
+ /// Gets the glyph colour and letter for a marker kind.
+ /// The marker kind.
+ /// The colour and a one-character label.
+ public static (Color Color, string Letter) For(MarkerKind kind) => kind switch
+ {
+ MarkerKind.CargoShip => (Color.DodgerBlue, "C"),
+ MarkerKind.PatrolHelicopter => (Color.Red, "H"),
+ MarkerKind.Chinook => (Color.Orange, "K"),
+ _ => (Color.Gray, "?"),
+ };
+}
diff --git a/src/RustPlusBot.Features.Map/Composing/BaseMapCache.cs b/src/RustPlusBot.Features.Map/Composing/BaseMapCache.cs
new file mode 100644
index 00000000..ee0295e7
--- /dev/null
+++ b/src/RustPlusBot.Features.Map/Composing/BaseMapCache.cs
@@ -0,0 +1,37 @@
+using System.Collections.Concurrent;
+using RustPlusBot.Features.Connections.Listening;
+
+namespace RustPlusBot.Features.Map.Composing;
+
+/// Caches the static-per-wipe base map image per (guild, server). Singleton so the cache survives across refreshes.
+/// The live query seam used to fetch the base map on a cache miss.
+public sealed class BaseMapCache(IRustServerQuery query)
+{
+ private readonly ConcurrentDictionary<(ulong Guild, Guid Server), byte[]> _images = new();
+
+ /// Gets the cached base map, fetching and caching it on a miss. Null results are not cached.
+ /// The owning guild snowflake.
+ /// The target server id.
+ /// A cancellation token.
+ /// The base-map JPEG bytes, or null if unavailable.
+ public async Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken)
+ {
+ if (_images.TryGetValue((guildId, serverId), out var cached))
+ {
+ return cached;
+ }
+
+ var fetched = await query.GetMapImageAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
+ if (fetched is not null)
+ {
+ _images[(guildId, serverId)] = fetched;
+ }
+
+ return fetched;
+ }
+
+ /// Evicts the cached base map for a server (called on disconnect).
+ /// The owning guild snowflake.
+ /// The target server id.
+ public void Clear(ulong guildId, Guid serverId) => _images.TryRemove((guildId, serverId), out _);
+}
diff --git a/src/RustPlusBot.Features.Map/Composing/MapComposer.cs b/src/RustPlusBot.Features.Map/Composing/MapComposer.cs
new file mode 100644
index 00000000..a87ff01e
--- /dev/null
+++ b/src/RustPlusBot.Features.Map/Composing/MapComposer.cs
@@ -0,0 +1,53 @@
+using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Features.Events.State;
+using RustPlusBot.Features.Map.Rendering;
+
+namespace RustPlusBot.Features.Map.Composing;
+
+/// Gathers the cached base map + live markers and renders the map PNG.
+/// The base-map cache.
+/// Live marker state.
+/// Live query seam (supplies the static map dimensions).
+/// The image renderer.
+public sealed class MapComposer(BaseMapCache cache, IEventState events, IRustServerQuery query, MapRenderer renderer)
+{
+ private static readonly MarkerKind[] DrawnKinds =
+ [
+ MarkerKind.CargoShip, MarkerKind.PatrolHelicopter, MarkerKind.Chinook,
+ ];
+
+ /// Composes the map PNG for a server, or null when no base map is available yet.
+ /// The owning guild snowflake.
+ /// The target server id.
+ /// A cancellation token.
+ /// PNG bytes, or null.
+ public async Task ComposeAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken)
+ {
+ var baseImage = await cache.GetAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
+ if (baseImage is null)
+ {
+ return null;
+ }
+
+ // Dimensions come from the map itself (not from a marker), so the grid renders even when no
+ // markers are present — e.g. on a freshly-connected or low-activity server.
+ var dims = await query.GetMapDimensionsAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
+ if (dims is null)
+ {
+ // Dimensions unavailable: render the base tile only (grid/markers both need world→pixel).
+ return renderer.Render(baseImage, new MapDimensions(0, 0, 0), markers: [],
+ new MapLayerSet(Grid: false, Markers: false, Monuments: false, Vendor: false, Rigs: false));
+ }
+
+ var placements = DrawnKinds
+ .SelectMany(kind => events.GetActiveMarkers(guildId, serverId, kind))
+ .Select(m =>
+ {
+ var (px, py) = WorldToPixel.ToPixel(m.X, m.Y, dims, MapRenderer.OutputSize);
+ return new MarkerPlacement(m.Kind, px, py);
+ })
+ .ToList();
+
+ return renderer.Render(baseImage, dims, placements, MapLayerSet.Default2b);
+ }
+}
diff --git a/src/RustPlusBot.Features.Map/Hosting/MapHostedService.cs b/src/RustPlusBot.Features.Map/Hosting/MapHostedService.cs
new file mode 100644
index 00000000..186d39a2
--- /dev/null
+++ b/src/RustPlusBot.Features.Map/Hosting/MapHostedService.cs
@@ -0,0 +1,213 @@
+using System.Collections.Concurrent;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Domain.Connections;
+using RustPlusBot.Features.Map.Composing;
+using RustPlusBot.Features.Map.Posting;
+using RustPlusBot.Features.Workspace.Locating;
+using RustPlusBot.Persistence.Connections;
+
+namespace RustPlusBot.Features.Map.Hosting;
+
+///
+/// Keeps the #map image current: re-renders on marker changes, on a steady interval (so moving
+/// markers track even though their ids are stable), and on connect; clears the base-map cache on
+/// disconnect. All refreshes pass through a per-server throttle so the surfaces never double-post.
+///
+/// The in-process event bus.
+/// Renders the map PNG from the cached base + live markers.
+/// The base-map cache, cleared on disconnect.
+/// Resolves the #map Discord channel for a server.
+/// Posts the rendered PNG to Discord.
+/// Supplies the current time for the throttle.
+/// Supplies the refresh interval.
+/// Opens scopes to read connection state.
+/// The logger.
+internal sealed partial class MapHostedService(
+ IEventBus eventBus,
+ MapComposer composer,
+ BaseMapCache cache,
+ IMapChannelLocator locator,
+ IMapChannelPoster poster,
+ IClock clock,
+ IOptions options,
+ IServiceScopeFactory scopeFactory,
+ ILogger logger) : IHostedService, IDisposable
+{
+ /// A value-less concurrent set of currently-connected servers the periodic loop repaints.
+ private readonly ConcurrentDictionary<(ulong Guild, Guid Server), byte> _connected = new();
+
+ private readonly CancellationTokenSource _cts = new();
+ private readonly MapRefreshThrottle _throttle = new(clock);
+ private Task? _markerLoop;
+ private Task? _statusLoop;
+ private Task? _tickLoop;
+
+ ///
+ public void Dispose() => _cts.Dispose();
+
+ ///
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ _markerLoop = Task.Run(() => ConsumeMarkerEventsAsync(_cts.Token), CancellationToken.None);
+ _statusLoop = Task.Run(() => ConsumeConnectionStatusEventsAsync(_cts.Token), CancellationToken.None);
+ _tickLoop = Task.Run(() => RunPeriodicRefreshAsync(_cts.Token), CancellationToken.None);
+ return Task.CompletedTask;
+ }
+
+ ///
+ public async Task StopAsync(CancellationToken cancellationToken)
+ {
+ await _cts.CancelAsync().ConfigureAwait(false);
+ foreach (var loop in new[]
+ {
+ _markerLoop, _statusLoop, _tickLoop
+ }.Where(t => t is not null))
+ {
+ try
+ {
+#pragma warning disable VSTHRD003 // Our own loop tasks, joined on stop.
+ await loop!.ConfigureAwait(false);
+#pragma warning restore VSTHRD003
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected on shutdown.
+ }
+ }
+ }
+
+ private async Task ConsumeMarkerEventsAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ await foreach (var evt in eventBus.SubscribeAsync(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ await RefreshAsync(evt.GuildId, evt.ServerId, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Shutting down.
+ }
+#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ LogMarkerLoopFaulted(logger, ex);
+ }
+ }
+
+ ///
+ /// Repaints every connected server's #map on a steady interval. Marker ids are stable, so a moving
+ /// cargo ship / heli / chinook fires no ; this tick is what keeps
+ /// their positions current and posts the first image after connect.
+ ///
+ /// A cancellation token.
+ /// A task that completes when the loop stops.
+ private async Task RunPeriodicRefreshAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ await Task.Delay(options.Value.MapRefreshInterval, cancellationToken).ConfigureAwait(false);
+ foreach (var (guild, server) in _connected.Keys)
+ {
+ await RefreshAsync(guild, server, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Shutting down.
+ }
+#pragma warning disable CA1031 // Broad catch: a faulting tick must not crash the host.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ LogTickLoopFaulted(logger, ex);
+ }
+ }
+
+ private async Task RefreshAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken)
+ {
+ if (!_throttle.ShouldRefresh(guildId, serverId, options.Value.MapRefreshInterval))
+ {
+ return;
+ }
+
+ var channelId = await locator.GetChannelIdAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
+ if (channelId is not { } id)
+ {
+ return;
+ }
+
+ var png = await composer.ComposeAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
+ if (png is null)
+ {
+ return;
+ }
+
+ await poster.PostAsync(id, png, cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task ConsumeConnectionStatusEventsAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ await foreach (var evt in eventBus.SubscribeAsync(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ await OnConnectionStatusAsync(evt, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Shutting down.
+ }
+#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ LogStatusLoopFaulted(logger, ex);
+ }
+ }
+
+ private async Task OnConnectionStatusAsync(ConnectionStatusChangedEvent evt, CancellationToken cancellationToken)
+ {
+ var key = (evt.GuildId, evt.ServerId);
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var connectionStore = scope.ServiceProvider.GetRequiredService();
+ var state = await connectionStore.GetStateAsync(evt.GuildId, evt.ServerId, cancellationToken)
+ .ConfigureAwait(false);
+ if (state is null || state.Status != ConnectionStatus.Connected)
+ {
+ _connected.TryRemove(key, out _);
+ cache.Clear(evt.GuildId, evt.ServerId);
+ return;
+ }
+
+ _connected[key] = 0;
+ }
+
+ // Post an initial image as soon as the server connects, rather than waiting for the first tick.
+ await RefreshAsync(evt.GuildId, evt.ServerId, cancellationToken).ConfigureAwait(false);
+ }
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Map marker loop faulted.")]
+ private static partial void LogMarkerLoopFaulted(ILogger logger, Exception exception);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Map connection-status loop faulted.")]
+ private static partial void LogStatusLoopFaulted(ILogger logger, Exception exception);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Map periodic-refresh loop faulted.")]
+ private static partial void LogTickLoopFaulted(ILogger logger, Exception exception);
+}
diff --git a/src/RustPlusBot.Features.Map/Hosting/MapRefreshThrottle.cs b/src/RustPlusBot.Features.Map/Hosting/MapRefreshThrottle.cs
new file mode 100644
index 00000000..6def9851
--- /dev/null
+++ b/src/RustPlusBot.Features.Map/Hosting/MapRefreshThrottle.cs
@@ -0,0 +1,29 @@
+using System.Collections.Concurrent;
+using RustPlusBot.Abstractions.Time;
+
+namespace RustPlusBot.Features.Map.Hosting;
+
+/// Per-(guild,server) gate that allows a refresh at most once per interval.
+/// Supplies the current time.
+internal sealed class MapRefreshThrottle(IClock clock)
+{
+ private readonly ConcurrentDictionary<(ulong Guild, Guid Server), DateTimeOffset> _last = new();
+
+ /// Returns true if a refresh is due for the key, recording the time when it returns true.
+ /// The owning guild snowflake.
+ /// The target server id.
+ /// The minimum spacing between refreshes.
+ /// True at most once per interval per key.
+ public bool ShouldRefresh(ulong guildId, Guid serverId, TimeSpan interval)
+ {
+ var now = clock.UtcNow;
+ var key = (guildId, serverId);
+ if (_last.TryGetValue(key, out var last) && now - last < interval)
+ {
+ return false;
+ }
+
+ _last[key] = now;
+ return true;
+ }
+}
diff --git a/src/RustPlusBot.Features.Map/MapOptions.cs b/src/RustPlusBot.Features.Map/MapOptions.cs
new file mode 100644
index 00000000..9a10bc35
--- /dev/null
+++ b/src/RustPlusBot.Features.Map/MapOptions.cs
@@ -0,0 +1,8 @@
+namespace RustPlusBot.Features.Map;
+
+/// Map feature configuration, bound from the "Map" config section.
+public sealed class MapOptions
+{
+ /// Minimum time between #map image re-renders per server (coalesces rapid marker changes). Default 45s.
+ public TimeSpan MapRefreshInterval { get; set; } = TimeSpan.FromSeconds(45);
+}
diff --git a/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs
new file mode 100644
index 00000000..7fa62f08
--- /dev/null
+++ b/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs
@@ -0,0 +1,27 @@
+using Microsoft.Extensions.DependencyInjection;
+using RustPlusBot.Features.Map.Composing;
+using RustPlusBot.Features.Map.Hosting;
+using RustPlusBot.Features.Map.Posting;
+using RustPlusBot.Features.Map.Rendering;
+
+namespace RustPlusBot.Features.Map;
+
+/// DI registration for the map-render feature.
+public static class MapServiceCollectionExtensions
+{
+ /// Registers the renderer, base-map cache, composer, poster, and hosted service.
+ /// The service collection to add to.
+ /// The same service collection, for chaining.
+ public static IServiceCollection AddMap(this IServiceCollection services)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddHostedService();
+
+ return services;
+ }
+}
diff --git a/src/RustPlusBot.Features.Map/Posting/DiscordMapChannelPoster.cs b/src/RustPlusBot.Features.Map/Posting/DiscordMapChannelPoster.cs
new file mode 100644
index 00000000..fea5fb77
--- /dev/null
+++ b/src/RustPlusBot.Features.Map/Posting/DiscordMapChannelPoster.cs
@@ -0,0 +1,82 @@
+using Discord;
+using Discord.WebSocket;
+using Microsoft.Extensions.Logging;
+
+namespace RustPlusBot.Features.Map.Posting;
+
+/// Posts the map image to Discord by deleting the bot's prior message and reposting. Untested integration shim.
+/// The Discord socket client.
+/// The logger.
+internal sealed partial class DiscordMapChannelPoster(
+ DiscordSocketClient client,
+ ILogger logger) : IMapChannelPoster
+{
+ /// Scan the last N messages for the bot's own prior map post (only one is expected).
+ private const int RecentMessageScan = 10;
+
+ ///
+ public async Task PostAsync(ulong channelId, byte[] pngBytes, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(pngBytes);
+ try
+ {
+ var options = new RequestOptions
+ {
+ CancelToken = cancellationToken
+ };
+ if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false) is not ITextChannel channel)
+ {
+ return;
+ }
+
+ try
+ {
+ await DeletePriorBotMessagesAsync(channel, options).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+#pragma warning disable CA1031 // Broad catch: a failed delete must not abort the repost (best-effort).
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ LogDeleteFailed(logger, ex, channelId);
+ }
+
+ var stream = new MemoryStream(pngBytes);
+ await using (stream.ConfigureAwait(false))
+ {
+ await channel.SendFileAsync(stream, "map.png", options: options, allowedMentions: AllowedMentions.None)
+ .ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ throw; // Shutdown: let the loop unwind.
+ }
+#pragma warning disable CA1031 // Broad catch: a Discord hiccup must not crash the map loop.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ LogPostFailed(logger, ex, channelId);
+ }
+ }
+
+ private async Task DeletePriorBotMessagesAsync(ITextChannel channel, RequestOptions options)
+ {
+ var batch = await channel.GetMessagesAsync(RecentMessageScan, options: options).FlattenAsync()
+ .ConfigureAwait(false);
+ foreach (var message in batch.Where(m => m.Author.Id == client.CurrentUser.Id))
+ {
+ await message.DeleteAsync(options).ConfigureAwait(false);
+ }
+ }
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Posting the map image to channel {ChannelId} failed.")]
+ private static partial void LogPostFailed(ILogger logger, Exception exception, ulong channelId);
+
+ [LoggerMessage(Level = LogLevel.Warning,
+ Message = "Deleting prior map messages in channel {ChannelId} failed; reposting anyway.")]
+ private static partial void LogDeleteFailed(ILogger logger, Exception exception, ulong channelId);
+}
diff --git a/src/RustPlusBot.Features.Map/Posting/IMapChannelPoster.cs b/src/RustPlusBot.Features.Map/Posting/IMapChannelPoster.cs
new file mode 100644
index 00000000..416e762d
--- /dev/null
+++ b/src/RustPlusBot.Features.Map/Posting/IMapChannelPoster.cs
@@ -0,0 +1,12 @@
+namespace RustPlusBot.Features.Map.Posting;
+
+/// Posts the rendered map image to a Discord channel, replacing the prior bot message.
+internal interface IMapChannelPoster
+{
+ /// Deletes the bot's prior map message (if any) and posts the new PNG.
+ /// The #map channel id.
+ /// The rendered PNG.
+ /// A cancellation token.
+ /// A task that completes when the post is issued.
+ Task PostAsync(ulong channelId, byte[] pngBytes, CancellationToken cancellationToken);
+}
diff --git a/src/RustPlusBot.Features.Map/Rendering/MapLayerSet.cs b/src/RustPlusBot.Features.Map/Rendering/MapLayerSet.cs
new file mode 100644
index 00000000..774be366
--- /dev/null
+++ b/src/RustPlusBot.Features.Map/Rendering/MapLayerSet.cs
@@ -0,0 +1,14 @@
+namespace RustPlusBot.Features.Map.Rendering;
+
+/// Which overlay layers the renderer should draw. 2b uses ; 2b-ii feeds this from per-server settings.
+/// Draw the map grid lines (cell labels are a 2b-ii addition).
+/// Draw live cargo/heli/chinook markers.
+/// Draw monument icons (2b-ii).
+/// Draw the travelling-vendor marker (2b-ii).
+/// Style oil rigs by activation state (2b-ii).
+public sealed record MapLayerSet(bool Grid, bool Markers, bool Monuments, bool Vendor, bool Rigs)
+{
+ /// The fixed layer set for subsystem 2b: grid + live markers on, the rest off.
+ public static MapLayerSet Default2b { get; } =
+ new(Grid: true, Markers: true, Monuments: false, Vendor: false, Rigs: false);
+}
diff --git a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs
new file mode 100644
index 00000000..58c150a1
--- /dev/null
+++ b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs
@@ -0,0 +1,121 @@
+using System.Globalization;
+using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Features.Map.Assets;
+using SixLabors.Fonts;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.Drawing;
+using SixLabors.ImageSharp.Drawing.Processing;
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Processing;
+
+namespace RustPlusBot.Features.Map.Rendering;
+
+///
+/// Renders the base map tile plus overlay layers to PNG bytes.
+/// Stateless; safe to register and use as a singleton.
+///
+public sealed class MapRenderer
+{
+ /// The square output edge length in pixels.
+ public const int OutputSize = 1024;
+
+ private const float GridDiameter = 146.25f;
+ private const float MarkerRadius = 9f;
+ private const float OutlinePenWidth = 1f;
+
+ private static readonly Font Font = LoadFont();
+
+ private static Font LoadFont()
+ {
+ var asm = typeof(MapRenderer).Assembly;
+ using var stream = asm.GetManifestResourceStream("RustPlusBot.Features.Map.Assets.LiberationSans-Regular.ttf")
+ ?? throw new InvalidOperationException(
+ "Embedded map font 'RustPlusBot.Features.Map.Assets.LiberationSans-Regular.ttf' not found.");
+ var collection = new FontCollection();
+ var family = collection.Add(stream, CultureInfo.InvariantCulture);
+ return family.CreateFont(12f);
+ }
+
+ /// Renders the map tile plus the requested overlay layers to PNG bytes.
+ /// The raw base-map JPEG bytes.
+ /// The map dimensions (world size + ocean margin).
+ /// Marker placements already projected to pixel coordinates.
+ /// Which overlay layers to draw.
+ /// PNG-encoded bytes of a square image with pixels on each side.
+ /// Kept as an instance method so the class can be registered as a DI singleton.
+#pragma warning disable CA1822, S2325 // Kept as instance method for DI singleton registration
+ public byte[] Render(byte[] baseJpeg,
+ MapDimensions dims,
+ IReadOnlyList markers,
+ MapLayerSet layers)
+#pragma warning restore CA1822, S2325
+ {
+ ArgumentNullException.ThrowIfNull(baseJpeg);
+ ArgumentNullException.ThrowIfNull(dims);
+ ArgumentNullException.ThrowIfNull(markers);
+ ArgumentNullException.ThrowIfNull(layers);
+
+ using var image = Image.Load(baseJpeg);
+ image.Mutate(ctx => ctx.Resize(OutputSize, OutputSize));
+
+ if (layers.Grid)
+ {
+ DrawGrid(image, dims);
+ }
+
+ if (layers.Markers)
+ {
+ foreach (var marker in markers)
+ {
+ DrawMarker(image, marker);
+ }
+ }
+
+ using var ms = new MemoryStream();
+ image.SaveAsPng(ms);
+ return ms.ToArray();
+ }
+
+ private static void DrawGrid(Image image, MapDimensions dims)
+ {
+ var gridColor = Color.FromRgba(255, 255, 255, 80);
+
+ image.Mutate(ctx =>
+ {
+ // Vertical lines step across the X axis (width); horizontal lines step across the Y axis
+ // (height). Driving each axis from its own dimension keeps the grid correct on a
+ // non-square map (Rust maps are square today, so Width == Height in practice).
+ for (var worldX = 0f; worldX <= dims.Width; worldX += GridDiameter)
+ {
+ var (vx, _) = WorldToPixel.ToPixel(worldX, 0f, dims, OutputSize);
+ ctx.DrawLine(gridColor, OutlinePenWidth, new PointF(vx, 0), new PointF(vx, OutputSize));
+ }
+
+ for (var worldY = 0f; worldY <= dims.Height; worldY += GridDiameter)
+ {
+ var (_, hy) = WorldToPixel.ToPixel(0f, worldY, dims, OutputSize);
+ ctx.DrawLine(gridColor, OutlinePenWidth, new PointF(0, hy), new PointF(OutputSize, hy));
+ }
+ });
+ }
+
+ private static void DrawMarker(Image image, MarkerPlacement m)
+ {
+ var (color, letter) = MarkerGlyphs.For(m.Kind);
+ var circle = new EllipsePolygon(m.PixelX, m.PixelY, MarkerRadius);
+
+ image.Mutate(ctx =>
+ {
+ ctx.Fill(color, circle);
+ ctx.Draw(Color.Black, OutlinePenWidth, circle);
+
+ var textOptions = new RichTextOptions(Font)
+ {
+ Origin = new PointF(m.PixelX, m.PixelY),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ ctx.DrawText(textOptions, letter, Color.White);
+ });
+ }
+}
diff --git a/src/RustPlusBot.Features.Map/Rendering/MarkerPlacement.cs b/src/RustPlusBot.Features.Map/Rendering/MarkerPlacement.cs
new file mode 100644
index 00000000..4e6c3183
--- /dev/null
+++ b/src/RustPlusBot.Features.Map/Rendering/MarkerPlacement.cs
@@ -0,0 +1,9 @@
+using RustPlusBot.Features.Connections.Listening;
+
+namespace RustPlusBot.Features.Map.Rendering;
+
+/// One marker to draw, already projected to pixel coordinates.
+/// The marker kind (selects the glyph).
+/// Pixel X on the rendered tile.
+/// Pixel Y on the rendered tile.
+public sealed record MarkerPlacement(MarkerKind Kind, float PixelX, float PixelY);
diff --git a/src/RustPlusBot.Features.Map/Rendering/WorldToPixel.cs b/src/RustPlusBot.Features.Map/Rendering/WorldToPixel.cs
new file mode 100644
index 00000000..844bbdc2
--- /dev/null
+++ b/src/RustPlusBot.Features.Map/Rendering/WorldToPixel.cs
@@ -0,0 +1,30 @@
+using RustPlusBot.Features.Connections.Listening;
+
+namespace RustPlusBot.Features.Map.Rendering;
+
+/// Maps world coordinates to pixel coordinates on a square rendered map tile.
+public static class WorldToPixel
+{
+ /// Converts a world (x, y) to a pixel (x, y) on a square output of the given edge length.
+ /// World X (west→east), in [0, Width].
+ /// World Y (south→north), in [0, Height].
+ /// The map dimensions (width/height in game units + ocean margin).
+ /// The output image edge length in pixels.
+ /// The pixel coordinate (origin top-left, Y down).
+ public static (float X, float Y) ToPixel(float worldX, float worldY, MapDimensions dims, int outputSize)
+ {
+ ArgumentNullException.ThrowIfNull(dims);
+
+ // The full tile adds the ocean margin on each side, so each axis spans
+ // [-margin, dimension+margin]. Each axis is scaled by its own dimension so the projection
+ // is correct even if a server ever reports a non-square map (Rust maps are square today).
+ var margin = dims.OceanMargin;
+ var perUnitX = outputSize / (dims.Width + (2f * margin));
+ var perUnitY = outputSize / (dims.Height + (2f * margin));
+
+ var px = (worldX + margin) * perUnitX;
+ // Flip Y: world south (0) is the visual bottom (image y = outputSize), world north is the top.
+ var py = outputSize - ((worldY + margin) * perUnitY);
+ return (px, py);
+ }
+}
diff --git a/src/RustPlusBot.Features.Map/RustPlusBot.Features.Map.csproj b/src/RustPlusBot.Features.Map/RustPlusBot.Features.Map.csproj
new file mode 100644
index 00000000..486b7e1a
--- /dev/null
+++ b/src/RustPlusBot.Features.Map/RustPlusBot.Features.Map.csproj
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs
index d8c11779..c3966cf8 100644
--- a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs
+++ b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs
@@ -20,6 +20,7 @@ internal sealed class LocalizationCatalog
["channel.info.name"] = "info",
["channel.teamchat.name"] = "teamchat",
["channel.events.name"] = "events",
+ ["channel.map.name"] = "map",
["information.title"] = "RustPlusBot",
["information.body"] = "Connect your Rust+ account in #setup, then pair a server in-game to begin.",
["information.servers"] = "Servers registered: {0}",
@@ -54,6 +55,7 @@ internal sealed class LocalizationCatalog
["channel.info.name"] = "info",
["channel.teamchat.name"] = "tchat-equipe",
["channel.events.name"] = "evenements",
+ ["channel.map.name"] = "carte",
["information.title"] = "RustPlusBot",
["information.body"] =
"Connectez votre compte Rust+ dans #configuration, puis appairez un serveur en jeu.",
diff --git a/src/RustPlusBot.Features.Workspace/Locating/IMapChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/IMapChannelLocator.cs
new file mode 100644
index 00000000..02723cd3
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Locating/IMapChannelLocator.cs
@@ -0,0 +1,12 @@
+namespace RustPlusBot.Features.Workspace.Locating;
+
+/// Resolves the per-server #map channel (game-to-Discord direction only).
+public interface IMapChannelLocator
+{
+ /// Gets the Discord channel id of the #map for (, ), or null.
+ /// The guild snowflake.
+ /// The server id.
+ /// A cancellation token.
+ /// The Discord channel id, or null if not provisioned.
+ Task GetChannelIdAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken);
+}
diff --git a/src/RustPlusBot.Features.Workspace/Locating/MapChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/MapChannelLocator.cs
new file mode 100644
index 00000000..9bc18579
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Locating/MapChannelLocator.cs
@@ -0,0 +1,75 @@
+using Microsoft.Extensions.DependencyInjection;
+using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Persistence.Workspace;
+
+namespace RustPlusBot.Features.Workspace.Locating;
+
+///
+/// Caches the small set of provisioned #map channels (rebuilt when the cache goes stale) and resolves
+/// the game-to-Discord direction only (there is no Discord→game path for the map).
+///
+/// Opens scopes for the scoped workspace store.
+/// Drives the cache TTL.
+internal sealed class MapChannelLocator(IServiceScopeFactory scopeFactory, IClock clock)
+ : IMapChannelLocator, IDisposable
+{
+ private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30);
+ private readonly SemaphoreSlim _refreshGate = new(1, 1);
+
+ private DateTimeOffset _builtAt = DateTimeOffset.MinValue;
+
+ private Dictionary<(ulong GuildId, Guid ServerId), ulong> _byServer = new();
+
+ ///
+ public void Dispose() => _refreshGate.Dispose();
+
+ ///
+ public async Task GetChannelIdAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken)
+ {
+ await EnsureFreshAsync(cancellationToken).ConfigureAwait(false);
+ return _byServer.TryGetValue((guildId, serverId), out var id) ? id : null;
+ }
+
+ private async Task EnsureFreshAsync(CancellationToken cancellationToken)
+ {
+ if (clock.UtcNow - _builtAt < CacheTtl)
+ {
+ return;
+ }
+
+ await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ if (clock.UtcNow - _builtAt < CacheTtl)
+ {
+ return;
+ }
+
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ var rows = await store.GetChannelsByKeyAsync(WorkspaceChannelKeys.ServerMap, cancellationToken)
+ .ConfigureAwait(false);
+
+ var byServer = new Dictionary<(ulong GuildId, Guid ServerId), ulong>();
+ foreach (var row in rows)
+ {
+ if (row.RustServerId is not { } serverId)
+ {
+ continue;
+ }
+
+ byServer[(row.GuildId, serverId)] = row.DiscordChannelId;
+ }
+
+ _byServer = byServer;
+ _builtAt = clock.UtcNow;
+ }
+ }
+ finally
+ {
+ _refreshGate.Release();
+ }
+ }
+}
diff --git a/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs
index 6511fb9f..e4cd5e5f 100644
--- a/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs
+++ b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs
@@ -14,6 +14,8 @@ public IEnumerable GetChannelSpecs() =>
ChannelPermissionProfile.Interactive, 1),
new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerEvents, "channel.events.name",
ChannelPermissionProfile.ReadOnly, 2),
+ new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerMap, "channel.map.name",
+ ChannelPermissionProfile.ReadOnly, 3),
];
///
diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs b/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs
index 1c7419af..ca167d32 100644
--- a/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs
+++ b/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs
@@ -20,6 +20,9 @@ internal static class WorkspaceChannelKeys
/// Key for the per-server #events channel.
public const string ServerEvents = "events";
+
+ /// The per-server rendered-map channel.
+ public const string ServerMap = "map";
}
/// Stable message keys persisted as ProvisionedMessage.MessageKey.
diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs
index 16f4e286..80b80f7b 100644
--- a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs
+++ b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs
@@ -60,6 +60,7 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services)
// Channel locators (singleton with TTL cache; IClock + IServiceScopeFactory provided by the host).
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddHostedService();
diff --git a/src/RustPlusBot.Host/Program.cs b/src/RustPlusBot.Host/Program.cs
index 79d07cde..be97c698 100644
--- a/src/RustPlusBot.Host/Program.cs
+++ b/src/RustPlusBot.Host/Program.cs
@@ -10,6 +10,7 @@
using RustPlusBot.Features.Commands;
using RustPlusBot.Features.Connections;
using RustPlusBot.Features.Events;
+using RustPlusBot.Features.Map;
using RustPlusBot.Features.Pairing;
using RustPlusBot.Features.Workspace;
using RustPlusBot.Host.Credentials;
@@ -67,6 +68,11 @@
.ValidateOnStart();
builder.Services.AddCommands();
builder.Services.AddEvents();
+builder.Services.AddOptions()
+ .Bind(builder.Configuration.GetSection("Map"))
+ .Validate(static o => o.MapRefreshInterval > TimeSpan.Zero, "Map:MapRefreshInterval must be positive.")
+ .ValidateOnStart();
+builder.Services.AddMap();
var host = builder.Build();
diff --git a/src/RustPlusBot.Host/RustPlusBot.Host.csproj b/src/RustPlusBot.Host/RustPlusBot.Host.csproj
index 59c9b2a3..b2456d02 100644
--- a/src/RustPlusBot.Host/RustPlusBot.Host.csproj
+++ b/src/RustPlusBot.Host/RustPlusBot.Host.csproj
@@ -24,5 +24,6 @@
+
diff --git a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs
index 76713ed1..8e3b4fab 100644
--- a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs
+++ b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs
@@ -133,6 +133,9 @@ internal sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocke
/// The monuments returned by . Defaults to empty.
public IReadOnlyList MonumentsResult { get; set; } = [];
+ /// The bytes returned by . Defaults to null.
+ public byte[]? MapImageResult { get; set; }
+
/// Raised when a team chat message arrives on this connection.
public event EventHandler? TeamMessageReceived;
@@ -194,6 +197,9 @@ public Task> GetMonumentsAsync(TimeSpan timeout,
CancellationToken cancellationToken = default) =>
Task.FromResult(MonumentsResult);
+ public Task GetMapImageAsync(TimeSpan timeout, CancellationToken cancellationToken = default) =>
+ Task.FromResult(MapImageResult);
+
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
///
diff --git a/tests/RustPlusBot.Features.Connections.Tests/MapImageQueryTests.cs b/tests/RustPlusBot.Features.Connections.Tests/MapImageQueryTests.cs
new file mode 100644
index 00000000..97d33ec3
--- /dev/null
+++ b/tests/RustPlusBot.Features.Connections.Tests/MapImageQueryTests.cs
@@ -0,0 +1,134 @@
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using NSubstitute;
+using RustPlusBot.Abstractions.Credentials;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Discord.Notifications;
+using RustPlusBot.Domain.Credentials;
+using RustPlusBot.Domain.Servers;
+using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Features.Connections.Supervisor;
+using RustPlusBot.Features.Connections.Tests.Fakes;
+using RustPlusBot.Persistence;
+using RustPlusBot.Persistence.Connections;
+using RustPlusBot.Persistence.Servers;
+
+namespace RustPlusBot.Features.Connections.Tests;
+
+public sealed class MapImageQueryTests
+{
+ private static (ServiceProvider Provider, ConnectionSupervisor Supervisor) CreateHarness(
+ FakeRustSocketSource source)
+ {
+ var protector = Substitute.For();
+ protector.Unprotect(Arg.Any()).Returns(c => c.Arg());
+ var dm = Substitute.For();
+
+ var clock = Substitute.For();
+ clock.UtcNow.Returns(DateTimeOffset.UnixEpoch);
+
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddSingleton(clock);
+ services.AddSingleton(protector);
+ services.AddSingleton(dm);
+ services.AddSingleton();
+
+ var cs = $"DataSource=mapimage-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
+ var keepAlive = new SqliteConnection(cs);
+ keepAlive.Open();
+ using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options))
+ {
+ seed.Database.Migrate();
+ }
+
+ services.AddSingleton(keepAlive);
+ services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options));
+ services.AddScoped();
+ services.AddScoped();
+ services.AddSingleton(source);
+ services.AddSingleton(Options.Create(new ConnectionOptions
+ {
+ ConnectTimeout = TimeSpan.FromSeconds(1),
+ InitialRetryDelay = TimeSpan.FromMilliseconds(5),
+ MaxRetryDelay = TimeSpan.FromMilliseconds(20),
+ HeartbeatInterval = TimeSpan.FromMilliseconds(20),
+ HeartbeatTimeout = TimeSpan.FromMilliseconds(200),
+ }));
+ services.AddSingleton();
+
+ var provider = services.BuildServiceProvider();
+ return (provider, provider.GetRequiredService());
+ }
+
+ private static async Task SeedServerWithActiveAsync(ServiceProvider provider, ulong steamId)
+ {
+ using var scope = provider.CreateScope();
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ var server = new RustServer
+ {
+ GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015
+ };
+ ctx.RustServers.Add(server);
+ ctx.PlayerCredentials.Add(new PlayerCredential
+ {
+ GuildId = 10UL,
+ RustServerId = server.Id,
+ OwnerUserId = 1UL,
+ SteamId = steamId,
+ ProtectedPlayerToken = "123",
+ Status = CredentialStatus.Active,
+ });
+ await ctx.SaveChangesAsync();
+ return server.Id;
+ }
+
+ [Fact]
+ public async Task GetMapImage_ReturnsBytes_WhenConnected()
+ {
+ var source = new FakeRustSocketSource();
+ var (provider, supervisor) = CreateHarness(source);
+ await using var _ = provider;
+ var serverId = await SeedServerWithActiveAsync(provider, steamId: 555UL);
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+ await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token);
+ await WaitUntilAsync(() => supervisor.HasLiveSocket(10UL, serverId), cts.Token);
+
+ source.LastConnection!.MapImageResult = new byte[]
+ {
+ 1, 2, 3
+ };
+ var image = await supervisor.GetMapImageAsync(10UL, serverId, cts.Token);
+
+ Assert.Equal(new byte[]
+ {
+ 1, 2, 3
+ }, image);
+ await supervisor.StopAllAsync();
+ }
+
+ [Fact]
+ public async Task GetMapImage_ReturnsNull_WhenNoLiveSocket()
+ {
+ var source = new FakeRustSocketSource();
+ var (provider, supervisor) = CreateHarness(source);
+ await using var _ = provider;
+
+ var image = await supervisor.GetMapImageAsync(10UL, Guid.NewGuid(), CancellationToken.None);
+
+ Assert.Null(image);
+ }
+
+ private static async Task WaitUntilAsync(Func condition, CancellationToken ct)
+ {
+ while (!condition())
+ {
+ ct.ThrowIfCancellationRequested();
+ await Task.Delay(10, ct);
+ }
+ }
+}
diff --git a/tests/RustPlusBot.Features.Map.Tests/BaseMapCacheTests.cs b/tests/RustPlusBot.Features.Map.Tests/BaseMapCacheTests.cs
new file mode 100644
index 00000000..1e24cb48
--- /dev/null
+++ b/tests/RustPlusBot.Features.Map.Tests/BaseMapCacheTests.cs
@@ -0,0 +1,76 @@
+using NSubstitute;
+using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Features.Map.Composing;
+using Xunit;
+
+namespace RustPlusBot.Features.Map.Tests;
+
+public sealed class BaseMapCacheTests
+{
+ private const ulong Guild = 1UL;
+ private static readonly Guid Server = Guid.NewGuid();
+
+ [Fact]
+ public async Task GetAsync_fetches_once_then_serves_from_cache()
+ {
+ var query = Substitute.For();
+ query.GetMapImageAsync(Guild, Server, Arg.Any())
+ .Returns(new byte[]
+ {
+ 9
+ });
+ var cache = new BaseMapCache(query);
+
+ var first = await cache.GetAsync(Guild, Server, CancellationToken.None);
+ var second = await cache.GetAsync(Guild, Server, CancellationToken.None);
+
+ Assert.Equal(new byte[]
+ {
+ 9
+ }, first);
+ Assert.Equal(new byte[]
+ {
+ 9
+ }, second);
+ await query.Received(1).GetMapImageAsync(Guild, Server, Arg.Any());
+ }
+
+ [Fact]
+ public async Task GetAsync_does_not_cache_null_and_retries()
+ {
+ var query = Substitute.For();
+ query.GetMapImageAsync(Guild, Server, Arg.Any())
+ .Returns((byte[]?)null, new byte[]
+ {
+ 7
+ });
+ var cache = new BaseMapCache(query);
+
+ var first = await cache.GetAsync(Guild, Server, CancellationToken.None);
+ var second = await cache.GetAsync(Guild, Server, CancellationToken.None);
+
+ Assert.Null(first);
+ Assert.Equal(new byte[]
+ {
+ 7
+ }, second);
+ await query.Received(2).GetMapImageAsync(Guild, Server, Arg.Any());
+ }
+
+ [Fact]
+ public async Task Clear_evicts_so_next_get_refetches()
+ {
+ var query = Substitute.For();
+ query.GetMapImageAsync(Guild, Server, Arg.Any()).Returns(new byte[]
+ {
+ 1
+ });
+ var cache = new BaseMapCache(query);
+
+ await cache.GetAsync(Guild, Server, CancellationToken.None);
+ cache.Clear(Guild, Server);
+ await cache.GetAsync(Guild, Server, CancellationToken.None);
+
+ await query.Received(2).GetMapImageAsync(Guild, Server, Arg.Any());
+ }
+}
diff --git a/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs
new file mode 100644
index 00000000..7d827da6
--- /dev/null
+++ b/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs
@@ -0,0 +1,90 @@
+using NSubstitute;
+using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Features.Events.State;
+using RustPlusBot.Features.Map.Composing;
+using RustPlusBot.Features.Map.Rendering;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+using Xunit;
+
+namespace RustPlusBot.Features.Map.Tests;
+
+public sealed class MapComposerTests
+{
+ private const ulong Guild = 1UL;
+ private static readonly Guid Server = Guid.NewGuid();
+ private static readonly MapDimensions Dims = new(4000, 4000, 500);
+
+ private static byte[] BaseJpeg()
+ {
+ using var img = new Image(64, 64, new Rgba32(0, 128, 0));
+ using var ms = new MemoryStream();
+ img.SaveAsJpeg(ms);
+ return ms.ToArray();
+ }
+
+ private static MapComposer Build(byte[]? baseImage, MapDimensions? dims, params ActiveMarker[] markers)
+ {
+ var query = Substitute.For();
+ query.GetMapImageAsync(Guild, Server, Arg.Any()).Returns(baseImage);
+ query.GetMapDimensionsAsync(Guild, Server, Arg.Any()).Returns(dims);
+ var events = Substitute.For();
+ events.GetActiveMarkers(Guild, Server, Arg.Any())
+ .Returns(ci => markers.Where(m => m.Kind == (MarkerKind)ci[2]!).ToList());
+ return new MapComposer(new BaseMapCache(query), events, query, new MapRenderer());
+ }
+
+ [Fact]
+ public async Task Returns_null_when_no_base_map_available()
+ {
+ var composer = Build(baseImage: null, dims: Dims);
+
+ var png = await composer.ComposeAsync(Guild, Server, CancellationToken.None);
+
+ Assert.Null(png);
+ }
+
+ [Fact]
+ public async Task Renders_a_png_when_base_map_available()
+ {
+ var marker = new ActiveMarker(1, MarkerKind.CargoShip, 2000f, 2000f, Dims, DateTimeOffset.UtcNow);
+ var composer = Build(BaseJpeg(), Dims, marker);
+
+ var png = await composer.ComposeAsync(Guild, Server, CancellationToken.None);
+
+ Assert.NotNull(png);
+ using var result = Image.Load(png!);
+ Assert.Equal(MapRenderer.OutputSize, result.Width);
+ }
+
+ [Fact]
+ public async Task Renders_the_grid_even_with_no_markers()
+ {
+ // Dimensions come from the query seam, not from a marker, so a connected server with no active
+ // markers still renders the gridded map (not a bare base tile).
+ var composer = Build(BaseJpeg(), Dims);
+
+ var withoutGrid = new MapRenderer().Render(BaseJpeg(), Dims, markers: [],
+ new MapLayerSet(Grid: false, Markers: false, Monuments: false, Vendor: false, Rigs: false));
+ var png = await composer.ComposeAsync(Guild, Server, CancellationToken.None);
+
+ Assert.NotNull(png);
+ using var result = Image.Load(png!);
+ Assert.Equal(MapRenderer.OutputSize, result.Width);
+ // The grid was drawn: the gridded output differs from a base-only (grid-off) render.
+ Assert.NotEqual(withoutGrid, png);
+ }
+
+ [Fact]
+ public async Task Renders_base_only_when_dimensions_unavailable()
+ {
+ var composer = Build(BaseJpeg(), dims: null,
+ new ActiveMarker(1, MarkerKind.CargoShip, 2000f, 2000f, Dims, DateTimeOffset.UtcNow));
+
+ var png = await composer.ComposeAsync(Guild, Server, CancellationToken.None);
+
+ Assert.NotNull(png);
+ using var result = Image.Load(png!);
+ Assert.Equal(MapRenderer.OutputSize, result.Width);
+ }
+}
diff --git a/tests/RustPlusBot.Features.Map.Tests/MapLayerSetTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapLayerSetTests.cs
new file mode 100644
index 00000000..94ada227
--- /dev/null
+++ b/tests/RustPlusBot.Features.Map.Tests/MapLayerSetTests.cs
@@ -0,0 +1,19 @@
+using RustPlusBot.Features.Map.Rendering;
+using Xunit;
+
+namespace RustPlusBot.Features.Map.Tests;
+
+public sealed class MapLayerSetTests
+{
+ [Fact]
+ public void Default2b_enables_grid_and_markers_only()
+ {
+ var set = MapLayerSet.Default2b;
+
+ Assert.True(set.Grid);
+ Assert.True(set.Markers);
+ Assert.False(set.Monuments);
+ Assert.False(set.Vendor);
+ Assert.False(set.Rigs);
+ }
+}
diff --git a/tests/RustPlusBot.Features.Map.Tests/MapRefreshThrottleTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapRefreshThrottleTests.cs
new file mode 100644
index 00000000..3973e4bb
--- /dev/null
+++ b/tests/RustPlusBot.Features.Map.Tests/MapRefreshThrottleTests.cs
@@ -0,0 +1,49 @@
+using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Features.Map.Hosting;
+using Xunit;
+
+namespace RustPlusBot.Features.Map.Tests;
+
+public sealed class MapRefreshThrottleTests
+{
+ private static readonly Guid Server = Guid.NewGuid();
+ private static readonly TimeSpan Interval = TimeSpan.FromSeconds(45);
+
+ [Fact]
+ public void First_call_allows_then_blocks_within_interval()
+ {
+ var clock = new TestClock();
+ var throttle = new MapRefreshThrottle(clock);
+
+ Assert.True(throttle.ShouldRefresh(1UL, Server, Interval));
+ clock.UtcNow = clock.UtcNow.AddSeconds(10);
+ Assert.False(throttle.ShouldRefresh(1UL, Server, Interval));
+ }
+
+ [Fact]
+ public void Allows_again_after_interval_elapses()
+ {
+ var clock = new TestClock();
+ var throttle = new MapRefreshThrottle(clock);
+
+ Assert.True(throttle.ShouldRefresh(1UL, Server, Interval));
+ clock.UtcNow = clock.UtcNow.AddSeconds(46);
+ Assert.True(throttle.ShouldRefresh(1UL, Server, Interval));
+ }
+
+ [Fact]
+ public void Separate_servers_throttle_independently()
+ {
+ var clock = new TestClock();
+ var throttle = new MapRefreshThrottle(clock);
+ var other = Guid.NewGuid();
+
+ Assert.True(throttle.ShouldRefresh(1UL, Server, Interval));
+ Assert.True(throttle.ShouldRefresh(1UL, other, Interval));
+ }
+
+ private sealed class TestClock : IClock
+ {
+ public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UnixEpoch;
+ }
+}
diff --git a/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs
new file mode 100644
index 00000000..2b994bd0
--- /dev/null
+++ b/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs
@@ -0,0 +1,42 @@
+using Microsoft.Extensions.DependencyInjection;
+using NSubstitute;
+using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Features.Events.State;
+using RustPlusBot.Features.Map.Composing;
+using RustPlusBot.Features.Map.Rendering;
+
+namespace RustPlusBot.Features.Map.Tests;
+
+public sealed class MapRegistrationTests
+{
+ [Fact]
+ public void AddMap_registers_renderer_cache_and_composer_as_singletons()
+ {
+ using var provider = BuildProvider();
+
+ var renderer = provider.GetRequiredService();
+ Assert.NotNull(renderer);
+
+ var cache = provider.GetRequiredService();
+ Assert.NotNull(cache);
+
+ var composer = provider.GetRequiredService();
+ Assert.NotNull(composer);
+
+ // Singletons: same instance each time.
+ Assert.Same(renderer, provider.GetRequiredService());
+ Assert.Same(cache, provider.GetRequiredService());
+ Assert.Same(composer, provider.GetRequiredService());
+ }
+
+ private static ServiceProvider BuildProvider()
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddSingleton(Substitute.For());
+ services.AddSingleton(Substitute.For());
+ services.AddMap();
+
+ return services.BuildServiceProvider(validateScopes: true);
+ }
+}
diff --git a/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs
new file mode 100644
index 00000000..ad43f26d
--- /dev/null
+++ b/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs
@@ -0,0 +1,46 @@
+using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Features.Map.Rendering;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace RustPlusBot.Features.Map.Tests;
+
+public sealed class MapRendererTests
+{
+ private static readonly MapDimensions Dims = new(Width: 4000, Height: 4000, OceanMargin: 500);
+
+ /// A 64x64 solid-green JPEG, generated once in-test so the renderer has a real base image to decode.
+ private static byte[] BaseJpeg()
+ {
+ using var img = new Image(64, 64, new Rgba32(0, 128, 0));
+ using var ms = new MemoryStream();
+ img.SaveAsJpeg(ms);
+ return ms.ToArray();
+ }
+
+ [Fact]
+ public void Render_produces_a_png_of_the_output_size()
+ {
+ var renderer = new MapRenderer();
+
+ var bytes = renderer.Render(BaseJpeg(), Dims, markers: [], MapLayerSet.Default2b);
+
+ using var result = Image.Load(bytes);
+ Assert.Equal(MapRenderer.OutputSize, result.Width);
+ Assert.Equal(MapRenderer.OutputSize, result.Height);
+ }
+
+ [Fact]
+ public void Render_with_a_marker_differs_from_render_without()
+ {
+ var renderer = new MapRenderer();
+ var jpeg = BaseJpeg();
+
+ var without = renderer.Render(jpeg, Dims, markers: [], new MapLayerSet(false, true, false, false, false));
+ var with = renderer.Render(jpeg, Dims,
+ markers: [new MarkerPlacement(MarkerKind.CargoShip, 512f, 512f)],
+ new MapLayerSet(false, true, false, false, false));
+
+ Assert.NotEqual(without, with); // The drawn marker changes the bytes.
+ }
+}
diff --git a/tests/RustPlusBot.Features.Map.Tests/MarkerGlyphsTests.cs b/tests/RustPlusBot.Features.Map.Tests/MarkerGlyphsTests.cs
new file mode 100644
index 00000000..565ccece
--- /dev/null
+++ b/tests/RustPlusBot.Features.Map.Tests/MarkerGlyphsTests.cs
@@ -0,0 +1,27 @@
+using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Features.Map.Assets;
+using SixLabors.ImageSharp;
+using Xunit;
+
+namespace RustPlusBot.Features.Map.Tests;
+
+public sealed class MarkerGlyphsTests
+{
+ [Theory]
+ [InlineData(MarkerKind.CargoShip, "C")]
+ [InlineData(MarkerKind.PatrolHelicopter, "H")]
+ [InlineData(MarkerKind.Chinook, "K")]
+ public void Known_kinds_have_distinct_letters(MarkerKind kind, string expected)
+ {
+ var (_, letter) = MarkerGlyphs.For(kind);
+ Assert.Equal(expected, letter);
+ }
+
+ [Fact]
+ public void Unknown_kind_falls_back_to_question_mark()
+ {
+ var (color, letter) = MarkerGlyphs.For(MarkerKind.Other);
+ Assert.Equal("?", letter);
+ Assert.NotEqual(default, color);
+ }
+}
diff --git a/tests/RustPlusBot.Features.Map.Tests/RustPlusBot.Features.Map.Tests.csproj b/tests/RustPlusBot.Features.Map.Tests/RustPlusBot.Features.Map.Tests.csproj
new file mode 100644
index 00000000..bdb8045f
--- /dev/null
+++ b/tests/RustPlusBot.Features.Map.Tests/RustPlusBot.Features.Map.Tests.csproj
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/RustPlusBot.Features.Map.Tests/WorldToPixelTests.cs b/tests/RustPlusBot.Features.Map.Tests/WorldToPixelTests.cs
new file mode 100644
index 00000000..44a88d19
--- /dev/null
+++ b/tests/RustPlusBot.Features.Map.Tests/WorldToPixelTests.cs
@@ -0,0 +1,40 @@
+using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Features.Map.Rendering;
+using Xunit;
+
+namespace RustPlusBot.Features.Map.Tests;
+
+public sealed class WorldToPixelTests
+{
+ private static readonly MapDimensions Dims = new(Width: 4000, Height: 4000, OceanMargin: 500);
+
+ [Fact]
+ public void Origin_world_maps_to_bottom_left_inside_margin()
+ {
+ // World (0,0) is the SW corner of the playable area. Full tile spans [-500, 4500] = 5000 units.
+ // Pixel-per-unit at outputSize 1000 = 1000/5000 = 0.2. World x=0 -> (0 - (-500)) * 0.2 = 100.
+ // World y=0 is the bottom -> image y = outputSize - 100 = 900.
+ var (px, py) = WorldToPixel.ToPixel(0f, 0f, Dims, outputSize: 1000);
+
+ Assert.Equal(100f, px, precision: 3);
+ Assert.Equal(900f, py, precision: 3);
+ }
+
+ [Fact]
+ public void Center_world_maps_to_center_pixel()
+ {
+ var (px, py) = WorldToPixel.ToPixel(2000f, 2000f, Dims, outputSize: 1000);
+
+ Assert.Equal(500f, px, precision: 3);
+ Assert.Equal(500f, py, precision: 3);
+ }
+
+ [Fact]
+ public void North_edge_maps_higher_than_south_edge()
+ {
+ var (_, southY) = WorldToPixel.ToPixel(2000f, 0f, Dims, outputSize: 1000);
+ var (_, northY) = WorldToPixel.ToPixel(2000f, 4000f, Dims, outputSize: 1000);
+
+ Assert.True(northY < southY); // North is visually higher = smaller image-Y.
+ }
+}
diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/MapChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/MapChannelLocatorTests.cs
new file mode 100644
index 00000000..633a121b
--- /dev/null
+++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/MapChannelLocatorTests.cs
@@ -0,0 +1,126 @@
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using NSubstitute;
+using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Domain.Servers;
+using RustPlusBot.Domain.Workspace;
+using RustPlusBot.Features.Workspace.Locating;
+using RustPlusBot.Persistence;
+using RustPlusBot.Persistence.Workspace;
+
+namespace RustPlusBot.Features.Workspace.Tests.Locating;
+
+public sealed class MapChannelLocatorTests
+{
+ private static (MapChannelLocator Locator, ServiceProvider Provider, string ConnectionString, IClock Clock)
+ CreateLocator()
+ {
+ var clock = Substitute.For();
+ clock.UtcNow.Returns(DateTimeOffset.UnixEpoch);
+
+ var cs = $"DataSource=map-locator-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
+ var keepAlive = new SqliteConnection(cs);
+ keepAlive.Open();
+ using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options))
+ {
+ seed.Database.Migrate();
+ }
+
+ var services = new ServiceCollection();
+ services.AddSingleton(keepAlive);
+ services.AddSingleton(clock);
+ services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options));
+ services.AddScoped();
+ var provider = services.BuildServiceProvider();
+
+ var locator = new MapChannelLocator(provider.GetRequiredService(), clock);
+ return (locator, provider, cs, clock);
+ }
+
+ private static async Task SeedAsync(string connectionString)
+ {
+ await using var context =
+ new BotDbContext(new DbContextOptionsBuilder().UseSqlite(connectionString).Options);
+
+ var server = new RustServer
+ {
+ GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015
+ };
+ context.RustServers.Add(server);
+ await context.SaveChangesAsync();
+
+ context.ProvisionedChannels.Add(new ProvisionedChannel
+ {
+ GuildId = 10UL,
+ RustServerId = server.Id,
+ ChannelKey = WorkspaceChannelKeys.ServerMap,
+ DiscordChannelId = 888UL,
+ CreatedAt = DateTimeOffset.UnixEpoch,
+ });
+ await context.SaveChangesAsync();
+
+ return server.Id;
+ }
+
+ [Fact]
+ public async Task GetChannelIdAsync_returns_provisioned_map_channel()
+ {
+ var (locator, provider, cs, _) = CreateLocator();
+ await using var _p = provider;
+ var serverId = await SeedAsync(cs);
+
+ var channelId = await locator.GetChannelIdAsync(10UL, serverId, CancellationToken.None);
+
+ Assert.Equal(888UL, channelId);
+ }
+
+ [Fact]
+ public async Task GetChannelIdAsync_returns_null_when_not_provisioned()
+ {
+ var (locator, provider, _, _) = CreateLocator();
+ await using var _p = provider;
+
+ Assert.Null(await locator.GetChannelIdAsync(10UL, Guid.NewGuid(), CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task Cache_refreshes_after_ttl_expires()
+ {
+ var (locator, provider, cs, clock) = CreateLocator();
+ await using var _p = provider;
+
+ // Cold load with empty DB — cache built at UnixEpoch, no rows.
+ var firstResult = await locator.GetChannelIdAsync(20UL, Guid.NewGuid(), CancellationToken.None);
+ Assert.Null(firstResult);
+
+ // Insert a server + channel into the DB after the first load.
+ await using var insertCtx =
+ new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options);
+ var server = new RustServer
+ {
+ GuildId = 20UL, Name = "T", Ip = "2.2.2.2", Port = 28015
+ };
+ insertCtx.RustServers.Add(server);
+ await insertCtx.SaveChangesAsync();
+ insertCtx.ProvisionedChannels.Add(new ProvisionedChannel
+ {
+ GuildId = 20UL,
+ RustServerId = server.Id,
+ ChannelKey = WorkspaceChannelKeys.ServerMap,
+ DiscordChannelId = 999UL,
+ CreatedAt = DateTimeOffset.UnixEpoch,
+ });
+ await insertCtx.SaveChangesAsync();
+
+ // Clock still at UnixEpoch — within the 30 s TTL, cache must NOT be reloaded.
+ var withinTtlResult = await locator.GetChannelIdAsync(20UL, server.Id, CancellationToken.None);
+ Assert.Null(withinTtlResult);
+
+ // Advance the clock past the 30 s TTL — next call must rebuild the cache.
+ clock.UtcNow.Returns(DateTimeOffset.UnixEpoch + TimeSpan.FromSeconds(31));
+
+ var afterTtlResult = await locator.GetChannelIdAsync(20UL, server.Id, CancellationToken.None);
+ Assert.Equal(999UL, afterTtlResult);
+ }
+}