Skip to content

Commit 47f6f1c

Browse files
HandyS11claude
andcommitted
feat(map): per-server grid style — in-game vs Rust+/RustMaps (100-unit row offset)
The in-game (F1) map and the Rust+ companion app / rustmaps.com use DIFFERENT grid conventions: both anchor columns at the world's west edge, but Rust+/RustMaps rows start exactly 100 game-units south of the world's north edge (measured from RustMaps' own pre-rendered grid tiles across sizes 1500–4500), while the in-game map starts them at the edge itself. That's why the render could never match both references at once. Add a per-server MapGridStyle option (default: InGame) governing BOTH the rendered #map grid and every event grid reference, so callouts read consistently with whichever map the team uses: - InGame — matches the F1 map; event callouts read like in-game grid refs. - RustPlus — matches the companion app and rustmaps.com. Plumbing: - MapGridStyle enum + MapGrid.RowInset/LabelFor(style); MapRenderer.DrawGrid anchors the lattice at (worldSize − inset). - ServerMapSettings.GridStyle + MapGridStyle EF migration; MapLayerSettings carries it; IMapSettingsStore.SetGridStyleAsync. - #map control message gains a third row with the two style buttons (active = Primary) and a help line explaining the difference; new workspace:map:gridstyle:* component handled by MapComponentModule (ManageGuild, reconcile + repaint like the layer toggles). - Style threads through EventRelay/EventEmbedRenderer, PlayerEventRelay/ PlayerEventRenderer, and the !cargo/!heli/!chinook/!events handlers via GridReference.From(x, y, dims, style). - Localization: map.gridstyle.{ingame,rustplus,help} EN/FR. Full suite green (17 projects, 916 tests, 1 skipped). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9c55231 commit 47f6f1c

38 files changed

Lines changed: 1238 additions & 90 deletions

File tree

src/RustPlusBot.Abstractions/Connections/MapGrid.cs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,19 @@ public static class MapGrid
1515
/// <summary>Edge length of one (whole) grid cell, in game units.</summary>
1616
public const float CellSize = 146.25f;
1717

18+
/// <summary>
19+
/// How far south of the world's north edge the Rust+/RustMaps grid rows start, in game units.
20+
/// Measured exactly (100.0) from RustMaps' own pre-rendered grid tiles across map sizes
21+
/// 1500–4500; the in-game (F1) map uses no inset. Columns have no inset in either style.
22+
/// </summary>
23+
public const float RustPlusRowInset = 100f;
24+
25+
/// <summary>Gets the row inset (south of the world's north edge) for a grid style.</summary>
26+
/// <param name="style">The grid style.</param>
27+
/// <returns>The inset in game units.</returns>
28+
public static float RowInset(MapGridStyle style) =>
29+
style == MapGridStyle.RustPlus ? RustPlusRowInset : 0f;
30+
1831
/// <summary>
1932
/// Number of grid cells per axis, covering the whole world size — <c>ceil(worldSize / CellSize)</c>.
2033
/// The last cell (east-most column / south-most row) is a partial edge cell whenever the world size
@@ -50,14 +63,15 @@ public static string ColumnLetters(int index)
5063
/// <param name="x">World X (west→east).</param>
5164
/// <param name="y">World Y (south→north).</param>
5265
/// <param name="worldSize">The world size in game units.</param>
66+
/// <param name="style">Which grid convention to bin against (defaults to the in-game map).</param>
5367
/// <returns>The grid label, rows numbered from the top; out-of-world coordinates clamp to the edge cell.</returns>
54-
public static string LabelFor(float x, float y, uint worldSize)
68+
public static string LabelFor(float x, float y, uint worldSize, MapGridStyle style = MapGridStyle.InGame)
5569
{
56-
// Rows bin from the NORTH edge (the grid anchor), not the south — with a partial edge cell the
57-
// two disagree, and the north anchoring is what the in-game map, the app and RustMaps use.
70+
// Rows bin from the style's row anchor (north edge in-game; 100 units south of it for
71+
// Rust+/RustMaps), not the south — with a partial edge cell the two directions disagree.
5872
var cells = CellCount(worldSize);
5973
var col = Math.Clamp((int)MathF.Floor(x / CellSize), 0, cells - 1);
60-
var row = Math.Clamp((int)MathF.Floor((worldSize - y) / CellSize), 0, cells - 1);
74+
var row = Math.Clamp((int)MathF.Floor((worldSize - RowInset(style) - y) / CellSize), 0, cells - 1);
6175
return string.Create(CultureInfo.InvariantCulture, $"{ColumnLetters(col)}{row}");
6276
}
6377
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
namespace RustPlusBot.Abstractions.Connections;
2+
3+
/// <summary>
4+
/// Which map-grid convention to use. The in-game (F1) map and the Rust+ companion app / RustMaps
5+
/// website disagree on where grid ROWS sit: the in-game map anchors row 0 at the world's north edge,
6+
/// while Rust+/RustMaps draw the rows 100 game-units further south. Columns are identical in both.
7+
/// </summary>
8+
public enum MapGridStyle
9+
{
10+
/// <summary>Match the in-game (F1) map — grid references read exactly like in-game callouts.</summary>
11+
InGame = 0,
12+
13+
/// <summary>Match the Rust+ companion app and rustmaps.com — rows sit 100 game-units south of in-game.</summary>
14+
RustPlus = 1,
15+
}

src/RustPlusBot.Domain/Map/ServerMapSettings.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using RustPlusBot.Abstractions.Connections;
2+
13
namespace RustPlusBot.Domain.Map;
24

35
/// <summary>Per-(guild, server) rendered-map layer settings; one row per server. Layers default on.</summary>
@@ -26,4 +28,7 @@ public sealed class ServerMapSettings
2628

2729
/// <summary>Whether oil rigs are styled by activation state.</summary>
2830
public bool ShowRigs { get; set; } = true;
31+
32+
/// <summary>Which grid convention the rendered map and event grid references use.</summary>
33+
public MapGridStyle GridStyle { get; set; } = MapGridStyle.InGame;
2934
}

