diff --git a/src/RustPlusBot.Domain/Map/ServerMapSettings.cs b/src/RustPlusBot.Domain/Map/ServerMapSettings.cs index f6e44ee9..ce16aa7d 100644 --- a/src/RustPlusBot.Domain/Map/ServerMapSettings.cs +++ b/src/RustPlusBot.Domain/Map/ServerMapSettings.cs @@ -29,6 +29,9 @@ public sealed class ServerMapSettings /// Whether oil rigs are styled by activation state. public bool ShowRigs { get; set; } = true; + /// Whether train-tunnel entrance icons are drawn (separate from monuments). + public bool ShowTunnels { get; set; } = true; + /// Which grid convention the rendered map and event grid references use. public MapGridStyle GridStyle { get; set; } = MapGridStyle.InGame; } diff --git a/src/RustPlusBot.Features.Map/Assets/MapIcons.cs b/src/RustPlusBot.Features.Map/Assets/MapIcons.cs index 4111c26c..6e1f500d 100644 --- a/src/RustPlusBot.Features.Map/Assets/MapIcons.cs +++ b/src/RustPlusBot.Features.Map/Assets/MapIcons.cs @@ -27,7 +27,7 @@ public static class MapIcons public static Image? Marker(MarkerKind kind) { var key = KeyFor(kind); - return key is null ? null : Load(key); + return key is null ? null : Native(key); } /// Gets the marker icon scaled to fit a square box, or null when the kind has no icon. @@ -38,16 +38,7 @@ public static class MapIcons /// Gets the travelling vendor icon. /// The cached vendor icon image, or null. - public static Image? Vendor() => Load("vendor"); - - /// Gets the player position icon, or null when the asset is missing. - /// The player icon, or null. - public static Image? Player() => Load("player"); - - /// Gets the player icon scaled to fit a square box, or null when the asset is missing. - /// The box edge length in pixels. - /// The cached scaled icon, or null. - public static Image? Player(int size) => Scaled("player", size); + public static Image? Vendor() => Native("vendor"); private static string? KeyFor(MarkerKind kind) => kind switch { @@ -58,18 +49,27 @@ public static class MapIcons _ => null, }; - private static Image? Load(string key) => Cache.GetOrAdd(key, static k => + private static Image? Native(string? key) => key is null + ? null + : Cache.GetOrAdd(key, static k => k switch + { + "patrol" => MarkerIconComposer.Heli(), + "ch47" => MarkerIconComposer.Chinook(), + _ => LoadPng(k), + }); + + private static Image? LoadPng(string key) { var asm = typeof(MapIcons).Assembly; - using var stream = asm.GetManifestResourceStream(ResourcePrefix + k + ".png"); + using var stream = asm.GetManifestResourceStream(ResourcePrefix + key + ".png"); return stream is null ? null : Image.Load(stream); - }); + } private static Image? Scaled(string? key, int size) => key is null ? null : Cache.GetOrAdd($"{key}@{size}", _ => { - var native = Load(key); + var native = Native(key); return native?.Clone(ctx => ctx.Resize(new ResizeOptions { Mode = ResizeMode.Max, Size = new Size(size, size), diff --git a/src/RustPlusBot.Features.Map/Assets/MarkerIconComposer.cs b/src/RustPlusBot.Features.Map/Assets/MarkerIconComposer.cs new file mode 100644 index 00000000..86fe98df --- /dev/null +++ b/src/RustPlusBot.Features.Map/Assets/MarkerIconComposer.cs @@ -0,0 +1,87 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace RustPlusBot.Features.Map.Assets; + +/// +/// Builds the patrol-heli and CH47 marker icons by compositing a grey body with rotor blade(s): +/// the heli gets one blade on its hub, the CH47 gets two at its tandem-rotor positions. Blades are +/// drawn into a copy of the body canvas, so the composite keeps the body's dimensions and rotates +/// cleanly about its centre in . Offsets/scale are tuned against the +/// reference art (verify visually on a rendered tile). +/// +internal static class MarkerIconComposer +{ + private const string ResourcePrefix = "RustPlusBot.Features.Map.Assets.icons."; + + /// Main-rotor blade edge as a fraction of the body's shorter edge; spans past the fuselage. + private const float HeliBladeScale = 1.15f; + + /// Each tandem-rotor blade edge as a fraction of the body's shorter edge; smaller than the body. + private const float ChinookBladeScale = 0.62f; + + /// The single main-rotor hub offset, in body pixels (populated on first access). + internal static IReadOnlyList HeliRotorOffsets { get; } = HeliOffsets(); + + /// The two tandem-rotor offsets (front, rear), in body pixels. + internal static IReadOnlyList ChinookRotorOffsets { get; } = ChinookOffsets(); + + /// Builds the patrol-heli composite (body + one rotor blade on the hub). + /// The composited heli icon; caller owns disposal. + internal static Image Heli() => + Compose("heli", HeliRotorOffsets, HeliBladeScale); + + /// Builds the CH47 composite (body + two rotor blades at the tandem positions). + /// The composited chinook icon; caller owns disposal. + internal static Image Chinook() => + Compose("chinook", ChinookRotorOffsets, ChinookBladeScale); + + private static IReadOnlyList HeliOffsets() + { + using var body = Load("heli"); + + // Hub sits slightly forward of centre. + return [new PointF(body.Width / 2f, body.Height * 0.44f)]; + } + + private static IReadOnlyList ChinookOffsets() + { + using var body = Load("chinook"); + var cx = body.Width / 2f; + return + [ + new PointF(cx, body.Height * 0.20f), // front rotor + new PointF(cx, body.Height * 0.80f), // rear rotor + ]; + } + + private static Image Compose(string bodyKey, IReadOnlyList offsets, float bladeScale) + { + var canvas = Load(bodyKey); // returned to caller (cached by MapIcons); not disposed here + using var blade = Load("blade"); + var edge = (int)(Math.Min(canvas.Width, canvas.Height) * bladeScale); + using var scaledBlade = blade.Clone(c => c.Resize(edge, edge)); + + canvas.Mutate(ctx => + { + foreach (var offset in offsets) + { + var topLeft = new Point( + (int)(offset.X - (scaledBlade.Width / 2f)), + (int)(offset.Y - (scaledBlade.Height / 2f))); + ctx.DrawImage(scaledBlade, topLeft, 1f); + } + }); + + return canvas; + } + + private static Image Load(string key) + { + var asm = typeof(MarkerIconComposer).Assembly; + using var stream = asm.GetManifestResourceStream(ResourcePrefix + key + ".png") + ?? throw new InvalidOperationException($"Embedded marker asset '{key}.png' not found."); + return Image.Load(stream); + } +} diff --git a/src/RustPlusBot.Features.Map/Assets/TunnelTokens.cs b/src/RustPlusBot.Features.Map/Assets/TunnelTokens.cs new file mode 100644 index 00000000..6fc83d86 --- /dev/null +++ b/src/RustPlusBot.Features.Map/Assets/TunnelTokens.cs @@ -0,0 +1,14 @@ +namespace RustPlusBot.Features.Map.Assets; + +/// +/// The Rust+ monument tokens that belong to the separately-toggleable Tunnels layer rather than the +/// general Monuments layer. Trainyard and Military Tunnels stay ordinary monuments. +/// +public static class TunnelTokens +{ + /// The train-tunnel entrance and link tokens. + public static IReadOnlySet All { get; } = new HashSet(StringComparer.Ordinal) + { + "train_tunnel_display_name", "train_tunnel_link_display_name", + }; +} diff --git a/src/RustPlusBot.Features.Map/Assets/icons/blade.png b/src/RustPlusBot.Features.Map/Assets/icons/blade.png new file mode 100644 index 00000000..95b96877 Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/blade.png differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/ch47.png b/src/RustPlusBot.Features.Map/Assets/icons/ch47.png deleted file mode 100644 index 4af5d18b..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/ch47.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/chinook.png b/src/RustPlusBot.Features.Map/Assets/icons/chinook.png new file mode 100644 index 00000000..cbc32cbc Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/chinook.png differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/heli.png b/src/RustPlusBot.Features.Map/Assets/icons/heli.png new file mode 100644 index 00000000..a7bfa238 Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/heli.png differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/patrol.png b/src/RustPlusBot.Features.Map/Assets/icons/patrol.png deleted file mode 100644 index 9db1f49e..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/patrol.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/player.png b/src/RustPlusBot.Features.Map/Assets/icons/player.png deleted file mode 100644 index 5c6c8939..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/player.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/vendor.png b/src/RustPlusBot.Features.Map/Assets/icons/vendor.png index 1556ad97..676be5a2 100644 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/vendor.png and b/src/RustPlusBot.Features.Map/Assets/icons/vendor.png differ diff --git a/src/RustPlusBot.Features.Map/Composing/MapComposer.cs b/src/RustPlusBot.Features.Map/Composing/MapComposer.cs index a18e0de7..3ad82a97 100644 --- a/src/RustPlusBot.Features.Map/Composing/MapComposer.cs +++ b/src/RustPlusBot.Features.Map/Composing/MapComposer.cs @@ -2,6 +2,7 @@ using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Events.State; +using RustPlusBot.Features.Map.Assets; using RustPlusBot.Features.Map.Rendering; using RustPlusBot.Persistence.Map; using SixLabors.ImageSharp; @@ -31,8 +32,8 @@ public sealed class MapComposer( /// 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) + /// The rendered composition, or null when no base map is available yet. + public async Task ComposeAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) { // The settings store is scoped (EF context); this composer is a singleton, so we open a scope per // call to resolve it (mirroring MapHostedService.OnConnectionStatusAsync) — avoids a captive dependency. @@ -45,12 +46,12 @@ public sealed class MapComposer( } var layers = new MapLayerSet(settings.Grid, settings.Markers, settings.Monuments, - settings.Vendor, settings.Players, settings.Rigs); + settings.Vendor, settings.Players, settings.Rigs, settings.Tunnels); return await ComposeWithLayersAsync(guildId, serverId, layers, settings.GridStyle, cancellationToken) .ConfigureAwait(false); } - private async Task ComposeWithLayersAsync( + private async Task ComposeWithLayersAsync( ulong guildId, Guid serverId, MapLayerSet layers, @@ -69,10 +70,12 @@ public sealed class MapComposer( if (dims is null || dims.WorldSize == 0) { // Dimensions unavailable: render the base tile only (every overlay needs world→pixel). - return renderer.Render(baseImage.Bytes, new MapProjection(0, 1, 1, 0, MapRenderer.OutputSize), - markers: [], monuments: [], players: [], rigs: [], - new MapLayerSet(Grid: false, Markers: false, Monuments: false, Vendor: false, Players: false, - Rigs: false)); + return new MapComposition( + renderer.Render(baseImage.Bytes, new MapProjection(0, 1, 1, 0, MapRenderer.OutputSize), + markers: [], monuments: [], players: [], rigs: [], + new MapLayerSet(Grid: false, Markers: false, Monuments: false, Vendor: false, Players: false, + Rigs: false, Tunnels: false)), + Legend: null); } var projection = new MapProjection(dims.WorldSize, baseImage.PixelWidth, baseImage.PixelHeight, @@ -80,20 +83,22 @@ public sealed class MapComposer( var markers = GatherMarkers(guildId, serverId, projection, layers); - // Monuments feed both the monuments layer and the rig-styling layer; fetch them once when either is on. + // Monuments feed the monuments, rig-styling, and tunnels layers; fetch them once when any is on. IReadOnlyList serverMonuments = []; - if (layers.Monuments || layers.Rigs) + if (layers.Monuments || layers.Rigs || layers.Tunnels) { serverMonuments = await query.GetMonumentsAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); } var monuments = GatherMonuments(serverMonuments, projection, layers); - var players = await GatherPlayersAsync(guildId, serverId, projection, layers, cancellationToken) + var tunnels = GatherTunnels(serverMonuments, projection, layers); + var (players, legend) = await GatherPlayersAsync(guildId, serverId, projection, layers, cancellationToken) .ConfigureAwait(false); var rigPlacements = GatherRigs(guildId, serverId, serverMonuments, projection, layers); - return renderer.Render(baseImage.Bytes, projection, markers, monuments, players, rigPlacements, layers, - gridStyle); + var png = renderer.Render(baseImage.Bytes, projection, markers, monuments, players, rigPlacements, layers, + gridStyle, tunnels); + return new MapComposition(png, legend); } private List GatherMarkers( @@ -151,6 +156,11 @@ private static List GatherMonuments( { foreach (var mon in serverMonuments) { + if (TunnelTokens.All.Contains(mon.Token)) + { + continue; // routed to the Tunnels layer instead + } + var (px, py) = projection.ToPixel(mon.X, mon.Y); monuments.Add(new MonumentPlacement(mon.Token, px, py)); } @@ -159,25 +169,64 @@ private static List GatherMonuments( return monuments; } - private async Task> GatherPlayersAsync( + private static List GatherTunnels( + IReadOnlyList serverMonuments, + MapProjection projection, + MapLayerSet layers) + { + var tunnels = new List(); + if (layers.Tunnels) + { + foreach (var mon in serverMonuments.Where(m => TunnelTokens.All.Contains(m.Token))) + { + var (px, py) = projection.ToPixel(mon.X, mon.Y); + tunnels.Add(new MonumentPlacement(mon.Token, px, py)); + } + } + + return tunnels; + } + + private async Task<(List Players, MapLegend? Legend)> GatherPlayersAsync( ulong guildId, Guid serverId, MapProjection projection, MapLayerSet layers, CancellationToken cancellationToken) { - var players = new List(); - if (layers.Players) + if (!layers.Players) { - var team = await query.GetTeamInfoAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); - foreach (var member in team?.Members ?? []) - { - var (px, py) = projection.ToPixel(member.X, member.Y); - players.Add(new PlayerPlacement(member.Name, px, py, member.IsAlive, member.IsOnline)); - } + return ([], null); + } + + var team = await query.GetTeamInfoAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + // Stable color per player: order by SteamId, index into the palette. SteamId never changes, + // so a player keeps their color across refreshes regardless of online/offline ordering. The + // legend is built from the same index so a cross color and its legend row never drift. + var ordered = (team?.Members ?? []).OrderBy(m => m.SteamId).ToList(); + if (ordered.Count == 0) + { + return ([], null); } - return players; + var players = new List(ordered.Count); + var legend = new List(ordered.Count); + for (var i = 0; i < ordered.Count; i++) + { + var member = ordered[i]; + var color = PlayerPalette.For(i); + var (px, py) = projection.ToPixel(member.X, member.Y); + players.Add(new PlayerPlacement(member.Name, px, py, member.IsAlive, member.IsOnline, color.Rgba)); + legend.Add(new MapLegendEntry(color.Emoji, member.Name, StatusText(member))); + } + + return (players, new MapLegend(legend)); + } + + private static string StatusText(TeamMemberSnapshot member) + { + var presence = member.IsOnline ? "online" : "offline"; + return member.IsAlive ? presence : presence + ", dead"; } private List GatherRigs( diff --git a/src/RustPlusBot.Features.Map/Composing/MapLegend.cs b/src/RustPlusBot.Features.Map/Composing/MapLegend.cs new file mode 100644 index 00000000..89df17ef --- /dev/null +++ b/src/RustPlusBot.Features.Map/Composing/MapLegend.cs @@ -0,0 +1,16 @@ +namespace RustPlusBot.Features.Map.Composing; + +/// One legend row: a colored square, the player's name, and their status. +/// The colored-square emoji matching the player's on-map cross color. +/// The player's in-game display name. +/// Human-readable status, e.g. "online" or "offline, dead". +public sealed record MapLegendEntry(string Emoji, string Name, string Status); + +/// The player legend for a rendered map, one entry per teammate. +/// The legend rows, ordered as the crosses were assigned (by SteamId). +public sealed record MapLegend(IReadOnlyList Entries); + +/// A rendered map: the PNG plus its optional player legend. +/// The rendered PNG bytes. +/// The player legend, or null when the Players layer is off or the team is empty. +public sealed record MapComposition(byte[] Png, MapLegend? Legend); diff --git a/src/RustPlusBot.Features.Map/Hosting/MapHostedService.cs b/src/RustPlusBot.Features.Map/Hosting/MapHostedService.cs index 41c0c251..949a0a01 100644 --- a/src/RustPlusBot.Features.Map/Hosting/MapHostedService.cs +++ b/src/RustPlusBot.Features.Map/Hosting/MapHostedService.cs @@ -191,13 +191,13 @@ private async Task RefreshAsync(ulong guildId, Guid serverId, CancellationToken return; } - var png = await _composer.ComposeAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); - if (png is null) + var composition = await _composer.ComposeAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (composition is null) { return; } - await _poster.PostAsync(id, png, cancellationToken).ConfigureAwait(false); + await _poster.PostAsync(id, composition.Png, composition.Legend, cancellationToken).ConfigureAwait(false); } private async Task ConsumeConnectionStatusEventsAsync(CancellationToken cancellationToken) diff --git a/src/RustPlusBot.Features.Map/Posting/DiscordMapChannelPoster.cs b/src/RustPlusBot.Features.Map/Posting/DiscordMapChannelPoster.cs index 9af64031..85954eea 100644 --- a/src/RustPlusBot.Features.Map/Posting/DiscordMapChannelPoster.cs +++ b/src/RustPlusBot.Features.Map/Posting/DiscordMapChannelPoster.cs @@ -1,6 +1,7 @@ using Discord; using Discord.WebSocket; using Microsoft.Extensions.Logging; +using RustPlusBot.Features.Map.Composing; namespace RustPlusBot.Features.Map.Posting; @@ -15,7 +16,10 @@ internal sealed partial class DiscordMapChannelPoster( private const int RecentMessageScan = 10; /// - public async Task PostAsync(ulong channelId, byte[] pngBytes, CancellationToken cancellationToken) + public async Task PostAsync(ulong channelId, + byte[] pngBytes, + MapLegend? legend, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(pngBytes); try @@ -47,8 +51,8 @@ public async Task PostAsync(ulong channelId, byte[] pngBytes, CancellationToken var stream = new MemoryStream(pngBytes); await using (stream.ConfigureAwait(false)) { - await channel.SendFileAsync(stream, "map.png", options: options, allowedMentions: AllowedMentions.None) - .ConfigureAwait(false); + await channel.SendFileAsync(stream, "map.png", embed: MapLegendEmbed.Build(legend), + options: options, allowedMentions: AllowedMentions.None).ConfigureAwait(false); } } catch (OperationCanceledException) diff --git a/src/RustPlusBot.Features.Map/Posting/IMapChannelPoster.cs b/src/RustPlusBot.Features.Map/Posting/IMapChannelPoster.cs index 416e762d..1b19a242 100644 --- a/src/RustPlusBot.Features.Map/Posting/IMapChannelPoster.cs +++ b/src/RustPlusBot.Features.Map/Posting/IMapChannelPoster.cs @@ -1,3 +1,5 @@ +using RustPlusBot.Features.Map.Composing; + namespace RustPlusBot.Features.Map.Posting; /// Posts the rendered map image to a Discord channel, replacing the prior bot message. @@ -6,7 +8,8 @@ internal interface IMapChannelPoster /// Deletes the bot's prior map message (if any) and posts the new PNG. /// The #map channel id. /// The rendered PNG. + /// The player legend to attach as an embed, or null when there is none. /// A cancellation token. /// A task that completes when the post is issued. - Task PostAsync(ulong channelId, byte[] pngBytes, CancellationToken cancellationToken); + Task PostAsync(ulong channelId, byte[] pngBytes, MapLegend? legend, CancellationToken cancellationToken); } diff --git a/src/RustPlusBot.Features.Map/Posting/MapLegendEmbed.cs b/src/RustPlusBot.Features.Map/Posting/MapLegendEmbed.cs new file mode 100644 index 00000000..4de65afe --- /dev/null +++ b/src/RustPlusBot.Features.Map/Posting/MapLegendEmbed.cs @@ -0,0 +1,32 @@ +using System.Globalization; +using System.Text; +using Discord; +using RustPlusBot.Features.Map.Composing; + +namespace RustPlusBot.Features.Map.Posting; + +/// Builds the Discord legend embed for a rendered map's players (color square, name, status). +public static class MapLegendEmbed +{ + /// Builds the legend embed, or null when there is nothing to show. + /// The legend, or null. + /// An embed, or null when the legend is null or empty. + public static Embed? Build(MapLegend? legend) + { + if (legend is null || legend.Entries.Count == 0) + { + return null; + } + + var description = new StringBuilder(); + foreach (var entry in legend.Entries) + { + description.Append(CultureInfo.InvariantCulture, $"{entry.Emoji} **{entry.Name}** — {entry.Status}") + .Append('\n'); + } + + return new EmbedBuilder() + .WithDescription(description.ToString().TrimEnd('\n')) + .Build(); + } +} diff --git a/src/RustPlusBot.Features.Map/Rendering/MapLayerSet.cs b/src/RustPlusBot.Features.Map/Rendering/MapLayerSet.cs index 192cb97f..4ceefc42 100644 --- a/src/RustPlusBot.Features.Map/Rendering/MapLayerSet.cs +++ b/src/RustPlusBot.Features.Map/Rendering/MapLayerSet.cs @@ -7,8 +7,16 @@ namespace RustPlusBot.Features.Map.Rendering; /// Draw the travelling-vendor marker. /// Draw teammate position markers. /// Style oil rigs by activation state. -public sealed record MapLayerSet(bool Grid, bool Markers, bool Monuments, bool Vendor, bool Players, bool Rigs) +/// Draw train-tunnel entrance icons. +public sealed record MapLayerSet( + bool Grid, + bool Markers, + bool Monuments, + bool Vendor, + bool Players, + bool Rigs, + bool Tunnels = false) { /// All layers enabled — the defaults-on render. - public static MapLayerSet AllOn { get; } = new(true, true, true, true, true, true); + public static MapLayerSet AllOn { get; } = new(true, true, true, true, true, true, true); } diff --git a/src/RustPlusBot.Features.Map/Rendering/MapRenderStyle.cs b/src/RustPlusBot.Features.Map/Rendering/MapRenderStyle.cs index 6a985cb7..36463341 100644 --- a/src/RustPlusBot.Features.Map/Rendering/MapRenderStyle.cs +++ b/src/RustPlusBot.Features.Map/Rendering/MapRenderStyle.cs @@ -15,15 +15,30 @@ public static class MapRenderStyle /// Cargo-ship icon box edge, in output pixels (slightly larger — it is a big target). public const int CargoIconSize = 40; - /// Player icon box edge, in output pixels. - public const int PlayerIconSize = 20; + /// Player cross half-arm length, in output pixels. + public const float PlayerCrossArm = 7f; - /// Trail polyline stroke width, in output pixels. - public const float TrailWidth = 2f; + /// Player cross colored-stroke width, in output pixels. + public const float PlayerCrossWidth = 2.5f; + + /// Player cross dark-halo stroke width (drawn under the color for contrast). + public const float PlayerCrossHaloWidth = 4.5f; + + /// Opacity for offline players' crosses. + public const float PlayerOfflineAlpha = 0.5f; + + /// Trail polyline stroke width, in output pixels (thin, so it reads as a faint hint). + public const float TrailWidth = 1.25f; + + /// Opacity ceiling for the newest trail segment (older segments fade below this). + public const float TrailMaxAlpha = 0.4f; /// Grid cell label font size, in points. public const float GridLabelFontSize = 10f; + /// Dash pattern (on, off, on, off …) for the trail, in multiples of the pen width. + public static float[] TrailDash { get; } = [3f, 3f]; + /// Gets the icon box edge for a marker kind. /// The marker kind. /// The box edge length in output pixels. diff --git a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs index 56b5a03e..6e3147b1 100644 --- a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs +++ b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs @@ -22,10 +22,8 @@ public sealed class MapRenderer(MonumentIconSource monumentIcons) private const float OutlinePenWidth = 1f; private const float ActiveRingWidth = 3f; - private const float PlayerLabelOffset = 12f; private static readonly FontFamily Family = LoadFamily(); - private static readonly Font Font = Family.CreateFont(12f); private static readonly Font GridLabelFont = Family.CreateFont(MapRenderStyle.GridLabelFontSize); private static FontFamily LoadFamily() @@ -47,6 +45,7 @@ private static FontFamily LoadFamily() /// Oil-rig placements already projected to pixel coordinates. /// Which overlay layers to draw. /// Which grid convention to draw (in-game F1 map, or Rust+/RustMaps). + /// Train-tunnel placements already projected to pixel coordinates. /// PNG-encoded bytes of a square image with pixels on each side. public byte[] Render(byte[] baseJpeg, MapProjection projection, @@ -55,7 +54,8 @@ public byte[] Render(byte[] baseJpeg, IReadOnlyList players, IReadOnlyList rigs, MapLayerSet layers, - MapGridStyle gridStyle = MapGridStyle.InGame) + MapGridStyle gridStyle = MapGridStyle.InGame, + IReadOnlyList? tunnels = null) { ArgumentNullException.ThrowIfNull(baseJpeg); ArgumentNullException.ThrowIfNull(projection); @@ -78,6 +78,11 @@ public byte[] Render(byte[] baseJpeg, DrawMonuments(image, monuments); } + if (layers.Tunnels && tunnels is { Count: > 0 }) + { + DrawMonuments(image, tunnels); + } + if (layers.Markers || layers.Vendor) { DrawTrails(image, markers); @@ -173,10 +178,12 @@ private static void DrawTrails(Image image, IReadOnlyList image, IReadOnlyList rigs) private static void DrawPlayers(Image image, IReadOnlyList players) { - var icon = MapIcons.Player(MapRenderStyle.PlayerIconSize); - image.Mutate(ctx => { foreach (var player in players) { - DrawPlayerIcon(ctx, player, icon); - DrawPlayerLabel(ctx, player); + DrawPlayerCross(ctx, player); } }); } - private static void DrawPlayerIcon(IImageProcessingContext ctx, PlayerPlacement player, Image? icon) - { - if (icon is null) - { - return; - } - - var isActive = player is { IsAlive: true, IsOnline: true }; - // Dead/offline teammates render dimmed so status reads at a glance (label adds the suffix). - ctx.DrawImage(icon, CenterAt(player.PixelX, player.PixelY, icon), isActive ? 1f : 0.45f); - } - - private static void DrawPlayerLabel(IImageProcessingContext ctx, PlayerPlacement player) + private static void DrawPlayerCross(IImageProcessingContext ctx, PlayerPlacement player) { - var isActive = player is { IsAlive: true, IsOnline: true }; - var suffix = player.IsAlive ? " (offline)" : " (dead)"; - var label = isActive ? player.Name : player.Name + suffix; - var labelColor = isActive ? Color.White : Color.Gray; - var textOptions = new RichTextOptions(Font) - { - Origin = new PointF(player.PixelX, player.PixelY + PlayerLabelOffset), - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Top, - }; - ctx.DrawText(textOptions, label, labelColor); + const float arm = MapRenderStyle.PlayerCrossArm; + var alpha = player.IsOnline ? 1f : MapRenderStyle.PlayerOfflineAlpha; + var color = player.CrossColor.WithAlpha(alpha); + var halo = Color.FromRgba(0, 0, 0, (byte)(180 * alpha)); + + // Alive = '+', dead = 'x'. + var (a1, a2, b1, b2) = player.IsAlive + ? (new PointF(player.PixelX - arm, player.PixelY), new PointF(player.PixelX + arm, player.PixelY), + new PointF(player.PixelX, player.PixelY - arm), new PointF(player.PixelX, player.PixelY + arm)) + : (new PointF(player.PixelX - arm, player.PixelY - arm), + new PointF(player.PixelX + arm, player.PixelY + arm), + new PointF(player.PixelX - arm, player.PixelY + arm), + new PointF(player.PixelX + arm, player.PixelY - arm)); + + // Halo first (wider, dark), then the colored strokes on top. + ctx.DrawLine(halo, MapRenderStyle.PlayerCrossHaloWidth, a1, a2); + ctx.DrawLine(halo, MapRenderStyle.PlayerCrossHaloWidth, b1, b2); + ctx.DrawLine(color, MapRenderStyle.PlayerCrossWidth, a1, a2); + ctx.DrawLine(color, MapRenderStyle.PlayerCrossWidth, b1, b2); } private static Point CenterAt(float x, float y, Image icon) => diff --git a/src/RustPlusBot.Features.Map/Rendering/PlayerPalette.cs b/src/RustPlusBot.Features.Map/Rendering/PlayerPalette.cs new file mode 100644 index 00000000..b28bcd44 --- /dev/null +++ b/src/RustPlusBot.Features.Map/Rendering/PlayerPalette.cs @@ -0,0 +1,35 @@ +using SixLabors.ImageSharp; + +namespace RustPlusBot.Features.Map.Rendering; + +/// One palette entry: the on-map cross color and the matching Discord legend square. +/// The cross color drawn on the map. +/// The colored-square emoji used for this color in the legend. +public readonly record struct PlayerColor(Color Rgba, string Emoji); + +/// +/// The fixed nine-color player palette. Each color's RGB matches its Discord colored-square emoji so +/// an on-map cross and its legend row read as the same color. Players are assigned by stable index +/// (see the composer), wrapping past nine. +/// +public static class PlayerPalette +{ + /// The palette, ordered for maximum distinctiveness in the first slots. + public static IReadOnlyList Entries { get; } = + [ + new(Color.ParseHex("E03131"), "🟥"), // red + new(Color.ParseHex("1971C2"), "🟦"), // blue + new(Color.ParseHex("2F9E44"), "🟩"), // green + new(Color.ParseHex("F2CC0C"), "🟨"), // yellow + new(Color.ParseHex("9C36B5"), "🟪"), // purple + new(Color.ParseHex("E8590C"), "🟧"), // orange + new(Color.ParseHex("8B5E34"), "🟫"), // brown + new(Color.ParseHex("212529"), "⬛"), // black + new(Color.ParseHex("F1F3F5"), "⬜"), // white + ]; + + /// Gets the palette entry for a zero-based player index, wrapping past the palette length. + /// The stable zero-based player index. + /// The palette entry. + public static PlayerColor For(int index) => Entries[((index % Entries.Count) + Entries.Count) % Entries.Count]; +} diff --git a/src/RustPlusBot.Features.Map/Rendering/PlayerPlacement.cs b/src/RustPlusBot.Features.Map/Rendering/PlayerPlacement.cs index 6a578f3a..40d47a81 100644 --- a/src/RustPlusBot.Features.Map/Rendering/PlayerPlacement.cs +++ b/src/RustPlusBot.Features.Map/Rendering/PlayerPlacement.cs @@ -8,7 +8,14 @@ namespace RustPlusBot.Features.Map.Rendering; /// Pixel Y on the rendered tile. /// Whether the player is alive (dead players are styled differently). /// Whether the player is online. -public sealed record PlayerPlacement(string Name, float PixelX, float PixelY, bool IsAlive, bool IsOnline); +/// The palette color the on-map cross is drawn in. +public sealed record PlayerPlacement( + string Name, + float PixelX, + float PixelY, + bool IsAlive, + bool IsOnline, + SixLabors.ImageSharp.Color CrossColor); /// One monument to draw, already projected to pixel coordinates. /// The monument protobuf token (selects the icon). diff --git a/src/RustPlusBot.Features.Workspace/Messages/MapControlMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/MapControlMessageRenderer.cs index b69b0c79..fdc718a0 100644 --- a/src/RustPlusBot.Features.Workspace/Messages/MapControlMessageRenderer.cs +++ b/src/RustPlusBot.Features.Workspace/Messages/MapControlMessageRenderer.cs @@ -7,7 +7,7 @@ namespace RustPlusBot.Features.Workspace.Messages; -/// Renders the per-server #map control message: six ManageGuild layer-toggle buttons plus the +/// Renders the per-server #map control message: seven ManageGuild layer-toggle buttons plus the /// grid-style picker (in-game vs Rust+/RustMaps grid convention). /// Per-(guild, server) layer toggles. /// String resolution. @@ -31,7 +31,7 @@ public async ValueTask RenderAsync(MessageRenderContext context, var settings = await mapSettings.GetAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); var builder = new ComponentBuilder(); - // Discord caps an action row at five buttons, so split the six toggles across two rows. + // Discord caps an action row at five buttons, so split the seven toggles across two rows. AddToggle(builder, MapLayer.Grid, "map.layer.grid", settings.Grid, serverId, context.Culture, row: 0); AddToggle(builder, MapLayer.Markers, "map.layer.markers", settings.Markers, serverId, context.Culture, row: 0); AddToggle(builder, MapLayer.Monuments, "map.layer.monuments", settings.Monuments, serverId, context.Culture, @@ -39,6 +39,8 @@ public async ValueTask RenderAsync(MessageRenderContext context, AddToggle(builder, MapLayer.Vendor, "map.layer.vendor", settings.Vendor, serverId, context.Culture, row: 1); AddToggle(builder, MapLayer.Players, "map.layer.players", settings.Players, serverId, context.Culture, row: 1); AddToggle(builder, MapLayer.Rigs, "map.layer.rigs", settings.Rigs, serverId, context.Culture, row: 1); + AddToggle(builder, MapLayer.Tunnels, "map.layer.tunnels", settings.Tunnels, serverId, context.Culture, + row: 1); AddGridStyle(builder, MapGridStyle.InGame, "map.gridstyle.ingame", settings.GridStyle, serverId, context.Culture); AddGridStyle(builder, MapGridStyle.RustPlus, "map.gridstyle.rustplus", settings.GridStyle, serverId, diff --git a/src/RustPlusBot.Features.Workspace/Modules/MapComponentModule.cs b/src/RustPlusBot.Features.Workspace/Modules/MapComponentModule.cs index 76d0f810..c3dc2261 100644 --- a/src/RustPlusBot.Features.Workspace/Modules/MapComponentModule.cs +++ b/src/RustPlusBot.Features.Workspace/Modules/MapComponentModule.cs @@ -113,6 +113,7 @@ public async Task SetGridStyleAsync(string tail) MapLayer.Vendor => settings.Vendor, MapLayer.Players => settings.Players, MapLayer.Rigs => settings.Rigs, + MapLayer.Tunnels => settings.Tunnels, _ => true, }; } diff --git a/src/RustPlusBot.Localization/Strings.fr.resx b/src/RustPlusBot.Localization/Strings.fr.resx index 7f16ea7f..facc37f4 100644 --- a/src/RustPlusBot.Localization/Strings.fr.resx +++ b/src/RustPlusBot.Localization/Strings.fr.resx @@ -573,6 +573,9 @@ Plateformes + + Tunnels + Marchand diff --git a/src/RustPlusBot.Localization/Strings.resx b/src/RustPlusBot.Localization/Strings.resx index 94b863a6..55063fce 100644 --- a/src/RustPlusBot.Localization/Strings.resx +++ b/src/RustPlusBot.Localization/Strings.resx @@ -573,6 +573,9 @@ Rigs + + Tunnels + Vendor diff --git a/src/RustPlusBot.Persistence/Map/MapLayer.cs b/src/RustPlusBot.Persistence/Map/MapLayer.cs index 06e650fa..b92c1bc8 100644 --- a/src/RustPlusBot.Persistence/Map/MapLayer.cs +++ b/src/RustPlusBot.Persistence/Map/MapLayer.cs @@ -22,6 +22,9 @@ public enum MapLayer /// The oil-rig activation-styling layer. Rigs = 5, + + /// The train-tunnel entrance layer. + Tunnels = 6, } /// The resolved per-server layer toggles (all-on when no row is stored). @@ -31,6 +34,7 @@ public enum MapLayer /// Travelling vendor. /// Teammate positions. /// Oil-rig activation styling. +/// Train-tunnel entrance icons. /// Which grid convention the render and event grid references use. public sealed record MapLayerSettings( bool Grid, @@ -39,6 +43,7 @@ public sealed record MapLayerSettings( bool Vendor, bool Players, bool Rigs, + bool Tunnels = true, MapGridStyle GridStyle = MapGridStyle.InGame) { /// All layers enabled — the default when no settings row exists. diff --git a/src/RustPlusBot.Persistence/Map/MapSettingsStore.cs b/src/RustPlusBot.Persistence/Map/MapSettingsStore.cs index d6e96b79..d35a6089 100644 --- a/src/RustPlusBot.Persistence/Map/MapSettingsStore.cs +++ b/src/RustPlusBot.Persistence/Map/MapSettingsStore.cs @@ -19,7 +19,7 @@ public async Task GetAsync(ulong guildId, return row is null ? MapLayerSettings.AllOn : new MapLayerSettings(row.ShowGrid, row.ShowMarkers, row.ShowMonuments, row.ShowVendor, - row.ShowPlayers, row.ShowRigs, row.GridStyle); + row.ShowPlayers, row.ShowRigs, row.ShowTunnels, row.GridStyle); } /// @@ -57,6 +57,9 @@ public async Task SetLayerAsync(ulong guildId, case MapLayer.Rigs: row.ShowRigs = enabled; break; + case MapLayer.Tunnels: + row.ShowTunnels = enabled; + break; default: throw new ArgumentOutOfRangeException(nameof(layer), layer, "Unknown map layer."); } diff --git a/src/RustPlusBot.Persistence/Migrations/20260711191524_MapTunnelsLayer.Designer.cs b/src/RustPlusBot.Persistence/Migrations/20260711191524_MapTunnelsLayer.Designer.cs new file mode 100644 index 00000000..98fc0697 --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260711191524_MapTunnelsLayer.Designer.cs @@ -0,0 +1,729 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using RustPlusBot.Persistence; + +#nullable disable + +namespace RustPlusBot.Persistence.Migrations +{ + [DbContext(typeof(BotDbContext))] + [Migration("20260711191524_MapTunnelsLayer")] + partial class MapTunnelsLayer + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.9"); + + modelBuilder.Entity("Persistord.Core.Entities.ChannelEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildId"); + + b.HasIndex("ParentId"); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("Persistord.Core.Entities.GuildEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Guilds"); + }); + + modelBuilder.Entity("Persistord.Core.Entities.MemberEntity", b => + { + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("Nickname") + .HasColumnType("TEXT"); + + b.HasKey("GuildId", "UserId"); + + b.ToTable("Members"); + }); + + modelBuilder.Entity("Persistord.Core.Entities.RoleEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Color") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildId"); + + b.ToTable("Roles"); + }); + + modelBuilder.Entity("Persistord.Core.Entities.UserEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("GlobalName") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Alarms.SmartAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("LastIsActive") + .HasColumnType("INTEGER"); + + b.Property("LastTriggeredUtc") + .HasColumnType("TEXT"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PairedByUserId") + .HasColumnType("INTEGER"); + + b.Property("PingEveryone") + .HasColumnType("INTEGER"); + + b.Property("Reachability") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("RelayToTeamChat") + .HasColumnType("INTEGER"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "EntityId") + .IsUnique(); + + b.ToTable("SmartAlarms"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b => + { + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Muted") + .HasColumnType("INTEGER"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.HasKey("ServerId"); + + b.ToTable("ServerCommandSettings"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b => + { + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.Property("ActiveCredentialId") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("PlayerCount") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("RustServerId"); + + b.ToTable("ConnectionStates"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Credentials.FcmRegistration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .HasColumnType("INTEGER"); + + b.Property("ProtectedFcmCredentials") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildId", "OwnerUserId") + .IsUnique(); + + b.ToTable("FcmRegistrations"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .HasColumnType("INTEGER"); + + b.Property("ProtectedPlayerToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SteamId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RustServerId"); + + b.HasIndex("GuildId", "RustServerId", "OwnerUserId") + .IsUnique(); + + b.ToTable("PlayerCredentials"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Entities.PairedEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildId", "RustServerId"); + + b.ToTable("PairedEntities"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Events.EventSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EventKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildId", "RustServerId"); + + b.ToTable("EventSubscriptions"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Guilds.GuildSettings", b => + { + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("GuildId"); + + b.ToTable("GuildSettings"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Map.ServerMapSettings", b => + { + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("GridStyle") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("ShowGrid") + .HasColumnType("INTEGER"); + + b.Property("ShowMarkers") + .HasColumnType("INTEGER"); + + b.Property("ShowMonuments") + .HasColumnType("INTEGER"); + + b.Property("ShowPlayers") + .HasColumnType("INTEGER"); + + b.Property("ShowRigs") + .HasColumnType("INTEGER"); + + b.Property("ShowTunnels") + .HasColumnType("INTEGER"); + + b.Property("ShowVendor") + .HasColumnType("INTEGER"); + + b.HasKey("ServerId"); + + b.ToTable("ServerMapSettings"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Servers.RustServer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedByUserId") + .HasColumnType("INTEGER"); + + b.Property("FacepunchServerId") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Ip") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("FacepunchServerId"); + + b.HasIndex("GuildId"); + + b.HasIndex("GuildId", "Ip", "Port") + .IsUnique(); + + b.ToTable("RustServers"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.StorageMonitors.SmartStorageMonitor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PairedByUserId") + .HasColumnType("INTEGER"); + + b.Property("Reachability") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "EntityId") + .IsUnique(); + + b.ToTable("SmartStorageMonitors"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Switches.SmartSwitch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("LastIsActive") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PairedByUserId") + .HasColumnType("INTEGER"); + + b.Property("Reachability") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "EntityId") + .IsUnique(); + + b.ToTable("SmartSwitches"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DiscordCategoryId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RustServerId"); + + b.HasIndex("GuildId", "RustServerId") + .IsUnique(); + + b.ToTable("ProvisionedCategories"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChannelKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DiscordChannelId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RustServerId"); + + b.HasIndex("GuildId", "RustServerId", "ChannelKey") + .IsUnique(); + + b.ToTable("ProvisionedChannels"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DiscordChannelId") + .HasColumnType("INTEGER"); + + b.Property("DiscordMessageId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("MessageKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RustServerId"); + + b.HasIndex("GuildId", "RustServerId", "MessageKey") + .IsUnique(); + + b.ToTable("ProvisionedMessages"); + }); + + modelBuilder.Entity("Persistord.Core.Entities.ChannelEntity", b => + { + b.HasOne("Persistord.Core.Entities.ChannelEntity", null) + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Alarms.SmartAlarm", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithOne() + .HasForeignKey("RustPlusBot.Domain.Commands.ServerCommandSettings", "ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithOne() + .HasForeignKey("RustPlusBot.Domain.Connections.ConnectionState", "RustServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("RustServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Map.ServerMapSettings", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithOne() + .HasForeignKey("RustPlusBot.Domain.Map.ServerMapSettings", "ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.StorageMonitors.SmartStorageMonitor", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Switches.SmartSwitch", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedCategory", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("RustServerId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedChannel", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("RustServerId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedMessage", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("RustServerId") + .OnDelete(DeleteBehavior.Cascade); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/RustPlusBot.Persistence/Migrations/20260711191524_MapTunnelsLayer.cs b/src/RustPlusBot.Persistence/Migrations/20260711191524_MapTunnelsLayer.cs new file mode 100644 index 00000000..1194967c --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260711191524_MapTunnelsLayer.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RustPlusBot.Persistence.Migrations +{ + /// + public partial class MapTunnelsLayer : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ShowTunnels", + table: "ServerMapSettings", + type: "INTEGER", + nullable: false, + defaultValue: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ShowTunnels", + table: "ServerMapSettings"); + } + } +} diff --git a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs index 21b0ed21..e441cd7f 100644 --- a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs +++ b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs @@ -384,6 +384,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ShowRigs") .HasColumnType("INTEGER"); + b.Property("ShowTunnels") + .HasColumnType("INTEGER"); + b.Property("ShowVendor") .HasColumnType("INTEGER"); diff --git a/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs index 20452f6b..fe84a1ee 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs @@ -83,9 +83,9 @@ public async Task ComposeAsync_returns_null_when_no_base_map() var composer = Build(baseImage: null, Dims, NewQuery(), NewEvents(), NewRigs(), NewSettings(MapLayerSettings.AllOn)); - var png = await composer.ComposeAsync(Guild, Server, CancellationToken.None); + var result = await composer.ComposeAsync(Guild, Server, CancellationToken.None); - Assert.Null(png); + Assert.Null(result); } [Fact] @@ -96,11 +96,11 @@ public async Task Renders_a_png_when_base_map_available() var composer = Build(BaseJpeg(), Dims, NewQuery(), NewEvents(marker), NewRigs(), NewSettings(MapLayerSettings.AllOn)); - var png = await composer.ComposeAsync(Guild, Server, CancellationToken.None); + var result = await composer.ComposeAsync(Guild, Server, CancellationToken.None); - Assert.NotNull(png); - using var result = Image.Load(png!); - Assert.Equal(MapRenderer.OutputSize, result.Width); + Assert.NotNull(result); + using var image = Image.Load(result!.Png); + Assert.Equal(MapRenderer.OutputSize, image.Width); } [Fact] @@ -111,11 +111,11 @@ public async Task Renders_base_only_when_dimensions_unavailable() [new TrailPoint(2000f, 2000f)], null)), NewRigs(), NewSettings(MapLayerSettings.AllOn)); - var png = await composer.ComposeAsync(Guild, Server, CancellationToken.None); + var result = await composer.ComposeAsync(Guild, Server, CancellationToken.None); - Assert.NotNull(png); - using var result = Image.Load(png!); - Assert.Equal(MapRenderer.OutputSize, result.Width); + Assert.NotNull(result); + using var image = Image.Load(result!.Png); + Assert.Equal(MapRenderer.OutputSize, image.Width); } [Fact] @@ -129,19 +129,53 @@ public async Task ComposeAsync_renders_only_enabled_layers() var events = NewEvents(new ActiveMarker(1, MarkerKind.CargoShip, 2000f, 2000f, Dims, DateTimeOffset.UtcNow, [new TrailPoint(2000f, 2000f)], null)); var settings = NewSettings(new MapLayerSettings( - Grid: true, Markers: true, Monuments: false, Vendor: true, Players: true, Rigs: false)); + Grid: true, Markers: true, Monuments: false, Vendor: true, Players: true, Rigs: false, Tunnels: false)); var composer = Build(BaseJpeg(), Dims, query, events, NewRigs(), settings); - var png = await composer.ComposeAsync(Guild, Server, CancellationToken.None); + var result = await composer.ComposeAsync(Guild, Server, CancellationToken.None); - Assert.NotNull(png); + Assert.NotNull(result); // Monuments + Rigs both disabled -> no monument round-trip at all. await query.DidNotReceive().GetMonumentsAsync(Guild, Server, Arg.Any()); // Players enabled -> the team seam was consulted. await query.Received().GetTeamInfoAsync(Guild, Server, Arg.Any()); } + [Fact] + public async Task Tunnel_tokens_render_under_the_tunnels_layer_not_the_monuments_layer() + { + // A single tunnel-entrance monument. Every layer except Monuments/Tunnels is held OFF in all + // three configs, so any byte difference is attributable ONLY to the tunnel token's draw pass. + // - baseOnly: Monuments off, Tunnels off -> tunnel token never drawn (bare base render) + // - monumentsOnly: Monuments on, Tunnels off -> tunnel token EXCLUDED from the monuments pass + // - tunnelsOnly: Monuments off, Tunnels on -> tunnel token drawn via the tunnels pass + var baseResult = await ComposeTunnelScenarioAsync(monuments: false, tunnels: false); + var monResult = await ComposeTunnelScenarioAsync(monuments: true, tunnels: false); + var tunResult = await ComposeTunnelScenarioAsync(monuments: false, tunnels: true); + + Assert.NotNull(baseResult); + Assert.NotNull(monResult); + Assert.NotNull(tunResult); + // Monuments layer must NOT draw the tunnel token -> identical to the bare base render. + Assert.True(monResult!.Png.SequenceEqual(baseResult!.Png), + "tunnel token must be excluded from the Monuments pass"); + // Tunnels layer MUST draw the tunnel token -> differs from the bare base render. + Assert.False(tunResult!.Png.SequenceEqual(baseResult!.Png), "tunnel token must render under the Tunnels layer"); + } + + private static async Task ComposeTunnelScenarioAsync(bool monuments, bool tunnels) + { + var query = NewQuery(); + query.GetMonumentsAsync(Guild, Server, Arg.Any()) + .Returns([new MonumentSnapshot("train_tunnel_display_name", 2000f, 2000f)]); + var settings = new MapLayerSettings( + Grid: false, Markers: false, Monuments: monuments, Vendor: false, Players: false, Rigs: false, + Tunnels: tunnels); + var composer = Build(BaseJpeg(), Dims, query, NewEvents(), NewRigs(), NewSettings(settings)); + return await composer.ComposeAsync(Guild, Server, CancellationToken.None); + } + [Fact] public async Task ComposeAsync_uses_all_on_when_no_settings_row() { @@ -157,9 +191,9 @@ public async Task ComposeAsync_uses_all_on_when_no_settings_row() var composer = Build(BaseJpeg(), Dims, query, events, NewRigs(), NewSettings(MapLayerSettings.AllOn)); - var png = await composer.ComposeAsync(Guild, Server, CancellationToken.None); + var result = await composer.ComposeAsync(Guild, Server, CancellationToken.None); - Assert.NotNull(png); + Assert.NotNull(result); await query.Received().GetMonumentsAsync(Guild, Server, Arg.Any()); await query.Received().GetTeamInfoAsync(Guild, Server, Arg.Any()); } @@ -179,13 +213,13 @@ public async Task Renders_the_grid_even_with_no_markers() NewSettings(new MapLayerSettings( Grid: false, Markers: true, Monuments: false, Vendor: false, Players: false, Rigs: false))); - var pngOn = await composerOn.ComposeAsync(Guild, Server, CancellationToken.None); - var pngOff = await composerOff.ComposeAsync(Guild, Server, CancellationToken.None); + var resultOn = await composerOn.ComposeAsync(Guild, Server, CancellationToken.None); + var resultOff = await composerOff.ComposeAsync(Guild, Server, CancellationToken.None); - Assert.NotNull(pngOn); - Assert.NotNull(pngOff); + Assert.NotNull(resultOn); + Assert.NotNull(resultOff); // The grid layer must have painted at least one pixel differently. - Assert.False(pngOn!.SequenceEqual(pngOff!), "Grid-on and grid-off renders must differ."); + Assert.False(resultOn!.Png.SequenceEqual(resultOff!.Png), "Grid-on and grid-off renders must differ."); } [Fact] @@ -206,17 +240,51 @@ public async Task ComposeAsync_forwards_vendor_marker_history_into_rendered_trai var composerNoTrail = Build(jpeg, Dims, NewQuery(), NewEvents(vendorNoTrail), NewRigs(), NewSettings(layers)); - var pngWithTrail = await composerWithTrail.ComposeAsync(Guild, Server, CancellationToken.None); - var pngNoTrail = await composerNoTrail.ComposeAsync(Guild, Server, CancellationToken.None); + var resultWithTrail = await composerWithTrail.ComposeAsync(Guild, Server, CancellationToken.None); + var resultNoTrail = await composerNoTrail.ComposeAsync(Guild, Server, CancellationToken.None); - Assert.NotNull(pngWithTrail); - Assert.NotNull(pngNoTrail); + Assert.NotNull(resultWithTrail); + Assert.NotNull(resultNoTrail); // MapRenderer.DrawTrails only paints when Trail.Count >= 2, so a 2-point History must render // differently from a 1-point History — proving the history ring made it through to the trail. - Assert.False(pngWithTrail!.SequenceEqual(pngNoTrail!), + Assert.False(resultWithTrail!.Png.SequenceEqual(resultNoTrail!.Png), "Vendor trail with 2-point history must render differently than a 1-point history."); } + [Fact] + public async Task ComposeAsync_builds_a_legend_entry_per_player() + { + var query = NewQuery(); + query.GetTeamInfoAsync(Guild, Server, Arg.Any()) + .Returns(new TeamInfoSnapshot(0, + [ + new TeamMemberSnapshot(20, "Bob", 2000f, 2000f, IsOnline: false, IsAlive: false, default, default), + new TeamMemberSnapshot(10, "Ada", 2000f, 2000f, IsOnline: true, IsAlive: true, default, default), + ])); + var composer = Build(BaseJpeg(), Dims, query, NewEvents(), NewRigs(), + NewSettings(MapLayerSettings.AllOn)); + + var result = await composer.ComposeAsync(Guild, Server, CancellationToken.None); + + Assert.NotNull(result); + Assert.NotNull(result!.Legend); + // Ordered by SteamId: Ada (10) first, Bob (20) second. + Assert.Collection(result.Legend!.Entries, + e => + { + Assert.Equal("Ada", e.Name); + Assert.Equal("online", e.Status); + }, + e => + { + Assert.Equal("Bob", e.Name); + Assert.Equal("offline, dead", e.Status); + }); + // Ada gets palette[0], Bob palette[1]. + Assert.Equal(PlayerPalette.For(0).Emoji, result.Legend.Entries[0].Emoji); + Assert.Equal(PlayerPalette.For(1).Emoji, result.Legend.Entries[1].Emoji); + } + private sealed class FakeSource(BaseMapImage? result) : IBaseMapSource { public Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) => diff --git a/tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs index fc9cf2c8..0727162e 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs @@ -12,10 +12,6 @@ public sealed class MapIconsTests public void Marker_resolves_for_known_kinds(MarkerKind kind) => Assert.NotNull(MapIcons.Marker(kind)); - [Fact] - public void Player_resolves_to_vendored_icon() => - Assert.NotNull(MapIcons.Player()); - [Fact] public void Sized_marker_icon_fits_the_requested_box() { @@ -29,7 +25,7 @@ public void Sized_marker_icon_fits_the_requested_box() [Fact] public void Sized_icons_are_cached_per_size() { - Assert.Same(MapIcons.Player(20), MapIcons.Player(20)); - Assert.NotSame(MapIcons.Player(20), MapIcons.Player(24)); + Assert.Same(MapIcons.Marker(MarkerKind.CargoShip, 40), MapIcons.Marker(MarkerKind.CargoShip, 40)); + Assert.NotSame(MapIcons.Marker(MarkerKind.CargoShip, 40), MapIcons.Marker(MarkerKind.CargoShip, 44)); } } diff --git a/tests/RustPlusBot.Features.Map.Tests/MapLayerSetTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapLayerSetTests.cs index 8d3a7f9d..9f364c11 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapLayerSetTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapLayerSetTests.cs @@ -15,5 +15,6 @@ public void AllOn_enables_every_layer() Assert.True(set.Vendor); Assert.True(set.Players); Assert.True(set.Rigs); + Assert.True(set.Tunnels); } } diff --git a/tests/RustPlusBot.Features.Map.Tests/MapLegendEmbedTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapLegendEmbedTests.cs new file mode 100644 index 00000000..e1600058 --- /dev/null +++ b/tests/RustPlusBot.Features.Map.Tests/MapLegendEmbedTests.cs @@ -0,0 +1,32 @@ +using RustPlusBot.Features.Map.Composing; +using RustPlusBot.Features.Map.Posting; + +namespace RustPlusBot.Features.Map.Tests; + +public sealed class MapLegendEmbedTests +{ + [Fact] + public void Build_returns_null_for_no_legend() + { + Assert.Null(MapLegendEmbed.Build(null)); + Assert.Null(MapLegendEmbed.Build(new MapLegend([]))); + } + + [Fact] + public void Build_lists_one_line_per_player() + { + var legend = new MapLegend( + [ + new MapLegendEntry("🟥", "Ada", "online"), + new MapLegendEntry("🟦", "Bob", "offline, dead"), + ]); + + var embed = MapLegendEmbed.Build(legend); + + Assert.NotNull(embed); + Assert.Contains("Ada", embed!.Description, StringComparison.Ordinal); + Assert.Contains("online", embed.Description, StringComparison.Ordinal); + Assert.Contains("Bob", embed.Description, StringComparison.Ordinal); + Assert.Contains("🟦", embed.Description, StringComparison.Ordinal); + } +} diff --git a/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs index 157d8bda..28933ef4 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs @@ -100,7 +100,8 @@ public void Render_with_all_layers_produces_valid_png() }; var players = new[] { - new PlayerPlacement("Alice", 300, 300, IsAlive: true, IsOnline: true) + new PlayerPlacement("Alice", 300, 300, IsAlive: true, IsOnline: true, + PlayerPalette.For(0).Rgba) }; var rigs = new[] { @@ -189,4 +190,55 @@ public void Rotation_changes_the_rendered_icon() Assert.False(unrotated.AsSpan().SequenceEqual(rotated)); } + + [Fact] + public void Player_cross_paints_in_the_assigned_color() + { + var renderer = CreateRenderer(); + var projection = new MapProjection(4000, 2000, 2000, 100, MapRenderer.OutputSize); + var baseJpeg = SolidJpeg(2000); + var (px, py) = projection.ToPixel(2000f, 2000f); + var layers = new MapLayerSet(false, false, false, false, true, false); + var red = SixLabors.ImageSharp.Color.ParseHex("E03131"); + + var without = renderer.Render(baseJpeg, projection, [], [], [], [], layers); + var with = renderer.Render(baseJpeg, projection, [], [], + [new PlayerPlacement("A", px, py, IsAlive: true, IsOnline: true, red)], [], layers); + + Assert.False(without.AsSpan().SequenceEqual(with)); + // A red-dominant pixel must appear where the cross was drawn. + using var img = SixLabors.ImageSharp.Image.Load(with); + var found = false; + for (var dy = -8; dy <= 8 && !found; dy++) + { + for (var dx = -8; dx <= 8 && !found; dx++) + { + var p = img[(int)px + dx, (int)py + dy]; + if (p.R > 150 && p.G < 120 && p.B < 120) + { + found = true; + } + } + } + + Assert.True(found, "no red cross pixel near the player position"); + } + + [Fact] + public void Alive_and_dead_players_render_differently() + { + var renderer = CreateRenderer(); + var projection = new MapProjection(4000, 2000, 2000, 100, MapRenderer.OutputSize); + var baseJpeg = SolidJpeg(2000); + var (px, py) = projection.ToPixel(2000f, 2000f); + var layers = new MapLayerSet(false, false, false, false, true, false); + var blue = SixLabors.ImageSharp.Color.ParseHex("1971C2"); + + var alive = renderer.Render(baseJpeg, projection, [], [], + [new PlayerPlacement("A", px, py, IsAlive: true, IsOnline: true, blue)], [], layers); + var dead = renderer.Render(baseJpeg, projection, [], [], + [new PlayerPlacement("A", px, py, IsAlive: false, IsOnline: true, blue)], [], layers); + + Assert.False(alive.AsSpan().SequenceEqual(dead)); // '+' vs 'x' + } } diff --git a/tests/RustPlusBot.Features.Map.Tests/MarkerIconComposerTests.cs b/tests/RustPlusBot.Features.Map.Tests/MarkerIconComposerTests.cs new file mode 100644 index 00000000..770b6460 --- /dev/null +++ b/tests/RustPlusBot.Features.Map.Tests/MarkerIconComposerTests.cs @@ -0,0 +1,59 @@ +using RustPlusBot.Features.Map.Assets; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace RustPlusBot.Features.Map.Tests; + +public sealed class MarkerIconComposerTests +{ + [Fact] + public void Heli_has_one_rotor() + { + Assert.Single(MarkerIconComposer.HeliRotorOffsets); + using var heli = MarkerIconComposer.Heli(); + Assert.True(heli.Width > 0 && heli.Height > 0); + } + + [Fact] + public void Chinook_has_two_rotors_placed_apart() + { + Assert.Equal(2, MarkerIconComposer.ChinookRotorOffsets.Count); + using var chinook = MarkerIconComposer.Chinook(); + + // One rotor in the top half of the body, one in the bottom half (tandem placement). + var midY = chinook.Height / 2f; + Assert.Contains(MarkerIconComposer.ChinookRotorOffsets, p => p.Y < midY); + Assert.Contains(MarkerIconComposer.ChinookRotorOffsets, p => p.Y > midY); + } + + [Fact] + public void Composited_chinook_adds_blade_pixels_to_the_bare_body() + { + // The composite must differ from the bare body — proving blades were actually drawn on. + using var composite = MarkerIconComposer.Chinook(); + using var body = LoadEmbedded("chinook"); + + Assert.Equal(body.Width, composite.Width); + Assert.Equal(body.Height, composite.Height); + var changed = 0; + for (var y = 0; y < body.Height; y++) + { + for (var x = 0; x < body.Width; x++) + { + if (body[x, y] != composite[x, y]) + { + changed++; + } + } + } + + Assert.True(changed > 0, "composite identical to bare body — no blades drawn"); + } + + private static Image LoadEmbedded(string key) + { + var asm = typeof(MarkerIconComposer).Assembly; + using var stream = asm.GetManifestResourceStream($"RustPlusBot.Features.Map.Assets.icons.{key}.png")!; + return Image.Load(stream); + } +} diff --git a/tests/RustPlusBot.Features.Map.Tests/PlayerPaletteTests.cs b/tests/RustPlusBot.Features.Map.Tests/PlayerPaletteTests.cs new file mode 100644 index 00000000..9a4b7fa2 --- /dev/null +++ b/tests/RustPlusBot.Features.Map.Tests/PlayerPaletteTests.cs @@ -0,0 +1,21 @@ +using RustPlusBot.Features.Map.Rendering; + +namespace RustPlusBot.Features.Map.Tests; + +public sealed class PlayerPaletteTests +{ + [Fact] + public void Has_nine_distinct_colors_with_emoji() + { + Assert.Equal(9, PlayerPalette.Entries.Count); + Assert.Equal(9, PlayerPalette.Entries.Select(e => e.Emoji).Distinct().Count()); + Assert.All(PlayerPalette.Entries, e => Assert.False(string.IsNullOrEmpty(e.Emoji))); + } + + [Fact] + public void For_wraps_past_the_palette_length() + { + Assert.Equal(PlayerPalette.For(0), PlayerPalette.For(9)); + Assert.Equal(PlayerPalette.For(1), PlayerPalette.For(10)); + } +} diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/MapControlMessageRendererTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/MapControlMessageRendererTests.cs index 8a8ba981..98cf0eeb 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Messages/MapControlMessageRendererTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/MapControlMessageRendererTests.cs @@ -20,7 +20,7 @@ private static List Buttons(MessagePayload payload) => ]; [Fact] - public async Task Renders_six_toggle_buttons_reflecting_settings() + public async Task Renders_seven_toggle_buttons_reflecting_settings() { var serverId = Guid.NewGuid(); var settings = Substitute.For(); @@ -35,7 +35,7 @@ public async Task Renders_six_toggle_buttons_reflecting_settings() var toggles = Buttons(payload) .Where(b => b.CustomId!.StartsWith(WorkspaceComponentIds.MapTogglePrefix, StringComparison.Ordinal)) .ToList(); - Assert.Equal(6, toggles.Count); + Assert.Equal(7, toggles.Count); var monuments = toggles.Single(b => b.CustomId == $"workspace:map:toggle:{MapLayer.Monuments}:{serverId}"); diff --git a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs index c19d6c47..7e051955 100644 --- a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs +++ b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs @@ -41,6 +41,6 @@ public void English_covers_every_french_key() [Fact] public void Catalog_has_expected_key_count() { - Assert.Equal(267, EnglishKeys().Count); + Assert.Equal(268, EnglishKeys().Count); } } diff --git a/tests/RustPlusBot.Persistence.Tests/Map/MapSettingsStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Map/MapSettingsStoreTests.cs index a5fea841..a5a38350 100644 --- a/tests/RustPlusBot.Persistence.Tests/Map/MapSettingsStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Map/MapSettingsStoreTests.cs @@ -64,6 +64,21 @@ public async Task SetLayerAsync_updates_existing_row() Assert.True(result.Players); } + [Fact] + public async Task SetLayerAsync_can_disable_tunnels_only() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + var server = SeedServer(context); + + await new MapSettingsStore(context).SetLayerAsync(1UL, server.Id, MapLayer.Tunnels, enabled: false); + var result = await new MapSettingsStore(context).GetAsync(1UL, server.Id); + + Assert.False(result.Tunnels); + Assert.True(result.Monuments); // other layers untouched + } + [Fact] public async Task GridStyle_defaults_to_in_game() { diff --git a/tests/RustPlusBot.Persistence.Tests/Map/ServerMapSettingsSchemaTests.cs b/tests/RustPlusBot.Persistence.Tests/Map/ServerMapSettingsSchemaTests.cs index 4354647c..b033d7e0 100644 --- a/tests/RustPlusBot.Persistence.Tests/Map/ServerMapSettingsSchemaTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Map/ServerMapSettingsSchemaTests.cs @@ -33,6 +33,7 @@ public async Task ServerMapSettings_persists_with_all_layers_on_by_default() Assert.True(read.ShowVendor); Assert.True(read.ShowPlayers); Assert.True(read.ShowRigs); + Assert.True(read.ShowTunnels); } [Fact]