diff --git a/.editorconfig b/.editorconfig index b1b7658d..2897f41f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -392,6 +392,7 @@ roslynator_compiler_diagnostic_fixes.enabled = true dotnet_diagnostic.CA1002.severity = none # Do not expose generic lists — API uses List intentionally for protobuf collections dotnet_diagnostic.CA1008.severity = none # Enums should have zero value dotnet_diagnostic.CA1031.severity = none # Do not catch general exception types (intentional for fail-safe CLI resilience) +dotnet_diagnostic.CA1054.severity = none # Uri parameters should not be strings — same call as CA1056 below; RustMaps URLs travel as plain strings end to end dotnet_diagnostic.CA1056.severity = none # Uri properties should not be strings dotnet_diagnostic.CA1303.severity = none # Do not pass literals as localized parameters (literal strings in console output — CLI tool not localized) dotnet_diagnostic.CA1308.severity = none # Normalize strings to uppercase diff --git a/Directory.Packages.props b/Directory.Packages.props index 98d76429..12c2b1e2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,8 +13,9 @@ - - + + + diff --git a/RustPlusBot.slnx b/RustPlusBot.slnx index 40f51b35..fea52606 100644 --- a/RustPlusBot.slnx +++ b/RustPlusBot.slnx @@ -21,6 +21,7 @@ + diff --git a/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs b/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs index bd4b8cb9..c5e2d603 100644 --- a/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs +++ b/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs @@ -46,6 +46,13 @@ public interface IRustServerQuery /// The map dimensions, or null when there is no live socket. Task GetMapDimensionsAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken); + /// Gets the world size and seed of a connected server, or null when unavailable. + /// The owning guild snowflake. + /// The target server id. + /// A cancellation token. + /// The world snapshot, or null. + Task GetWorldAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken); + /// Gets the server's monuments, or an empty list when there is no live socket. /// The owning guild snowflake. /// The target server id. diff --git a/src/RustPlusBot.Abstractions/Connections/MapDimensions.cs b/src/RustPlusBot.Abstractions/Connections/MapDimensions.cs index 1cc4e4d0..f25de7dc 100644 --- a/src/RustPlusBot.Abstractions/Connections/MapDimensions.cs +++ b/src/RustPlusBot.Abstractions/Connections/MapDimensions.cs @@ -1,7 +1,8 @@ namespace RustPlusBot.Abstractions.Connections; -/// The static-per-wipe map size needed to convert world coordinates to a grid reference. -/// Map image width. -/// Map image height. -/// The ocean margin around the playable area. -public sealed record MapDimensions(uint Width, uint Height, int OceanMargin); +/// Dimensions of the server-rendered map tile plus the world size. +/// Width of the base map image, in pixels. +/// Height of the base map image, in pixels. +/// Ocean border baked into the base map image, in pixels. +/// Size of the playable world, in game units (from server info). +public sealed record MapDimensions(uint Width, uint Height, int OceanMargin, uint WorldSize); diff --git a/src/RustPlusBot.Abstractions/Connections/MapGrid.cs b/src/RustPlusBot.Abstractions/Connections/MapGrid.cs new file mode 100644 index 00000000..2b78a069 --- /dev/null +++ b/src/RustPlusBot.Abstractions/Connections/MapGrid.cs @@ -0,0 +1,77 @@ +using System.Globalization; +using System.Text; + +namespace RustPlusBot.Abstractions.Connections; + +/// +/// Rust map grid math shared by the map renderer and grid-reference formatting. +/// One cell is 146.25 game units; the grid is anchored at the world's NORTH-WEST corner +/// (Rust / RustMaps / companion-app behaviour): columns are lettered west→east from A, rows are +/// numbered north→south from 0, and the partial edge cell (when the world size is not a whole +/// multiple of the cell size) sits along the south and east edges. +/// +public static class MapGrid +{ + /// Edge length of one (whole) grid cell, in game units. + public const float CellSize = 146.25f; + + /// + /// How far south of the world's north edge the Rust+/RustMaps grid rows start, in game units. + /// Measured exactly (100.0) from RustMaps' own pre-rendered grid tiles across map sizes + /// 1500–4500; the in-game (F1) map uses no inset. Columns have no inset in either style. + /// + public const float RustPlusRowInset = 100f; + + /// Gets the row inset (south of the world's north edge) for a grid style. + /// The grid style. + /// The inset in game units. + public static float RowInset(MapGridStyle style) => + style == MapGridStyle.RustPlus ? RustPlusRowInset : 0f; + + /// + /// Number of grid cells per axis, covering the whole world size — ceil(worldSize / CellSize). + /// The last cell (east-most column / south-most row) is a partial edge cell whenever the world size + /// is not a whole multiple of ; it is still a labelled cell, matching how Rust, + /// the Rust+ companion app and RustMaps draw the grid. (A 1500 world → 11 cells: A–K, rows 0–10.) + /// + /// The world size in game units. + /// The cell count (at least 1). + public static int CellCount(uint worldSize) + { + var full = (int)(worldSize / CellSize); + var hasPartial = worldSize - (full * CellSize) > 0.5f; + return Math.Max(1, hasPartial ? full + 1 : full); + } + + /// Formats a spreadsheet-style column label (0→A … 25→Z, 26→AA …). + /// The zero-based column index. + /// The column letters. + public static string ColumnLetters(int index) + { + var sb = new StringBuilder(); + var n = index; + do + { + sb.Insert(0, (char)('A' + (n % 26))); + n = (n / 26) - 1; + } while (n >= 0); + + return sb.ToString(); + } + + /// Formats the grid label ("D7") for a world coordinate. + /// World X (west→east). + /// World Y (south→north). + /// The world size in game units. + /// Which grid convention to bin against (defaults to the in-game map). + /// The grid label, rows numbered from the top; out-of-world coordinates clamp to the edge cell. + public static string LabelFor(float x, float y, uint worldSize, MapGridStyle style = MapGridStyle.InGame) + { + // Rows bin from the style's row anchor (north edge in-game; 100 units south of it for + // Rust+/RustMaps), not the south — with a partial edge cell the two directions disagree. + var cells = CellCount(worldSize); + var col = Math.Clamp((int)MathF.Floor(x / CellSize), 0, cells - 1); + var row = Math.Clamp((int)MathF.Floor((worldSize - RowInset(style) - y) / CellSize), 0, cells - 1); + return string.Create(CultureInfo.InvariantCulture, $"{ColumnLetters(col)}{row}"); + } +} diff --git a/src/RustPlusBot.Abstractions/Connections/MapGridStyle.cs b/src/RustPlusBot.Abstractions/Connections/MapGridStyle.cs new file mode 100644 index 00000000..b87f9a1b --- /dev/null +++ b/src/RustPlusBot.Abstractions/Connections/MapGridStyle.cs @@ -0,0 +1,15 @@ +namespace RustPlusBot.Abstractions.Connections; + +/// +/// Which map-grid convention to use. The in-game (F1) map and the Rust+ companion app / RustMaps +/// website disagree on where grid ROWS sit: the in-game map anchors row 0 at the world's north edge, +/// while Rust+/RustMaps draw the rows 100 game-units further south. Columns are identical in both. +/// +public enum MapGridStyle +{ + /// Match the in-game (F1) map — grid references read exactly like in-game callouts. + InGame = 0, + + /// Match the Rust+ companion app and rustmaps.com — rows sit 100 game-units south of in-game. + RustPlus = 1, +} diff --git a/src/RustPlusBot.Abstractions/Connections/MapMarkerSnapshot.cs b/src/RustPlusBot.Abstractions/Connections/MapMarkerSnapshot.cs index 5c6cd5ca..da370ffd 100644 --- a/src/RustPlusBot.Abstractions/Connections/MapMarkerSnapshot.cs +++ b/src/RustPlusBot.Abstractions/Connections/MapMarkerSnapshot.cs @@ -1,9 +1,18 @@ namespace RustPlusBot.Abstractions.Connections; +// Rotation defaults to null so existing call sites stay valid and "no heading" is distinguishable +// from "heading north" once RustPlusApi beta.4 supplies values. /// One map marker observed in a GetMapMarkers poll. /// The stable marker id (used to diff polls). /// The classified marker kind. /// World X coordinate. /// World Y coordinate. /// The marker name, if any. -public sealed record MapMarkerSnapshot(ulong Id, MarkerKind Kind, float X, float Y, string? Name); +/// Heading in degrees as sent by the server, or null when unavailable. +public sealed record MapMarkerSnapshot( + ulong Id, + MarkerKind Kind, + float X, + float Y, + string? Name, + float? Rotation = null); diff --git a/src/RustPlusBot.Abstractions/Connections/MonumentSnapshot.cs b/src/RustPlusBot.Abstractions/Connections/MonumentSnapshot.cs index c01fabd3..8308c63b 100644 --- a/src/RustPlusBot.Abstractions/Connections/MonumentSnapshot.cs +++ b/src/RustPlusBot.Abstractions/Connections/MonumentSnapshot.cs @@ -1,7 +1,7 @@ namespace RustPlusBot.Abstractions.Connections; /// One named monument observed in a GetMap response. -/// The monument token (e.g. oilrig_1, large_oil_rig). +/// The monument token (e.g. oil_rig_small, large_oil_rig). /// World X coordinate. /// World Y coordinate. public sealed record MonumentSnapshot(string Token, float X, float Y); diff --git a/src/RustPlusBot.Abstractions/Connections/WorldSnapshot.cs b/src/RustPlusBot.Abstractions/Connections/WorldSnapshot.cs new file mode 100644 index 00000000..57f0636c --- /dev/null +++ b/src/RustPlusBot.Abstractions/Connections/WorldSnapshot.cs @@ -0,0 +1,6 @@ +namespace RustPlusBot.Abstractions.Connections; + +/// The world identity of a connected server, used to resolve external map imagery. +/// Size of the playable world, in game units. +/// Procedural map generation seed. +public sealed record WorldSnapshot(uint WorldSize, uint Seed); diff --git a/src/RustPlusBot.Abstractions/Events/InfoMapReadyEvent.cs b/src/RustPlusBot.Abstractions/Events/InfoMapReadyEvent.cs new file mode 100644 index 00000000..c855e20d --- /dev/null +++ b/src/RustPlusBot.Abstractions/Events/InfoMapReadyEvent.cs @@ -0,0 +1,6 @@ +namespace RustPlusBot.Abstractions.Events; + +/// Published when a server's RustMaps #info map becomes ready, so its reconciled message repaints. +/// The owning guild snowflake. +/// The target server id. +public sealed record InfoMapReadyEvent(ulong GuildId, Guid ServerId); diff --git a/src/RustPlusBot.Abstractions/Events/MapMarkersChangedEvent.cs b/src/RustPlusBot.Abstractions/Events/MapMarkersChangedEvent.cs index c71da8e9..5fef18d5 100644 --- a/src/RustPlusBot.Abstractions/Events/MapMarkersChangedEvent.cs +++ b/src/RustPlusBot.Abstractions/Events/MapMarkersChangedEvent.cs @@ -2,15 +2,17 @@ namespace RustPlusBot.Abstractions.Events; -/// Published when a marker poll detects markers that appeared or disappeared since the previous poll. +/// Published when a marker poll detects markers that appeared, disappeared, or moved since the previous poll. /// The owning guild snowflake. /// The target server id. /// Map dimensions for grid-reference rendering, or null if unavailable. /// Markers present now that were absent in the previous poll. /// Markers absent now that were present in the previous poll. +/// Markers present in both polls whose position or rotation changed. public sealed record MapMarkersChangedEvent( ulong GuildId, Guid ServerId, MapDimensions? Dimensions, IReadOnlyList Added, - IReadOnlyList Removed); + IReadOnlyList Removed, + IReadOnlyList Moved); diff --git a/src/RustPlusBot.Abstractions/Events/RigKind.cs b/src/RustPlusBot.Abstractions/Events/RigKind.cs index de640849..1320b8bf 100644 --- a/src/RustPlusBot.Abstractions/Events/RigKind.cs +++ b/src/RustPlusBot.Abstractions/Events/RigKind.cs @@ -3,7 +3,7 @@ namespace RustPlusBot.Abstractions.Events; /// Which oil rig a rig event refers to. public enum RigKind { - /// The small oil rig (monument token oilrig_1). + /// The small oil rig (monument token oil_rig_small). Small = 0, /// The large oil rig (monument token large_oil_rig). diff --git a/src/RustPlusBot.Abstractions/Map/IInfoMapReadModel.cs b/src/RustPlusBot.Abstractions/Map/IInfoMapReadModel.cs new file mode 100644 index 00000000..40d8e0e4 --- /dev/null +++ b/src/RustPlusBot.Abstractions/Map/IInfoMapReadModel.cs @@ -0,0 +1,11 @@ +namespace RustPlusBot.Abstractions.Map; + +/// Read seam over the RustMaps generation state, for the #info map renderer (cross-assembly). +public interface IInfoMapReadModel +{ + /// The ready RustMaps map for a (size, seed), or null when it is not generated/ready yet. + /// The world size. + /// The map generation seed. + /// The ready map view, or null. + InfoMapView? GetReady(int size, int seed); +} diff --git a/src/RustPlusBot.Abstractions/Map/InfoMapView.cs b/src/RustPlusBot.Abstractions/Map/InfoMapView.cs new file mode 100644 index 00000000..5636e63e --- /dev/null +++ b/src/RustPlusBot.Abstractions/Map/InfoMapView.cs @@ -0,0 +1,6 @@ +namespace RustPlusBot.Abstractions.Map; + +/// A ready RustMaps map for the #info map message: its hosted image URL plus its page link. +/// The RustMaps-hosted image URL (the monument-icon render). +/// The RustMaps page URL for the map, when available. +public sealed record InfoMapView(string ImageUrl, string? RustMapsPageUrl); diff --git a/src/RustPlusBot.Domain/Map/ServerMapSettings.cs b/src/RustPlusBot.Domain/Map/ServerMapSettings.cs index 841ded60..f6e44ee9 100644 --- a/src/RustPlusBot.Domain/Map/ServerMapSettings.cs +++ b/src/RustPlusBot.Domain/Map/ServerMapSettings.cs @@ -1,3 +1,5 @@ +using RustPlusBot.Abstractions.Connections; + namespace RustPlusBot.Domain.Map; /// Per-(guild, server) rendered-map layer settings; one row per server. Layers default on. @@ -26,4 +28,7 @@ public sealed class ServerMapSettings /// Whether oil rigs are styled by activation state. public bool ShowRigs { 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.Commands/Handlers/CargoCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/CargoCommandHandler.cs index 6ca4d356..938960d7 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/CargoCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/CargoCommandHandler.cs @@ -3,6 +3,7 @@ using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Features.Events.State; using RustPlusBot.Localization; +using RustPlusBot.Persistence.Map; namespace RustPlusBot.Features.Commands.Handlers; @@ -10,17 +11,24 @@ namespace RustPlusBot.Features.Commands.Handlers; /// The live event state. /// The reply localizer. /// For "how long ago". -internal sealed class CargoCommandHandler(IEventState state, ILocalizer localizer, IClock clock) +/// Supplies the server's grid style. +internal sealed class CargoCommandHandler( + IEventState state, + ILocalizer localizer, + IClock clock, + IMapSettingsStore mapSettings) : ICommandHandler { /// public string Name => "cargo"; /// - public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) + public async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); - return Task.FromResult( - MarkerReply.For(state, context, MarkerKind.CargoShip, "command.cargo", localizer, clock)); + return await MarkerReply + .ForAsync(state, context, MarkerKind.CargoShip, "command.cargo", localizer, clock, mapSettings, + cancellationToken) + .ConfigureAwait(false); } } diff --git a/src/RustPlusBot.Features.Commands/Handlers/ChinookCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/ChinookCommandHandler.cs index 69999c35..ec01cb36 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/ChinookCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/ChinookCommandHandler.cs @@ -3,6 +3,7 @@ using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Features.Events.State; using RustPlusBot.Localization; +using RustPlusBot.Persistence.Map; namespace RustPlusBot.Features.Commands.Handlers; @@ -10,17 +11,24 @@ namespace RustPlusBot.Features.Commands.Handlers; /// The live event state. /// The reply localizer. /// For "how long ago". -internal sealed class ChinookCommandHandler(IEventState state, ILocalizer localizer, IClock clock) +/// Supplies the server's grid style. +internal sealed class ChinookCommandHandler( + IEventState state, + ILocalizer localizer, + IClock clock, + IMapSettingsStore mapSettings) : ICommandHandler { /// public string Name => "chinook"; /// - public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) + public async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); - return Task.FromResult( - MarkerReply.For(state, context, MarkerKind.Chinook, "command.chinook", localizer, clock)); + return await MarkerReply + .ForAsync(state, context, MarkerKind.Chinook, "command.chinook", localizer, clock, mapSettings, + cancellationToken) + .ConfigureAwait(false); } } diff --git a/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs index 63657a48..b79904e0 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs @@ -3,31 +3,38 @@ using RustPlusBot.Features.Events.Formatting; using RustPlusBot.Features.Events.State; using RustPlusBot.Localization; +using RustPlusBot.Persistence.Map; namespace RustPlusBot.Features.Commands.Handlers; /// !events — lists the most recent live events. /// The live event state. /// The reply localizer. -internal sealed class EventsCommandHandler(IEventState state, ILocalizer localizer) : ICommandHandler +/// Supplies the server's grid style. +internal sealed class EventsCommandHandler( + IEventState state, + ILocalizer localizer, + IMapSettingsStore mapSettings) : ICommandHandler { /// public string Name => "events"; /// /// A recent event has an unsupported . - public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) + public async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); var events = state.GetRecentEvents(context.GuildId, context.ServerId); if (events.Count == 0) { - return Task.FromResult(localizer.Get("command.events.none", context.Culture)); + return localizer.Get("command.events.none", context.Culture); } + var settings = await mapSettings.GetAsync(context.GuildId, context.ServerId, cancellationToken) + .ConfigureAwait(false); var parts = events.Select(e => { - var grid = GridReference.From(e.X, e.Y, e.Dimensions); + var grid = GridReference.From(e.X, e.Y, e.Dimensions, settings.GridStyle); var key = e.Kind switch { MapEventKind.CargoEntered => "command.event.cargoentered", @@ -40,7 +47,6 @@ internal sealed class EventsCommandHandler(IEventState state, ILocalizer localiz return localizer.Get(key, context.Culture, grid); }); - return Task.FromResult( - localizer.Get("command.events.ok", context.Culture, string.Join(", ", parts))); + return localizer.Get("command.events.ok", context.Culture, string.Join(", ", parts)); } } diff --git a/src/RustPlusBot.Features.Commands/Handlers/HeliCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/HeliCommandHandler.cs index a12f1c1e..3faae87c 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/HeliCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/HeliCommandHandler.cs @@ -3,6 +3,7 @@ using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Features.Events.State; using RustPlusBot.Localization; +using RustPlusBot.Persistence.Map; namespace RustPlusBot.Features.Commands.Handlers; @@ -10,17 +11,24 @@ namespace RustPlusBot.Features.Commands.Handlers; /// The live event state. /// The reply localizer. /// For "how long ago". -internal sealed class HeliCommandHandler(IEventState state, ILocalizer localizer, IClock clock) +/// Supplies the server's grid style. +internal sealed class HeliCommandHandler( + IEventState state, + ILocalizer localizer, + IClock clock, + IMapSettingsStore mapSettings) : ICommandHandler { /// public string Name => "heli"; /// - public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) + public async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); - return Task.FromResult( - MarkerReply.For(state, context, MarkerKind.PatrolHelicopter, "command.heli", localizer, clock)); + return await MarkerReply + .ForAsync(state, context, MarkerKind.PatrolHelicopter, "command.heli", localizer, clock, mapSettings, + cancellationToken) + .ConfigureAwait(false); } } diff --git a/src/RustPlusBot.Features.Commands/Handlers/MarkerReply.cs b/src/RustPlusBot.Features.Commands/Handlers/MarkerReply.cs index 5e40b8f4..cf4a49e2 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/MarkerReply.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/MarkerReply.cs @@ -5,6 +5,7 @@ using RustPlusBot.Features.Events.Formatting; using RustPlusBot.Features.Events.State; using RustPlusBot.Localization; +using RustPlusBot.Persistence.Map; namespace RustPlusBot.Features.Commands.Handlers; @@ -18,14 +19,18 @@ internal static class MarkerReply /// The localization key prefix ("command.cargo" / "command.heli" / "command.chinook"). /// The reply localizer. /// For the "how long ago" suffix. + /// Supplies the server's grid style for the reference. + /// A cancellation token. /// The localized reply. - public static string For( + public static async Task ForAsync( IEventState state, CommandContext context, MarkerKind kind, string prefix, ILocalizer localizer, - IClock clock) + IClock clock, + IMapSettingsStore mapSettings, + CancellationToken cancellationToken) { var markers = state.GetActiveMarkers(context.GuildId, context.ServerId, kind); if (markers.Count == 0) @@ -33,8 +38,10 @@ public static string For( return localizer.Get($"{prefix}.none", context.Culture); } + var settings = await mapSettings.GetAsync(context.GuildId, context.ServerId, cancellationToken) + .ConfigureAwait(false); var m = markers[0]; - var grid = GridReference.From(m.X, m.Y, m.Dimensions); + var grid = GridReference.From(m.X, m.Y, m.Dimensions, settings.GridStyle); var ago = DurationFormat.Compact(clock.UtcNow - m.SeenAtUtc); return localizer.Get($"{prefix}.ok", context.Culture, grid, ago); } diff --git a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs index d775e8f4..dac5adbb 100644 --- a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs +++ b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs @@ -106,6 +106,12 @@ Task> GetMapMarkersAsync(TimeSpan timeout, /// The map dimensions, or null on failure/timeout. Task GetMapDimensionsAsync(TimeSpan timeout, CancellationToken cancellationToken = default); + /// Gets the world size and seed from server info, or null when unavailable. + /// The per-call timeout. + /// A cancellation token. + /// The world snapshot, or null. + Task GetWorldAsync(TimeSpan timeout, CancellationToken cancellationToken = default); + /// Gets the map monuments (for locating oil rigs). Throws on failure. /// How long to wait for the response. /// A cancellation token. diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs index 96386d00..d61513fb 100644 --- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs +++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs @@ -81,6 +81,9 @@ public Task> GetMapMarkersAsync(TimeSpan timeou CancellationToken cancellationToken = default) => Task.FromResult(null); + public Task GetWorldAsync(TimeSpan timeout, CancellationToken cancellationToken = default) => + Task.FromResult(null); + public Task> GetMonumentsAsync(TimeSpan timeout, CancellationToken cancellationToken = default) => Task.FromResult>([]); @@ -514,10 +517,13 @@ public async Task> GetMapMarkersAsync( var data = response.Data; var markers = new List(); - AddMarkers(markers, data.CargoShipMarkers, MarkerKind.CargoShip); - AddMarkers(markers, data.PatrolHelicopterMarkers, MarkerKind.PatrolHelicopter); - AddMarkers(markers, data.Ch47Markers, MarkerKind.Chinook); - AddMarkers(markers, data.TravellingVendorMarkers, MarkerKind.TravellingVendor); + // RustPlusApi 2.0.0-beta.4 declares Rotation independently on each concrete marker record + // (CargoShipMarker/PatrolHelicopterMarker/Ch47Marker/TravellingVendorMarker) rather than on + // the shared base Marker, so the selector is resolved per call site via type inference. + AddMarkers(markers, data.CargoShipMarkers, MarkerKind.CargoShip, m => m.Rotation); + AddMarkers(markers, data.PatrolHelicopterMarkers, MarkerKind.PatrolHelicopter, m => m.Rotation); + AddMarkers(markers, data.Ch47Markers, MarkerKind.Chinook, m => m.Rotation); + AddMarkers(markers, data.TravellingVendorMarkers, MarkerKind.TravellingVendor, m => m.Rotation); return markers; } @@ -545,7 +551,42 @@ public async Task> GetMapMarkersAsync( return null; } - return new MapDimensions(width, height, margin); + var infoResponse = await _rustPlus.GetInfoAsync(timeoutCts.Token).WaitAsync(timeoutCts.Token) + .ConfigureAwait(false); + if (!infoResponse.IsSuccess || infoResponse.Data?.MapSize is not { } worldSize) + { + return null; + } + + return new MapDimensions(width, height, margin, worldSize); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return null; + } +#pragma warning disable CA1031 // Broad catch: any map-query failure maps to null; never surface a token/secret. + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) +#pragma warning restore CA1031 + { + LogQueryFailed(_logger, ex); + return null; + } + } + + public async Task GetWorldAsync(TimeSpan timeout, CancellationToken cancellationToken = default) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(timeout); + try + { + var response = await _rustPlus.GetInfoAsync(timeoutCts.Token).WaitAsync(timeoutCts.Token) + .ConfigureAwait(false); + if (!response.IsSuccess || response.Data is not { MapSize: { } size, Seed: { } seed }) + { + return null; + } + + return new WorldSnapshot(size, seed); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { @@ -567,7 +608,7 @@ public async Task> GetMonumentsAsync( using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(timeout); // CONFIRMED (2.0.0-beta.1): GetMapAsync returns Task>. - // ServerMap.Monuments is List with Name (= protobuf token, e.g. "oilrig_1"), + // ServerMap.Monuments is List with Name (= protobuf token, e.g. "oil_rig_small"), // Nullable X/Y. We surface (token, x, y) and skip monuments with incomplete coordinates. var response = await _rustPlus.GetMapAsync(timeoutCts.Token).WaitAsync(timeoutCts.Token) .ConfigureAwait(false); @@ -659,7 +700,8 @@ .. info.Items.Select(i => private static void AddMarkers( List into, IReadOnlyDictionary source, - MarkerKind kind) + MarkerKind kind, + Func rotationSelector) where TMarker : RustPlusApi.Data.Markers.Marker { foreach (var (id, marker) in source) @@ -671,7 +713,7 @@ private static void AddMarkers( continue; } - into.Add(new MapMarkerSnapshot(id, kind, x, y, Name: null)); + into.Add(new MapMarkerSnapshot(id, kind, x, y, Name: null, Rotation: rotationSelector(marker))); } } diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs index 901053d1..d5569cab 100644 --- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs +++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs @@ -245,6 +245,18 @@ public async Task PromoteToLeaderAsync( .ConfigureAwait(false); } + /// + public async Task GetWorldAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) + { + if (!_liveSockets.TryGetValue((guildId, serverId), out var live)) + { + return null; + } + + return await live.Connection.GetWorldAsync(_options.HeartbeatTimeout, cancellationToken) + .ConfigureAwait(false); + } + /// public async Task> GetMonumentsAsync( ulong guildId, @@ -659,12 +671,28 @@ private async Task PublishMarkerDeltaAsync( IReadOnlyList current, CancellationToken ct) { - var added = current.Where(c => previous.All(p => p.Id != c.Id)).ToList(); + var previousById = previous.ToDictionary(p => p.Id); + var added = new List(); + var moved = new List(); + foreach (var c in current) + { + if (!previousById.TryGetValue(c.Id, out var p)) + { + added.Add(c); + } +#pragma warning disable S1244 // Exact float compare is intentional: a stationary marker round-trips identical floats. + else if (c.X != p.X || c.Y != p.Y || !Nullable.Equals(c.Rotation, p.Rotation)) +#pragma warning restore S1244 + { + moved.Add(c); + } + } + var removed = previous.Where(p => current.All(c => c.Id != p.Id)).ToList(); - if (added.Count > 0 || removed.Count > 0) + if (added.Count > 0 || removed.Count > 0 || moved.Count > 0) { await eventBus.PublishAsync( - new MapMarkersChangedEvent(key.Guild, key.Server, dims, added, removed), ct) + new MapMarkersChangedEvent(key.Guild, key.Server, dims, added, removed, moved), ct) .ConfigureAwait(false); } } @@ -847,7 +875,7 @@ private async Task> GetRigPositionsAsync( { RigKind? kind = m.Token switch { - "oilrig_1" => RigKind.Small, + "oil_rig_small" => RigKind.Small, "large_oil_rig" => RigKind.Large, _ => null, }; diff --git a/src/RustPlusBot.Features.Events/Formatting/GridReference.cs b/src/RustPlusBot.Features.Events/Formatting/GridReference.cs index 59eeca52..845f3697 100644 --- a/src/RustPlusBot.Features.Events/Formatting/GridReference.cs +++ b/src/RustPlusBot.Features.Events/Formatting/GridReference.cs @@ -1,5 +1,4 @@ using System.Globalization; -using System.Text; using RustPlusBot.Abstractions.Connections; namespace RustPlusBot.Features.Events.Formatting; @@ -7,44 +6,21 @@ namespace RustPlusBot.Features.Events.Formatting; /// Converts world coordinates to a Rust map grid reference (e.g. "D7"), rustplusplus-compatible. public static class GridReference { - private const float GridDiameter = 146.25f; - /// Formats a grid reference, or raw rounded coordinates when is null. /// World X coordinate. /// World Y coordinate. /// Map dimensions, or null when unavailable. + /// Which grid convention to bin against (defaults to the in-game map). /// A grid reference like "D7", or "(x, y)" when dimensions are unavailable. - public static string From(float x, float y, MapDimensions? dims) + public static string From(float x, float y, MapDimensions? dims, MapGridStyle style = MapGridStyle.InGame) { - if (dims is null) + if (dims is null || dims.WorldSize == 0) { return string.Create( CultureInfo.InvariantCulture, $"({Math.Round(x)}, {Math.Round(y)})"); } - // Columns run along X (Width); rows along Y (Height). Rust maps are square in practice, but - // derive each axis from its own dimension so the math is correct by construction, not by accident. - var col = (int)Math.Floor(Math.Clamp(x, 0f, dims.Width - 1) / GridDiameter); - var rowCount = (int)Math.Ceiling(dims.Height / GridDiameter); - // Rows are numbered from the TOP; world Y increases upward, so invert. - var rowFromBottom = (int)Math.Floor(Math.Clamp(y, 0f, dims.Height - 1) / GridDiameter); - var row = Math.Max(0, rowCount - rowFromBottom - 1); - - return string.Create(CultureInfo.InvariantCulture, $"{ColumnLetters(col)}{row}"); - } - - private static string ColumnLetters(int index) - { - // 0->A .. 25->Z, 26->AA .. (spreadsheet-style, matching rustplusplus past Z). - var sb = new StringBuilder(); - var n = index; - do - { - sb.Insert(0, (char)('A' + (n % 26))); - n = (n / 26) - 1; - } while (n >= 0); - - return sb.ToString(); + return MapGrid.LabelFor(x, y, dims.WorldSize, style); } } diff --git a/src/RustPlusBot.Features.Events/Relaying/EventRelay.cs b/src/RustPlusBot.Features.Events/Relaying/EventRelay.cs index b960f7f3..7b58f36c 100644 --- a/src/RustPlusBot.Features.Events/Relaying/EventRelay.cs +++ b/src/RustPlusBot.Features.Events/Relaying/EventRelay.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Connections.Listening; using RustPlusBot.Features.Events.Classifying; @@ -6,6 +7,7 @@ using RustPlusBot.Features.Events.Rendering; using RustPlusBot.Features.Events.State; using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Map; using RustPlusBot.Persistence.Workspace; namespace RustPlusBot.Features.Events.Relaying; @@ -48,18 +50,19 @@ public async Task RelayAsync(MapMarkersChangedEvent evt, CancellationToken cance return; } - var culture = await GetCultureAsync(evt.GuildId, cancellationToken).ConfigureAwait(false); + var (culture, gridStyle) = await GetRenderSettingsAsync(evt.GuildId, evt.ServerId, cancellationToken) + .ConfigureAwait(false); var channelId = await channels.Locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken) .ConfigureAwait(false); foreach (var e in events) { await channels.TeamChatSender - .SendAsync(evt.GuildId, evt.ServerId, renderer.RenderLine(e, culture), cancellationToken) + .SendAsync(evt.GuildId, evt.ServerId, renderer.RenderLine(e, culture, gridStyle), cancellationToken) .ConfigureAwait(false); if (channelId is { } id) { - await channels.Poster.PostAsync(id, renderer.Render(e, culture), cancellationToken) + await channels.Poster.PostAsync(id, renderer.Render(e, culture, gridStyle), cancellationToken) .ConfigureAwait(false); } } @@ -77,27 +80,33 @@ public async Task RelayRigAsync(RigStateChangedEvent evt, CancellationToken canc rigStore.Apply(evt); } - var culture = await GetCultureAsync(evt.GuildId, cancellationToken).ConfigureAwait(false); + var (culture, gridStyle) = await GetRenderSettingsAsync(evt.GuildId, evt.ServerId, cancellationToken) + .ConfigureAwait(false); await channels.TeamChatSender - .SendAsync(evt.GuildId, evt.ServerId, renderer.RenderRigLine(evt, culture), cancellationToken) + .SendAsync(evt.GuildId, evt.ServerId, renderer.RenderRigLine(evt, culture, gridStyle), cancellationToken) .ConfigureAwait(false); var channelId = await channels.Locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken) .ConfigureAwait(false); if (channelId is { } id) { - await channels.Poster.PostAsync(id, renderer.RenderRig(evt, culture), cancellationToken) + await channels.Poster.PostAsync(id, renderer.RenderRig(evt, culture, gridStyle), cancellationToken) .ConfigureAwait(false); } } - private async Task GetCultureAsync(ulong guildId, CancellationToken cancellationToken) + private async Task<(string Culture, MapGridStyle GridStyle)> GetRenderSettingsAsync(ulong guildId, + Guid serverId, + CancellationToken cancellationToken) { var scope = scopeFactory.CreateAsyncScope(); await using (scope.ConfigureAwait(false)) { var store = scope.ServiceProvider.GetRequiredService(); - return await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); + var culture = await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); + var mapSettings = scope.ServiceProvider.GetRequiredService(); + var settings = await mapSettings.GetAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + return (culture, settings.GridStyle); } } } diff --git a/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs b/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs index 10c52a28..37863b85 100644 --- a/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs +++ b/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs @@ -1,4 +1,5 @@ using Discord; +using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Events.Classifying; using RustPlusBot.Features.Events.Formatting; @@ -13,12 +14,13 @@ internal sealed class EventEmbedRenderer(ILocalizer localizer) /// Renders the event for a guild culture. /// The event to render. /// The guild culture ("en"/"fr"). + /// Which grid convention the reference uses. /// The built embed. /// The event kind is not a supported . - public Embed Render(RustMapEvent evt, string culture) + public Embed Render(RustMapEvent evt, string culture, MapGridStyle gridStyle = MapGridStyle.InGame) { ArgumentNullException.ThrowIfNull(evt); - var grid = GridReference.From(evt.X, evt.Y, evt.Dimensions); + var grid = GridReference.From(evt.X, evt.Y, evt.Dimensions, gridStyle); var key = evt.Kind switch { MapEventKind.CargoEntered => "event.cargo.entered", @@ -39,11 +41,12 @@ public Embed Render(RustMapEvent evt, string culture) /// Renders a rig boundary event as a Discord embed. /// The rig event. /// The guild culture. + /// Which grid convention the reference uses. /// The built embed. - public Embed RenderRig(RigStateChangedEvent evt, string culture) + public Embed RenderRig(RigStateChangedEvent evt, string culture, MapGridStyle gridStyle = MapGridStyle.InGame) { ArgumentNullException.ThrowIfNull(evt); - var grid = GridReference.From(evt.X, evt.Y, evt.Dimensions); + var grid = GridReference.From(evt.X, evt.Y, evt.Dimensions, gridStyle); return new EmbedBuilder() .WithAuthor(localizer.Get("event.title", culture)) .WithDescription(localizer.Get(RigKey(evt), culture, grid)) @@ -53,23 +56,27 @@ public Embed RenderRig(RigStateChangedEvent evt, string culture) /// Renders the in-game team-chat line for a rig boundary event. /// The rig event. /// The guild culture. + /// Which grid convention the reference uses. /// The line text. - public string RenderRigLine(RigStateChangedEvent evt, string culture) + public string RenderRigLine(RigStateChangedEvent evt, + string culture, + MapGridStyle gridStyle = MapGridStyle.InGame) { ArgumentNullException.ThrowIfNull(evt); - var grid = GridReference.From(evt.X, evt.Y, evt.Dimensions); + var grid = GridReference.From(evt.X, evt.Y, evt.Dimensions, gridStyle); return localizer.Get(RigKey(evt) + ".line", culture, grid); } /// Renders the in-game team-chat line for a cargo/heli/chinook event. /// The map event. /// The guild culture. + /// Which grid convention the reference uses. /// The line text. /// The event kind is not a supported . - public string RenderLine(RustMapEvent evt, string culture) + public string RenderLine(RustMapEvent evt, string culture, MapGridStyle gridStyle = MapGridStyle.InGame) { ArgumentNullException.ThrowIfNull(evt); - var grid = GridReference.From(evt.X, evt.Y, evt.Dimensions); + var grid = GridReference.From(evt.X, evt.Y, evt.Dimensions, gridStyle); var key = evt.Kind switch { MapEventKind.CargoEntered => "event.cargo.entered.line", diff --git a/src/RustPlusBot.Features.Events/State/ActiveMarker.cs b/src/RustPlusBot.Features.Events/State/ActiveMarker.cs index 6e97f7f9..bea15704 100644 --- a/src/RustPlusBot.Features.Events/State/ActiveMarker.cs +++ b/src/RustPlusBot.Features.Events/State/ActiveMarker.cs @@ -9,10 +9,14 @@ namespace RustPlusBot.Features.Events.State; /// World Y coordinate. /// Map dimensions for grid rendering, or null. /// When the marker was first seen. +/// Recent positions, oldest first, newest (current) last; capped. +/// Heading in degrees as sent by the server, or null. public sealed record ActiveMarker( ulong Id, MarkerKind Kind, float X, float Y, MapDimensions? Dimensions, - DateTimeOffset SeenAtUtc); + DateTimeOffset SeenAtUtc, + IReadOnlyList History, + float? Rotation); diff --git a/src/RustPlusBot.Features.Events/State/EventStateStore.cs b/src/RustPlusBot.Features.Events/State/EventStateStore.cs index 162d5f3e..49eb083d 100644 --- a/src/RustPlusBot.Features.Events/State/EventStateStore.cs +++ b/src/RustPlusBot.Features.Events/State/EventStateStore.cs @@ -11,6 +11,7 @@ namespace RustPlusBot.Features.Events.State; internal sealed class EventStateStore(IClock clock) : IEventState { private const int RecentCapacity = 10; + private const int HistoryCapacity = 6; private readonly ConcurrentDictionary<(ulong Guild, Guid Server), ServerState> _byServer = new(); /// @@ -59,7 +60,8 @@ public void Apply(MapMarkersChangedEvent delta, IReadOnlyList even { foreach (var m in delta.Added) { - state.Active[m.Id] = new ActiveMarker(m.Id, m.Kind, m.X, m.Y, delta.Dimensions, now); + state.Active[m.Id] = new ActiveMarker(m.Id, m.Kind, m.X, m.Y, delta.Dimensions, now, + [new TrailPoint(m.X, m.Y)], m.Rotation); } foreach (var m in delta.Removed) @@ -67,6 +69,28 @@ public void Apply(MapMarkersChangedEvent delta, IReadOnlyList even state.Active.Remove(m.Id); } + foreach (var m in delta.Moved) + { + if (!state.Active.TryGetValue(m.Id, out var existing)) + { + continue; // moved-before-added can only happen after a Clear race; drop it + } + + var history = new List(existing.History) + { + new(m.X, m.Y) + }; + if (history.Count > HistoryCapacity) + { + history.RemoveAt(0); + } + + state.Active[m.Id] = existing with + { + X = m.X, Y = m.Y, Rotation = m.Rotation, History = history + }; + } + foreach (var e in events) { state.Recent.Insert(0, e); diff --git a/src/RustPlusBot.Features.Events/State/TrailPoint.cs b/src/RustPlusBot.Features.Events/State/TrailPoint.cs new file mode 100644 index 00000000..b9579224 --- /dev/null +++ b/src/RustPlusBot.Features.Events/State/TrailPoint.cs @@ -0,0 +1,6 @@ +namespace RustPlusBot.Features.Events.State; + +/// One historical marker position, in world coordinates. +/// World X coordinate. +/// World Y coordinate. +public sealed record TrailPoint(float X, float Y); diff --git a/src/RustPlusBot.Features.Map/Assets/MapIcons.cs b/src/RustPlusBot.Features.Map/Assets/MapIcons.cs index fc58d913..eedd7d30 100644 --- a/src/RustPlusBot.Features.Map/Assets/MapIcons.cs +++ b/src/RustPlusBot.Features.Map/Assets/MapIcons.cs @@ -3,6 +3,7 @@ using RustPlusBot.Abstractions.Events; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; namespace RustPlusBot.Features.Map.Assets; @@ -23,14 +24,17 @@ public static class MapIcons /// Gets the icon for a marker kind, or null when there is no icon for that kind. /// The marker kind. /// The cached icon image, or null. - public static Image? Marker(MarkerKind kind) => kind switch + public static Image? Marker(MarkerKind kind) { - MarkerKind.CargoShip => Load("cargo"), - MarkerKind.PatrolHelicopter => Load("patrol"), - MarkerKind.Chinook => Load("ch47"), - MarkerKind.TravellingVendor => Load("vendor"), - _ => null, - }; + var key = KeyFor(kind); + return key is null ? null : Load(key); + } + + /// Gets the marker icon scaled to fit a square box, or null when the kind has no icon. + /// The marker kind. + /// The box edge length in pixels; the longest icon edge is scaled to it. + /// The cached scaled icon, or null. + public static Image? Marker(MarkerKind kind, int size) => Scaled(KeyFor(kind), size); /// Gets the rig icon for a rig kind, or null when there is no icon for that kind. /// Which rig. @@ -49,6 +53,16 @@ public static class MapIcons }; #pragma warning restore RCS1163, IDE0060 + /// Gets the rig icon scaled to fit a square box, or null when the kind has no icon. + /// Which rig. + /// Whether the rig is active (reserved; styling is applied by the renderer). + /// The box edge length in pixels. + /// The cached scaled icon, or null. +#pragma warning disable RCS1163, IDE0060 // 'active' is part of the API contract; styling is the renderer's job. + public static Image? Rig(RigKind kind, bool active, int size) => + Scaled(kind switch { RigKind.Small => "oilrig", RigKind.Large => "largeoilrig", _ => null }, size); +#pragma warning restore RCS1163, IDE0060 + /// Gets the travelling vendor icon. /// The cached vendor icon image, or null. public static Image? Vendor() => Load("vendor"); @@ -57,6 +71,11 @@ public static class MapIcons /// 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); + /// Gets the icon for a monument token, or null when the token is unmapped or the file is missing. /// The Rust+ monument protobuf token. /// The cached icon image, or null. @@ -66,10 +85,36 @@ public static class MapIcons return key is null ? null : Load(key); } + /// Gets the monument icon scaled to fit a square box, or null for unmapped tokens. + /// The Rust+ monument protobuf token. + /// The box edge length in pixels. + /// The cached scaled icon, or null. + public static Image? Monument(string token, int size) => Scaled(MonumentIconMap.IconKeyFor(token), size); + + private static string? KeyFor(MarkerKind kind) => kind switch + { + MarkerKind.CargoShip => "cargo", + MarkerKind.PatrolHelicopter => "patrol", + MarkerKind.Chinook => "ch47", + MarkerKind.TravellingVendor => "vendor", + _ => null, + }; + private static Image? Load(string key) => Cache.GetOrAdd(key, static k => { var asm = typeof(MapIcons).Assembly; using var stream = asm.GetManifestResourceStream(ResourcePrefix + k + ".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); + return native?.Clone(ctx => ctx.Resize(new ResizeOptions + { + Mode = ResizeMode.Max, Size = new Size(size, size), + })); + }); } diff --git a/src/RustPlusBot.Features.Map/Assets/MonumentIconMap.cs b/src/RustPlusBot.Features.Map/Assets/MonumentIconMap.cs index bb3ae1dc..04842d3a 100644 --- a/src/RustPlusBot.Features.Map/Assets/MonumentIconMap.cs +++ b/src/RustPlusBot.Features.Map/Assets/MonumentIconMap.cs @@ -19,7 +19,7 @@ public static class MonumentIconMap ["harbor_2_display_name"] = "harbour2", ["junkyard_display_name"] = "junkyard", ["large_oil_rig"] = "largeoilrig", - ["oilrig_1"] = "oilrig", + ["oil_rig_small"] = "oilrig", ["launchsite"] = "launchsite", ["lighthouse_display_name"] = "lighthouse", ["military_tunnels_display_name"] = "militarytunnel", diff --git a/src/RustPlusBot.Features.Map/Composing/BaseMapCache.cs b/src/RustPlusBot.Features.Map/Composing/BaseMapCache.cs index 967ef90c..df2a4c4d 100644 --- a/src/RustPlusBot.Features.Map/Composing/BaseMapCache.cs +++ b/src/RustPlusBot.Features.Map/Composing/BaseMapCache.cs @@ -1,33 +1,36 @@ using System.Collections.Concurrent; -using RustPlusBot.Abstractions.Connections; namespace RustPlusBot.Features.Map.Composing; -/// Caches the static-per-wipe base map image per (guild, server). Singleton so the cache survives across refreshes. -/// The live query seam used to fetch the base map on a cache miss. -public sealed class BaseMapCache(IRustServerQuery query) +/// Caches the static-per-wipe base map per (guild, server), trying sources in order on a miss. +/// Base-map sources in priority order (first hit wins). +public sealed class BaseMapCache(IEnumerable sources) { - private readonly ConcurrentDictionary<(ulong Guild, Guid Server), byte[]> _images = new(); + private readonly ConcurrentDictionary<(ulong Guild, Guid Server), BaseMapImage> _images = new(); - /// Gets the cached base map, fetching and caching it on a miss. Null results are not cached. + /// Gets the cached base map, fetching from the source chain on a miss. Null results are not cached. /// The owning guild snowflake. /// The target server id. /// A cancellation token. - /// The base-map JPEG bytes, or null if unavailable. - public async Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) + /// The base map image, or null if no source can provide one. + public async Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) { if (_images.TryGetValue((guildId, serverId), out var cached)) { return cached; } - var fetched = await query.GetMapImageAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); - if (fetched is not null) + foreach (var source in sources) { - _images[(guildId, serverId)] = fetched; + var fetched = await source.GetAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (fetched is not null) + { + _images[(guildId, serverId)] = fetched; + return fetched; + } } - return fetched; + return null; } /// Evicts the cached base map for a server (called on disconnect). diff --git a/src/RustPlusBot.Features.Map/Composing/BaseMapImage.cs b/src/RustPlusBot.Features.Map/Composing/BaseMapImage.cs new file mode 100644 index 00000000..abc9af28 --- /dev/null +++ b/src/RustPlusBot.Features.Map/Composing/BaseMapImage.cs @@ -0,0 +1,8 @@ +namespace RustPlusBot.Features.Map.Composing; + +/// A fetched base-map image plus the projection metadata of that specific image. +/// The encoded image bytes (JPEG or PNG). +/// Image width in pixels. +/// Image height in pixels. +/// Ocean border baked into this image, in pixels per side. +public sealed record BaseMapImage(byte[] Bytes, int PixelWidth, int PixelHeight, int OceanMarginPx); diff --git a/src/RustPlusBot.Features.Map/Composing/IBaseMapSource.cs b/src/RustPlusBot.Features.Map/Composing/IBaseMapSource.cs new file mode 100644 index 00000000..ee2c29ab --- /dev/null +++ b/src/RustPlusBot.Features.Map/Composing/IBaseMapSource.cs @@ -0,0 +1,12 @@ +namespace RustPlusBot.Features.Map.Composing; + +/// One provider of the static-per-wipe base map image. Sources are tried in registration order. +public interface IBaseMapSource +{ + /// Fetches the base map, or null when this source cannot provide one (falls through). + /// The owning guild snowflake. + /// The target server id. + /// A cancellation token. + /// The base map image, or null. + Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken); +} diff --git a/src/RustPlusBot.Features.Map/Composing/MapComposer.cs b/src/RustPlusBot.Features.Map/Composing/MapComposer.cs index a69ec25d..a18e0de7 100644 --- a/src/RustPlusBot.Features.Map/Composing/MapComposer.cs +++ b/src/RustPlusBot.Features.Map/Composing/MapComposer.cs @@ -4,6 +4,7 @@ using RustPlusBot.Features.Events.State; using RustPlusBot.Features.Map.Rendering; using RustPlusBot.Persistence.Map; +using SixLabors.ImageSharp; namespace RustPlusBot.Features.Map.Composing; @@ -25,19 +26,14 @@ public sealed class MapComposer( private static readonly MarkerKind[] LiveMarkerKinds = [MarkerKind.CargoShip, MarkerKind.PatrolHelicopter, MarkerKind.Chinook]; - /// Composes the map PNG for a server, or null when no base map is available yet. + /// Composes the map PNG for a server using its saved layer toggles, or null when no base map + /// is available yet. /// The owning guild snowflake. /// The target server id. /// A cancellation token. /// PNG bytes, or null. public async Task ComposeAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) { - var baseImage = await cache.GetAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); - if (baseImage is null) - { - return null; - } - // 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. MapLayerSettings settings; @@ -50,20 +46,39 @@ public sealed class MapComposer( var layers = new MapLayerSet(settings.Grid, settings.Markers, settings.Monuments, settings.Vendor, settings.Players, settings.Rigs); + return await ComposeWithLayersAsync(guildId, serverId, layers, settings.GridStyle, cancellationToken) + .ConfigureAwait(false); + } + + private async Task ComposeWithLayersAsync( + ulong guildId, + Guid serverId, + MapLayerSet layers, + MapGridStyle gridStyle, + CancellationToken cancellationToken) + { + var baseImage = await cache.GetAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (baseImage is null) + { + return null; + } // Dimensions come from the map itself (not from a marker), so the grid renders even when no // markers are present — e.g. on a freshly-connected or low-activity server. var dims = await query.GetMapDimensionsAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); - if (dims is null) + if (dims is null || dims.WorldSize == 0) { // Dimensions unavailable: render the base tile only (every overlay needs world→pixel). - return renderer.Render(baseImage, new MapDimensions(0, 0, 0), markers: [], monuments: [], players: [], - rigs: [], + 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)); } - var markers = GatherMarkers(guildId, serverId, dims, layers); + var projection = new MapProjection(dims.WorldSize, baseImage.PixelWidth, baseImage.PixelHeight, + baseImage.OceanMarginPx, MapRenderer.OutputSize); + + 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. IReadOnlyList serverMonuments = []; @@ -72,15 +87,20 @@ public sealed class MapComposer( serverMonuments = await query.GetMonumentsAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); } - var monuments = GatherMonuments(serverMonuments, dims, layers); - var players = await GatherPlayersAsync(guildId, serverId, dims, layers, cancellationToken) + var monuments = GatherMonuments(serverMonuments, projection, layers); + var players = await GatherPlayersAsync(guildId, serverId, projection, layers, cancellationToken) .ConfigureAwait(false); - var rigPlacements = GatherRigs(guildId, serverId, serverMonuments, dims, layers); + var rigPlacements = GatherRigs(guildId, serverId, serverMonuments, projection, layers); - return renderer.Render(baseImage, dims, markers, monuments, players, rigPlacements, layers); + return renderer.Render(baseImage.Bytes, projection, markers, monuments, players, rigPlacements, layers, + gridStyle); } - private List GatherMarkers(ulong guildId, Guid serverId, MapDimensions dims, MapLayerSet layers) + private List GatherMarkers( + ulong guildId, + Guid serverId, + MapProjection projection, + MapLayerSet layers) { var markers = new List(); if (layers.Markers) @@ -89,8 +109,9 @@ private List GatherMarkers(ulong guildId, Guid serverId, MapDim { foreach (var m in events.GetActiveMarkers(guildId, serverId, kind)) { - var (px, py) = WorldToPixel.ToPixel(m.X, m.Y, dims, MapRenderer.OutputSize); - markers.Add(new MarkerPlacement(kind, px, py)); + var (px, py) = projection.ToPixel(m.X, m.Y); + var trail = ProjectTrail(m.History, projection); + markers.Add(new MarkerPlacement(kind, px, py, m.Rotation, trail)); } } } @@ -99,17 +120,30 @@ private List GatherMarkers(ulong guildId, Guid serverId, MapDim { foreach (var m in events.GetActiveMarkers(guildId, serverId, MarkerKind.TravellingVendor)) { - var (px, py) = WorldToPixel.ToPixel(m.X, m.Y, dims, MapRenderer.OutputSize); - markers.Add(new MarkerPlacement(MarkerKind.TravellingVendor, px, py)); + var (px, py) = projection.ToPixel(m.X, m.Y); + var trail = ProjectTrail(m.History, projection); + markers.Add(new MarkerPlacement(MarkerKind.TravellingVendor, px, py, m.Rotation, trail)); } } return markers; } + private static List ProjectTrail(IReadOnlyList history, MapProjection projection) + { + var trail = new List(history.Count); + foreach (var h in history) + { + var (tx, ty) = projection.ToPixel(h.X, h.Y); + trail.Add(new PointF(tx, ty)); + } + + return trail; + } + private static List GatherMonuments( IReadOnlyList serverMonuments, - MapDimensions dims, + MapProjection projection, MapLayerSet layers) { var monuments = new List(); @@ -117,7 +151,7 @@ private static List GatherMonuments( { foreach (var mon in serverMonuments) { - var (px, py) = WorldToPixel.ToPixel(mon.X, mon.Y, dims, MapRenderer.OutputSize); + var (px, py) = projection.ToPixel(mon.X, mon.Y); monuments.Add(new MonumentPlacement(mon.Token, px, py)); } } @@ -128,7 +162,7 @@ private static List GatherMonuments( private async Task> GatherPlayersAsync( ulong guildId, Guid serverId, - MapDimensions dims, + MapProjection projection, MapLayerSet layers, CancellationToken cancellationToken) { @@ -138,7 +172,7 @@ private async Task> GatherPlayersAsync( var team = await query.GetTeamInfoAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); foreach (var member in team?.Members ?? []) { - var (px, py) = WorldToPixel.ToPixel(member.X, member.Y, dims, MapRenderer.OutputSize); + var (px, py) = projection.ToPixel(member.X, member.Y); players.Add(new PlayerPlacement(member.Name, px, py, member.IsAlive, member.IsOnline)); } } @@ -150,7 +184,7 @@ private List GatherRigs( ulong guildId, Guid serverId, IReadOnlyList serverMonuments, - MapDimensions dims, + MapProjection projection, MapLayerSet layers) { var rigPlacements = new List(); @@ -161,14 +195,14 @@ private List GatherRigs( { RigKind? kind = mon.Token switch { - "oilrig_1" => RigKind.Small, + "oil_rig_small" => RigKind.Small, "large_oil_rig" => RigKind.Large, _ => null, }; if (kind is { } k) { var state = rigs.Get(guildId, serverId, k); - var (px, py) = WorldToPixel.ToPixel(mon.X, mon.Y, dims, MapRenderer.OutputSize); + var (px, py) = projection.ToPixel(mon.X, mon.Y); rigPlacements.Add(new RigPlacement(k, px, py, state.Status == RigStatus.Active)); } } diff --git a/src/RustPlusBot.Features.Map/Composing/RustPlusBaseMapSource.cs b/src/RustPlusBot.Features.Map/Composing/RustPlusBaseMapSource.cs new file mode 100644 index 00000000..7cd9cbaa --- /dev/null +++ b/src/RustPlusBot.Features.Map/Composing/RustPlusBaseMapSource.cs @@ -0,0 +1,26 @@ +using RustPlusBot.Abstractions.Connections; + +namespace RustPlusBot.Features.Map.Composing; + +/// Fallback base-map source: the JPEG tile served by the Rust+ server itself. +/// The live query seam. +public sealed class RustPlusBaseMapSource(IRustServerQuery query) : IBaseMapSource +{ + /// + public async Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) + { + var bytes = await query.GetMapImageAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (bytes is null) + { + return null; + } + + var dims = await query.GetMapDimensionsAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (dims is null) + { + return null; + } + + return new BaseMapImage(bytes, (int)dims.Width, (int)dims.Height, dims.OceanMargin); + } +} diff --git a/src/RustPlusBot.Features.Map/Hosting/InfoMapHostedService.cs b/src/RustPlusBot.Features.Map/Hosting/InfoMapHostedService.cs new file mode 100644 index 00000000..51e5990d --- /dev/null +++ b/src/RustPlusBot.Features.Map/Hosting/InfoMapHostedService.cs @@ -0,0 +1,217 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Domain.Connections; +using RustPlusBot.Features.Map.RustMaps; +using RustPlusBot.Persistence.Connections; + +namespace RustPlusBot.Features.Map.Hosting; + +/// +/// Drives credit-safe RustMaps generation for the #info map: registers each connected server's (size, seed) +/// key with the coordinator, advances pending generations on a poll interval, and publishes +/// at most once per key so the workspace reconciler picks up the ready render. +/// No posting here — delivery is a reconciled Workspace message (ServerInfoMapMessageRenderer) that +/// reads the coordinator through IInfoMapReadModel. +/// +/// The in-process event bus. +/// The shared RustMaps generation state. +/// Advances one map key's generation one step per tick. +/// Live query seam (world size/seed). +/// Supplies the generation-poll interval. +/// Opens scopes to read connection state. +/// The logger. +internal sealed partial class InfoMapHostedService( + IEventBus eventBus, + IRustMapsMapCoordinator coordinator, + RustMapsGenerationDriver driver, + IRustServerQuery query, + IOptions options, + IServiceScopeFactory scopeFactory, + ILogger logger) : IHostedService, IDisposable +{ + /// Keys already announced via — each key is published at most once. + private readonly HashSet _announced = []; + + private readonly CancellationTokenSource _cts = new(); + private Task? _statusLoop; + private Task? _tickLoop; + + /// + public void Dispose() => _cts.Dispose(); + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _statusLoop = Task.Run(() => ConsumeConnectionStatusAsync(_cts.Token), CancellationToken.None); + _tickLoop = Task.Run(() => RunTickAsync(_cts.Token), CancellationToken.None); + return Task.CompletedTask; + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + await _cts.CancelAsync().ConfigureAwait(false); + foreach (var loop in new[] + { + _statusLoop, _tickLoop + }.Where(t => t is not null)) + { + try + { +#pragma warning disable VSTHRD003 // Our own loop tasks, joined on stop. + await loop!.ConfigureAwait(false); +#pragma warning restore VSTHRD003 + } + catch (OperationCanceledException) + { + // Expected on shutdown. + } + } + } + + /// + /// Advances every pending RustMaps generation key strictly one-at-a-time (sequential, never + /// concurrent) — the driver's check-then-spend path is not atomic across awaits, so concurrent + /// calls for the same key could double-spend real RustMaps credits. Then announces any key that + /// just reached Ready to every one of its requesters. + /// + /// A cancellation token. + private async Task RunTickAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(options.Value.RustMaps.GenerationPollInterval, cancellationToken) + .ConfigureAwait(false); + + // Register from the source of truth every tick, not only from the live-only + // ConnectionStatusChangedEvent (which the connection may publish before this service has + // subscribed on startup, so it is missed and the server would otherwise never generate). + await RegisterConnectedServersAsync(cancellationToken).ConfigureAwait(false); + + var pending = coordinator.PendingKeys(); + foreach (var key in pending) + { + await driver.AdvanceAsync(key, cancellationToken).ConfigureAwait(false); + } + + await AnnounceReadyKeysAsync(pending, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch: a faulting tick must not crash the host. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogTickFaulted(logger, ex); + } + } + + /// + /// Registers every currently-connected server's (size, seed) key with the coordinator. Idempotent, and + /// independent of the connection-status event — returns null + /// for a server that is not connected/queryable, so only live servers are registered. + /// + /// A cancellation token. + private async Task RegisterConnectedServersAsync(CancellationToken cancellationToken) + { + IReadOnlyList<(ulong Guild, Guid Server)> servers; + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + servers = await store.ListConnectableServersAsync(cancellationToken).ConfigureAwait(false); + } + + foreach (var (guild, server) in servers) + { + var world = await query.GetWorldAsync(guild, server, cancellationToken).ConfigureAwait(false); + if (world is not null) + { + coordinator.Register(new RustMapsMapKey((int)world.WorldSize, (int)world.Seed), guild, server); + } + } + } + + private async Task AnnounceReadyKeysAsync(IReadOnlyList keys, CancellationToken cancellationToken) + { + foreach (var key in keys) + { + if (_announced.Contains(key) || coordinator.Snapshot(key).State != RustMapsGenerationState.Ready) + { + continue; + } + + _announced.Add(key); + foreach (var (guild, server) in coordinator.Requesters(key)) + { + await eventBus.PublishAsync(new InfoMapReadyEvent(guild, server), cancellationToken) + .ConfigureAwait(false); + } + } + } + + private async Task ConsumeConnectionStatusAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var evt in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + await OnConnectionStatusAsync(evt, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogStatusFaulted(logger, ex); + } + } + + private async Task OnConnectionStatusAsync(ConnectionStatusChangedEvent evt, CancellationToken cancellationToken) + { + // Re-derive current status from the store rather than trusting the event's IsConnected payload: the + // in-process bus is an unbounded queue, so by the time this loop dequeues the event the live status + // may already have moved on (mirrors MapHostedService.OnConnectionStatusAsync). + bool connected; + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + var state = await store.GetStateAsync(evt.GuildId, evt.ServerId, cancellationToken).ConfigureAwait(false); + connected = state is { Status: ConnectionStatus.Connected }; + } + + if (!connected) + { + return; + } + + var world = await query.GetWorldAsync(evt.GuildId, evt.ServerId, cancellationToken).ConfigureAwait(false); + if (world is null) + { + return; + } + + coordinator.Register(new RustMapsMapKey((int)world.WorldSize, (int)world.Seed), evt.GuildId, evt.ServerId); + } + + [LoggerMessage(Level = LogLevel.Error, Message = "Info-map tick loop faulted.")] + private static partial void LogTickFaulted(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Error, Message = "Info-map connection-status loop faulted.")] + private static partial void LogStatusFaulted(ILogger logger, Exception exception); +} diff --git a/src/RustPlusBot.Features.Map/Hosting/MapHostedService.cs b/src/RustPlusBot.Features.Map/Hosting/MapHostedService.cs index c779dad3..41c0c251 100644 --- a/src/RustPlusBot.Features.Map/Hosting/MapHostedService.cs +++ b/src/RustPlusBot.Features.Map/Hosting/MapHostedService.cs @@ -25,9 +25,10 @@ internal sealed record MapPipeline( IMapChannelPoster Poster); /// -/// Keeps the #map image current: re-renders on marker changes, on a steady interval (so moving -/// markers track even though their ids are stable), and on connect; clears the base-map cache on -/// disconnect. All refreshes pass through a per-server throttle so the surfaces never double-post. +/// Keeps the #map image current: re-renders on marker changes (including moved markers), on a +/// steady interval (a backstop in case a delta is missed), and on connect; clears the base-map +/// cache on disconnect. All refreshes pass through a per-server throttle so the surfaces never +/// double-post. /// /// The in-process event bus. /// Bundles the rendering-pipeline collaborators. @@ -146,9 +147,9 @@ private async Task ConsumeSettingsEventsAsync(CancellationToken cancellationToke } /// - /// Repaints every connected server's #map on a steady interval. Marker ids are stable, so a moving - /// cargo ship / heli / chinook fires no ; this tick is what keeps - /// their positions current and posts the first image after connect. + /// Repaints every connected server's #map on a steady interval. A moving cargo ship / heli / chinook + /// now fires a "Moved" delta that drives its own refresh; this + /// tick is a backstop for any missed delta and posts the first image after connect. /// /// A cancellation token. /// A task that completes when the loop stops. diff --git a/src/RustPlusBot.Features.Map/MapOptions.cs b/src/RustPlusBot.Features.Map/MapOptions.cs index 9a10bc35..816cc776 100644 --- a/src/RustPlusBot.Features.Map/MapOptions.cs +++ b/src/RustPlusBot.Features.Map/MapOptions.cs @@ -3,6 +3,16 @@ namespace RustPlusBot.Features.Map; /// Map feature configuration, bound from the "Map" config section. public sealed class MapOptions { - /// Minimum time between #map image re-renders per server (coalesces rapid marker changes). Default 45s. - public TimeSpan MapRefreshInterval { get; set; } = TimeSpan.FromSeconds(45); + /// Minimum time between #map image re-renders per server (coalesces rapid marker changes). Default 30s. + public TimeSpan MapRefreshInterval { get; set; } = TimeSpan.FromSeconds(30); + + /// RustMaps integration settings. + public RustMapsOptions RustMaps { get; set; } = new(); +} + +/// RustMaps settings. The integration is inactive when no API key is configured. +public sealed class RustMapsOptions +{ + /// How often the background service checks generation progress. Default 20s. + public TimeSpan GenerationPollInterval { get; set; } = TimeSpan.FromSeconds(20); } diff --git a/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs index ff7429b2..051cb325 100644 --- a/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs @@ -1,22 +1,49 @@ +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Abstractions.Map; using RustPlusBot.Features.Map.Composing; using RustPlusBot.Features.Map.Hosting; using RustPlusBot.Features.Map.Posting; using RustPlusBot.Features.Map.Rendering; +using RustPlusBot.Features.Map.RustMaps; namespace RustPlusBot.Features.Map; /// DI registration for the map-render feature. public static class MapServiceCollectionExtensions { - /// Registers the renderer, base-map cache, composer, poster, pipeline bundle, and hosted service. + /// + /// Registers the renderer, base-map source chain, composer, pipeline bundle, and hosted service. + /// When a RustMaps API key is configured, also registers the credit-safe generation coordinator/driver + /// and the hosted service that drives generation and publishes so the + /// reconciled #info map message (in Features.Workspace) shows the RustMaps render. + /// /// The service collection to add to. + /// The host configuration (reads Map:RustMaps:ApiKey). /// The same service collection, for chaining. - public static IServiceCollection AddMap(this IServiceCollection services) + public static IServiceCollection AddMap(this IServiceCollection services, IConfiguration configuration) { ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); services.AddSingleton(); + + // RustMaps is NOT a base-map source (the #map render draws its own layers on the Rust+ tile). + // The client drives the #info static-map surface: credit-safe generation, no posting here. + var rustMapsKey = configuration["Map:RustMaps:ApiKey"]; + if (!string.IsNullOrWhiteSpace(rustMapsKey)) + { + services.AddRustMapsClientV4(o => o.ApiKey = rustMapsKey); + services.AddSingleton(); + // IRustMapsMapCoordinator and IInfoMapReadModel resolve to the SAME singleton instance, so the + // driver's writes are visible to the Workspace renderer's reads. + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddHostedService(); + } + + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/RustPlusBot.Features.Map/Rendering/MapProjection.cs b/src/RustPlusBot.Features.Map/Rendering/MapProjection.cs new file mode 100644 index 00000000..5007631b --- /dev/null +++ b/src/RustPlusBot.Features.Map/Rendering/MapProjection.cs @@ -0,0 +1,26 @@ +namespace RustPlusBot.Features.Map.Rendering; + +/// +/// Projects world coordinates onto the rendered output image. +/// The canonical Rust+ transform: the playable world spans +/// [OceanMarginPx, ImageWidth - OceanMarginPx] on the base image, then the base image is +/// stretched to the square output. Matches the official app and rustplusplus. +/// +/// Size of the playable world, in game units. +/// Base map image width, in pixels. +/// Base map image height, in pixels. +/// Ocean border baked into the base image, in pixels per side. +/// Output image edge length, in pixels. +public sealed record MapProjection(uint WorldSize, int ImageWidth, int ImageHeight, int OceanMarginPx, int OutputSize) +{ + /// Converts a world (x, y) to an output pixel (x, y); origin top-left, Y down. + /// World X (west→east). + /// World Y (south→north). + /// The output-space pixel coordinate. + public (float X, float Y) ToPixel(float worldX, float worldY) + { + var imgX = (worldX * ((ImageWidth - (2f * OceanMarginPx)) / WorldSize)) + OceanMarginPx; + var imgY = ImageHeight - ((worldY * ((ImageHeight - (2f * OceanMarginPx)) / WorldSize)) + OceanMarginPx); + return (imgX * ((float)OutputSize / ImageWidth), imgY * ((float)OutputSize / ImageHeight)); + } +} diff --git a/src/RustPlusBot.Features.Map/Rendering/MapRenderStyle.cs b/src/RustPlusBot.Features.Map/Rendering/MapRenderStyle.cs new file mode 100644 index 00000000..6a985cb7 --- /dev/null +++ b/src/RustPlusBot.Features.Map/Rendering/MapRenderStyle.cs @@ -0,0 +1,44 @@ +using RustPlusBot.Abstractions.Connections; +using SixLabors.ImageSharp; + +namespace RustPlusBot.Features.Map.Rendering; + +/// All on-map sizing and styling constants, expressed in pixels at the 1024px output. +public static class MapRenderStyle +{ + /// Monument icon box edge, in output pixels (~3% of the output edge). + public const int MonumentIconSize = 30; + + /// Default event-marker icon box edge, in output pixels. + public const int EventIconSize = 36; + + /// 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; + + /// Trail polyline stroke width, in output pixels. + public const float TrailWidth = 2f; + + /// Grid cell label font size, in points. + public const float GridLabelFontSize = 10f; + + /// Gets the icon box edge for a marker kind. + /// The marker kind. + /// The box edge length in output pixels. + public static int MarkerIconSize(MarkerKind kind) => + kind == MarkerKind.CargoShip ? CargoIconSize : EventIconSize; + + /// Gets the trail color for a marker kind. + /// The marker kind. + /// The base (fully opaque) trail color. + public static Color TrailColor(MarkerKind kind) => kind switch + { + MarkerKind.CargoShip => Color.ParseHex("4FC3F7"), + MarkerKind.PatrolHelicopter => Color.ParseHex("EF5350"), + MarkerKind.Chinook => Color.ParseHex("FFB74D"), + MarkerKind.TravellingVendor => Color.ParseHex("81C784"), + _ => Color.White, + }; +} diff --git a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs index 1dd7d5eb..95f396bc 100644 --- a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs +++ b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs @@ -19,47 +19,48 @@ public sealed class MapRenderer /// The square output edge length in pixels. public const int OutputSize = 1024; - private const float GridDiameter = 146.25f; - private const float PlayerRadius = 6f; private const float OutlinePenWidth = 1f; private const float ActiveRingWidth = 3f; private const float PlayerLabelOffset = 12f; - private static readonly Font Font = LoadFont(); + 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 Font LoadFont() + private static FontFamily LoadFamily() { var asm = typeof(MapRenderer).Assembly; using var stream = asm.GetManifestResourceStream("RustPlusBot.Features.Map.Assets.LiberationSans-Regular.ttf") ?? throw new InvalidOperationException( "Embedded map font 'RustPlusBot.Features.Map.Assets.LiberationSans-Regular.ttf' not found."); var collection = new FontCollection(); - var family = collection.Add(stream, CultureInfo.InvariantCulture); - return family.CreateFont(12f); + return collection.Add(stream, CultureInfo.InvariantCulture); } /// Renders the map tile plus the requested overlay layers to PNG bytes. /// The raw base-map JPEG bytes. - /// The map dimensions (world size + ocean margin). + /// The world-to-pixel projection (world size, base image dims, ocean margin). /// Marker placements already projected to pixel coordinates. /// Monument placements already projected to pixel coordinates. /// Player placements already projected to pixel coordinates. /// 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). /// PNG-encoded bytes of a square image with pixels on each side. /// Kept as an instance method so the class can be registered as a DI singleton. #pragma warning disable CA1822, S2325 // Kept as instance method for DI singleton registration public byte[] Render(byte[] baseJpeg, - MapDimensions dims, + MapProjection projection, IReadOnlyList markers, IReadOnlyList monuments, IReadOnlyList players, IReadOnlyList rigs, - MapLayerSet layers) + MapLayerSet layers, + MapGridStyle gridStyle = MapGridStyle.InGame) #pragma warning restore CA1822, S2325 { ArgumentNullException.ThrowIfNull(baseJpeg); - ArgumentNullException.ThrowIfNull(dims); + ArgumentNullException.ThrowIfNull(projection); ArgumentNullException.ThrowIfNull(markers); ArgumentNullException.ThrowIfNull(monuments); ArgumentNullException.ThrowIfNull(players); @@ -71,7 +72,7 @@ public byte[] Render(byte[] baseJpeg, if (layers.Grid) { - DrawGrid(image, dims); + DrawGrid(image, projection, gridStyle); } if (layers.Monuments) @@ -79,6 +80,11 @@ public byte[] Render(byte[] baseJpeg, DrawMonuments(image, monuments); } + if (layers.Markers || layers.Vendor) + { + DrawTrails(image, markers); + } + if (layers.Markers) { DrawMarkers(image, markers); @@ -99,25 +105,81 @@ public byte[] Render(byte[] baseJpeg, return ms.ToArray(); } - private static void DrawGrid(Image image, MapDimensions dims) + private static void DrawGrid(Image image, MapProjection projection, MapGridStyle gridStyle) { - var gridColor = Color.FromRgba(255, 255, 255, 80); + if (projection.WorldSize == 0) + { + return; + } + + var lineColor = Color.FromRgba(255, 255, 255, 80); + var labelColor = Color.FromRgba(255, 255, 255, 140); + var cells = MapGrid.CellCount(projection.WorldSize); + + // The lattice is anchored at the west edge and at the style's row anchor (the world's north + // edge for the in-game style; 100 game-units south of it for Rust+/RustMaps), and is clipped + // to the LABELLED cell block only (A0 … the last ceil-count cell) — no lines out over the + // ocean margin. The partial edge cell sits at the south/east and simply bleeds past the world + // edge, so every rendered cell looks full-size. + var anchorWorldY = projection.WorldSize - MapGrid.RowInset(gridStyle); + var (anchorX, anchorY) = projection.ToPixel(0f, anchorWorldY); + var (cellEndX, cellEndY) = projection.ToPixel(MapGrid.CellSize, anchorWorldY - MapGrid.CellSize); + var stepX = cellEndX - anchorX; + var stepY = cellEndY - anchorY; + if (stepX <= 0f || stepY <= 0f) + { + return; // Degenerate projection (no drawable world area). + } + + var right = anchorX + (cells * stepX); + var bottom = anchorY + (cells * stepY); image.Mutate(ctx => { - // Vertical lines step across the X axis (width); horizontal lines step across the Y axis - // (height). Driving each axis from its own dimension keeps the grid correct on a - // non-square map (Rust maps are square today, so Width == Height in practice). - for (var worldX = 0f; worldX <= dims.Width; worldX += GridDiameter) + for (var k = 0; k <= cells; k++) { - var (vx, _) = WorldToPixel.ToPixel(worldX, 0f, dims, OutputSize); - ctx.DrawLine(gridColor, OutlinePenWidth, new PointF(vx, 0), new PointF(vx, OutputSize)); + var x = anchorX + (k * stepX); + ctx.DrawLine(lineColor, OutlinePenWidth, new PointF(x, anchorY), new PointF(x, bottom)); + var y = anchorY + (k * stepY); + ctx.DrawLine(lineColor, OutlinePenWidth, new PointF(anchorX, y), new PointF(right, y)); } - for (var worldY = 0f; worldY <= dims.Height; worldY += GridDiameter) + // Every labelled cell (A0 in the north-west corner), each label just inside its cell's + // top-left corner (companion-app placement). + for (var col = 0; col < cells; col++) + { + for (var row = 0; row < cells; row++) + { + var label = MapGrid.ColumnLetters(col) + row.ToString(CultureInfo.InvariantCulture); + ctx.DrawText(new RichTextOptions(GridLabelFont) + { + Origin = new PointF(anchorX + (col * stepX) + 2f, anchorY + (row * stepY) + 2f) + }, + label, labelColor); + } + } + }); + } + + private static void DrawTrails(Image image, IReadOnlyList markers) + { + image.Mutate(ctx => + { + foreach (var marker in markers) { - var (_, hy) = WorldToPixel.ToPixel(0f, worldY, dims, OutputSize); - ctx.DrawLine(gridColor, OutlinePenWidth, new PointF(0, hy), new PointF(OutputSize, hy)); + if (marker.Trail.Count < 2) + { + continue; + } + + var baseColor = MapRenderStyle.TrailColor(marker.Kind); + for (var i = 1; i < marker.Trail.Count; i++) + { + // Fade from faint (oldest) to strong (newest) so travel direction reads instantly. + var alpha = 0.15f + (0.45f * i / (marker.Trail.Count - 1)); + ctx.DrawLine(baseColor.WithAlpha(alpha), MapRenderStyle.TrailWidth, + marker.Trail[i - 1], marker.Trail[i]); + } } }); } @@ -130,8 +192,18 @@ private static void DrawMarkers(Image image, IReadOnlyList 0.01f) + { + using var rotated = icon.Clone(c => c.Rotate(-rotation)); + ctx.DrawImage(rotated, CenterAt(marker.PixelX, marker.PixelY, rotated), 1f); + } + else { ctx.DrawImage(icon, CenterAt(marker.PixelX, marker.PixelY, icon), 1f); } @@ -147,7 +219,7 @@ private static void DrawMonuments(Image image, IReadOnlyList image, IReadOnlyList ri { foreach (var rig in rigs) { - var icon = MapIcons.Rig(rig.Kind, rig.Active); + var icon = MapIcons.Rig(rig.Kind, rig.Active, MapRenderStyle.MonumentIconSize); if (icon is null) { continue; @@ -183,7 +255,7 @@ private static void DrawRigs(Image image, IReadOnlyList ri private static void DrawPlayers(Image image, IReadOnlyList players) { - var icon = MapIcons.Player(); + var icon = MapIcons.Player(MapRenderStyle.PlayerIconSize); image.Mutate(ctx => { @@ -197,17 +269,14 @@ private static void DrawPlayers(Image image, IReadOnlyList? icon) { - if (icon is not null) + if (icon is null) { - ctx.DrawImage(icon, CenterAt(player.PixelX, player.PixelY, icon), 1f); return; } var isActive = player is { IsAlive: true, IsOnline: true }; - var dotColor = isActive ? Color.LimeGreen : Color.Gray; - var dot = new EllipsePolygon(player.PixelX, player.PixelY, PlayerRadius); - ctx.Fill(dotColor, dot); - ctx.Draw(Color.Black, OutlinePenWidth, dot); + // 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) diff --git a/src/RustPlusBot.Features.Map/Rendering/MarkerPlacement.cs b/src/RustPlusBot.Features.Map/Rendering/MarkerPlacement.cs index f986f02a..da6b1e4e 100644 --- a/src/RustPlusBot.Features.Map/Rendering/MarkerPlacement.cs +++ b/src/RustPlusBot.Features.Map/Rendering/MarkerPlacement.cs @@ -1,9 +1,17 @@ using RustPlusBot.Abstractions.Connections; +using SixLabors.ImageSharp; namespace RustPlusBot.Features.Map.Rendering; /// One marker to draw, already projected to pixel coordinates. -/// The marker kind (selects the glyph). +/// The marker kind (selects the icon). /// Pixel X on the rendered tile. /// Pixel Y on the rendered tile. -public sealed record MarkerPlacement(MarkerKind Kind, float PixelX, float PixelY); +/// Heading in degrees as sent by the server, or null for no rotation. +/// Recent positions in output pixels, oldest first; empty for no trail. +public sealed record MarkerPlacement( + MarkerKind Kind, + float PixelX, + float PixelY, + float? Rotation, + IReadOnlyList Trail); diff --git a/src/RustPlusBot.Features.Map/Rendering/WorldToPixel.cs b/src/RustPlusBot.Features.Map/Rendering/WorldToPixel.cs deleted file mode 100644 index bcf38e98..00000000 --- a/src/RustPlusBot.Features.Map/Rendering/WorldToPixel.cs +++ /dev/null @@ -1,30 +0,0 @@ -using RustPlusBot.Abstractions.Connections; - -namespace RustPlusBot.Features.Map.Rendering; - -/// Maps world coordinates to pixel coordinates on a square rendered map tile. -public static class WorldToPixel -{ - /// Converts a world (x, y) to a pixel (x, y) on a square output of the given edge length. - /// World X (west→east), in [0, Width]. - /// World Y (south→north), in [0, Height]. - /// The map dimensions (width/height in game units + ocean margin). - /// The output image edge length in pixels. - /// The pixel coordinate (origin top-left, Y down). - public static (float X, float Y) ToPixel(float worldX, float worldY, MapDimensions dims, int outputSize) - { - ArgumentNullException.ThrowIfNull(dims); - - // The full tile adds the ocean margin on each side, so each axis spans - // [-margin, dimension+margin]. Each axis is scaled by its own dimension so the projection - // is correct even if a server ever reports a non-square map (Rust maps are square today). - var margin = dims.OceanMargin; - var perUnitX = outputSize / (dims.Width + (2f * margin)); - var perUnitY = outputSize / (dims.Height + (2f * margin)); - - var px = (worldX + margin) * perUnitX; - // Flip Y: world south (0) is the visual bottom (image y = outputSize), world north is the top. - var py = outputSize - ((worldY + margin) * perUnitY); - return (px, py); - } -} diff --git a/src/RustPlusBot.Features.Map/RustMaps/IRustMapsMapCoordinator.cs b/src/RustPlusBot.Features.Map/RustMaps/IRustMapsMapCoordinator.cs new file mode 100644 index 00000000..6c3d9d70 --- /dev/null +++ b/src/RustPlusBot.Features.Map/RustMaps/IRustMapsMapCoordinator.cs @@ -0,0 +1,40 @@ +namespace RustPlusBot.Features.Map.RustMaps; + +/// Tracks RustMaps generation state per (size, seed) and the servers awaiting each map. Thread-safe singleton. +public interface IRustMapsMapCoordinator +{ + /// Registers a server's interest in a map key (idempotent; never revives a terminal state). + /// The (size, seed) map key. + /// The requesting guild's id. + /// The requesting server's id. + void Register(RustMapsMapKey key, ulong guildId, Guid serverId); + + /// Gets an immutable snapshot of a key (an Idle snapshot for an unseen key). + /// The (size, seed) map key. + RustMapsMapSnapshot Snapshot(RustMapsMapKey key); + + /// Moves a key to Generating with its map id; returns false if it was already terminal. + /// The (size, seed) map key. + /// The RustMaps map id, when already known. + bool TrySetGenerating(RustMapsMapKey key, string? mapId); + + /// Marks a key Ready and caches its image. + /// The (size, seed) map key. + /// The generated image and its RustMaps page link. + void SetReady(RustMapsMapKey key, RustMapsReadyMap ready); + + /// Marks a key Failed (no retry this wipe). + /// The (size, seed) map key. + void SetFailed(RustMapsMapKey key); + + /// Marks a key LimitReached (credits exhausted; no spend). + /// The (size, seed) map key. + void SetLimitReached(RustMapsMapKey key); + + /// Keys with at least one requester still in Idle or Generating. + IReadOnlyList PendingKeys(); + + /// The (guild, server) pairs awaiting a key. + /// The (size, seed) map key. + IReadOnlyList<(ulong Guild, Guid Server)> Requesters(RustMapsMapKey key); +} diff --git a/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationDriver.cs b/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationDriver.cs new file mode 100644 index 00000000..a6177943 --- /dev/null +++ b/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationDriver.cs @@ -0,0 +1,152 @@ +using Microsoft.Extensions.Logging; +using RustMapsApi.Results; +using RustMapsApi.V4; +using RustMapsApi.V4.Models; +using RustMapsApi.V4.Requests; + +namespace RustPlusBot.Features.Map.RustMaps; + +/// +/// Advances one RustMaps map key's generation one step: GET → (limits-gated) CreateMap → poll. Stores the +/// RustMaps-hosted image URL directly — no download. Credit-safe: CreateMap only on a genuine NotFound with +/// confirmed budget, once per key, no retry. +/// +/// The RustMaps API client. +/// The shared generation state. +/// The logger. +public sealed partial class RustMapsGenerationDriver( + IRustMapsClient client, + IRustMapsMapCoordinator coordinator, + ILogger logger) +{ + /// Advances the key one step based on its current state. + /// The map key. + /// A cancellation token. + /// A task that completes when the step is done. + public async Task AdvanceAsync(RustMapsMapKey key, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(key); + try + { + var snapshot = coordinator.Snapshot(key); + switch (snapshot.State) + { + case RustMapsGenerationState.Idle: + await StartAsync(key, cancellationToken).ConfigureAwait(false); + break; + case RustMapsGenerationState.Generating: + await PollAsync(key, snapshot.MapId, cancellationToken).ConfigureAwait(false); + break; + // Ready / Failed / LimitReached — terminal; nothing to do. + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } +#pragma warning disable CA1031 // Broad catch: any RustMaps failure marks the key Failed; the #info fallback still renders. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogAdvanceFailed(logger, ex, key.Size, key.Seed); + coordinator.SetFailed(key); + } + } + + private async Task StartAsync(RustMapsMapKey key, CancellationToken cancellationToken) + { + var get = await client.GetMapBySeedAndSizeAsync(key.Size, key.Seed, staging: false, cancellationToken) + .ConfigureAwait(false); + if (IsReady(get)) + { + SetReady(key, get.Data!); + return; + } + + if (get.Error?.Kind == RustMapsErrorKind.Queued) + { + // Already generating (elsewhere/earlier) — poll without spending. + coordinator.TrySetGenerating(key, mapId: null); + return; + } + + if (get.Error?.Kind != RustMapsErrorKind.NotFound) + { + // Transient/other GET error (or an unexpected non-ready success): do NOT spend a credit. + // Leave the key Idle so the free GET simply retries on the next tick. + return; + } + + // Genuine miss → pre-check limits (fail closed) before spending a credit. + var limits = await client.GetLimitsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + if (!limits.IsSuccess || limits.Data is null) + { + LogLimitsUnavailable(logger, key.Size, key.Seed); + coordinator.SetFailed(key); + return; + } + + if (IsExhausted(limits.Data.Concurrent) || IsExhausted(limits.Data.Monthly)) + { + LogLimitReached(logger, key.Size, key.Seed); + coordinator.SetLimitReached(key); + return; + } + + var create = await client.CreateMapAsync( + new MapGenerationRequest + { + Size = key.Size, Seed = key.Seed, Staging = false + }, cancellationToken) + .ConfigureAwait(false); + if (!create.IsSuccess) + { + LogCreateFailed(logger, key.Size, key.Seed, create.StatusCode); + coordinator.SetFailed(key); + return; + } + + coordinator.TrySetGenerating(key, create.Data?.MapId); + } + + private async Task PollAsync(RustMapsMapKey key, string? mapId, CancellationToken cancellationToken) + { + var get = mapId is { } id + ? await client.GetMapByIdAsync(id, cancellationToken).ConfigureAwait(false) + : await client.GetMapBySeedAndSizeAsync(key.Size, key.Seed, staging: false, cancellationToken) + .ConfigureAwait(false); + if (IsReady(get)) + { + SetReady(key, get.Data!); + } + // else: still generating (or a transient poll miss) — leave Generating, poll again next tick. + } + + /// + /// Stores the ready render's URL. ImageIconUrl (map_icons.png) is the render WITH monument icon + /// markers; ImageUrl (map_raw_normalized.png) is plain terrain with no markers. Prefer the iconned one. + /// + /// The map key. + /// The RustMaps map info. + private void SetReady(RustMapsMapKey key, MapInfo info) => + coordinator.SetReady(key, new RustMapsReadyMap(info.ImageIconUrl ?? info.ImageUrl!, info.Url)); + + private static bool IsExhausted(MapGenerationStat? stat) => stat is { } s && s.Current >= s.Allowed; + + private static bool IsReady(Result? get) => get is { IsSuccess: true, Data.ImageUrl: not null }; + + [LoggerMessage(Level = LogLevel.Warning, Message = "RustMaps advance failed for size {Size} seed {Seed}.")] + private static partial void LogAdvanceFailed(ILogger logger, Exception exception, int size, int seed); + + [LoggerMessage(Level = LogLevel.Warning, + Message = "RustMaps limits unavailable for size {Size} seed {Seed}; skipping generation (fail closed).")] + private static partial void LogLimitsUnavailable(ILogger logger, int size, int seed); + + [LoggerMessage(Level = LogLevel.Information, + Message = "RustMaps credit limit reached; skipping generation for size {Size} seed {Seed}.")] + private static partial void LogLimitReached(ILogger logger, int size, int seed); + + [LoggerMessage(Level = LogLevel.Warning, + Message = "RustMaps CreateMap failed for size {Size} seed {Seed} (status {StatusCode}).")] + private static partial void LogCreateFailed(ILogger logger, int size, int seed, int statusCode); +} diff --git a/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationState.cs b/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationState.cs new file mode 100644 index 00000000..28ec17b4 --- /dev/null +++ b/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationState.cs @@ -0,0 +1,20 @@ +namespace RustPlusBot.Features.Map.RustMaps; + +/// Lifecycle of a RustMaps map for one (size, seed). Ready/Failed/LimitReached are terminal for the wipe. +public enum RustMapsGenerationState +{ + /// Not yet checked/started. + Idle = 0, + + /// Generation requested; polling for completion. + Generating = 1, + + /// The RustMaps render is available and cached. + Ready = 2, + + /// Generation failed; no retry this wipe. + Failed = 3, + + /// Generation skipped because the RustMaps credit limit is reached. + LimitReached = 4, +} diff --git a/src/RustPlusBot.Features.Map/RustMaps/RustMapsMapCoordinator.cs b/src/RustPlusBot.Features.Map/RustMaps/RustMapsMapCoordinator.cs new file mode 100644 index 00000000..e944d51f --- /dev/null +++ b/src/RustPlusBot.Features.Map/RustMaps/RustMapsMapCoordinator.cs @@ -0,0 +1,130 @@ +using System.Collections.Concurrent; +using RustPlusBot.Abstractions.Map; + +namespace RustPlusBot.Features.Map.RustMaps; + +/// +public sealed class RustMapsMapCoordinator : IRustMapsMapCoordinator, IInfoMapReadModel +{ + private readonly ConcurrentDictionary _byKey = new(); + + /// + public InfoMapView? GetReady(int size, int seed) + { + var snap = Snapshot(new RustMapsMapKey(size, seed)); + return snap is { State: RustMapsGenerationState.Ready, Ready: { } r } + ? new InfoMapView(r.ImageUrl, r.RustMapsUrl) + : null; + } + + /// + public void Register(RustMapsMapKey key, ulong guildId, Guid serverId) + { + var entry = _byKey.GetOrAdd(key, static _ => new Entry()); + lock (entry.Gate) + { + entry.Requesters.Add((guildId, serverId)); + } + } + + /// + public RustMapsMapSnapshot Snapshot(RustMapsMapKey key) + { + if (!_byKey.TryGetValue(key, out var entry)) + { + return new RustMapsMapSnapshot(RustMapsGenerationState.Idle, null, null); + } + + lock (entry.Gate) + { + return new RustMapsMapSnapshot(entry.State, entry.MapId, entry.Ready); + } + } + + /// + public bool TrySetGenerating(RustMapsMapKey key, string? mapId) + { + var entry = _byKey.GetOrAdd(key, static _ => new Entry()); + lock (entry.Gate) + { + if (entry.State is RustMapsGenerationState.Ready or RustMapsGenerationState.Failed + or RustMapsGenerationState.LimitReached) + { + return false; + } + + entry.State = RustMapsGenerationState.Generating; + entry.MapId = mapId; + return true; + } + } + + /// + public void SetReady(RustMapsMapKey key, RustMapsReadyMap ready) + { + ArgumentNullException.ThrowIfNull(ready); + var entry = _byKey.GetOrAdd(key, static _ => new Entry()); + lock (entry.Gate) + { + entry.State = RustMapsGenerationState.Ready; + entry.Ready = ready; + } + } + + /// + public void SetFailed(RustMapsMapKey key) => SetTerminal(key, RustMapsGenerationState.Failed); + + /// + public void SetLimitReached(RustMapsMapKey key) => SetTerminal(key, RustMapsGenerationState.LimitReached); + + /// + public IReadOnlyList PendingKeys() + { + var pending = new List(); + foreach (var (key, entry) in _byKey) + { + lock (entry.Gate) + { + if (entry.Requesters.Count > 0 + && entry.State is RustMapsGenerationState.Idle or RustMapsGenerationState.Generating) + { + pending.Add(key); + } + } + } + + return pending; + } + + /// + public IReadOnlyList<(ulong Guild, Guid Server)> Requesters(RustMapsMapKey key) + { + if (!_byKey.TryGetValue(key, out var entry)) + { + return []; + } + + lock (entry.Gate) + { + return [.. entry.Requesters]; + } + } + + private void SetTerminal(RustMapsMapKey key, RustMapsGenerationState state) + { + var entry = _byKey.GetOrAdd(key, static _ => new Entry()); + lock (entry.Gate) + { + entry.State = state; + } + } + + private sealed class Entry + { + public object Gate { get; } = new(); + public RustMapsGenerationState State { get; set; } = RustMapsGenerationState.Idle; + public string? MapId { get; set; } + public RustMapsReadyMap? Ready { get; set; } + public HashSet<(ulong Guild, Guid Server)> Requesters { get; } = []; + } +} diff --git a/src/RustPlusBot.Features.Map/RustMaps/RustMapsMapKey.cs b/src/RustPlusBot.Features.Map/RustMaps/RustMapsMapKey.cs new file mode 100644 index 00000000..1acd884b --- /dev/null +++ b/src/RustPlusBot.Features.Map/RustMaps/RustMapsMapKey.cs @@ -0,0 +1,6 @@ +namespace RustPlusBot.Features.Map.RustMaps; + +/// Identity of a RustMaps procedural map, shared by every server on a wipe. +/// The world size. +/// The map seed. +public sealed record RustMapsMapKey(int Size, int Seed); diff --git a/src/RustPlusBot.Features.Map/RustMaps/RustMapsMapSnapshot.cs b/src/RustPlusBot.Features.Map/RustMaps/RustMapsMapSnapshot.cs new file mode 100644 index 00000000..f021f5bc --- /dev/null +++ b/src/RustPlusBot.Features.Map/RustMaps/RustMapsMapSnapshot.cs @@ -0,0 +1,7 @@ +namespace RustPlusBot.Features.Map.RustMaps; + +/// An immutable view of a map key's generation progress. +/// The current lifecycle state. +/// The RustMaps map id once assigned, else null. +/// The ready image when is . +public sealed record RustMapsMapSnapshot(RustMapsGenerationState State, string? MapId, RustMapsReadyMap? Ready); diff --git a/src/RustPlusBot.Features.Map/RustMaps/RustMapsReadyMap.cs b/src/RustPlusBot.Features.Map/RustMaps/RustMapsReadyMap.cs new file mode 100644 index 00000000..faa5f35a --- /dev/null +++ b/src/RustPlusBot.Features.Map/RustMaps/RustMapsReadyMap.cs @@ -0,0 +1,6 @@ +namespace RustPlusBot.Features.Map.RustMaps; + +/// A generated RustMaps map ready to reference: its hosted image URL plus its RustMaps page link. +/// The RustMaps-hosted image URL (the monument-icon render). +/// The RustMaps page URL, when available. +public sealed record RustMapsReadyMap(string ImageUrl, string? RustMapsUrl); diff --git a/src/RustPlusBot.Features.Map/RustPlusBot.Features.Map.csproj b/src/RustPlusBot.Features.Map/RustPlusBot.Features.Map.csproj index 1ff8ad92..7e5c4a44 100644 --- a/src/RustPlusBot.Features.Map/RustPlusBot.Features.Map.csproj +++ b/src/RustPlusBot.Features.Map/RustPlusBot.Features.Map.csproj @@ -21,6 +21,7 @@ + diff --git a/src/RustPlusBot.Features.Players/Relaying/PlayerEventRelay.cs b/src/RustPlusBot.Features.Players/Relaying/PlayerEventRelay.cs index 7641cbda..83c1567f 100644 --- a/src/RustPlusBot.Features.Players/Relaying/PlayerEventRelay.cs +++ b/src/RustPlusBot.Features.Players/Relaying/PlayerEventRelay.cs @@ -1,9 +1,11 @@ using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Connections.Listening; using RustPlusBot.Features.Players.Posting; using RustPlusBot.Features.Players.Rendering; using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Map; using RustPlusBot.Persistence.Workspace; namespace RustPlusBot.Features.Players.Relaying; @@ -32,31 +34,37 @@ public async Task RelayAsync(PlayerStateChangedEvent evt, CancellationToken canc return; } - var culture = await GetCultureAsync(evt.GuildId, cancellationToken).ConfigureAwait(false); + var (culture, gridStyle) = await GetRenderSettingsAsync(evt.GuildId, evt.ServerId, cancellationToken) + .ConfigureAwait(false); var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken) .ConfigureAwait(false); foreach (var t in evt.Transitions) { await teamChatSender - .SendAsync(evt.GuildId, evt.ServerId, renderer.RenderLine(t, evt.Dimensions, culture), + .SendAsync(evt.GuildId, evt.ServerId, renderer.RenderLine(t, evt.Dimensions, culture, gridStyle), cancellationToken) .ConfigureAwait(false); if (channelId is { } id) { - await poster.PostAsync(id, renderer.Render(t, evt.Dimensions, culture), cancellationToken) + await poster.PostAsync(id, renderer.Render(t, evt.Dimensions, culture, gridStyle), cancellationToken) .ConfigureAwait(false); } } } - private async Task GetCultureAsync(ulong guildId, CancellationToken cancellationToken) + private async Task<(string Culture, MapGridStyle GridStyle)> GetRenderSettingsAsync(ulong guildId, + Guid serverId, + CancellationToken cancellationToken) { var scope = scopeFactory.CreateAsyncScope(); await using (scope.ConfigureAwait(false)) { var store = scope.ServiceProvider.GetRequiredService(); - return await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); + var culture = await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); + var mapSettings = scope.ServiceProvider.GetRequiredService(); + var settings = await mapSettings.GetAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + return (culture, settings.GridStyle); } } } diff --git a/src/RustPlusBot.Features.Players/Rendering/PlayerEventRenderer.cs b/src/RustPlusBot.Features.Players/Rendering/PlayerEventRenderer.cs index 45130cc3..3b9f2132 100644 --- a/src/RustPlusBot.Features.Players/Rendering/PlayerEventRenderer.cs +++ b/src/RustPlusBot.Features.Players/Rendering/PlayerEventRenderer.cs @@ -14,12 +14,16 @@ internal sealed class PlayerEventRenderer(ILocalizer localizer) /// The player transition to render. /// The map dimensions, or null if unavailable. /// The BCP-47 culture tag. - public Embed Render(PlayerTransition transition, MapDimensions? dims, string culture) + /// Which grid convention the reference uses. + public Embed Render(PlayerTransition transition, + MapDimensions? dims, + string culture, + MapGridStyle gridStyle = MapGridStyle.InGame) { ArgumentNullException.ThrowIfNull(transition); return new EmbedBuilder() .WithAuthor(localizer.Get("player.title", culture)) - .WithDescription(Describe(transition, dims, culture, suffix: string.Empty)) + .WithDescription(Describe(transition, dims, culture, gridStyle, suffix: string.Empty)) .WithCurrentTimestamp() .Build(); } @@ -28,28 +32,34 @@ public Embed Render(PlayerTransition transition, MapDimensions? dims, string cul /// The player transition to render. /// The map dimensions, or null if unavailable. /// The BCP-47 culture tag. - public string RenderLine(PlayerTransition transition, MapDimensions? dims, string culture) + /// Which grid convention the reference uses. + public string RenderLine(PlayerTransition transition, + MapDimensions? dims, + string culture, + MapGridStyle gridStyle = MapGridStyle.InGame) { ArgumentNullException.ThrowIfNull(transition); - return Describe(transition, dims, culture, suffix: ".line"); + return Describe(transition, dims, culture, gridStyle, suffix: ".line"); } - private string Describe(PlayerTransition t, MapDimensions? dims, string culture, string suffix) + private string Describe(PlayerTransition t, MapDimensions? dims, string culture, MapGridStyle style, string suffix) { return t.Kind switch { PlayerTransitionKind.Connect => localizer.Get("player.connect" + suffix, culture, t.Name), PlayerTransitionKind.Disconnect => localizer.Get("player.disconnect" + suffix, culture, t.Name), - PlayerTransitionKind.Respawn => localizer.Get("player.respawn" + suffix, culture, t.Name, Grid(t, dims)), + PlayerTransitionKind.Respawn => localizer.Get("player.respawn" + suffix, culture, t.Name, + Grid(t, dims, style)), PlayerTransitionKind.ReturnedFromAfk => localizer.Get("player.afk.back" + suffix, culture, t.Name), - PlayerTransitionKind.BecameAfk => localizer.Get("player.afk" + suffix, culture, t.Name, Grid(t, dims)), + PlayerTransitionKind.BecameAfk => localizer.Get("player.afk" + suffix, culture, t.Name, + Grid(t, dims, style)), PlayerTransitionKind.Death => t.Location is null ? localizer.Get("player.death.unknown" + suffix, culture, t.Name) - : localizer.Get("player.death" + suffix, culture, t.Name, Grid(t, dims)), + : localizer.Get("player.death" + suffix, culture, t.Name, Grid(t, dims, style)), _ => throw new ArgumentOutOfRangeException(nameof(t), t.Kind, "Unsupported transition kind."), }; } - private static string Grid(PlayerTransition t, MapDimensions? dims) - => t.Location is { } loc ? GridReference.From(loc.X, loc.Y, dims) : string.Empty; + private static string Grid(PlayerTransition t, MapDimensions? dims, MapGridStyle style) + => t.Location is { } loc ? GridReference.From(loc.X, loc.Y, dims, style) : string.Empty; } diff --git a/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs b/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs index b9efd24b..c43110f7 100644 --- a/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs +++ b/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs @@ -1,4 +1,5 @@ using Discord; +using Discord.Net; using Discord.WebSocket; using RustPlusBot.Features.Workspace.Registry; @@ -138,6 +139,32 @@ await channel.ModifyMessageAsync(messageId, props => }).ConfigureAwait(false); } + /// + public async Task DeleteMessageAsync(ulong guildId, + ulong channelId, + ulong messageId, + CancellationToken cancellationToken) + { + var channel = client.GetGuild(guildId)?.GetTextChannel(channelId); + if (channel is null) + { + return; + } + + try + { + await channel.DeleteMessageAsync(messageId, new RequestOptions + { + CancelToken = cancellationToken + }) + .ConfigureAwait(false); + } + catch (HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.NotFound) + { + // Already gone: best-effort delete succeeds trivially. + } + } + /// public async Task DeleteChannelAsync(ulong guildId, ulong channelId, CancellationToken cancellationToken) { diff --git a/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs b/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs index add747be..2e8cce00 100644 --- a/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs +++ b/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs @@ -97,6 +97,13 @@ Task EditMessageAsync(ulong guildId, MessagePayload payload, CancellationToken cancellationToken); + /// Deletes a single message; a no-op if it is already gone. + /// The snowflake ID of the guild. + /// The snowflake ID of the channel containing the message. + /// The snowflake ID of the message to delete. + /// Token to cancel the operation. + Task DeleteMessageAsync(ulong guildId, ulong channelId, ulong messageId, CancellationToken cancellationToken); + /// Deletes a channel by snowflake (no-op if already gone). /// The snowflake ID of the guild. /// The snowflake ID of the channel to delete. diff --git a/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs b/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs index c29c2904..a1dfd29e 100644 --- a/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs +++ b/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs @@ -21,6 +21,7 @@ internal sealed class WorkspaceHostedService( { private readonly CancellationTokenSource _cts = new(); private Task? _connectionStatusLoop; + private Task? _infoMapReadyLoop; private Task? _serverCredentialsLoop; private Task? _serverRegisteredLoop; private bool _startupDone; @@ -36,6 +37,7 @@ public Task StartAsync(CancellationToken cancellationToken) _serverRegisteredLoop = Task.Run(() => ConsumeServerRegisteredAsync(_cts.Token), CancellationToken.None); _connectionStatusLoop = Task.Run(() => ConsumeConnectionStatusAsync(_cts.Token), CancellationToken.None); _serverCredentialsLoop = Task.Run(() => ConsumeServerCredentialsAsync(_cts.Token), CancellationToken.None); + _infoMapReadyLoop = Task.Run(() => ConsumeInfoMapReadyAsync(_cts.Token), CancellationToken.None); return Task.CompletedTask; } @@ -47,7 +49,7 @@ public async Task StopAsync(CancellationToken cancellationToken) await _cts.CancelAsync().ConfigureAwait(false); foreach (var loop in new[] { - _serverRegisteredLoop, _connectionStatusLoop, _serverCredentialsLoop + _serverRegisteredLoop, _connectionStatusLoop, _serverCredentialsLoop, _infoMapReadyLoop }) { if (loop is null) @@ -184,6 +186,34 @@ await reconciler.ReconcileServerAsync(changed.GuildId, changed.ServerId, cancell } } + private async Task ConsumeInfoMapReadyAsync(CancellationToken cancellationToken) + { + // If this loop faults (broad catch), the consumer exits permanently and the #info map message stops + // updating until the host restarts. Acceptable: the reconciler is idempotent and a restart heals. + try + { + await foreach (var ready in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var reconciler = scope.ServiceProvider.GetRequiredService(); + await reconciler.ReconcileServerAsync(ready.GuildId, ready.ServerId, cancellationToken) + .ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) + { + // Shutting down. + } + catch (Exception ex) // Broad catch is intentional: a faulting consumer must not crash the host. + { + logger.LogError(ex, "InfoMapReady consumer faulted."); + } + } + private async Task ConsumeServerRegisteredAsync(CancellationToken cancellationToken) { // Subscription is registered when this loop first calls SubscribeAsync; the in-process bus does diff --git a/src/RustPlusBot.Features.Workspace/Messages/MapControlMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/MapControlMessageRenderer.cs index 3c5113ed..b69b0c79 100644 --- a/src/RustPlusBot.Features.Workspace/Messages/MapControlMessageRenderer.cs +++ b/src/RustPlusBot.Features.Workspace/Messages/MapControlMessageRenderer.cs @@ -1,4 +1,5 @@ using Discord; +using RustPlusBot.Abstractions.Connections; using RustPlusBot.Features.Workspace.Gateway; using RustPlusBot.Features.Workspace.Registry; using RustPlusBot.Localization; @@ -6,7 +7,8 @@ namespace RustPlusBot.Features.Workspace.Messages; -/// Renders the per-server #map control message: six ManageGuild layer-toggle buttons. +/// Renders the per-server #map control message: six ManageGuild layer-toggle buttons plus the +/// grid-style picker (in-game vs Rust+/RustMaps grid convention). /// Per-(guild, server) layer toggles. /// String resolution. internal sealed class MapControlMessageRenderer( @@ -37,8 +39,13 @@ 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); + AddGridStyle(builder, MapGridStyle.InGame, "map.gridstyle.ingame", settings.GridStyle, serverId, + context.Culture); + AddGridStyle(builder, MapGridStyle.RustPlus, "map.gridstyle.rustplus", settings.GridStyle, serverId, + context.Culture); - var header = localizer.Get("map.control.header", context.Culture); + var header = localizer.Get("map.control.header", context.Culture) + + "\n-# " + localizer.Get("map.gridstyle.help", context.Culture); return new MessagePayload(header, null, builder.Build()); } @@ -54,4 +61,16 @@ private void AddToggle(ComponentBuilder builder, $"{WorkspaceComponentIds.MapTogglePrefix}{layer}:{serverId}", enabled ? ButtonStyle.Success : ButtonStyle.Secondary, row: row); + + private void AddGridStyle(ComponentBuilder builder, + MapGridStyle style, + string labelKey, + MapGridStyle current, + Guid serverId, + string culture) => + builder.WithButton( + localizer.Get(labelKey, culture), + $"{WorkspaceComponentIds.MapGridStylePrefix}{style}:{serverId}", + style == current ? ButtonStyle.Primary : ButtonStyle.Secondary, + row: 2); } diff --git a/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMapMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMapMessageRenderer.cs new file mode 100644 index 00000000..8658a3e4 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMapMessageRenderer.cs @@ -0,0 +1,70 @@ +using System.Globalization; +using Discord; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Map; +using RustPlusBot.Features.Workspace.Gateway; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; + +namespace RustPlusBot.Features.Workspace.Messages; + +/// +/// Renders the per-server #info map message: the RustMaps monument-icon render once generated. Until the +/// render is ready it returns an EMPTY payload so the reconciler leaves any existing map message untouched +/// (preserving the last image instead of overwriting it with a placeholder on every boot). The message is +/// (re)positioned above the status embed by the reconciler's channel-order repair when it first becomes +/// non-empty, so it does not need to be non-empty on the very first reconcile. +/// +/// Live query seam (world size/seed). +/// String resolution. +/// +/// Read seam over the RustMaps generation state. Null when no RustMaps API key is configured — the message +/// then never has a render to show, and the empty payload leaves the channel untouched. +/// +internal sealed class ServerInfoMapMessageRenderer( + IRustServerQuery query, + ILocalizer localizer, + IInfoMapReadModel? readModel = null) : IMessageRenderer +{ + private static readonly MessagePayload Empty = new(null, null, null); + + /// + public string MessageKey => WorkspaceMessageKeys.ServerInfoMap; + + /// + public async ValueTask RenderAsync(MessageRenderContext context, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + if (context.ServerId is not Guid serverId) + { + return Empty; + } + + var world = await query.GetWorldAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); + var ready = world is not null + ? readModel?.GetReady((int)world.WorldSize, (int)world.Seed) + : null; + if (ready is null) + { + // Not generated yet: return nothing so the reconciler leaves any existing map message (with its + // last image) untouched, rather than overwriting it with a placeholder on every boot. + return Empty; + } + + var embed = new EmbedBuilder() + .WithTitle(localizer.Get("map.info.title", context.Culture)) + .WithColor(Color.DarkGreen) + .AddField(localizer.Get("map.info.size", context.Culture), + world!.WorldSize.ToString(CultureInfo.InvariantCulture), inline: true) + .AddField(localizer.Get("map.info.seed", context.Culture), + world.Seed.ToString(CultureInfo.InvariantCulture), inline: true) + .WithImageUrl(ready.ImageUrl); + if (ready.RustMapsPageUrl is not null) + { + embed.WithUrl(ready.RustMapsPageUrl); + } + + return new MessagePayload(null, embed.Build(), null); + } +} diff --git a/src/RustPlusBot.Features.Workspace/Modules/MapComponentModule.cs b/src/RustPlusBot.Features.Workspace/Modules/MapComponentModule.cs index 2d7ae6d3..76d0f810 100644 --- a/src/RustPlusBot.Features.Workspace/Modules/MapComponentModule.cs +++ b/src/RustPlusBot.Features.Workspace/Modules/MapComponentModule.cs @@ -1,6 +1,7 @@ using Discord; using Discord.Interactions; using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Workspace.Reconciler; using RustPlusBot.Persistence.Map; @@ -8,7 +9,7 @@ namespace RustPlusBot.Features.Workspace.Modules; -/// Handles the #map layer-toggle buttons (ManageGuild). +/// Handles the #map layer-toggle and grid-style buttons (ManageGuild). /// Creates a short-lived DI scope per interaction. /// Publishes a settings-changed event to trigger an immediate repaint. public sealed class MapComponentModule(IServiceScopeFactory scopeFactory, IEventBus eventBus) @@ -60,6 +61,50 @@ public async Task ToggleAsync(string tail) await FollowupAsync("Updated map layers.", ephemeral: true).ConfigureAwait(false); } + /// Sets the grid style, re-renders the control message, and triggers a map repaint. + /// The "{style}:{serverId}" tail captured from the custom id. + [ComponentInteraction(WorkspaceComponentIds.MapGridStylePrefix + "*")] + [RequireUserPermission(GuildPermission.ManageGuild)] + public async Task SetGridStyleAsync(string tail) + { + ArgumentNullException.ThrowIfNull(tail); + if (Context.Guild is null) + { + await RespondAsync("This control must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + + var parts = tail.Split(':'); + if (parts.Length != 2 + || !Enum.TryParse(parts[0], ignoreCase: false, out var style) + || !Guid.TryParse(parts[1], out var serverId)) + { + await RespondAsync("That control wasn't valid.", ephemeral: true).ConfigureAwait(false); + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var servers = scope.ServiceProvider.GetRequiredService(); + if (await servers.GetAsync(Context.Guild.Id, serverId).ConfigureAwait(false) is null) + { + await FollowupAsync("That server isn't available.", ephemeral: true).ConfigureAwait(false); + return; + } + + var store = scope.ServiceProvider.GetRequiredService(); + await store.SetGridStyleAsync(Context.Guild.Id, serverId, style).ConfigureAwait(false); + + var reconciler = scope.ServiceProvider.GetRequiredService(); + await reconciler.ReconcileServerAsync(Context.Guild.Id, serverId).ConfigureAwait(false); + } + + await eventBus.PublishAsync(new MapSettingsChangedEvent(Context.Guild.Id, serverId)).ConfigureAwait(false); + await FollowupAsync("Updated map grid style.", ephemeral: true).ConfigureAwait(false); + } + private static bool IsEnabled(MapLayerSettings settings, MapLayer layer) => layer switch { MapLayer.Grid => settings.Grid, diff --git a/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs b/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs index feab5810..4e842de0 100644 --- a/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs +++ b/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs @@ -235,60 +235,93 @@ private async Task EnsureMessagesAsync(ulong guildId, WorkspaceScope scope, CancellationToken cancellationToken) { - foreach (var spec in backends.Registry.GetMessageSpecs(scope)) - { - if (!channelIds.TryGetValue(spec.ChannelKey, out var channelId) || - !_renderers.TryGetValue(spec.Key, out var renderer)) - { - continue; - } + var specsByChannel = backends.Registry.GetMessageSpecs(scope) + .Where(s => channelIds.ContainsKey(s.ChannelKey) && _renderers.ContainsKey(s.Key)) + .GroupBy(s => s.ChannelKey, StringComparer.Ordinal); - var payload = await renderer - .RenderAsync(new MessageRenderContext(guildId, serverId, culture), cancellationToken) - .ConfigureAwait(false); + foreach (var group in specsByChannel) + { + var channelId = channelIds[group.Key]; + var items = new List(); - // A renderer with nothing to show (e.g. the source entity vanished mid-reconcile) returns an - // empty payload; Discord rejects a message with no content/embed/components, so skip it. - if (payload.Text is null && payload.Embed is null && payload.Components is null) + foreach (var spec in group) { - continue; - } + var payload = await _renderers[spec.Key] + .RenderAsync(new MessageRenderContext(guildId, serverId, culture), cancellationToken) + .ConfigureAwait(false); - var record = await backends.Store.GetMessageAsync(guildId, serverId, spec.Key, cancellationToken) - .ConfigureAwait(false); + // A renderer with nothing to show (e.g. the source entity vanished mid-reconcile) returns an + // empty payload; Discord rejects a message with no content/embed/components, so skip it. + var isEmpty = payload.Text is null && payload.Embed is null && payload.Components is null; + + ulong? liveId = null; + if (!isEmpty) + { + var record = await backends.Store.GetMessageAsync(guildId, serverId, spec.Key, cancellationToken) + .ConfigureAwait(false); + if (record is not null + && record.DiscordChannelId == channelId + && await backends.Gateway + .MessageExistsAsync(guildId, channelId, record.DiscordMessageId, cancellationToken) + .ConfigureAwait(false)) + { + liveId = record.DiscordMessageId; + } + } - var canEditInPlace = record is not null - && record.DiscordChannelId == channelId - && await backends.Gateway - .MessageExistsAsync(guildId, channelId, record.DiscordMessageId, cancellationToken) - .ConfigureAwait(false); + items.Add(new MessageItem(spec, payload, isEmpty, liveId)); + } - if (canEditInPlace) + // Discord orders messages by creation time. If an earlier-declared message still needs to be + // posted while a later-declared one is already live, the channel would render out of spec + // order. Delete the live messages after that first to-post one so they re-post fresh, below + // it, in declaration order. Live messages before it already sit in their correct earlier + // position and are left untouched. + var firstToPost = items.FindIndex(i => !i.IsEmpty && i.LiveId is null); + if (firstToPost >= 0) { - await backends.Gateway.EditMessageAsync(guildId, channelId, record!.DiscordMessageId, payload, - cancellationToken) - .ConfigureAwait(false); - await backends.Store.SaveMessageAsync( - new ProvisionedMessage + for (var k = firstToPost + 1; k < items.Count; k++) + { + if (items[k].LiveId is { } staleId) { - GuildId = guildId, - RustServerId = serverId, - MessageKey = spec.Key, - DiscordChannelId = channelId, - DiscordMessageId = record.DiscordMessageId - }, - cancellationToken).ConfigureAwait(false); + await backends.Gateway.DeleteMessageAsync(guildId, channelId, staleId, cancellationToken) + .ConfigureAwait(false); + items[k] = items[k] with + { + LiveId = null + }; + } + } } - else + + foreach (var item in items) { - var messageId = await backends.Gateway.PostMessageAsync(guildId, channelId, payload, cancellationToken) - .ConfigureAwait(false); + if (item.IsEmpty) + { + continue; + } + + ulong messageId; + if (item.LiveId is { } liveId) + { + await backends.Gateway + .EditMessageAsync(guildId, channelId, liveId, item.Payload, cancellationToken) + .ConfigureAwait(false); + messageId = liveId; + } + else + { + messageId = await backends.Gateway + .PostMessageAsync(guildId, channelId, item.Payload, cancellationToken) + .ConfigureAwait(false); + } + await backends.Store.SaveMessageAsync( new ProvisionedMessage { GuildId = guildId, RustServerId = serverId, - MessageKey = spec.Key, + MessageKey = item.Spec.Key, DiscordChannelId = channelId, DiscordMessageId = messageId }, @@ -296,4 +329,11 @@ await backends.Store.SaveMessageAsync( } } } + + /// A rendered message spec paired with its current live materialization, if any. + /// The declared message spec. + /// The freshly rendered content. + /// True if the renderer had nothing to show (skipped entirely). + /// The snowflake of the currently-live message, or null if it must be posted. + private sealed record MessageItem(MessageSpec Spec, MessagePayload Payload, bool IsEmpty, ulong? LiveId); } diff --git a/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs index bf894ed1..ce92fafc 100644 --- a/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs +++ b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs @@ -27,6 +27,8 @@ public IEnumerable GetChannelSpecs() => /// public IEnumerable GetMessageSpecs() => [ + // Declared FIRST so it posts above the status embed (within-channel order = declaration order). + new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ServerInfoMap, WorkspaceChannelKeys.ServerInfo), new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ServerInfo, WorkspaceChannelKeys.ServerInfo), new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ServerMap, WorkspaceChannelKeys.ServerMap), ]; diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs b/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs index 8f00ffe7..6c710b48 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs @@ -20,4 +20,7 @@ public static class WorkspaceComponentIds /// Prefix for a #map layer toggle button; "{layer}:{serverId}" is appended. Handled by Workspace. public const string MapTogglePrefix = "workspace:map:toggle:"; + + /// Prefix for a #map grid-style button; "{style}:{serverId}" is appended. Handled by Workspace. + public const string MapGridStylePrefix = "workspace:map:gridstyle:"; } diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs b/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs index 7d9841c7..e950adfe 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs @@ -46,6 +46,9 @@ internal static class WorkspaceMessageKeys /// Key for the main settings message. public const string SettingsMain = "settings.main"; + /// Key for the per-server #info map message (reconciled RustMaps monument-icon render). + public const string ServerInfoMap = "server.info.map"; + /// Key for the per-server info message. public const string ServerInfo = "server.info"; diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs index bfaa09e5..a112790b 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs @@ -42,6 +42,10 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services) // ServerInfoMessageRenderer requires IRustServerQuery, which is registered by AddConnections — // the host must compose both AddWorkspace and AddConnections. services.AddScoped(); + // ServerInfoMapMessageRenderer takes IInfoMapReadModel as an OPTIONAL dependency: it only resolves + // when Features.Map registered it (RustMaps API key present); otherwise the renderer returns an + // empty payload and the reconciler never posts a map message. + services.AddScoped(); services.AddScoped(); // Reconciler + teardown (scoped). Register the teardown service once and expose both interfaces diff --git a/src/RustPlusBot.Host/Program.cs b/src/RustPlusBot.Host/Program.cs index ceabbc0b..5195edf9 100644 --- a/src/RustPlusBot.Host/Program.cs +++ b/src/RustPlusBot.Host/Program.cs @@ -80,8 +80,10 @@ builder.Services.AddOptions() .Bind(builder.Configuration.GetSection("Map")) .Validate(static o => o.MapRefreshInterval > TimeSpan.Zero, "Map:MapRefreshInterval must be positive.") + .Validate(static o => o.RustMaps.GenerationPollInterval > TimeSpan.Zero, + "Map:RustMaps:GenerationPollInterval must be positive.") .ValidateOnStart(); -builder.Services.AddMap(); +builder.Services.AddMap(builder.Configuration); builder.Services.AddSwitches(); builder.Services.AddAlarms(); builder.Services.AddStorageMonitors(); diff --git a/src/RustPlusBot.Host/appsettings.json b/src/RustPlusBot.Host/appsettings.json index 3c7327e4..38472106 100644 --- a/src/RustPlusBot.Host/appsettings.json +++ b/src/RustPlusBot.Host/appsettings.json @@ -54,6 +54,10 @@ "Cooldown": "00:00:04" }, "Map": { - "MapRefreshInterval": "00:00:45" + "MapRefreshInterval": "00:00:30", + "RustMaps": { + "ApiKey": "", + "GenerationPollInterval": "00:00:20" + } } } diff --git a/src/RustPlusBot.Localization/Strings.fr.resx b/src/RustPlusBot.Localization/Strings.fr.resx index 9a19fc77..7f16ea7f 100644 --- a/src/RustPlusBot.Localization/Strings.fr.resx +++ b/src/RustPlusBot.Localization/Strings.fr.resx @@ -540,6 +540,24 @@ Calques de la carte — activer/désactiver : + + Style de grille : En jeu correspond à la carte F1 (les positions des événements se lisent comme en jeu) ; Rust+/RustMaps correspond à l'application mobile et à rustmaps.com (lignes légèrement plus basses). + + + Grille : En jeu + + + Grille : Rust+/RustMaps + + + Seed + + + Taille + + + Carte du serveur + Grille diff --git a/src/RustPlusBot.Localization/Strings.resx b/src/RustPlusBot.Localization/Strings.resx index a59d1ccb..94b863a6 100644 --- a/src/RustPlusBot.Localization/Strings.resx +++ b/src/RustPlusBot.Localization/Strings.resx @@ -540,6 +540,24 @@ Map layers — toggle to show/hide: + + Grid style: In-game matches the F1 map (event callouts read like in-game grid refs); Rust+/RustMaps matches the companion app and rustmaps.com (rows sit slightly lower). + + + Grid: In-game + + + Grid: Rust+/RustMaps + + + Seed + + + Size + + + Server Map + Grid diff --git a/src/RustPlusBot.Persistence/Map/IMapSettingsStore.cs b/src/RustPlusBot.Persistence/Map/IMapSettingsStore.cs index 47a4af17..30c1b782 100644 --- a/src/RustPlusBot.Persistence/Map/IMapSettingsStore.cs +++ b/src/RustPlusBot.Persistence/Map/IMapSettingsStore.cs @@ -1,3 +1,5 @@ +using RustPlusBot.Abstractions.Connections; + namespace RustPlusBot.Persistence.Map; /// Reads/writes per-(guild, server) map layer settings. @@ -22,4 +24,15 @@ Task SetLayerAsync(ulong guildId, MapLayer layer, bool enabled, CancellationToken cancellationToken = default); + + /// Sets the grid style, creating the row (all-on layers) if needed. + /// Owning Discord guild snowflake. + /// The Rust server id. + /// The grid convention to use. + /// A cancellation token. + /// A task that completes when persisted. + Task SetGridStyleAsync(ulong guildId, + Guid serverId, + MapGridStyle style, + CancellationToken cancellationToken = default); } diff --git a/src/RustPlusBot.Persistence/Map/MapLayer.cs b/src/RustPlusBot.Persistence/Map/MapLayer.cs index c17f2092..06e650fa 100644 --- a/src/RustPlusBot.Persistence/Map/MapLayer.cs +++ b/src/RustPlusBot.Persistence/Map/MapLayer.cs @@ -1,3 +1,5 @@ +using RustPlusBot.Abstractions.Connections; + namespace RustPlusBot.Persistence.Map; /// A single toggleable map render layer. @@ -29,13 +31,15 @@ public enum MapLayer /// Travelling vendor. /// Teammate positions. /// Oil-rig activation styling. +/// Which grid convention the render and event grid references use. public sealed record MapLayerSettings( bool Grid, bool Markers, bool Monuments, bool Vendor, bool Players, - bool Rigs) + bool Rigs, + MapGridStyle GridStyle = MapGridStyle.InGame) { /// All layers enabled — the default when no settings row exists. public static MapLayerSettings AllOn { get; } = new(true, true, true, true, true, true); diff --git a/src/RustPlusBot.Persistence/Map/MapSettingsStore.cs b/src/RustPlusBot.Persistence/Map/MapSettingsStore.cs index ef09cc69..d6e96b79 100644 --- a/src/RustPlusBot.Persistence/Map/MapSettingsStore.cs +++ b/src/RustPlusBot.Persistence/Map/MapSettingsStore.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using RustPlusBot.Abstractions.Connections; using RustPlusBot.Domain.Map; namespace RustPlusBot.Persistence.Map; @@ -18,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.ShowPlayers, row.ShowRigs, row.GridStyle); } /// @@ -67,4 +68,27 @@ public async Task SetLayerAsync(ulong guildId, await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } + + /// + public async Task SetGridStyleAsync(ulong guildId, + Guid serverId, + MapGridStyle style, + CancellationToken cancellationToken = default) + { + var existing = await context.ServerMapSettings + .SingleOrDefaultAsync(s => s.GuildId == guildId && s.ServerId == serverId, cancellationToken) + .ConfigureAwait(false); + + var row = existing ?? new ServerMapSettings + { + GuildId = guildId, ServerId = serverId + }; + row.GridStyle = style; + if (existing is null) + { + context.ServerMapSettings.Add(row); + } + + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } } diff --git a/src/RustPlusBot.Persistence/Migrations/20260709213331_MapGridStyle.Designer.cs b/src/RustPlusBot.Persistence/Migrations/20260709213331_MapGridStyle.Designer.cs new file mode 100644 index 00000000..9e1987be --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260709213331_MapGridStyle.Designer.cs @@ -0,0 +1,726 @@ +// +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("20260709213331_MapGridStyle")] + partial class MapGridStyle + { + /// + 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("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/20260709213331_MapGridStyle.cs b/src/RustPlusBot.Persistence/Migrations/20260709213331_MapGridStyle.cs new file mode 100644 index 00000000..c5499c61 --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260709213331_MapGridStyle.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RustPlusBot.Persistence.Migrations +{ + /// + public partial class MapGridStyle : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "GridStyle", + table: "ServerMapSettings", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "GridStyle", + table: "ServerMapSettings"); + } + } +} diff --git a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs index 1f149feb..21b0ed21 100644 --- a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs +++ b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs @@ -363,6 +363,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ServerId") .HasColumnType("TEXT"); + b.Property("GridStyle") + .HasColumnType("INTEGER"); + b.Property("GuildId") .HasColumnType("INTEGER"); diff --git a/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs b/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs new file mode 100644 index 00000000..952867cc --- /dev/null +++ b/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs @@ -0,0 +1,84 @@ +using RustPlusBot.Abstractions.Connections; + +namespace RustPlusBot.Abstractions.Tests.Connections; + +public sealed class MapGridTests +{ + [Theory] + [InlineData(1500u, 11)] // 1500 / 146.25 = 10.26 -> 10 whole + 1 partial edge cell = 11 (A-K, rows 0-10) + [InlineData(3000u, 21)] // 3000 / 146.25 = 20.51 -> 20 whole + 1 partial = 21 + [InlineData(3500u, 24)] // 3500 / 146.25 = 23.93 -> 23 whole + 1 partial = 24 + [InlineData(4250u, 30)] // 4250 / 146.25 = 29.06 -> 29 whole + 1 partial = 30 + [InlineData(4500u, 31)] // 4500 / 146.25 = 30.77 -> 30 whole + 1 partial = 31 + public void CellCount_covers_the_world_including_the_partial_edge_cell(uint worldSize, int expected) => + Assert.Equal(expected, MapGrid.CellCount(worldSize)); + + [Theory] + [InlineData(0, "A")] + [InlineData(25, "Z")] + [InlineData(26, "AA")] + [InlineData(27, "AB")] + public void ColumnLetters_is_spreadsheet_style(int index, string expected) => + Assert.Equal(expected, MapGrid.ColumnLetters(index)); + + [Fact] + public void LabelFor_origin_is_bottom_left_last_row() + { + // 4000 world: 4000 / 146.25 = 27.35 -> 27 whole + 1 partial = 28 cells. + // World (0,0) = SW corner = column A, bottom row (cells - 1 = 27). + Assert.Equal("A27", MapGrid.LabelFor(0f, 0f, 4000u)); + } + + [Fact] + public void LabelFor_north_west_corner_is_A0() + { + Assert.Equal("A0", MapGrid.LabelFor(0f, 3999f, 4000u)); + } + + [Fact] + public void LabelFor_rows_bin_from_the_north_edge() + { + // 1500 world -> 11 cells with a 37.5-unit partial edge cell. y = 1360 is 140 units below the + // north edge: row 0 with north-anchored rows (in-game / app / RustMaps behaviour); binning from + // the south would put it in row 1. Locks in the anchoring. + Assert.Equal("A0", MapGrid.LabelFor(0f, 1360f, 1500u)); + } + + [Fact] + public void LabelFor_south_edge_falls_in_the_partial_row() + { + // 1500 world: the southernmost 37.5 units are the partial row 10. + Assert.Equal("A10", MapGrid.LabelFor(0f, 10f, 1500u)); + } + + [Fact] + public void LabelFor_beyond_world_size_clamps_to_last_cell() + { + // Regression for the old GridReference bug: clamping against IMAGE pixels, not world units. + // 4000 world -> 28 cells (see LabelFor_origin_is_bottom_left_last_row); last column index 27 = "AB". + Assert.Equal("AB27", MapGrid.LabelFor(4500f, 0f, 4000u)); + } + + [Fact] + public void LabelFor_rustplus_style_shifts_rows_100_units_south() + { + // Rust+/RustMaps rows start 100 units below the north edge (measured from RustMaps' own grid + // tiles). y = 830 on a 1500 world: in-game row = floor((1500-830)/146.25) = 4; Rust+ row = + // floor((1400-830)/146.25) = 3. Columns are identical in both styles. + Assert.Equal("A4", MapGrid.LabelFor(0f, 830f, 1500u)); + Assert.Equal("A3", MapGrid.LabelFor(0f, 830f, 1500u, MapGridStyle.RustPlus)); + } + + [Fact] + public void LabelFor_rustplus_style_clamps_the_top_strip_to_row_0() + { + // The 100 units above the Rust+ row anchor still read as row 0 (best effort). + Assert.Equal("A0", MapGrid.LabelFor(0f, 1450f, 1500u, MapGridStyle.RustPlus)); + } + + [Theory] + [InlineData(MapGridStyle.InGame, 0f)] + [InlineData(MapGridStyle.RustPlus, 100f)] + public void RowInset_is_zero_in_game_and_100_for_rustplus(MapGridStyle style, float expected) => + Assert.Equal(expected, MapGrid.RowInset(style)); +} diff --git a/tests/RustPlusBot.Abstractions.Tests/Connections/MapMarkerSnapshotTests.cs b/tests/RustPlusBot.Abstractions.Tests/Connections/MapMarkerSnapshotTests.cs index 7b280b22..899488d3 100644 --- a/tests/RustPlusBot.Abstractions.Tests/Connections/MapMarkerSnapshotTests.cs +++ b/tests/RustPlusBot.Abstractions.Tests/Connections/MapMarkerSnapshotTests.cs @@ -19,7 +19,7 @@ public void Marker_carries_kind_and_coordinates() [Fact] public void Dimensions_carry_size_and_margin() { - var dims = new MapDimensions(4000u, 4000u, 500); + var dims = new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u); Assert.Equal(4000u, dims.Width); Assert.Equal(500, dims.OceanMargin); diff --git a/tests/RustPlusBot.Abstractions.Tests/Events/RigStateChangedEventTests.cs b/tests/RustPlusBot.Abstractions.Tests/Events/RigStateChangedEventTests.cs index 2a828816..4dabec00 100644 --- a/tests/RustPlusBot.Abstractions.Tests/Events/RigStateChangedEventTests.cs +++ b/tests/RustPlusBot.Abstractions.Tests/Events/RigStateChangedEventTests.cs @@ -9,7 +9,7 @@ public sealed class RigStateChangedEventTests public void Carries_rig_kind_event_kind_position_and_dimensions() { var server = Guid.NewGuid(); - var dims = new MapDimensions(4000u, 4000u, 500); + var dims = new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u); var evt = new RigStateChangedEvent(7UL, server, RigKind.Large, RigEventKind.CrateLootable, 1f, 2f, dims); @@ -25,8 +25,8 @@ public void Carries_rig_kind_event_kind_position_and_dimensions() [Fact] public void Monument_snapshot_carries_token_and_position() { - var m = new MonumentSnapshot("oilrig_1", 10f, 20f); - Assert.Equal("oilrig_1", m.Token); + var m = new MonumentSnapshot("oil_rig_small", 10f, 20f); + Assert.Equal("oil_rig_small", m.Token); Assert.Equal(10f, m.X); Assert.Equal(20f, m.Y); } diff --git a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs index 6a7e3626..1068b591 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs @@ -13,6 +13,7 @@ using RustPlusBot.Features.ItemData; using RustPlusBot.Localization; using RustPlusBot.Persistence.Commands; +using RustPlusBot.Persistence.Map; using RustPlusBot.Persistence.Servers; using RustPlusBot.Persistence.Workspace; @@ -34,6 +35,7 @@ public void Dispatcher_and_handlers_resolve() services.AddSingleton(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); + services.AddScoped(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); services.AddOptions(); services.AddItemData(); @@ -92,6 +94,7 @@ public void Commands_contribute_an_interaction_module_assembly() services.AddSingleton(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); + services.AddScoped(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); services.AddOptions(); services.AddItemData(); diff --git a/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs index e19ae1e4..63f696ad 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs @@ -6,6 +6,7 @@ using RustPlusBot.Features.Events.Classifying; using RustPlusBot.Features.Events.State; using RustPlusBot.Localization; +using RustPlusBot.Persistence.Map; namespace RustPlusBot.Features.Commands.Tests.Handlers; @@ -22,6 +23,14 @@ private static (IClock Clock, ILocalizer Loc) Deps() return (clock, new ResxLocalizer()); } + /// A settings store returning the all-on defaults (in-game grid style). + private static IMapSettingsStore Settings() + { + var store = Substitute.For(); + store.GetAsync(Guild, Server, Arg.Any()).Returns(MapLayerSettings.AllOn); + return store; + } + private static CommandContext Ctx() => new(Guild, Server, "en", 0UL, string.Empty, []); [Fact] @@ -29,10 +38,14 @@ public async Task Cargo_with_active_marker_reports_grid() { var (clock, loc) = Deps(); var state = Substitute.For(); - var dims = new MapDimensions(4000u, 4000u, 500); + var dims = new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u); state.GetActiveMarkers(Guild, Server, MarkerKind.CargoShip).Returns( - [new ActiveMarker(1, MarkerKind.CargoShip, 10f, 3990f, dims, Now.AddMinutes(-5))]); - var reply = await new CargoCommandHandler(state, loc, clock).ExecuteAsync(Ctx(), CancellationToken.None); + [ + new ActiveMarker(1, MarkerKind.CargoShip, 10f, 3990f, dims, Now.AddMinutes(-5), + [new TrailPoint(10f, 3990f)], null) + ]); + var reply = await new CargoCommandHandler(state, loc, clock, Settings()).ExecuteAsync(Ctx(), + CancellationToken.None); Assert.NotNull(reply); Assert.Contains("Cargo Ship at", reply, StringComparison.Ordinal); @@ -46,7 +59,8 @@ public async Task Cargo_without_marker_reports_none() var (clock, loc) = Deps(); var state = Substitute.For(); state.GetActiveMarkers(Guild, Server, MarkerKind.CargoShip).Returns([]); - var reply = await new CargoCommandHandler(state, loc, clock).ExecuteAsync(Ctx(), CancellationToken.None); + var reply = await new CargoCommandHandler(state, loc, clock, Settings()).ExecuteAsync(Ctx(), + CancellationToken.None); Assert.Equal("No cargo ship on the map.", reply); } @@ -57,11 +71,14 @@ public async Task Events_lists_recent_or_reports_none() var state = Substitute.For(); state.GetRecentEvents(Guild, Server).Returns([]); Assert.Equal("No recent events.", - await new EventsCommandHandler(state, loc).ExecuteAsync(Ctx(), CancellationToken.None)); + await new EventsCommandHandler(state, loc, Settings()).ExecuteAsync(Ctx(), CancellationToken.None)); state.GetRecentEvents(Guild, Server).Returns( - [new RustMapEvent(MapEventKind.CargoEntered, 10f, 3990f, new MapDimensions(4000u, 4000u, 500), Now)]); - var reply = await new EventsCommandHandler(state, loc).ExecuteAsync(Ctx(), CancellationToken.None); + [ + new RustMapEvent(MapEventKind.CargoEntered, 10f, 3990f, + new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u), Now) + ]); + var reply = await new EventsCommandHandler(state, loc, Settings()).ExecuteAsync(Ctx(), CancellationToken.None); Assert.Contains("Recent:", reply, StringComparison.Ordinal); } @@ -70,9 +87,9 @@ public async Task Handlers_expose_expected_names() { var (clock, loc) = Deps(); var state = Substitute.For(); - Assert.Equal("cargo", new CargoCommandHandler(state, loc, clock).Name); - Assert.Equal("heli", new HeliCommandHandler(state, loc, clock).Name); - Assert.Equal("chinook", new ChinookCommandHandler(state, loc, clock).Name); - Assert.Equal("events", new EventsCommandHandler(state, loc).Name); + Assert.Equal("cargo", new CargoCommandHandler(state, loc, clock, Settings()).Name); + Assert.Equal("heli", new HeliCommandHandler(state, loc, clock, Settings()).Name); + Assert.Equal("chinook", new ChinookCommandHandler(state, loc, clock, Settings()).Name); + Assert.Equal("events", new EventsCommandHandler(state, loc, Settings()).Name); } } diff --git a/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs index 22f622ac..74584a1d 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs @@ -402,8 +402,8 @@ public async Task Marker_added_on_a_later_poll_publishes_changed_event() } }, CancellationToken.None); - // FakeConnection default DimensionsResult is new(4000u, 4000u, 500); assert those exact values. - var expectedDims = new MapDimensions(4000u, 4000u, 500); + // FakeConnection default DimensionsResult is new(4000u, 4000u, 500, 4000u); assert those exact values. + var expectedDims = new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u); // Script polls before EnsureConnectionAsync so the marker script is in the connection before // the poll loop can start — eliminates any setup race. @@ -435,6 +435,59 @@ public async Task Marker_added_on_a_later_poll_publishes_changed_event() } } + [Fact] + public async Task Marker_position_change_publishes_moved_bucket() + { + // Contract: a marker present in consecutive polls whose position changed lands in Moved + // (not Added/Removed), so the map can track cargo/heli movement. + // + // Script: + // Poll 1 → [Cargo id 7 @ (100, 100)] (baseline — no event) + // Poll 2 → [Cargo id 7 @ (150, 130)] (moved → one event) + var source = new FakeRustSocketSource(); + source.EnqueueConnect(SocketConnectOutcome.Connected); + source.EnqueueHeartbeat(HeartbeatResult.Ok(1)); + await using var h = CreateHarness(source); + var (serverId, _, _) = await SeedAsync(h.Provider); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var captured = new System.Collections.Concurrent.ConcurrentQueue(); + var subTask = Task.Run(async () => + { + await foreach (var e in h.Bus.SubscribeAsync(cts.Token)) + { + captured.Enqueue(e); + } + }, CancellationToken.None); + + source.EnqueueMarkers([new MapMarkerSnapshot(7UL, MarkerKind.CargoShip, 100f, 100f, "Cargo A")]); + source.EnqueueMarkers([new MapMarkerSnapshot(7UL, MarkerKind.CargoShip, 150f, 130f, "Cargo A")]); + + await h.Supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + await WaitUntilAsync(() => !captured.IsEmpty, cts.Token); + + Assert.Single(captured); + Assert.True(captured.TryPeek(out var evt)); + Assert.NotNull(evt); + Assert.Empty(evt!.Added); + Assert.Empty(evt.Removed); + var moved = Assert.Single(evt.Moved); + Assert.Equal(7UL, moved.Id); + Assert.Equal(150f, moved.X); + Assert.Equal(130f, moved.Y); + + await h.Supervisor.StopAllAsync(); + await cts.CancelAsync(); + try + { + await subTask; + } + catch (OperationCanceledException) + { + /* expected */ + } + } + [Fact] public async Task Failed_marker_poll_retains_previous_snapshot() { @@ -504,7 +557,7 @@ public async Task Ch47_entering_rig_radius_publishes_activated_once_per_visit() var source = new FakeRustSocketSource(); source.EnqueueConnect(SocketConnectOutcome.Connected); source.EnqueueHeartbeat(HeartbeatResult.Ok(1)); - source.SetMonuments([new MonumentSnapshot("oilrig_1", 1000f, 1000f)]); + source.SetMonuments([new MonumentSnapshot("oil_rig_small", 1000f, 1000f)]); source.EnqueueMarkers([new MapMarkerSnapshot(1UL, MarkerKind.Chinook, 0f, 0f, null)]); // poll 1: far source.EnqueueMarkers([ new MapMarkerSnapshot(1UL, MarkerKind.Chinook, 1010f, 1010f, null) @@ -560,7 +613,7 @@ public async Task Ch47_far_from_rig_publishes_chinook_event_but_no_rig_event() var source = new FakeRustSocketSource(); source.EnqueueConnect(SocketConnectOutcome.Connected); source.EnqueueHeartbeat(HeartbeatResult.Ok(1)); - source.SetMonuments([new MonumentSnapshot("oilrig_1", 1000f, 1000f)]); + source.SetMonuments([new MonumentSnapshot("oil_rig_small", 1000f, 1000f)]); source.EnqueueMarkers([]); // poll 1: baseline source.EnqueueMarkers([new MapMarkerSnapshot(1UL, MarkerKind.Chinook, 0f, 0f, null)]); // poll 2: CH47 far source.EnqueueMarkers([]); // poll 3: gone diff --git a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs index e7929194..66658535 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs @@ -217,7 +217,10 @@ internal sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocke public bool MarkersThrow { get; set; } /// The dimensions returned by . Defaults to a non-null snapshot. - public MapDimensions? DimensionsResult { get; set; } = new(4000u, 4000u, 500); + public MapDimensions? DimensionsResult { get; set; } = new(4000u, 4000u, 500, 4000u); + + /// The snapshot returned by . Defaults to null. + public WorldSnapshot? World { get; set; } /// The monuments returned by . Defaults to empty. public IReadOnlyList MonumentsResult { get; set; } = []; @@ -342,6 +345,9 @@ public Task> GetMapMarkersAsync(TimeSpan timeou CancellationToken cancellationToken = default) => Task.FromResult(DimensionsResult); + public Task GetWorldAsync(TimeSpan timeout, CancellationToken cancellationToken = default) => + Task.FromResult(World); + public Task> GetMonumentsAsync(TimeSpan timeout, CancellationToken cancellationToken = default) => Task.FromResult(MonumentsResult); diff --git a/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs b/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs index d7fdb74b..153de6a1 100644 --- a/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs @@ -21,7 +21,7 @@ private static MarkerEventClassifier Build() private static MapMarkersChangedEvent Evt( IReadOnlyList added, IReadOnlyList removed) => - new(1UL, Server, new MapDimensions(4000u, 4000u, 500), added, removed); + new(1UL, Server, new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u), added, removed, []); [Fact] public void Cargo_added_is_CargoEntered() diff --git a/tests/RustPlusBot.Features.Events.Tests/EventRegistrationTests.cs b/tests/RustPlusBot.Features.Events.Tests/EventRegistrationTests.cs index 8466eb3b..c1309282 100644 --- a/tests/RustPlusBot.Features.Events.Tests/EventRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/EventRegistrationTests.cs @@ -13,6 +13,7 @@ using RustPlusBot.Features.Events.State; using RustPlusBot.Features.Workspace.Locating; using RustPlusBot.Persistence.Connections; +using RustPlusBot.Persistence.Map; using RustPlusBot.Persistence.Workspace; namespace RustPlusBot.Features.Events.Tests; @@ -44,6 +45,7 @@ private static ServiceProvider BuildProvider() services.AddSingleton(); services.AddSingleton(Substitute.For()); services.AddScoped(_ => Substitute.For()); + services.AddScoped(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); services.AddSingleton(Substitute.For()); services.AddSingleton(Options.Create(new ConnectionOptions())); diff --git a/tests/RustPlusBot.Features.Events.Tests/Formatting/GridReferenceTests.cs b/tests/RustPlusBot.Features.Events.Tests/Formatting/GridReferenceTests.cs index 0b2bbd12..a00af917 100644 --- a/tests/RustPlusBot.Features.Events.Tests/Formatting/GridReferenceTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/Formatting/GridReferenceTests.cs @@ -5,11 +5,19 @@ namespace RustPlusBot.Features.Events.Tests.Formatting; public sealed class GridReferenceTests { + [Fact] + public void From_uses_world_size_not_image_pixels() + { + // Image is 2000px but the world is 4000 units: coords beyond 2000 must still resolve. + var dims = new MapDimensions(2000, 2000, 500, WorldSize: 4000); + Assert.Equal(GridReference.From(3900f, 3900f, dims), MapGrid.LabelFor(3900f, 3900f, 4000u)); + } + [Fact] public void Origin_is_top_left_cell() { // x near 0, y near top => column A, top row. - var dims = new MapDimensions(4000u, 4000u, 500); + var dims = new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u); var grid = GridReference.From(10f, 3990f, dims); Assert.StartsWith("A", grid, StringComparison.Ordinal); @@ -28,7 +36,7 @@ public void Null_dimensions_fall_back_to_raw_coordinates() [InlineData(150f, 4000f, "B")] public void Column_letter_advances_every_cell(float x, float y, string expectedColumnPrefix) { - var dims = new MapDimensions(4000u, 4000u, 500); + var dims = new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u); var grid = GridReference.From(x, y, dims); Assert.StartsWith(expectedColumnPrefix, grid, StringComparison.Ordinal); } diff --git a/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs b/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs index 02081439..2c016c5e 100644 --- a/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs @@ -14,6 +14,7 @@ using RustPlusBot.Features.Events.State; using RustPlusBot.Features.Workspace.Locating; using RustPlusBot.Localization; +using RustPlusBot.Persistence.Map; using RustPlusBot.Persistence.Workspace; namespace RustPlusBot.Features.Events.Tests.Relaying; @@ -39,8 +40,12 @@ private static (EventRelay Relay, EventStateStore Store, IEventChannelPoster Pos var workspaceStore = Substitute.For(); workspaceStore.GetCultureAsync(Guild, Arg.Any()).Returns("en"); + var mapSettings = Substitute.For(); + mapSettings.GetAsync(Guild, Server, Arg.Any()).Returns(MapLayerSettings.AllOn); + var services = new ServiceCollection(); services.AddScoped(_ => workspaceStore); + services.AddScoped(_ => mapSettings); var provider = services.BuildServiceProvider(); var sender = Substitute.For(); @@ -66,7 +71,7 @@ public async Task Posts_one_embed_per_classified_event_and_updates_state() await relay.RelayAsync( new MapMarkersChangedEvent(Guild, Server, null, [new MapMarkerSnapshot(1, MarkerKind.CargoShip, 0f, 0f, null)], - []), + [], []), CancellationToken.None); await poster.Received(1).PostAsync(999UL, Arg.Any(), Arg.Any()); @@ -81,7 +86,7 @@ public async Task When_channel_missing_updates_state_but_does_not_post() await relay.RelayAsync( new MapMarkersChangedEvent(Guild, Server, null, [new MapMarkerSnapshot(1, MarkerKind.CargoShip, 0f, 0f, null)], - []), + [], []), CancellationToken.None); await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any()); @@ -98,7 +103,7 @@ public async Task Empty_classification_posts_nothing() await relay.RelayAsync( new MapMarkersChangedEvent(Guild, Server, null, [new MapMarkerSnapshot(2, MarkerKind.Other, 0f, 0f, null)], - []), + [], []), CancellationToken.None); await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any()); @@ -112,7 +117,7 @@ public async Task Relay_broadcasts_each_event_in_game_in_addition_to_discord() await relay.RelayAsync( new MapMarkersChangedEvent(Guild, Server, null, [new MapMarkerSnapshot(1, MarkerKind.CargoShip, 0f, 0f, null)], - []), + [], []), CancellationToken.None); await poster.Received(1).PostAsync(999UL, Arg.Any(), Arg.Any()); diff --git a/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs b/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs index 00d8ccc8..5c4c41aa 100644 --- a/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs @@ -15,7 +15,7 @@ private static EventEmbedRenderer Build() => [Fact] public void Cargo_entered_renders_english_with_grid() { - var dims = new MapDimensions(4000u, 4000u, 500); + var dims = new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u); var embed = Build().Render(new RustMapEvent(MapEventKind.CargoEntered, 10f, 3990f, dims, Now), "en"); Assert.NotNull(embed.Description); diff --git a/tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs b/tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs index f12bc4be..a6175cf7 100644 --- a/tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs @@ -23,19 +23,22 @@ private static EventStateStore Build() private static MapMarkersChangedEvent Delta( IReadOnlyList added, IReadOnlyList removed) => - new(Guild, Server, null, added, removed); + new(Guild, Server, null, added, removed, []); private static MapMarkersChangedEvent DeltaWithDims( IReadOnlyList added, IReadOnlyList removed, MapDimensions? dims) => - new(Guild, Server, dims, added, removed); + new(Guild, Server, dims, added, removed, []); + + private static MapMarkersChangedEvent DeltaMoved(IReadOnlyList moved) => + new(Guild, Server, null, [], [], moved); [Fact] public void Added_marker_becomes_active_and_carries_dimensions() { var store = Build(); - var dims = new MapDimensions(4000u, 4000u, 500); + var dims = new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u); store.Apply( DeltaWithDims([new MapMarkerSnapshot(1, MarkerKind.CargoShip, 10f, 20f, null)], [], dims), [new RustMapEvent(MapEventKind.CargoEntered, 10f, 20f, dims, Now)]); @@ -88,4 +91,45 @@ public void Clear_drops_active_and_recent_for_that_server() Assert.Empty(store.GetActiveMarkers(Guild, Server, MarkerKind.CargoShip)); Assert.Empty(store.GetRecentEvents(Guild, Server)); } + + [Fact] + public void Moved_marker_updates_position_and_rotation() + { + var store = Build(); + store.Apply(Delta([new MapMarkerSnapshot(1, MarkerKind.CargoShip, 10f, 20f, null)], []), []); + + store.Apply(DeltaMoved([new MapMarkerSnapshot(1, MarkerKind.CargoShip, 30f, 40f, null, Rotation: 90f)]), []); + + var active = Assert.Single(store.GetActiveMarkers(Guild, Server, MarkerKind.CargoShip)); + Assert.Equal(30f, active.X); + Assert.Equal(40f, active.Y); + Assert.Equal(90f, active.Rotation); + } + + [Fact] + public void History_ring_appends_and_caps_at_six() + { + var store = Build(); + store.Apply(Delta([new MapMarkerSnapshot(1, MarkerKind.CargoShip, 0f, 0f, null)], []), []); + + for (var i = 1; i <= 8; i++) + { + store.Apply(DeltaMoved([new MapMarkerSnapshot(1, MarkerKind.CargoShip, i * 10f, 0f, null)]), []); + } + + var active = Assert.Single(store.GetActiveMarkers(Guild, Server, MarkerKind.CargoShip)); + Assert.Equal(6, active.History.Count); + Assert.Equal(80f, active.History[^1].X); // newest last = current position + Assert.Equal(30f, active.History[0].X); // oldest surviving point + } + + [Fact] + public void Moved_for_unknown_id_is_ignored() + { + var store = Build(); + + store.Apply(DeltaMoved([new MapMarkerSnapshot(99, MarkerKind.CargoShip, 1f, 2f, null)]), []); + + Assert.Empty(store.GetActiveMarkers(Guild, Server, MarkerKind.CargoShip)); + } } diff --git a/tests/RustPlusBot.Features.Map.Tests/BaseMapCacheTests.cs b/tests/RustPlusBot.Features.Map.Tests/BaseMapCacheTests.cs index d7d70daa..a2289b62 100644 --- a/tests/RustPlusBot.Features.Map.Tests/BaseMapCacheTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/BaseMapCacheTests.cs @@ -1,69 +1,75 @@ -using NSubstitute; -using RustPlusBot.Abstractions.Connections; using RustPlusBot.Features.Map.Composing; namespace RustPlusBot.Features.Map.Tests; public sealed class BaseMapCacheTests { - private const ulong Guild = 1UL; - private static readonly Guid Server = Guid.NewGuid(); - [Fact] - public async Task GetAsync_fetches_once_then_serves_from_cache() + public async Task First_source_wins_and_is_cached() { - var query = Substitute.For(); - query.GetMapImageAsync(Guild, Server, Arg.Any()) - .Returns( - [ - 9 - ]); - var cache = new BaseMapCache(query); + var preferred = new FakeSource(new BaseMapImage([1, 2], 100, 100, 0)); + var fallback = new FakeSource(new BaseMapImage([9, 9], 200, 200, 50)); + var cache = new BaseMapCache([preferred, fallback]); + var server = Guid.NewGuid(); - var first = await cache.GetAsync(Guild, Server, CancellationToken.None); - var second = await cache.GetAsync(Guild, Server, CancellationToken.None); + var first = await cache.GetAsync(1, server, CancellationToken.None); + var second = await cache.GetAsync(1, server, CancellationToken.None); - Assert.Equal("\t"u8.ToArray(), first); - Assert.Equal("\t"u8.ToArray(), second); - await query.Received(1).GetMapImageAsync(Guild, Server, Arg.Any()); + Assert.Equal(100, first!.PixelWidth); + Assert.Same(first, second); + Assert.Equal(1, preferred.Calls); // cached after the first hit + Assert.Equal(0, fallback.Calls); } [Fact] - public async Task GetAsync_does_not_cache_null_and_retries() + public async Task Falls_through_to_next_source_when_first_returns_null() { - var query = Substitute.For(); - query.GetMapImageAsync(Guild, Server, Arg.Any()) - .Returns((byte[]?)null, - [ - 7 - ]); - var cache = new BaseMapCache(query); + var preferred = new FakeSource(null); + var fallback = new FakeSource(new BaseMapImage([9], 200, 200, 50)); + var cache = new BaseMapCache([preferred, fallback]); - var first = await cache.GetAsync(Guild, Server, CancellationToken.None); - var second = await cache.GetAsync(Guild, Server, CancellationToken.None); + var result = await cache.GetAsync(1, Guid.NewGuid(), CancellationToken.None); - Assert.Null(first); - Assert.Equal(new byte[] - { - 7 - }, second); - await query.Received(2).GetMapImageAsync(Guild, Server, Arg.Any()); + Assert.Equal(200, result!.PixelWidth); + } + + [Fact] + public async Task All_null_is_not_cached_and_retries() + { + var source = new FakeSource(null); + var cache = new BaseMapCache([source]); + var server = Guid.NewGuid(); + + Assert.Null(await cache.GetAsync(1, server, CancellationToken.None)); + Assert.Null(await cache.GetAsync(1, server, CancellationToken.None)); + Assert.Equal(2, source.Calls); } [Fact] public async Task Clear_evicts_so_next_get_refetches() { - var query = Substitute.For(); - query.GetMapImageAsync(Guild, Server, Arg.Any()).Returns( - [ - 1 - ]); - var cache = new BaseMapCache(query); + var source = new FakeSource(new BaseMapImage([1], 100, 100, 0)); + var cache = new BaseMapCache([source]); + const ulong guild = 1UL; + var server = Guid.NewGuid(); - await cache.GetAsync(Guild, Server, CancellationToken.None); - cache.Clear(Guild, Server); - await cache.GetAsync(Guild, Server, CancellationToken.None); + await cache.GetAsync(guild, server, CancellationToken.None); + Assert.Equal(1, source.Calls); - await query.Received(2).GetMapImageAsync(Guild, Server, Arg.Any()); + cache.Clear(guild, server); + await cache.GetAsync(guild, server, CancellationToken.None); + + Assert.Equal(2, source.Calls); + } + + private sealed class FakeSource(BaseMapImage? result) : IBaseMapSource + { + public int Calls { get; private set; } + + public Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) + { + Calls++; + return Task.FromResult(result); + } } } diff --git a/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapHostedServiceTests.cs b/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapHostedServiceTests.cs new file mode 100644 index 00000000..29f31fe6 --- /dev/null +++ b/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapHostedServiceTests.cs @@ -0,0 +1,224 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using RustMapsApi.Results; +using RustMapsApi.V4; +using RustMapsApi.V4.Models; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Domain.Connections; +using RustPlusBot.Features.Map.Hosting; +using RustPlusBot.Features.Map.RustMaps; +using RustPlusBot.Persistence.Connections; + +namespace RustPlusBot.Features.Map.Tests.Hosting; + +public sealed class InfoMapHostedServiceTests +{ + private const ulong GuildA = 1UL; + private const ulong GuildB = 2UL; + private static readonly Guid ServerA = Guid.NewGuid(); + private static readonly Guid ServerB = Guid.NewGuid(); + private static readonly RustMapsMapKey Key = new(4000, 12345); + + private static IServiceScopeFactory ScopeFactory(IConnectionStore store) => + new ServiceCollection() + .AddScoped(_ => store) + .BuildServiceProvider() + .GetRequiredService(); + + private static IOptions ShortPollOptions() => Options.Create(new MapOptions + { + RustMaps = new RustMapsOptions + { + GenerationPollInterval = TimeSpan.FromMilliseconds(10) + } + }); + + /// + /// A fake bus that records every published . Deterministic, unlike + /// subscribing to the real from a background task: that bus drops + /// events published before the subscriber is active, which races the service's first tick. + /// + private static (IEventBus Bus, ConcurrentQueue Received) CapturingBus() + { + var bus = new CapturingEventBus(); + return (bus, bus.Received); + } + + [Fact] + public async Task Ready_key_publishes_InfoMapReadyEvent_once_per_requester() + { + var coordinator = new RustMapsMapCoordinator(); + coordinator.Register(Key, GuildA, ServerA); + coordinator.Register(Key, GuildB, ServerB); + + var client = Substitute.For(); + client.GetMapBySeedAndSizeAsync(Key.Size, Key.Seed, false, Arg.Any()) + .Returns(Result.Success( + new MapInfo + { + ImageUrl = "https://img/plain.png", + ImageIconUrl = "https://img/icons.png", + Url = "https://rustmaps/x" + }, 200)); + var driver = new RustMapsGenerationDriver(client, coordinator, NullLogger.Instance); + + var query = Substitute.For(); + // Capture publishes off a substituted bus: the real InMemoryEventBus drops events published + // before a subscriber is active, so collecting via a background subscription races the + // service's first tick and flakes on slow CI runners. + var (bus, received) = CapturingBus(); + + var service = new InfoMapHostedService( + bus, coordinator, driver, query, ShortPollOptions(), + ScopeFactory(Substitute.For()), NullLogger.Instance); + + await service.StartAsync(CancellationToken.None); + try + { + var deadline = DateTimeOffset.UtcNow.AddSeconds(5); + while (DateTimeOffset.UtcNow < deadline && received.Count < 2) + { + await Task.Delay(20); + } + } + finally + { + await service.StopAsync(CancellationToken.None); + } + + Assert.Equal(2, received.Count); + Assert.Contains(received, e => e.GuildId == GuildA && e.ServerId == ServerA); + Assert.Contains(received, e => e.GuildId == GuildB && e.ServerId == ServerB); + Assert.Equal(RustMapsGenerationState.Ready, coordinator.Snapshot(Key).State); + Assert.Equal("https://img/icons.png", coordinator.GetReady(Key.Size, Key.Seed)!.ImageUrl); + } + + [Fact] + public async Task Tick_registers_and_generates_connected_servers_without_any_connect_event() + { + // The ConnectionStatusChangedEvent is live-only and can be missed on startup (the connection may + // publish it before this service subscribes). Registration must therefore come from the connection + // store each tick, so a connected server still generates even if the event was never seen. + var coordinator = new RustMapsMapCoordinator(); + var serverId = Guid.NewGuid(); + + var client = Substitute.For(); + client.GetMapBySeedAndSizeAsync(Key.Size, Key.Seed, false, Arg.Any()) + .Returns(Result.Success( + new MapInfo + { + ImageUrl = "https://img/plain.png", + ImageIconUrl = "https://img/icons.png", + Url = "https://rustmaps/x" + }, 200)); + var driver = new RustMapsGenerationDriver(client, coordinator, NullLogger.Instance); + + var query = Substitute.For(); + query.GetWorldAsync(GuildA, serverId, Arg.Any()) + .Returns(new WorldSnapshot((uint)Key.Size, (uint)Key.Seed)); + + var store = Substitute.For(); + IReadOnlyList<(ulong GuildId, Guid ServerId)> connectable = [(GuildA, serverId)]; + store.ListConnectableServersAsync(Arg.Any()).Returns(connectable); + + var (bus, received) = CapturingBus(); + + var service = new InfoMapHostedService( + bus, coordinator, driver, query, ShortPollOptions(), + ScopeFactory(store), NullLogger.Instance); + + await service.StartAsync(CancellationToken.None); + try + { + var deadline = DateTimeOffset.UtcNow.AddSeconds(5); + while (DateTimeOffset.UtcNow < deadline && received.IsEmpty) + { + await Task.Delay(20); + } + } + finally + { + await service.StopAsync(CancellationToken.None); + } + + // No ConnectionStatusChangedEvent was ever published — registration came from the store tick. + Assert.Contains(received, e => e.GuildId == GuildA && e.ServerId == serverId); + Assert.Equal(RustMapsGenerationState.Ready, coordinator.Snapshot(Key).State); + } + + [Fact] + public async Task Connect_registers_the_servers_world_key_with_the_coordinator() + { + // No tick should be needed for registration — the connection-status path resolves the world and + // registers immediately, independent of the (here, effectively-never-firing) generation poll. + var coordinator = new RustMapsMapCoordinator(); + var driver = new RustMapsGenerationDriver( + Substitute.For(), coordinator, NullLogger.Instance); + + var serverId = Guid.NewGuid(); + var query = Substitute.For(); + query.GetWorldAsync(GuildA, serverId, Arg.Any()) + .Returns(new WorldSnapshot((uint)Key.Size, (uint)Key.Seed)); + + var connectionStore = Substitute.For(); + connectionStore.GetStateAsync(GuildA, serverId, Arg.Any()) + .Returns(new ConnectionState + { + GuildId = GuildA, RustServerId = serverId, Status = ConnectionStatus.Connected + }); + + var bus = new InMemoryEventBus(); + var pollOptions = Options.Create(new MapOptions + { + RustMaps = new RustMapsOptions + { + GenerationPollInterval = TimeSpan.FromSeconds(30) // must not need to fire for this test to pass + } + }); + var service = new InfoMapHostedService( + bus, coordinator, driver, query, pollOptions, + ScopeFactory(connectionStore), NullLogger.Instance); + + await service.StartAsync(CancellationToken.None); + try + { + var deadline = DateTimeOffset.UtcNow.AddSeconds(5); + while (DateTimeOffset.UtcNow < deadline && coordinator.PendingKeys().Count == 0) + { + await bus.PublishAsync(new ConnectionStatusChangedEvent(GuildA, serverId, true, false)); + await Task.Delay(20); + } + } + finally + { + await service.StopAsync(CancellationToken.None); + } + + Assert.Contains(Key, coordinator.PendingKeys()); + Assert.Contains((GuildA, serverId), coordinator.Requesters(Key)); + } + + private sealed class CapturingEventBus : IEventBus + { + public ConcurrentQueue Received { get; } = new(); + + public ValueTask PublishAsync(TEvent @event, CancellationToken cancellationToken = default) + where TEvent : notnull + { + if (@event is InfoMapReadyEvent ready) + { + Received.Enqueue(ready); + } + + return ValueTask.CompletedTask; + } + + public IAsyncEnumerable SubscribeAsync(CancellationToken cancellationToken = default) + where TEvent : notnull => + AsyncEnumerable.Empty(); // No live subscriptions needed: assertions read Received. + } +} diff --git a/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs index c6361841..ce54b7d8 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs @@ -15,7 +15,7 @@ public sealed class MapComposerTests { private const ulong Guild = 1UL; private static readonly Guid Server = Guid.NewGuid(); - private static readonly MapDimensions Dims = new(4000, 4000, 500); + private static readonly MapDimensions Dims = new(2000, 2000, 100, 4000); private static byte[] BaseJpeg() { @@ -39,9 +39,12 @@ private static MapComposer Build( IRigState rigs, IMapSettingsStore settingsStore) { - query.GetMapImageAsync(Guild, Server, Arg.Any()).Returns(baseImage); query.GetMapDimensionsAsync(Guild, Server, Arg.Any()).Returns(dims); - return new MapComposer(new BaseMapCache(query), events, rigs, query, new MapRenderer(), + var source = new FakeSource( + baseImage is null + ? null + : new BaseMapImage(baseImage, (int)Dims.Width, (int)Dims.Height, Dims.OceanMargin)); + return new MapComposer(new BaseMapCache([source]), events, rigs, query, new MapRenderer(), ScopeFactory(settingsStore)); } @@ -84,7 +87,8 @@ public async Task ComposeAsync_returns_null_when_no_base_map() [Fact] public async Task Renders_a_png_when_base_map_available() { - var marker = new ActiveMarker(1, MarkerKind.CargoShip, 2000f, 2000f, Dims, DateTimeOffset.UtcNow); + var marker = new ActiveMarker(1, MarkerKind.CargoShip, 2000f, 2000f, Dims, DateTimeOffset.UtcNow, + [new TrailPoint(2000f, 2000f)], null); var composer = Build(BaseJpeg(), Dims, NewQuery(), NewEvents(marker), NewRigs(), NewSettings(MapLayerSettings.AllOn)); @@ -99,7 +103,8 @@ public async Task Renders_a_png_when_base_map_available() public async Task Renders_base_only_when_dimensions_unavailable() { var composer = Build(BaseJpeg(), dims: null, NewQuery(), - NewEvents(new ActiveMarker(1, MarkerKind.CargoShip, 2000f, 2000f, Dims, DateTimeOffset.UtcNow)), + NewEvents(new ActiveMarker(1, MarkerKind.CargoShip, 2000f, 2000f, Dims, DateTimeOffset.UtcNow, + [new TrailPoint(2000f, 2000f)], null)), NewRigs(), NewSettings(MapLayerSettings.AllOn)); var png = await composer.ComposeAsync(Guild, Server, CancellationToken.None); @@ -117,7 +122,8 @@ public async Task ComposeAsync_renders_only_enabled_layers() var team = new TeamInfoSnapshot(0, [new TeamMemberSnapshot(1, "Ada", 2000f, 2000f, true, true, default, default)]); query.GetTeamInfoAsync(Guild, Server, Arg.Any()).Returns(team); - var events = NewEvents(new ActiveMarker(1, MarkerKind.CargoShip, 2000f, 2000f, Dims, DateTimeOffset.UtcNow)); + 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)); @@ -138,11 +144,12 @@ public async Task ComposeAsync_uses_all_on_when_no_settings_row() // A store with no row returns AllOn (its documented default) -> every gather path runs. var query = NewQuery(); query.GetMonumentsAsync(Guild, Server, Arg.Any()) - .Returns([new MonumentSnapshot("oilrig_1", 2000f, 2000f)]); + .Returns([new MonumentSnapshot("oil_rig_small", 2000f, 2000f)]); query.GetTeamInfoAsync(Guild, Server, Arg.Any()) .Returns(new TeamInfoSnapshot(0, [new TeamMemberSnapshot(1, "Ada", 2000f, 2000f, true, true, default, default)])); - var events = NewEvents(new ActiveMarker(1, MarkerKind.CargoShip, 2000f, 2000f, Dims, DateTimeOffset.UtcNow)); + var events = NewEvents(new ActiveMarker(1, MarkerKind.CargoShip, 2000f, 2000f, Dims, DateTimeOffset.UtcNow, + [new TrailPoint(2000f, 2000f)], null)); var composer = Build(BaseJpeg(), Dims, query, events, NewRigs(), NewSettings(MapLayerSettings.AllOn)); @@ -176,4 +183,39 @@ public async Task Renders_the_grid_even_with_no_markers() // The grid layer must have painted at least one pixel differently. Assert.False(pngOn!.SequenceEqual(pngOff!), "Grid-on and grid-off renders must differ."); } + + [Fact] + public async Task ComposeAsync_forwards_vendor_marker_history_into_rendered_trail() + { + // Regression guard for the Task-8 ProjectTrail refactor: GatherMarkers's Vendor branch must + // forward each marker's History ring through to the rendered trail, not just the current point. + var jpeg = BaseJpeg(); + var vendorWithTrail = new ActiveMarker(1, MarkerKind.TravellingVendor, 2000f, 2000f, Dims, + DateTimeOffset.UtcNow, [new TrailPoint(1200f, 1200f), new TrailPoint(2000f, 2000f)], null); + var vendorNoTrail = new ActiveMarker(1, MarkerKind.TravellingVendor, 2000f, 2000f, Dims, + DateTimeOffset.UtcNow, [new TrailPoint(2000f, 2000f)], null); + var layers = new MapLayerSettings( + Grid: false, Markers: false, Monuments: false, Vendor: true, Players: false, Rigs: false); + + var composerWithTrail = + Build(jpeg, Dims, NewQuery(), NewEvents(vendorWithTrail), NewRigs(), NewSettings(layers)); + 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); + + Assert.NotNull(pngWithTrail); + Assert.NotNull(pngNoTrail); + // 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!), + "Vendor trail with 2-point history must render differently than a 1-point history."); + } + + private sealed class FakeSource(BaseMapImage? result) : IBaseMapSource + { + public Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) => + Task.FromResult(result); + } } diff --git a/tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs index 81c2cc00..3c9c0e0e 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs @@ -24,7 +24,7 @@ public void Monument_returns_null_for_unknown_token() => [Fact] public void MonumentIconMap_maps_rig_tokens_to_icons() { - Assert.Equal("oilrig", MonumentIconMap.IconKeyFor("oilrig_1")); + Assert.Equal("oilrig", MonumentIconMap.IconKeyFor("oil_rig_small")); Assert.Equal("largeoilrig", MonumentIconMap.IconKeyFor("large_oil_rig")); } @@ -37,4 +37,32 @@ public void Rig_resolves_for_known_kinds(RigKind kind) => [Fact] public void Player_resolves_to_vendored_icon() => Assert.NotNull(MapIcons.Player()); + + [Fact] + public void Sized_marker_icon_fits_the_requested_box() + { + var icon = MapIcons.Marker(MarkerKind.CargoShip, 40); + + Assert.NotNull(icon); + Assert.True(icon!.Width <= 40 && icon.Height <= 40); + Assert.True(icon.Width == 40 || icon.Height == 40); // aspect-preserving fit, longest edge = size + } + + [Fact] + public void Sized_monument_icon_is_scaled_down_from_native() + { + var native = MapIcons.Monument("oil_rig_small"); // 875x875 native + var sized = MapIcons.Monument("oil_rig_small", 30); + + Assert.NotNull(native); + Assert.NotNull(sized); + Assert.True(sized!.Width <= 30 && sized.Height <= 30); + } + + [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)); + } } diff --git a/tests/RustPlusBot.Features.Map.Tests/MapOptionsTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapOptionsTests.cs new file mode 100644 index 00000000..62d91b47 --- /dev/null +++ b/tests/RustPlusBot.Features.Map.Tests/MapOptionsTests.cs @@ -0,0 +1,13 @@ +namespace RustPlusBot.Features.Map.Tests; + +public sealed class MapOptionsTests +{ + [Fact] + public void Defaults_are_sane() + { + var options = new MapOptions(); + Assert.Equal(TimeSpan.FromSeconds(30), options.MapRefreshInterval); + Assert.NotNull(options.RustMaps); + Assert.Equal(TimeSpan.FromSeconds(20), options.RustMaps.GenerationPollInterval); + } +} diff --git a/tests/RustPlusBot.Features.Map.Tests/MapProjectionTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapProjectionTests.cs new file mode 100644 index 00000000..b92f6f08 --- /dev/null +++ b/tests/RustPlusBot.Features.Map.Tests/MapProjectionTests.cs @@ -0,0 +1,57 @@ +using RustPlusBot.Features.Map.Rendering; + +namespace RustPlusBot.Features.Map.Tests; + +public sealed class MapProjectionTests +{ + [Fact] + public void Rust_plus_style_margin_projects_origin_inside_margin() + { + // 4000-unit world on a 2000px image with 100px margin, output 1000px. + // Playable spans [100, 1900]px on the image -> world(0,0) at image (100, 1900) -> output (50, 950). + var p = new MapProjection(WorldSize: 4000, ImageWidth: 2000, ImageHeight: 2000, OceanMarginPx: 100, + OutputSize: 1000); + + var (x, y) = p.ToPixel(0f, 0f); + + Assert.Equal(50f, x, precision: 3); + Assert.Equal(950f, y, precision: 3); + } + + [Fact] + public void Center_of_world_is_center_of_output() + { + var p = new MapProjection(4000, 2000, 2000, 100, 1000); + + var (x, y) = p.ToPixel(2000f, 2000f); + + Assert.Equal(500f, x, precision: 3); + Assert.Equal(500f, y, precision: 3); + } + + [Fact] + public void Rustmaps_style_zero_margin_maps_world_to_full_image() + { + // RustMaps raw render: no ocean margin. World (0,0) -> output bottom-left corner. + var p = new MapProjection(3500, 1750, 1750, 0, 1024); + + var (x0, y0) = p.ToPixel(0f, 0f); + var (x1, y1) = p.ToPixel(3500f, 3500f); + + Assert.Equal(0f, x0, precision: 3); + Assert.Equal(1024f, y0, precision: 3); + Assert.Equal(1024f, x1, precision: 3); + Assert.Equal(0f, y1, precision: 3); + } + + [Fact] + public void North_is_visually_above_south() + { + var p = new MapProjection(4000, 2000, 2000, 100, 1000); + + var (_, southY) = p.ToPixel(2000f, 0f); + var (_, northY) = p.ToPixel(2000f, 4000f); + + Assert.True(northY < southY); + } +} diff --git a/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs index e272320c..2639210d 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs @@ -1,9 +1,13 @@ +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Map; using RustPlusBot.Features.Events.State; using RustPlusBot.Features.Map.Composing; +using RustPlusBot.Features.Map.Hosting; using RustPlusBot.Features.Map.Rendering; +using RustPlusBot.Features.Map.RustMaps; using RustPlusBot.Persistence.Map; namespace RustPlusBot.Features.Map.Tests; @@ -13,11 +17,14 @@ public sealed class MapRegistrationTests [Fact] public void AddMap_registers_renderer_cache_and_composer_as_singletons() { - using var provider = BuildProvider(); + using var provider = BuildProvider(EmptyConfiguration()); var renderer = provider.GetRequiredService(); Assert.NotNull(renderer); + var source = provider.GetRequiredService(); + Assert.NotNull(source); + var cache = provider.GetRequiredService(); Assert.NotNull(cache); @@ -30,7 +37,64 @@ public void AddMap_registers_renderer_cache_and_composer_as_singletons() Assert.Same(composer, provider.GetRequiredService()); } - private static ServiceProvider BuildProvider() + [Fact] + public void AddMap_registers_only_the_RustPlus_base_source_without_key() + { + using var provider = BuildProvider(EmptyConfiguration()); + var source = Assert.Single(provider.GetServices()); + Assert.IsType(source); + } + + [Fact] + public void AddMap_with_RustMaps_key_still_uses_only_the_RustPlus_base_source() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection([new KeyValuePair("Map:RustMaps:ApiKey", "test-key")]) + .Build(); + using var provider = BuildProvider(configuration); + var source = Assert.Single(provider.GetServices()); + Assert.IsType(source); + } + + [Fact] + public void AddMap_with_RustMaps_key_registers_the_generation_components() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddScoped(_ => Substitute.For()); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection([new KeyValuePair("Map:RustMaps:ApiKey", "test-key")]) + .Build(); + services.AddMap(configuration); + + using var provider = services.BuildServiceProvider(validateScopes: true); + var coordinator = provider.GetService(); + var readModel = provider.GetService(); + Assert.NotNull(coordinator); + Assert.NotNull(readModel); + // IRustMapsMapCoordinator and IInfoMapReadModel must resolve to the SAME singleton so the driver's + // writes are visible to the Workspace renderer's reads. + Assert.Same(coordinator, readModel); + Assert.NotNull(provider.GetService()); + // Discord-dependent — assert registration without constructing DiscordSocketClient. + Assert.Contains(services, d => d.ImplementationType == typeof(InfoMapHostedService)); + } + + [Fact] + public void AddMap_without_RustMaps_key_registers_no_generation_components() + { + using var provider = BuildProvider(EmptyConfiguration()); + Assert.Null(provider.GetService()); + Assert.Null(provider.GetService()); + } + + private static IConfiguration EmptyConfiguration() => + new ConfigurationBuilder().Build(); + + private static ServiceProvider BuildProvider(IConfiguration configuration) { var services = new ServiceCollection(); services.AddLogging(); @@ -38,7 +102,7 @@ private static ServiceProvider BuildProvider() services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); services.AddScoped(_ => Substitute.For()); - services.AddMap(); + services.AddMap(configuration); return services.BuildServiceProvider(validateScopes: true); } diff --git a/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs index c3d5c209..65852fd5 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs @@ -8,7 +8,9 @@ namespace RustPlusBot.Features.Map.Tests; public sealed class MapRendererTests { - private static readonly MapDimensions Dims = new(Width: 4000, Height: 4000, OceanMargin: 500); + private static readonly MapProjection Projection = + new(WorldSize: 4000, ImageWidth: 4000, ImageHeight: 4000, OceanMarginPx: 500, + OutputSize: MapRenderer.OutputSize); /// A 64x64 solid-green JPEG, generated once in-test so the renderer has a real base image to decode. private static byte[] BaseJpeg() @@ -19,12 +21,42 @@ private static byte[] BaseJpeg() return ms.ToArray(); } + private static byte[] SolidJpeg(int size) + { + using var img = new Image(size, size, new Rgba32(40, 90, 120)); + using var ms = new MemoryStream(); + img.SaveAsJpeg(ms); + return ms.ToArray(); + } + + private static Rectangle ChangedPixelBounds(byte[] a, byte[] b) + { + using var ia = Image.Load(a); + using var ib = Image.Load(b); + int minX = int.MaxValue, minY = int.MaxValue, maxX = -1, maxY = -1; + for (var y = 0; y < ia.Height; y++) + { + for (var x = 0; x < ia.Width; x++) + { + if (ia[x, y] != ib[x, y]) + { + minX = Math.Min(minX, x); + minY = Math.Min(minY, y); + maxX = Math.Max(maxX, x); + maxY = Math.Max(maxY, y); + } + } + } + + return maxX < 0 ? Rectangle.Empty : new Rectangle(minX, minY, maxX - minX + 1, maxY - minY + 1); + } + [Fact] public void Render_produces_a_png_of_the_output_size() { var renderer = new MapRenderer(); - var bytes = renderer.Render(BaseJpeg(), Dims, markers: [], monuments: [], players: [], rigs: [], + var bytes = renderer.Render(BaseJpeg(), Projection, markers: [], monuments: [], players: [], rigs: [], MapLayerSet.AllOn); using var result = Image.Load(bytes); @@ -38,10 +70,10 @@ public void Render_with_a_marker_differs_from_render_without() var renderer = new MapRenderer(); var jpeg = BaseJpeg(); - var without = renderer.Render(jpeg, Dims, markers: [], monuments: [], players: [], rigs: [], + var without = renderer.Render(jpeg, Projection, markers: [], monuments: [], players: [], rigs: [], new MapLayerSet(false, true, false, false, false, false)); - var with = renderer.Render(jpeg, Dims, - markers: [new MarkerPlacement(MarkerKind.CargoShip, 512f, 512f)], + var with = renderer.Render(jpeg, Projection, + markers: [new MarkerPlacement(MarkerKind.CargoShip, 512f, 512f, null, [])], monuments: [], players: [], rigs: [], new MapLayerSet(false, true, false, false, false, false)); @@ -54,7 +86,7 @@ public void Render_with_all_layers_produces_valid_png() var renderer = new MapRenderer(); var markers = new[] { - new MarkerPlacement(MarkerKind.CargoShip, 100, 100) + new MarkerPlacement(MarkerKind.CargoShip, 100, 100, null, []) }; var monuments = new[] { @@ -69,9 +101,86 @@ public void Render_with_all_layers_produces_valid_png() new RigPlacement(RigKind.Large, 400, 400, Active: true) }; - var png = renderer.Render(BaseJpeg(), Dims, markers, monuments, players, rigs, MapLayerSet.AllOn); + var png = renderer.Render(BaseJpeg(), Projection, markers, monuments, players, rigs, MapLayerSet.AllOn); using var img = Image.Load(png); // throws if not a valid image Assert.Equal(MapRenderer.OutputSize, img.Width); } + + [Fact] + public void Monument_icon_is_drawn_scaled_not_native() + { + var renderer = new MapRenderer(); + var projection = new MapProjection(4000, 2000, 2000, 100, MapRenderer.OutputSize); + var baseJpeg = SolidJpeg(2000); + var (px, py) = projection.ToPixel(2000f, 2000f); + + var without = renderer.Render(baseJpeg, projection, [], [], [], [], + new MapLayerSet(false, false, false, false, false, false)); + var with = renderer.Render(baseJpeg, projection, [], + [new MonumentPlacement("oil_rig_small", px, py)], [], [], + new MapLayerSet(false, false, true, false, false, false)); + + var bounds = ChangedPixelBounds(without, with); + Assert.True(bounds.Width <= MapRenderStyle.MonumentIconSize + 2, + $"changed area {bounds.Width}px wide — icon not scaled"); + Assert.True(bounds.Height <= MapRenderStyle.MonumentIconSize + 2); + } + + [Fact] + public void Trail_draws_pixels_between_history_points() + { + var renderer = new MapRenderer(); + var projection = new MapProjection(4000, 2000, 2000, 100, MapRenderer.OutputSize); + var baseJpeg = SolidJpeg(2000); + var (ax, ay) = projection.ToPixel(1000f, 2000f); + var (bx, by) = projection.ToPixel(2000f, 2000f); + var layers = new MapLayerSet(false, true, false, false, false, false); + + var without = renderer.Render(baseJpeg, projection, + [new MarkerPlacement(MarkerKind.CargoShip, bx, by, null, [])], [], [], [], layers); + var with = renderer.Render(baseJpeg, projection, + [ + new MarkerPlacement(MarkerKind.CargoShip, bx, by, null, + [new PointF(ax, ay), new PointF(bx, by)]) + ], [], [], [], layers); + + var bounds = ChangedPixelBounds(without, with); + // The trail spans from A to B — far wider than the icon alone. + Assert.True(bounds.Width > MapRenderStyle.CargoIconSize * 2, $"no trail drawn (width {bounds.Width}px)"); + } + + [Fact] + public void Grid_style_shifts_the_rendered_rows() + { + // Rust+/RustMaps rows sit 100 world-units south of the in-game rows, so the two styles must + // produce different grid pixels on the same base image. + var renderer = new MapRenderer(); + var projection = new MapProjection(1500, 2000, 2000, 100, MapRenderer.OutputSize); + var baseJpeg = SolidJpeg(2000); + var layers = new MapLayerSet(Grid: true, Markers: false, Monuments: false, Vendor: false, + Players: false, Rigs: false); + + var inGame = renderer.Render(baseJpeg, projection, [], [], [], [], layers); + var rustPlus = renderer.Render(baseJpeg, projection, [], [], [], [], layers, MapGridStyle.RustPlus); + + Assert.False(inGame.AsSpan().SequenceEqual(rustPlus)); + } + + [Fact] + public void Rotation_changes_the_rendered_icon() + { + var renderer = new MapRenderer(); + 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, true, false, false, false, false); + + var unrotated = renderer.Render(baseJpeg, projection, + [new MarkerPlacement(MarkerKind.CargoShip, px, py, null, [])], [], [], [], layers); + var rotated = renderer.Render(baseJpeg, projection, + [new MarkerPlacement(MarkerKind.CargoShip, px, py, 45f, [])], [], [], [], layers); + + Assert.False(unrotated.AsSpan().SequenceEqual(rotated)); + } } diff --git a/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsGenerationDriverTests.cs b/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsGenerationDriverTests.cs new file mode 100644 index 00000000..518e5aa4 --- /dev/null +++ b/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsGenerationDriverTests.cs @@ -0,0 +1,178 @@ +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using RustMapsApi.Results; +using RustMapsApi.V4; +using RustMapsApi.V4.Models; +using RustMapsApi.V4.Requests; +using RustPlusBot.Features.Map.RustMaps; + +namespace RustPlusBot.Features.Map.Tests.RustMaps; + +public sealed class RustMapsGenerationDriverTests +{ + private static readonly RustMapsMapKey Key = new(4000, 12345); + private static readonly Guid Server = Guid.NewGuid(); + + private static RustMapsError Error(RustMapsErrorKind kind) => new(kind, null, null, null); + + private static (RustMapsGenerationDriver Driver, IRustMapsClient Client, RustMapsMapCoordinator Coord) Build() + { + var client = Substitute.For(); + var coord = new RustMapsMapCoordinator(); + var driver = new RustMapsGenerationDriver(client, coord, NullLogger.Instance); + return (driver, client, coord); + } + + [Fact] + public async Task Existing_map_goes_straight_to_ready_without_generating() + { + var (driver, client, coord) = Build(); + coord.Register(Key, 1UL, Server); + client.GetMapBySeedAndSizeAsync(4000, 12345, false, Arg.Any()) + .Returns(Result.Success( + new MapInfo + { + ImageUrl = "https://img/x.png", Url = "https://rustmaps/x" + }, 200)); + + await driver.AdvanceAsync(Key, CancellationToken.None); + + Assert.Equal(RustMapsGenerationState.Ready, coord.Snapshot(Key).State); + Assert.Equal("https://img/x.png", coord.Snapshot(Key).Ready!.ImageUrl); + await client.DidNotReceiveWithAnyArgs().CreateMapAsync(default!, default); + } + + [Fact] + public async Task Ready_prefers_the_icon_render_url_over_the_plain_terrain_one() + { + // ImageIconUrl (map_icons.png) carries the monument markers; ImageUrl (map_raw_normalized.png) + // is plain terrain. The driver must store the iconned render's URL. + var (driver, client, coord) = Build(); + coord.Register(Key, 1UL, Server); + client.GetMapBySeedAndSizeAsync(4000, 12345, false, Arg.Any()) + .Returns(Result.Success( + new MapInfo + { + ImageUrl = "https://img/plain.png", + ImageIconUrl = "https://img/icons.png", + Url = "https://rustmaps/x" + }, 200)); + + await driver.AdvanceAsync(Key, CancellationToken.None); + + Assert.Equal("https://img/icons.png", coord.Snapshot(Key).Ready!.ImageUrl); + } + + [Fact] + public async Task NotFound_with_budget_generates_exactly_once_across_ticks() + { + var (driver, client, coord) = Build(); + coord.Register(Key, 1UL, Server); + client.GetMapBySeedAndSizeAsync(4000, 12345, false, Arg.Any()) + .Returns(Result.Failure(Error(RustMapsErrorKind.NotFound), 404)); + client.GetLimitsAsync(Arg.Any(), Arg.Any()) + .Returns(Result.Success( + new MapGenLimits + { + Concurrent = new(0, 5), Monthly = new(3, 100) + }, 200)); + client.CreateMapAsync(Arg.Any(), Arg.Any()) + .Returns(Result.Success(new MapGenerationStatus + { + MapId = "map-1" + }, 200)); + + await driver.AdvanceAsync(Key, CancellationToken.None); // Idle → Generating + await driver.AdvanceAsync(Key, CancellationToken.None); // Generating → poll (still not ready) + + Assert.Equal(RustMapsGenerationState.Generating, coord.Snapshot(Key).State); + await client.Received(1).CreateMapAsync( + Arg.Is(r => r.Size == 4000 && r.Seed == 12345 && !r.Staging), + Arg.Any()); + } + + [Fact] + public async Task Transient_get_error_that_is_not_NotFound_never_spends_a_credit() + { + var (driver, client, coord) = Build(); + coord.Register(Key, 1UL, Server); + client.GetMapBySeedAndSizeAsync(4000, 12345, false, Arg.Any()) + .Returns(Result.Failure(Error(RustMapsErrorKind.Transport), 503)); + + await driver.AdvanceAsync(Key, CancellationToken.None); + + Assert.Equal(RustMapsGenerationState.Idle, coord.Snapshot(Key).State); + await client.DidNotReceiveWithAnyArgs().GetLimitsAsync(default, default); + await client.DidNotReceiveWithAnyArgs().CreateMapAsync(default!, default); + } + + [Fact] + public async Task Limit_reached_never_generates() + { + var (driver, client, coord) = Build(); + coord.Register(Key, 1UL, Server); + client.GetMapBySeedAndSizeAsync(4000, 12345, false, Arg.Any()) + .Returns(Result.Failure(Error(RustMapsErrorKind.NotFound), 404)); + client.GetLimitsAsync(Arg.Any(), Arg.Any()) + .Returns(Result.Success( + new MapGenLimits + { + Concurrent = new(5, 5), Monthly = new(3, 100) + }, 200)); // concurrent exhausted + + await driver.AdvanceAsync(Key, CancellationToken.None); + + Assert.Equal(RustMapsGenerationState.LimitReached, coord.Snapshot(Key).State); + await client.DidNotReceiveWithAnyArgs().CreateMapAsync(default!, default); + } + + [Fact] + public async Task Limits_call_failing_fails_closed_no_generate() + { + var (driver, client, coord) = Build(); + coord.Register(Key, 1UL, Server); + client.GetMapBySeedAndSizeAsync(4000, 12345, false, Arg.Any()) + .Returns(Result.Failure(Error(RustMapsErrorKind.NotFound), 404)); + client.GetLimitsAsync(Arg.Any(), Arg.Any()) + .Returns(Result.Failure(Error(RustMapsErrorKind.Transport), 503)); + + await driver.AdvanceAsync(Key, CancellationToken.None); + + Assert.Equal(RustMapsGenerationState.Failed, coord.Snapshot(Key).State); + await client.DidNotReceiveWithAnyArgs().CreateMapAsync(default!, default); + } + + [Fact] + public async Task Poll_reaches_ready_and_stores_the_image_url() + { + var (driver, client, coord) = Build(); + coord.Register(Key, 1UL, Server); + coord.TrySetGenerating(Key, "map-1"); + client.GetMapByIdAsync("map-1", Arg.Any()) + .Returns(Result.Success( + new MapInfo + { + ImageUrl = "https://img/x.png", Url = "https://rustmaps/x" + }, 200)); + + await driver.AdvanceAsync(Key, CancellationToken.None); + + Assert.Equal(RustMapsGenerationState.Ready, coord.Snapshot(Key).State); + Assert.Equal("https://img/x.png", coord.Snapshot(Key).Ready!.ImageUrl); + } + + [Fact] + public async Task Poll_still_generating_stays_generating_without_spending() + { + var (driver, client, coord) = Build(); + coord.Register(Key, 1UL, Server); + coord.TrySetGenerating(Key, "map-1"); + client.GetMapByIdAsync("map-1", Arg.Any()) + .Returns(Result.Failure(Error(RustMapsErrorKind.Queued), 409)); + + await driver.AdvanceAsync(Key, CancellationToken.None); + + Assert.Equal(RustMapsGenerationState.Generating, coord.Snapshot(Key).State); + await client.DidNotReceiveWithAnyArgs().CreateMapAsync(default!, default); + } +} diff --git a/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsMapCoordinatorTests.cs b/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsMapCoordinatorTests.cs new file mode 100644 index 00000000..d4b6237d --- /dev/null +++ b/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsMapCoordinatorTests.cs @@ -0,0 +1,89 @@ +using RustPlusBot.Features.Map.RustMaps; + +namespace RustPlusBot.Features.Map.Tests.RustMaps; + +public sealed class RustMapsMapCoordinatorTests +{ + private static readonly RustMapsMapKey Key = new(4000, 12345); + private static readonly Guid Server = Guid.NewGuid(); + + [Fact] + public void Unseen_key_snapshots_as_idle() + { + var c = new RustMapsMapCoordinator(); + Assert.Equal(RustMapsGenerationState.Idle, c.Snapshot(Key).State); + } + + [Fact] + public void Register_is_idempotent_and_accumulates_requesters() + { + var c = new RustMapsMapCoordinator(); + var s2 = Guid.NewGuid(); + c.Register(Key, 1UL, Server); + c.Register(Key, 1UL, Server); // same requester again + c.Register(Key, 1UL, s2); // second server, same (size,seed) + + Assert.Equal(2, c.Requesters(Key).Count); + Assert.Contains(Key, c.PendingKeys()); + } + + [Fact] + public void Lifecycle_transitions_and_ready_caches_the_image() + { + var c = new RustMapsMapCoordinator(); + c.Register(Key, 1UL, Server); + Assert.True(c.TrySetGenerating(Key, "map-1")); + Assert.Equal(RustMapsGenerationState.Generating, c.Snapshot(Key).State); + Assert.Equal("map-1", c.Snapshot(Key).MapId); + + c.SetReady(Key, new RustMapsReadyMap("https://img/x.png", "https://rustmaps/x")); + var snap = c.Snapshot(Key); + Assert.Equal(RustMapsGenerationState.Ready, snap.State); + Assert.Equal("https://img/x.png", snap.Ready!.ImageUrl); + Assert.Equal("https://rustmaps/x", snap.Ready.RustMapsUrl); + } + + [Fact] + public void Terminal_states_are_not_pending_and_are_not_reset_by_register() + { + var c = new RustMapsMapCoordinator(); + c.Register(Key, 1UL, Server); + c.SetFailed(Key); + c.Register(Key, 1UL, Server); // must not revive + + Assert.Equal(RustMapsGenerationState.Failed, c.Snapshot(Key).State); + Assert.DoesNotContain(Key, c.PendingKeys()); + } + + [Fact] + public void GetReady_returns_the_view_when_ready() + { + var c = new RustMapsMapCoordinator(); + c.Register(Key, 1UL, Server); + c.SetReady(Key, new RustMapsReadyMap("https://img/x.png", "https://rustmaps/x")); + + var view = c.GetReady(Key.Size, Key.Seed); + + Assert.NotNull(view); + Assert.Equal("https://img/x.png", view!.ImageUrl); + Assert.Equal("https://rustmaps/x", view.RustMapsPageUrl); + } + + [Fact] + public void GetReady_returns_null_when_not_ready() + { + var c = new RustMapsMapCoordinator(); + c.Register(Key, 1UL, Server); + c.TrySetGenerating(Key, "map-1"); + + Assert.Null(c.GetReady(Key.Size, Key.Seed)); + } + + [Fact] + public void GetReady_returns_null_for_an_unseen_key() + { + var c = new RustMapsMapCoordinator(); + + Assert.Null(c.GetReady(Key.Size, Key.Seed)); + } +} diff --git a/tests/RustPlusBot.Features.Map.Tests/RustMapsParityTests.cs b/tests/RustPlusBot.Features.Map.Tests/RustMapsParityTests.cs new file mode 100644 index 00000000..301b9d69 --- /dev/null +++ b/tests/RustPlusBot.Features.Map.Tests/RustMapsParityTests.cs @@ -0,0 +1,55 @@ +using System.Text.Json; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Features.Map.Rendering; + +namespace RustPlusBot.Features.Map.Tests; + +/// +/// Ground-truth parity: monuments from a committed RustMaps response, projected through our +/// MapProjection, must land in the grid cells RustMaps world coordinates imply. Skips when no +/// fixture has been generated yet (tools/RustPlusBot.MapParity produces one). +/// +public sealed class RustMapsParityTests +{ + private static readonly string FixtureDir = + Path.Combine(AppContext.BaseDirectory, "Fixtures"); + + [SkippableFact] + public void Monument_world_coords_project_into_consistent_grid_cells() + { + // Order the enumeration so the selected fixture is deterministic across machines/filesystems. + var fixture = Directory.Exists(FixtureDir) + ? Directory.EnumerateFiles(FixtureDir, "rustmaps-*.json").Order(StringComparer.Ordinal).FirstOrDefault() + : null; + Skip.If(fixture is null, "No RustMaps fixture committed yet (run tools/RustPlusBot.MapParity)."); + + using var doc = JsonDocument.Parse(File.ReadAllText(fixture)); + var root = doc.RootElement; + var size = (uint)root.GetProperty("size").GetInt32(); + var projection = new MapProjection(size, 2048, 2048, 0, 2048); + + foreach (var monument in root.GetProperty("monuments").EnumerateArray()) + { + if (!monument.TryGetProperty("coordinates", out var coords) || coords.ValueKind == JsonValueKind.Null) + { + continue; + } + + var x = coords.GetProperty("x").GetSingle(); + var y = coords.GetProperty("y").GetSingle(); + var (px, py) = projection.ToPixel(x, y); + + // Independent re-derivation: the cell computed from the projected PIXEL must equal the + // cell computed from the WORLD coordinate. Catches any scale/offset/Y-flip regression. + var cells = MapGrid.CellCount(size); + var cellPx = MapGrid.CellSize * (2048f / size); + var colFromPixel = Math.Clamp((int)(px / cellPx), 0, cells - 1); + var rowFromPixel = Math.Clamp((int)(py / cellPx), 0, cells - 1); + var colFromWorld = Math.Clamp((int)(Math.Clamp(x, 0f, size - 1) / MapGrid.CellSize), 0, cells - 1); + var rowFromWorld = + cells - 1 - Math.Clamp((int)(Math.Clamp(y, 0f, size - 1) / MapGrid.CellSize), 0, cells - 1); + Assert.Equal(colFromWorld, colFromPixel); + Assert.Equal(rowFromWorld, rowFromPixel); + } + } +} diff --git a/tests/RustPlusBot.Features.Map.Tests/RustPlusBaseMapSourceTests.cs b/tests/RustPlusBot.Features.Map.Tests/RustPlusBaseMapSourceTests.cs new file mode 100644 index 00000000..1a29a39d --- /dev/null +++ b/tests/RustPlusBot.Features.Map.Tests/RustPlusBaseMapSourceTests.cs @@ -0,0 +1,56 @@ +using NSubstitute; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Features.Map.Composing; + +namespace RustPlusBot.Features.Map.Tests; + +public sealed class RustPlusBaseMapSourceTests +{ + private const ulong Guild = 1UL; + private static readonly Guid Server = Guid.NewGuid(); + + [Fact] + public async Task GetAsync_returns_image_when_bytes_and_dimensions_present() + { + var query = Substitute.For(); + query.GetMapImageAsync(Guild, Server, Arg.Any()).Returns((byte[])[1, 2, 3]); + query.GetMapDimensionsAsync(Guild, Server, Arg.Any()) + .Returns(new MapDimensions(1000, 1000, 50, 2000)); + var source = new RustPlusBaseMapSource(query); + + var result = await source.GetAsync(Guild, Server, CancellationToken.None); + + Assert.NotNull(result); + Assert.Equal((byte[])[1, 2, 3], result!.Bytes); + Assert.Equal(1000, result.PixelWidth); + Assert.Equal(1000, result.PixelHeight); + Assert.Equal(50, result.OceanMarginPx); + } + + [Fact] + public async Task GetAsync_returns_null_when_image_bytes_missing() + { + var query = Substitute.For(); + query.GetMapImageAsync(Guild, Server, Arg.Any()).Returns((byte[]?)null); + query.GetMapDimensionsAsync(Guild, Server, Arg.Any()) + .Returns(new MapDimensions(1000, 1000, 50, 2000)); + var source = new RustPlusBaseMapSource(query); + + var result = await source.GetAsync(Guild, Server, CancellationToken.None); + + Assert.Null(result); + } + + [Fact] + public async Task GetAsync_returns_null_when_dimensions_missing() + { + var query = Substitute.For(); + query.GetMapImageAsync(Guild, Server, Arg.Any()).Returns((byte[])[1, 2, 3]); + query.GetMapDimensionsAsync(Guild, Server, Arg.Any()).Returns((MapDimensions?)null); + var source = new RustPlusBaseMapSource(query); + + var result = await source.GetAsync(Guild, Server, CancellationToken.None); + + Assert.Null(result); + } +} diff --git a/tests/RustPlusBot.Features.Map.Tests/RustPlusBot.Features.Map.Tests.csproj b/tests/RustPlusBot.Features.Map.Tests/RustPlusBot.Features.Map.Tests.csproj index bdb8045f..aeeeea83 100644 --- a/tests/RustPlusBot.Features.Map.Tests/RustPlusBot.Features.Map.Tests.csproj +++ b/tests/RustPlusBot.Features.Map.Tests/RustPlusBot.Features.Map.Tests.csproj @@ -7,6 +7,7 @@ + @@ -18,4 +19,8 @@ + + + + diff --git a/tests/RustPlusBot.Features.Map.Tests/WorldToPixelTests.cs b/tests/RustPlusBot.Features.Map.Tests/WorldToPixelTests.cs deleted file mode 100644 index 368c6369..00000000 --- a/tests/RustPlusBot.Features.Map.Tests/WorldToPixelTests.cs +++ /dev/null @@ -1,39 +0,0 @@ -using RustPlusBot.Abstractions.Connections; -using RustPlusBot.Features.Map.Rendering; - -namespace RustPlusBot.Features.Map.Tests; - -public sealed class WorldToPixelTests -{ - private static readonly MapDimensions Dims = new(Width: 4000, Height: 4000, OceanMargin: 500); - - [Fact] - public void Origin_world_maps_to_bottom_left_inside_margin() - { - // World (0,0) is the SW corner of the playable area. Full tile spans [-500, 4500] = 5000 units. - // Pixel-per-unit at outputSize 1000 = 1000/5000 = 0.2. World x=0 -> (0 - (-500)) * 0.2 = 100. - // World y=0 is the bottom -> image y = outputSize - 100 = 900. - var (px, py) = WorldToPixel.ToPixel(0f, 0f, Dims, outputSize: 1000); - - Assert.Equal(100f, px, precision: 3); - Assert.Equal(900f, py, precision: 3); - } - - [Fact] - public void Center_world_maps_to_center_pixel() - { - var (px, py) = WorldToPixel.ToPixel(2000f, 2000f, Dims, outputSize: 1000); - - Assert.Equal(500f, px, precision: 3); - Assert.Equal(500f, py, precision: 3); - } - - [Fact] - public void North_edge_maps_higher_than_south_edge() - { - var (_, southY) = WorldToPixel.ToPixel(2000f, 0f, Dims, outputSize: 1000); - var (_, northY) = WorldToPixel.ToPixel(2000f, 4000f, Dims, outputSize: 1000); - - Assert.True(northY < southY); // North is visually higher = smaller image-Y. - } -} diff --git a/tests/RustPlusBot.Features.Players.Tests/Hosting/PlayersHostedServiceTests.cs b/tests/RustPlusBot.Features.Players.Tests/Hosting/PlayersHostedServiceTests.cs index 02ec2391..f4248473 100644 --- a/tests/RustPlusBot.Features.Players.Tests/Hosting/PlayersHostedServiceTests.cs +++ b/tests/RustPlusBot.Features.Players.Tests/Hosting/PlayersHostedServiceTests.cs @@ -11,6 +11,7 @@ using RustPlusBot.Features.Players.Rendering; using RustPlusBot.Features.Workspace.Locating; using RustPlusBot.Localization; +using RustPlusBot.Persistence.Map; using RustPlusBot.Persistence.Workspace; namespace RustPlusBot.Features.Players.Tests.Hosting; @@ -21,8 +22,12 @@ private static Harness Create() { var workspace = Substitute.For(); workspace.GetCultureAsync(Arg.Any(), Arg.Any()).Returns("en"); + var mapSettings = Substitute.For(); + mapSettings.GetAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(MapLayerSettings.AllOn); var services = new ServiceCollection(); services.AddScoped(_ => workspace); + services.AddScoped(_ => mapSettings); var provider = services.BuildServiceProvider(); var scopeFactory = provider.GetRequiredService(); @@ -61,7 +66,7 @@ public async Task PlayerStateChangedEvent_routes_to_relay_and_sends_ingame_line( { await h.Bus.PublishAsync(new PlayerStateChangedEvent( 10UL, serverId, - new MapDimensions(3000, 3000, 0), + new MapDimensions(3000, 3000, 0, WorldSize: 3000), [new PlayerTransition(PlayerTransitionKind.Connect, 1UL, "Alice", null)])); await Task.Delay(20); } @@ -101,7 +106,7 @@ public async Task RelayLoop_faults_on_sender_exception_but_StopAsync_completes_c { await h.Bus.PublishAsync(new PlayerStateChangedEvent( 10UL, Guid.NewGuid(), - new MapDimensions(3000, 3000, 0), + new MapDimensions(3000, 3000, 0, WorldSize: 3000), [new PlayerTransition(PlayerTransitionKind.Connect, 1UL, "Bob", null)])); await Task.Delay(20); } diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs index af2dba1d..16ff7851 100644 --- a/tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs +++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs @@ -11,7 +11,7 @@ public sealed class PlayerEventEndToEndTests public void All_transition_kinds_render_without_throwing() { var renderer = new PlayerEventRenderer(new ResxLocalizer()); - var dims = new MapDimensions(3000, 3000, 0); + var dims = new MapDimensions(3000, 3000, 0, WorldSize: 3000); foreach (var kind in Enum.GetValues()) { var loc = kind is PlayerTransitionKind.Death or PlayerTransitionKind.Respawn diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRegistrationTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRegistrationTests.cs index 95ae7aea..00a8b04e 100644 --- a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRegistrationTests.cs @@ -8,6 +8,7 @@ using RustPlusBot.Features.Players.Hosting; using RustPlusBot.Features.Players.Relaying; using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Map; using RustPlusBot.Persistence.Workspace; namespace RustPlusBot.Features.Players.Tests; @@ -32,6 +33,7 @@ private static ServiceProvider BuildProvider() services.AddSingleton(); services.AddSingleton(Substitute.For()); services.AddScoped(_ => Substitute.For()); + services.AddScoped(_ => Substitute.For()); services.AddSingleton(Substitute.For()); services.AddPlayers(); return services.BuildServiceProvider(validateScopes: true); diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs index 59e3c042..478529f2 100644 --- a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs +++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs @@ -8,6 +8,7 @@ using RustPlusBot.Features.Players.Rendering; using RustPlusBot.Features.Workspace.Locating; using RustPlusBot.Localization; +using RustPlusBot.Persistence.Map; using RustPlusBot.Persistence.Workspace; namespace RustPlusBot.Features.Players.Tests; @@ -30,13 +31,17 @@ private PlayerEventRelay BuildRelay() private static IServiceScopeFactory BuildScopeFactory(IWorkspaceStore workspace) { + var mapSettings = Substitute.For(); + mapSettings.GetAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(MapLayerSettings.AllOn); var services = new ServiceCollection(); services.AddScoped(_ => workspace); + services.AddScoped(_ => mapSettings); return services.BuildServiceProvider().GetRequiredService(); } private static PlayerStateChangedEvent Evt(params PlayerTransition[] ts) - => new(1UL, Guid.NewGuid(), new MapDimensions(3000, 3000, 0), ts); + => new(1UL, Guid.NewGuid(), new MapDimensions(3000, 3000, 0, WorldSize: 3000), ts); [Fact] public async Task Sends_ingame_line_for_each_transition() diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRendererTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRendererTests.cs index 1b8e3fdd..9f594fed 100644 --- a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRendererTests.cs +++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRendererTests.cs @@ -8,7 +8,7 @@ namespace RustPlusBot.Features.Players.Tests; public sealed class PlayerEventRendererTests { private static readonly PlayerEventRenderer Renderer = new(new ResxLocalizer()); - private static readonly MapDimensions Dims = new(3000, 3000, 0); + private static readonly MapDimensions Dims = new(3000, 3000, 0, 3000); [Fact] public void Connect_line_names_member_without_location() diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs index 5156cb1e..9b5f4cb8 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs @@ -9,7 +9,9 @@ internal sealed class FakeWorkspaceGateway : IWorkspaceGateway { private readonly ConcurrentDictionary _categories = new(); private readonly ConcurrentDictionary _channels = new(); + private readonly List _deletedMessageIds = []; private readonly ConcurrentDictionary _messages = new(); + private readonly List _postedPayloads = []; private ulong _nextId = 1000; public IReadOnlyList MissingPermissions { get; set; } = []; @@ -20,6 +22,12 @@ internal sealed class FakeWorkspaceGateway : IWorkspaceGateway public IReadOnlyCollection ChannelIds => [.. _channels.Keys]; public IReadOnlyCollection CategoryIds => [.. _categories.Keys]; + /// Snowflakes deleted via , in call order. + public IReadOnlyList DeletedMessageIds => _deletedMessageIds; + + /// Payloads posted via , in call order. + public IReadOnlyList PostedPayloads => _postedPayloads; + public bool CategoryExists(ulong guildId, ulong categoryId) => _categories.ContainsKey(categoryId); public Task FindCategoryAsync(ulong guildId, string name, CancellationToken cancellationToken) @@ -86,6 +94,7 @@ public Task PostMessageAsync(ulong guildId, var id = NextId(); _messages[id] = new Message(id, channelId, payload); PostedMessages++; + _postedPayloads.Add(payload); return Task.FromResult(id); } @@ -100,6 +109,13 @@ public Task EditMessageAsync(ulong guildId, return Task.CompletedTask; } + public Task DeleteMessageAsync(ulong guildId, ulong channelId, ulong messageId, CancellationToken cancellationToken) + { + _messages.TryRemove(messageId, out _); + _deletedMessageIds.Add(messageId); + return Task.CompletedTask; + } + public Task DeleteChannelAsync(ulong guildId, ulong channelId, CancellationToken cancellationToken) { _channels.TryRemove(channelId, out _); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/MapControlMessageRendererTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/MapControlMessageRendererTests.cs index 3b0591ee..8a8ba981 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Messages/MapControlMessageRendererTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/MapControlMessageRendererTests.cs @@ -1,5 +1,7 @@ using Discord; using NSubstitute; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Features.Workspace.Gateway; using RustPlusBot.Features.Workspace.Messages; using RustPlusBot.Features.Workspace.Registry; using RustPlusBot.Localization; @@ -11,6 +13,12 @@ public sealed class MapControlMessageRendererTests { private static readonly ResxLocalizer Loc = new(); + private static List Buttons(MessagePayload payload) => + [ + .. payload.Components!.Components.OfType() + .SelectMany(r => r.Components).OfType(), + ]; + [Fact] public async Task Renders_six_toggle_buttons_reflecting_settings() { @@ -24,22 +32,45 @@ public async Task Renders_six_toggle_buttons_reflecting_settings() var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); Assert.NotNull(payload.Components); - var buttons = payload.Components!.Components.OfType() - .SelectMany(r => r.Components).OfType().ToList(); - Assert.Equal(6, buttons.Count); + var toggles = Buttons(payload) + .Where(b => b.CustomId!.StartsWith(WorkspaceComponentIds.MapTogglePrefix, StringComparison.Ordinal)) + .ToList(); + Assert.Equal(6, toggles.Count); - var monuments = buttons.Single(b => + var monuments = toggles.Single(b => b.CustomId == $"workspace:map:toggle:{MapLayer.Monuments}:{serverId}"); Assert.Equal(ButtonStyle.Secondary, monuments.Style); - foreach (var other in buttons.Where(b => b.CustomId != monuments.CustomId)) + foreach (var other in toggles.Where(b => b.CustomId != monuments.CustomId)) { Assert.Equal(ButtonStyle.Success, other.Style); } } [Fact] - public async Task Renders_a_header_text() + public async Task Renders_grid_style_buttons_marking_the_active_style() + { + var serverId = Guid.NewGuid(); + var settings = Substitute.For(); + settings.GetAsync(1, serverId, Arg.Any()) + .Returns(MapLayerSettings.AllOn with + { + GridStyle = MapGridStyle.RustPlus + }); + var renderer = new MapControlMessageRenderer(settings, Loc); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); + + var inGame = Buttons(payload).Single(b => + b.CustomId == $"workspace:map:gridstyle:{MapGridStyle.InGame}:{serverId}"); + var rustPlus = Buttons(payload).Single(b => + b.CustomId == $"workspace:map:gridstyle:{MapGridStyle.RustPlus}:{serverId}"); + Assert.Equal(ButtonStyle.Primary, rustPlus.Style); + Assert.Equal(ButtonStyle.Secondary, inGame.Style); + } + + [Fact] + public async Task Renders_a_header_text_with_the_grid_style_help() { var serverId = Guid.NewGuid(); var settings = Substitute.For(); @@ -49,7 +80,8 @@ public async Task Renders_a_header_text() var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); - Assert.Equal(Loc.Get("map.control.header", "en"), payload.Text); + Assert.StartsWith(Loc.Get("map.control.header", "en"), payload.Text, StringComparison.Ordinal); + Assert.Contains(Loc.Get("map.gridstyle.help", "en"), payload.Text, StringComparison.Ordinal); } [Fact] diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/ServerInfoMapMessageRendererTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/ServerInfoMapMessageRendererTests.cs new file mode 100644 index 00000000..19fc7fdd --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/ServerInfoMapMessageRendererTests.cs @@ -0,0 +1,132 @@ +using NSubstitute; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Map; +using RustPlusBot.Features.Workspace.Messages; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; + +namespace RustPlusBot.Features.Workspace.Tests.Messages; + +public sealed class ServerInfoMapMessageRendererTests +{ + private static readonly ResxLocalizer Loc = new(); + + private static IRustServerQuery QueryWithWorld(ulong guildId, Guid serverId, uint size = 4000, uint seed = 12345) + { + var query = Substitute.For(); + query.GetWorldAsync(guildId, serverId, Arg.Any()) + .Returns(new WorldSnapshot(size, seed)); + return query; + } + + [Fact] + public async Task NullServerId_RendersEmptyPayload() + { + var query = Substitute.For(); + var renderer = new ServerInfoMapMessageRenderer(query, Loc); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, null, "en"), default); + + Assert.Null(payload.Text); + Assert.Null(payload.Embed); + Assert.Null(payload.Components); + } + + [Fact] + public async Task Ready_view_sets_the_embed_image_and_url() + { + var serverId = Guid.NewGuid(); + var query = QueryWithWorld(1, serverId); + var readModel = Substitute.For(); + readModel.GetReady(4000, 12345).Returns(new InfoMapView("https://img/icons.png", "https://rustmaps/x")); + var renderer = new ServerInfoMapMessageRenderer(query, Loc, readModel); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); + + Assert.NotNull(payload.Embed); + Assert.Equal("https://img/icons.png", payload.Embed!.Image!.Value.Url); + Assert.Equal("https://rustmaps/x", payload.Embed.Url); + } + + [Fact] + public async Task Ready_view_without_page_url_omits_the_embed_url() + { + var serverId = Guid.NewGuid(); + var query = QueryWithWorld(1, serverId); + var readModel = Substitute.For(); + readModel.GetReady(4000, 12345).Returns(new InfoMapView("https://img/icons.png", null)); + var renderer = new ServerInfoMapMessageRenderer(query, Loc, readModel); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); + + Assert.Equal("https://img/icons.png", payload.Embed!.Image!.Value.Url); + Assert.Null(payload.Embed.Url); + } + + [Fact] + public async Task No_readModel_configured_renders_empty_payload() + { + // IInfoMapReadModel is only registered when a RustMaps API key is configured. With no render source + // there is nothing to show, so the renderer returns an empty payload and the reconciler leaves the + // channel untouched (rather than overwriting an existing map message with a placeholder). + var serverId = Guid.NewGuid(); + var query = QueryWithWorld(1, serverId); + var renderer = new ServerInfoMapMessageRenderer(query, Loc); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); + + Assert.Null(payload.Text); + Assert.Null(payload.Embed); + Assert.Null(payload.Components); + } + + [Fact] + public async Task Not_ready_yet_renders_empty_payload() + { + // Until the RustMaps render is ready the renderer returns nothing, so the reconciler preserves any + // existing map message (with its last image) instead of flickering to a placeholder on every boot. + var serverId = Guid.NewGuid(); + var query = QueryWithWorld(1, serverId); + var readModel = Substitute.For(); + readModel.GetReady(4000, 12345).Returns((InfoMapView?)null); + var renderer = new ServerInfoMapMessageRenderer(query, Loc, readModel); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); + + Assert.Null(payload.Text); + Assert.Null(payload.Embed); + Assert.Null(payload.Components); + } + + [Fact] + public async Task No_world_yet_renders_empty_payload() + { + // No world snapshot means the map key is unknown and nothing can be ready — empty payload, no post. + var serverId = Guid.NewGuid(); + var query = Substitute.For(); + query.GetWorldAsync(1, serverId, Arg.Any()).Returns((WorldSnapshot?)null); + var renderer = new ServerInfoMapMessageRenderer(query, Loc); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); + + Assert.Null(payload.Text); + Assert.Null(payload.Embed); + Assert.Null(payload.Components); + } + + [Fact] + public async Task Ready_view_shows_size_and_seed_fields() + { + var serverId = Guid.NewGuid(); + var query = QueryWithWorld(1, serverId); + var readModel = Substitute.For(); + readModel.GetReady(4000, 12345).Returns(new InfoMapView("https://img/icons.png", null)); + var renderer = new ServerInfoMapMessageRenderer(query, Loc, readModel); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); + + var body = string.Concat(payload.Embed!.Fields.Select(f => f.Value)); + Assert.Contains("4000", body, StringComparison.Ordinal); + Assert.Contains("12345", body, StringComparison.Ordinal); + } +} diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs index 3450de35..6d639b2d 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs @@ -77,6 +77,13 @@ public ReconcilerBuilderReusing WithChannel(WorkspaceScope scope, string key, st return this; } + public ReconcilerBuilderReusing WithMessage(WorkspaceScope scope, string key, string channelKey, string text) + { + _messageProviders.Add(new ListMessageProvider([new MessageSpec(scope, key, channelKey)])); + _renderers.Add(new ListMessageRenderer(key, text)); + return this; + } + public WorkspaceReconciler Build() => new( new WorkspaceBackends(new WorkspaceRegistry(_channelProviders, _messageProviders), source.Gateway, source.Store), @@ -88,4 +95,18 @@ private sealed class ListChannelProvider(IEnumerable specs) : IChan { public IEnumerable GetChannelSpecs() => specs; } + + private sealed class ListMessageProvider(IEnumerable specs) : IMessageSpecProvider + { + public IEnumerable GetMessageSpecs() => specs; + } + + private sealed class ListMessageRenderer(string key, string text) : IMessageRenderer + { + public string MessageKey { get; } = key; + + public ValueTask + RenderAsync(MessageRenderContext context, CancellationToken cancellationToken) => + ValueTask.FromResult(new MessagePayload(text, null, null)); + } } diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerMessageTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerMessageTests.cs index 18f237f9..d1670bd1 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerMessageTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerMessageTests.cs @@ -1,3 +1,6 @@ +using NSubstitute; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Features.Workspace.Reconciler; using RustPlusBot.Features.Workspace.Registry; namespace RustPlusBot.Features.Workspace.Tests.Reconciler; @@ -42,4 +45,93 @@ public async Task ChannelKeyRemovedFromRegistry_RetainsExistingRecord() var channels = await harness.Store.GetChannelsAsync(1, null); Assert.Contains(channels, c => c.ChannelKey == "settings"); } + + [Fact] + public async Task NewEarlierMessageBehindLiveOne_DeletesAndRepostsInDeclarationOrder() + { + // Simulates the #info map bug: "server.info" was already provisioned by a prior deploy, then + // "server.info.map" is declared BEFORE it. Without reorder-repair the map would post below the + // already-live status embed (Discord orders by creation time). + var serverId = Guid.NewGuid(); + var harness = new ReconcilerHarness() + .WithChannel(WorkspaceScope.PerServer, "info", "channel.info.name", 0) + .WithMessage(WorkspaceScope.PerServer, "server.info", "info", "info-text"); + harness.Servers.GetAsync(1, serverId, Arg.Any()) + .Returns(new RustServer + { + Id = serverId, + GuildId = 1, + Name = "Rustopia EU", + Ip = "1.1.1.1", + Port = 28015 + }); + var sut = harness.Build(); + await sut.ReconcileServerAsync(1, serverId); + + var originalInfoMessage = await harness.Store.GetMessageAsync(1, serverId, "server.info"); + Assert.NotNull(originalInfoMessage); + var originalInfoMessageId = originalInfoMessage!.DiscordMessageId; + Assert.Equal(1, harness.Gateway.PostedMessages); + Assert.Empty(harness.Gateway.DeletedMessageIds); + + // Next deploy: the registry now declares "server.info.map" BEFORE "server.info", but only the + // latter has a provisioned record (reusing the same store + gateway). + var sut2 = new ReconcilerBuilderReusing(harness) + .WithChannel(WorkspaceScope.PerServer, "info", "channel.info.name", 0) + .WithMessage(WorkspaceScope.PerServer, "server.info.map", "info", "map-text") + .WithMessage(WorkspaceScope.PerServer, "server.info", "info", "info-text") + .Build(); + + var result = await sut2.ReconcileServerAsync(1, serverId); + + Assert.Equal(ReconcileStatus.Provisioned, result.Status); + + // The pre-existing "server.info" message was deleted exactly once. + Assert.Single(harness.Gateway.DeletedMessageIds); + Assert.Equal(originalInfoMessageId, harness.Gateway.DeletedMessageIds[0]); + + // Both messages were freshly posted this run (the prior run already posted one), map before info. + Assert.Equal(3, harness.Gateway.PostedMessages); + var repostedThisRun = harness.Gateway.PostedPayloads.TakeLast(2).ToList(); + Assert.Equal("map-text", repostedThisRun[0].Text); + Assert.Equal("info-text", repostedThisRun[1].Text); + + // The persisted records now point at the freshly posted messages. + var mapRecord = await harness.Store.GetMessageAsync(1, serverId, "server.info.map"); + var infoRecord = await harness.Store.GetMessageAsync(1, serverId, "server.info"); + Assert.NotNull(mapRecord); + Assert.NotNull(infoRecord); + Assert.NotEqual(originalInfoMessageId, infoRecord!.DiscordMessageId); + } + + [Fact] + public async Task ChannelAlreadyInDeclarationOrder_EditsInPlace_NoDelete() + { + var serverId = Guid.NewGuid(); + var harness = new ReconcilerHarness() + .WithChannel(WorkspaceScope.PerServer, "info", "channel.info.name", 0) + .WithMessage(WorkspaceScope.PerServer, "server.info.map", "info", "map-text") + .WithMessage(WorkspaceScope.PerServer, "server.info", "info", "info-text"); + harness.Servers.GetAsync(1, serverId, Arg.Any()) + .Returns(new RustServer + { + Id = serverId, + GuildId = 1, + Name = "Rustopia EU", + Ip = "1.1.1.1", + Port = 28015 + }); + var sut = harness.Build(); + + await sut.ReconcileServerAsync(1, serverId); + Assert.Equal(2, harness.Gateway.PostedMessages); + Assert.Empty(harness.Gateway.DeletedMessageIds); + + var result = await sut.ReconcileServerAsync(1, serverId); + + Assert.Equal(ReconcileStatus.Provisioned, result.Status); + Assert.Empty(harness.Gateway.DeletedMessageIds); // already in order: no repair needed + Assert.Equal(2, harness.Gateway.PostedMessages); // no reposts + Assert.Equal(2, harness.Gateway.EditedMessages); // both edited in place + } } diff --git a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs index ec4396e7..c19d6c47 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(261, EnglishKeys().Count); + Assert.Equal(267, EnglishKeys().Count); } } diff --git a/tests/RustPlusBot.Persistence.Tests/Map/MapSettingsStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Map/MapSettingsStoreTests.cs index 4aad7e5a..a5fea841 100644 --- a/tests/RustPlusBot.Persistence.Tests/Map/MapSettingsStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Map/MapSettingsStoreTests.cs @@ -1,3 +1,4 @@ +using RustPlusBot.Abstractions.Connections; using RustPlusBot.Domain.Servers; using RustPlusBot.Persistence.Map; @@ -62,4 +63,49 @@ public async Task SetLayerAsync_updates_existing_row() Assert.True(result.Players); } + + [Fact] + public async Task GridStyle_defaults_to_in_game() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + + var result = await new MapSettingsStore(context).GetAsync(1UL, Guid.NewGuid()); + + Assert.Equal(MapGridStyle.InGame, result.GridStyle); + } + + [Fact] + public async Task SetGridStyleAsync_creates_row_and_round_trips() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + var server = SeedServer(context); + var store = new MapSettingsStore(context); + + await store.SetGridStyleAsync(1UL, server.Id, MapGridStyle.RustPlus); + var result = await store.GetAsync(1UL, server.Id); + + Assert.Equal(MapGridStyle.RustPlus, result.GridStyle); + Assert.True(result.Grid); // Layers stay at their all-on defaults. + } + + [Fact] + public async Task SetGridStyleAsync_keeps_existing_layer_toggles() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + var server = SeedServer(context); + var store = new MapSettingsStore(context); + + await store.SetLayerAsync(1UL, server.Id, MapLayer.Monuments, enabled: false); + await store.SetGridStyleAsync(1UL, server.Id, MapGridStyle.RustPlus); + var result = await store.GetAsync(1UL, server.Id); + + Assert.False(result.Monuments); + Assert.Equal(MapGridStyle.RustPlus, result.GridStyle); + } } diff --git a/tools/RustPlusBot.MapParity/Program.cs b/tools/RustPlusBot.MapParity/Program.cs new file mode 100644 index 00000000..6c13d489 --- /dev/null +++ b/tools/RustPlusBot.MapParity/Program.cs @@ -0,0 +1,76 @@ +using System.Globalization; +using System.Text.Json; +using RustMapsApi.V4; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Features.Map.Rendering; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +if (args.Length < 4 + || !int.TryParse(args[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var size) + || !int.TryParse(args[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var seed)) +{ + await Console.Error.WriteLineAsync("Usage: map-parity ").ConfigureAwait(false); + return 1; +} + +var apiKey = args[2]; +var outDir = Directory.CreateDirectory(args[3]).FullName; + +#pragma warning disable S1075 // Dev-only CLI tool: the RustMaps API root is fixed, not app configuration. +using var http = new HttpClient +{ + BaseAddress = new Uri("https://api.rustmaps.com") +}; +#pragma warning restore S1075 +http.DefaultRequestHeaders.Add("X-API-Key", apiKey); +var client = new RustMapsClient(http); // verified: RustMapsClient has a single HttpClient primary ctor + +var result = await client.GetMapBySeedAndSizeAsync(size, seed, staging: false).ConfigureAwait(false); +if (!result.IsSuccess || result.Data is not { } map) +{ + await Console.Error.WriteLineAsync( + $"RustMaps lookup failed (status {result.StatusCode}): {result.Error?.Message}") + .ConfigureAwait(false); + return 2; +} + +await Console.Out.WriteLineAsync( + $"Map {map.Id}: ImageUrl={map.ImageUrl} RawImageUrl={map.RawImageUrl} monuments={map.TotalMonuments}") + .ConfigureAwait(false); +var imageBytes = await http.GetByteArrayAsync(new Uri(map.ImageUrl!)).ConfigureAwait(false); +await File.WriteAllBytesAsync(Path.Combine(outDir, "rustmaps-render.png"), imageBytes).ConfigureAwait(false); +await File.WriteAllTextAsync(Path.Combine(outDir, "monuments.json"), + JsonSerializer.Serialize(map, new JsonSerializerOptions + { + WriteIndented = true + })) + .ConfigureAwait(false); + +// Project every monument through OUR MapProjection onto THEIR render and drop crosshairs. +// If our math is right, every crosshair lands on the matching RustMaps monument icon. +using var overlay = Image.Load(imageBytes); +var projection = + new MapProjection((uint)size, overlay.Width, overlay.Height, OceanMarginPx: 0, OutputSize: overlay.Width); +overlay.Mutate(ctx => +{ + foreach (var monument in map.Monuments ?? []) + { + if (monument.Coordinates is not { } c) + { + continue; + } + + var (px, py) = projection.ToPixel(c.X, c.Y); + ctx.DrawLine(Color.Magenta, 2f, new PointF(px - 12, py), new PointF(px + 12, py)); + ctx.DrawLine(Color.Magenta, 2f, new PointF(px, py - 12), new PointF(px, py + 12)); + Console.WriteLine( + $"{monument.Type,-30} world=({c.X,6},{c.Y,6}) grid={MapGrid.LabelFor(c.X, c.Y, (uint)size)} px=({px:F0},{py:F0})"); + } +}); +await overlay.SaveAsPngAsync(Path.Combine(outDir, "overlay.png")).ConfigureAwait(false); +await Console.Out.WriteLineAsync($"Wrote {outDir}/overlay.png — crosshairs must sit on the RustMaps monument markers.") + .ConfigureAwait(false); +return 0; diff --git a/tools/RustPlusBot.MapParity/README.md b/tools/RustPlusBot.MapParity/README.md new file mode 100644 index 00000000..f8c1a05d --- /dev/null +++ b/tools/RustPlusBot.MapParity/README.md @@ -0,0 +1,24 @@ +# RustPlusBot.MapParity + +Dev-only manual tool that proves our `MapProjection` math agrees with RustMaps' own +ground-truth monument placement. It fetches a map from the RustMaps v4 API (rendered +`ImageUrl`, which has RustMaps' own monument icons baked in, plus the monument list), +projects every monument's world coordinate through our `MapProjection`, and draws a +magenta crosshair at the resulting pixel on a copy of RustMaps' render. If our +transform is correct, every crosshair lands squarely on the matching RustMaps monument +icon — an eyeball diff, not an assertion. + +## Usage + +```bash +dotnet run --project tools/RustPlusBot.MapParity -- +``` + +This writes `/rustmaps-render.png` (the untouched RustMaps render), +`/overlay.png` (the render with crosshairs — inspect this), and +`/monuments.json` (the serialized RustMaps `MapInfo` response, including the +monument list consumed by the parity test). + +Once the crosshairs check out, commit `monuments.json` to +`tests/RustPlusBot.Features.Map.Tests/Fixtures/rustmaps--.json` so +`RustMapsParityTests` picks it up (it skips automatically when no fixture exists). diff --git a/tools/RustPlusBot.MapParity/RustPlusBot.MapParity.csproj b/tools/RustPlusBot.MapParity/RustPlusBot.MapParity.csproj new file mode 100644 index 00000000..cd5fe347 --- /dev/null +++ b/tools/RustPlusBot.MapParity/RustPlusBot.MapParity.csproj @@ -0,0 +1,17 @@ + + + + Exe + + + + + + + + + + + + +