src/RustPlusBot.Features.Commands/Handlers/CargoCommandHandler.cs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,32 @@
33
using RustPlusBot.Features.Commands.Dispatching;
44
using RustPlusBot.Features.Events.State;
55
using RustPlusBot.Localization;
6+
using RustPlusBot.Persistence.Map;
67

78
namespace RustPlusBot.Features.Commands.Handlers;
89

910
/// <summary>!cargo — reports the cargo ship's current grid position, if any.</summary>
1011
/// <param name="state">The live event state.</param>
1112
/// <param name="localizer">The reply localizer.</param>
1213
/// <param name="clock">For "how long ago".</param>
13-
internal sealed class CargoCommandHandler(IEventState state, ILocalizer localizer, IClock clock)
14+
/// <param name="mapSettings">Supplies the server's grid style.</param>
15+
internal sealed class CargoCommandHandler(
16+
IEventState state,
17+
ILocalizer localizer,
18+
IClock clock,
19+
IMapSettingsStore mapSettings)
1420
: ICommandHandler
1521
{
1622
/// <inheritdoc />
1723
public string Name => "cargo";
1824

1925
/// <inheritdoc />
20-
public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
26+
public async Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
2127
{
2228
ArgumentNullException.ThrowIfNull(context);
23-
return Task.FromResult<string?>(
24-
MarkerReply.For(state, context, MarkerKind.CargoShip, "command.cargo", localizer, clock));
29+
return await MarkerReply
30+
.ForAsync(state, context, MarkerKind.CargoShip, "command.cargo", localizer, clock, mapSettings,
31+
cancellationToken)
32+
.ConfigureAwait(false);
2533
}
2634
}

src/RustPlusBot.Features.Commands/Handlers/ChinookCommandHandler.cs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,32 @@
33
using RustPlusBot.Features.Commands.Dispatching;
44
using RustPlusBot.Features.Events.State;
55
using RustPlusBot.Localization;
6+
using RustPlusBot.Persistence.Map;
67

78
namespace RustPlusBot.Features.Commands.Handlers;
89

