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); + } +}