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]