910
/// <summary>!chinook — reports the Chinook helicopter's current grid position, if any.</summary>
1011
/// <param name="state">The live event state.</param>
1112
/// <param name="localizer">The reply localizer.</param>
1213
/// <param name="clock">For "how long ago".</param>
13-
internal sealed class ChinookCommandHandler(IEventState state, ILocalizer localizer, IClock clock)
14+
/// <param name="mapSettings">Supplies the server's grid style.</param>
15+
internal sealed class ChinookCommandHandler(
16+
IEventState state,
17+
ILocalizer localizer,
18+
IClock clock,
19+
IMapSettingsStore mapSettings)
1420
: ICommandHandler
1521
{
1622
/// <inheritdoc />
1723
public string Name => "chinook";
1824

1925
/// <inheritdoc />
20-
public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
26+
public async Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
2127
{
2228
ArgumentNullException.ThrowIfNull(context);
23-
return Task.FromResult<string?>(
24-
MarkerReply.For(state, context, MarkerKind.Chinook, "command.chinook", localizer, clock));
29+
return await MarkerReply
30+
.ForAsync(state, context, MarkerKind.Chinook, "command.chinook", localizer, clock, mapSettings,
31+
cancellationToken)
32+
.ConfigureAwait(false);
2533
}
2634
}

src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,31 +3,38 @@
33
using RustPlusBot.Features.Events.Formatting;
44
using RustPlusBot.Features.Events.State;
55
using RustPlusBot.Localization;
6+
using RustPlusBot.Persistence.Map;
67

78
namespace RustPlusBot.Features.Commands.Handlers;
89

910
/// <summary>!events — lists the most recent live events.</summary>
1011
/// <param name="state">The live event state.</param>
1112
/// <param name="localizer">The reply localizer.</param>
12-
internal sealed class EventsCommandHandler(IEventState state, ILocalizer localizer) : ICommandHandler
13+
/// <param name="mapSettings">Supplies the server's grid style.</param>
14+
internal sealed class EventsCommandHandler(
15+
IEventState state,
16+
ILocalizer localizer,
17+
IMapSettingsStore mapSettings) : ICommandHandler
1318
{
1419
/// <inheritdoc />
1520
public string Name => "events";
1621

1722
/// <inheritdoc />
1823
/// <exception cref="ArgumentOutOfRangeException">A recent event has an unsupported <see cref="MapEventKind"/>.</exception>
19-
public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
24+
public async Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
2025
{
2126
ArgumentNullException.ThrowIfNull(context);
2227
var events = state.GetRecentEvents(context.GuildId, context.ServerId);
2328
if (events.Count == 0)
2429
{
25-
return Task.FromResult<string?>(localizer.Get("command.events.none", context.Culture));
30+
return localizer.Get("command.events.none", context.Culture);
2631
}
2732

33+
var settings = await mapSettings.GetAsync(context.GuildId, context.ServerId, cancellationToken)
34+
.ConfigureAwait(false);
2835
var parts = events.Select(e =>
2936
{
30-
var grid = GridReference.From(e.X, e.Y, e.Dimensions);
37+
var grid = GridReference.From(e.X, e.Y, e.Dimensions, settings.GridStyle);
3138
var key = e.Kind switch
3239
{
3340
MapEventKind.CargoEntered => "command.event.cargoentered",
@@ -40,7 +47,6 @@ internal sealed class EventsCommandHandler(IEventState state, ILocalizer localiz
4047
return localizer.Get(key, context.Culture, grid);
4148
});
4249

43-
return Task.FromResult<string?>(
44-
localizer.Get("command.events.ok", context.Culture, string.Join(", ", parts)));
50+
return localizer.Get("command.events.ok", context.Culture, string.Join(", ", parts));
4551
}
4652
}

src/RustPlusBot.Features.Commands/Handlers/HeliCommandHandler.cs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,32 @@
33
using RustPlusBot.Features.Commands.Dispatching;
44
using RustPlusBot.Features.Events.State;
55
using RustPlusBot.Localization;
6+
using RustPlusBot.Persistence.Map;
67

78
namespace RustPlusBot.Features.Commands.Handlers;
89

910
/// <summary>!heli — reports the patrol helicopter's current grid position, if any.</summary>
1011
/// <param name="state">The live event state.</param>
1112
/// <param name="localizer">The reply localizer.</param>
1213
/// <param name="clock">For "how long ago".</param>
13-
internal sealed class HeliCommandHandler(IEventState state, ILocalizer localizer, IClock clock)
14+
/// <param name="mapSettings">Supplies the server's grid style.</param>
15+
internal sealed class HeliCommandHandler(
16+
IEventState state,
17+
ILocalizer localizer,
18+
IClock clock,
19+
IMapSettingsStore mapSettings)
1420
: ICommandHandler
1521
{
1622
/// <inheritdoc />
1723
public string Name => "heli";
1824

1925
/// <inheritdoc />
20-
public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
26+
public async Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
2127
{
2228
ArgumentNullException.ThrowIfNull(context);
23-
return Task.FromResult<string?>(
24-
MarkerReply.For(state, context, MarkerKind.PatrolHelicopter, "command.heli", localizer, clock));
29+
return await MarkerReply
30+
.ForAsync(state, context, MarkerKind.PatrolHelicopter, "command.heli", localizer, clock, mapSettings,
31+
cancellationToken)
32+
.ConfigureAwait(false);
2533
}
2634
}

src/RustPlusBot.Features.Commands/Handlers/MarkerReply.cs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using RustPlusBot.Features.Events.Formatting;
66
using RustPlusBot.Features.Events.State;
77
using RustPlusBot.Localization;
8+
using RustPlusBot.Persistence.Map;
89

910
namespace RustPlusBot.Features.Commands.Handlers;
1011

@@ -18,23 +19,29 @@ internal static class MarkerReply
1819
/// <param name="prefix">The localization key prefix ("command.cargo" / "command.heli" / "command.chinook").</param>
1920
/// <param name="localizer">The reply localizer.</param>
2021
/// <param name="clock">For the "how long ago" suffix.</param>
22+
/// <param name="mapSettings">Supplies the server's grid style for the reference.</param>
23+
/// <param name="cancellationToken">A cancellation token.</param>
2124
/// <returns>The localized reply.</returns>
22-
public static string For(
25+
public static async Task<string> ForAsync(
2326
IEventState state,
2427
CommandContext context,
2528
MarkerKind kind,
2629
string prefix,
2730
ILocalizer localizer,
28-
IClock clock)
31+
IClock clock,
32+
IMapSettingsStore mapSettings,
33+
CancellationToken cancellationToken)
2934
{
3035
var markers = state.GetActiveMarkers(context.GuildId, context.ServerId, kind);
3136
if (markers.Count == 0)
3237
{
3338
return localizer.Get($"{prefix}.none", context.Culture);
3439
}
3540

41+
var settings = await mapSettings.GetAsync(context.GuildId, context.ServerId, cancellationToken)
42+
.ConfigureAwait(false);
3643
var m = markers[0];
37-
var grid = GridReference.From(m.X, m.Y, m.Dimensions);
44+
var grid = GridReference.From(m.X, m.Y, m.Dimensions, settings.GridStyle);
3845
var ago = DurationFormat.Compact(clock.UtcNow - m.SeenAtUtc);
3946
return localizer.Get($"{prefix}.ok", context.Culture, grid, ago);
4047
}

src/RustPlusBot.Features.Events/Formatting/GridReference.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ public static class GridReference
1010
/// <param name="x">World X coordinate.</param>
1111
/// <param name="y">World Y coordinate.</param>
1212
/// <param name="dims">Map dimensions, or null when unavailable.</param>
13+
/// <param name="style">Which grid convention to bin against (defaults to the in-game map).</param>
1314
/// <returns>A grid reference like "D7", or "(x, y)" when dimensions are unavailable.</returns>
14-
public static string From(float x, float y, MapDimensions? dims)
15+
public static string From(float x, float y, MapDimensions? dims, MapGridStyle style = MapGridStyle.InGame)
1516
{
1617
if (dims is null || dims.WorldSize == 0)
1718
{
@@ -20,6 +21,6 @@ public static string From(float x, float y, MapDimensions? dims)
2021
$"({Math.Round(x)}, {Math.Round(y)})");
2122
}
2223

23-
return MapGrid.LabelFor(x, y, dims.WorldSize);
24+
return MapGrid.LabelFor(x, y, dims.WorldSize, style);
2425
}
2526
}

src/RustPlusBot.Features.Events/Relaying/EventRelay.cs

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
using Microsoft.Extensions.DependencyInjection;
2+
using RustPlusBot.Abstractions.Connections;
23
using RustPlusBot.Abstractions.Events;
34
using RustPlusBot.Features.Connections.Listening;
45
using RustPlusBot.Features.Events.Classifying;
56
using RustPlusBot.Features.Events.Posting;
67
using RustPlusBot.Features.Events.Rendering;
78
using RustPlusBot.Features.Events.State;
89
using RustPlusBot.Features.Workspace.Locating;
10+
using RustPlusBot.Persistence.Map;
911
using RustPlusBot.Persistence.Workspace;
1012

1113
namespace RustPlusBot.Features.Events.Relaying;
@@ -48,18 +50,19 @@ public async Task RelayAsync(MapMarkersChangedEvent evt, CancellationToken cance
4850
return;
4951
}
5052

51-
var culture = await GetCultureAsync(evt.GuildId, cancellationToken).ConfigureAwait(false);
53+
var (culture, gridStyle) = await GetRenderSettingsAsync(evt.GuildId, evt.ServerId, cancellationToken)
54+
.ConfigureAwait(false);
5255
var channelId = await channels.Locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken)
5356
.ConfigureAwait(false);
5457

5558
foreach (var e in events)
5659
{
5760
await channels.TeamChatSender
58-
.SendAsync(evt.GuildId, evt.ServerId, renderer.RenderLine(e, culture), cancellationToken)
61+
.SendAsync(evt.GuildId, evt.ServerId, renderer.RenderLine(e, culture, gridStyle), cancellationToken)
5962
.ConfigureAwait(false);
6063
if (channelId is { } id)
6164
{
62-
await channels.Poster.PostAsync(id, renderer.Render(e, culture), cancellationToken)
65+
await channels.Poster.PostAsync(id, renderer.Render(e, culture, gridStyle), cancellationToken)
6366
.ConfigureAwait(false);
6467
}
6568
}
@@ -77,27 +80,33 @@ public async Task RelayRigAsync(RigStateChangedEvent evt, CancellationToken canc
7780
rigStore.Apply(evt);
7881
}
7982

80-
var culture = await GetCultureAsync(evt.GuildId, cancellationToken).ConfigureAwait(false);
83+
var (culture, gridStyle) = await GetRenderSettingsAsync(evt.GuildId, evt.ServerId, cancellationToken)
84+
.ConfigureAwait(false);
8185
await channels.TeamChatSender
82-
.SendAsync(evt.GuildId, evt.ServerId, renderer.RenderRigLine(evt, culture), cancellationToken)
86+
.SendAsync(evt.GuildId, evt.ServerId, renderer.RenderRigLine(evt, culture, gridStyle), cancellationToken)
8387
.ConfigureAwait(false);
8488

8589
var channelId = await channels.Locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken)
8690
.ConfigureAwait(false);
8791
if (channelId is { } id)
8892
{
89-
await channels.Poster.PostAsync(id, renderer.RenderRig(evt, culture), cancellationToken)
93+
await channels.Poster.PostAsync(id, renderer.RenderRig(evt, culture, gridStyle), cancellationToken)
9094
.ConfigureAwait(false);
9195
}
9296
}
9397

94-
private async Task<string> GetCultureAsync(ulong guildId, CancellationToken cancellationToken)
98+
private async Task<(string Culture, MapGridStyle GridStyle)> GetRenderSettingsAsync(ulong guildId,
99+
Guid serverId,
100+
CancellationToken cancellationToken)
95101
{
96102
var scope = scopeFactory.CreateAsyncScope();
97103
await using (scope.ConfigureAwait(false))
98104
{
99105
var store = scope.ServiceProvider.GetRequiredService<IWorkspaceStore>();
100-
return await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false);
106+
var culture = await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false);
107+
var mapSettings = scope.ServiceProvider.GetRequiredService<IMapSettingsStore>();
108+
var settings = await mapSettings.GetAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
109+
return (culture, settings.GridStyle);
101110
}
102111
}
103112
}

0 commit comments

Comments
 (0)