Skip to content

Commit f9897fd

Browse files
HandyS11claude
andauthored
Map render rework: correct projection & grid, RustMaps #info map, per-server grid style (#45)
* feat(map): plumb WorldSize through MapDimensions + GetWorldAsync seam * feat(map): shared MapGrid math; GridReference uses real world size * feat(map): MapProjection canonical world-to-pixel transform Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(map): MapRenderStyle constants + size-scaled icon cache * fix(map): correct projection, scaled icons, per-cell grid labels * feat(events): Moved bucket in marker deltas + nullable marker rotation * feat(events): active markers track movement + 6-deep history ring * feat(map): fading motion trails + rotation-aware marker drawing * feat(map): base-map source chain with per-image projection metadata Generalize base-map fetch into a source chain (IBaseMapSource, tried in registration order) so a future RustMaps source can slot ahead of the Rust+ JPEG fallback. BaseMapImage carries the projection metadata of the specific fetched image (pixel width/height + ocean margin); BaseMapCache now caches BaseMapImage per (guild, server) instead of raw bytes, trying each source on a miss and not caching null. MapComposer builds its MapProjection from the fetched image's own metadata instead of MapDimensions, fulfilling the Task 5 interim note. * test(map): restore BaseMapCache.Clear eviction coverage * feat(map): wire marker rotation via RustPlusApi 2.0.0-beta.4 * feat(map): RustMaps preferred base-map source behind optional API key; 30s refresh Adds RustMapsBaseMapSource ahead of the Rust+ JPEG fallback in the base-map source chain: fetches the RustMaps procedural render (clean terrain, no baked icons) by world size + seed, active only when Map:RustMaps:ApiKey is configured. Any failure (no key, unpublished map, HTTP error) falls through to the existing Rust+ source. Also bumps the default map refresh interval 45s -> 30s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(map): RustMaps parity harness + skippable ground-truth test Dev-only CLI (tools/RustPlusBot.MapParity) fetches a RustMaps render + monument list, projects each monument through our MapProjection, and draws crosshairs for eyeball diffing against RustMaps' own icon placement. RustMapsParityTests reads a committed fixture and asserts pixel-cell == world-cell for every monument; skips until the user generates and commits a fixture with their API key. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(map): RustMaps timeout fall-through + doc/config cleanup from branch review * fix(map): correct grid to whole 146.25 cells (rustplusplus getCorrectedMapSize) Snap the world size to a whole multiple of 146.25 before any grid math (MapGrid.CorrectedWorldSize, ported from rustplusplus getCorrectedMapSize), so the north-edge row and east-edge column are no longer a smaller partial cell. CellCount and LabelFor route through the corrected size; DrawGrid's line/label boundaries do too, with a small epsilon nudge on the label Y probe so it agrees with LabelFor's row binning (see grid-fix-report.md for the trace). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(deps): bump RustMapsApi 1.0.0-beta.1 → beta.2 (tolerant MonumentType enum) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * revert(map): drop RustMaps as a base-map source; keep client for #info * feat(map): MapComposer.ComposeStaticAsync (grid+monuments static render) Extracts a shared private ComposeWithLayersAsync(guild, server, MapLayerSet, ct) core from ComposeAsync; ComposeAsync now reads the per-server toggle settings then delegates to it, and the new ComposeStaticAsync calls it with a fixed grid+monuments-only layer set for the #info static fallback render. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(map): RustMaps generation coordinator (per size+seed state machine) * feat(map): #info channel locator + info-map attachment poster * feat(map): credit-safe RustMaps generation driver (limits fail-closed, one-shot) Advances one (size,seed) map key per tick: GET -> Ready if ImageUrl present, else pre-check GetLimitsAsync (fail closed on error) before a single CreateMapAsync, then poll GetMapById until ready and download the image. CreateMapAsync is guaranteed to fire at most once per key. Poll responses that aren't a clean success are null-tolerant so a transient miss retries next tick instead of tripping the key to Failed. * test(map): explicit still-generating poll test + shared IsReady helper Add Poll_still_generating_stays_generating_without_spending, which stubs a real Queued/409 GetMapByIdAsync failure (not NSubstitute's default null) and asserts the key stays Generating with no CreateMapAsync call — pinning the PollAsync null-tolerance fix. Extract IsReady(Result<MapInfo>?) and use it at both the StartAsync and PollAsync "is the GET ready" checks, replacing two divergent idioms with one null-tolerant predicate. No behavior change. * feat(map): RustMaps generation poll-interval option + config * feat(map): #info static RustMaps map + credit-safe auto-generate service InfoMapHostedService owns the #info surface: tracks connected servers via ConnectionStatusChangedEvent, drives RustMapsGenerationDriver.AdvanceAsync strictly sequentially (one key at a time, single tick loop — never concurrent, since the driver's check-then-spend path is not atomic across awaits), and posts via IInfoMapPoster (RustMaps bytes when Ready, else the grid+monuments MapComposer.ComposeStaticAsync fallback). Posts only on state change (None→Fallback→Ready) to avoid per-tick repost churn. Registers the RustMaps coordinator/driver/poster/hosted-service in AddMap when Map:RustMaps:ApiKey is configured. Adds the map.info.* EN/FR strings and swept a pre-existing jb formatting drift in DiscordInfoMapPoster. * fix(map): only spend a RustMaps credit on genuine NotFound + registered #info channel StartAsync fell through to GetLimits/CreateMap on any non-Queued GET result (Transport, RateLimited, Forbidden, Validation, Unknown), wrongly spending a credit on transient GET failures instead of only a genuine NotFound. Gate added right after the Queued check; non-NotFound now bails, leaving the key Idle so the free GET retries next tick. EnsureInfoMapAsync registered the (size,seed) key with the coordinator before confirming the #info channel exists, letting a connected server with no provisioned #info channel enroll a pending key the tick loop could still spend a credit generating. Channel resolution now happens before Register. * fix(map): serialize #info map posting per server (no duplicate messages) The connection-status loop and the periodic tick loop both call EnsureInfoMapAsync for the same server. On connect one composes+posts the fallback while the other detects the RustMaps render is ready and posts it; both delete-prior-image scans run before either message commits, so neither deletes the other and two #info map messages survive. Serialize EnsureInfoMapAsync with a per-server SemaphoreSlim so the fallback fully posts before the RustMaps post runs its delete. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(map): single edited-in-place #info map + use the iconned RustMaps render Two live-run fixes for the #info map: 1. Edit in place, never duplicate. The map is now one tracked message: the poster edits it (embed + attachment swapped via ModifyAsync) instead of delete+reposting, so the channel never shows two maps. On first post (or if the message was deleted / an edit fails) it sweeps stray prior posts and posts one fresh, tracking its id. Combined with the per-server serialization, the fallback->RustMaps transition is a clean in-place swap. 2. Download imageIconUrl (map_icons.png) instead of imageUrl (map_raw_normalized.png). Verified against the live API: imageUrl is plain terrain with no markers; imageIconUrl carries the monument icons. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(map): #info map is a reconciled top-of-channel message (persistent, iconned) Replaces the home-grown #info map poster (in-memory message-id tracking, new image posted on every restart) with a reconciled Workspace message: DB-persisted id via ProvisionedMessage, edited in place across restarts, declared first so it renders above the #info status embed. - Abstractions: IInfoMapReadModel/InfoMapView read seam + InfoMapReadyEvent, so Features.Workspace (which cannot reference Features.Map) can read RustMaps generation state and Features.Map can signal readiness across the boundary. - Features.Map: the coordinator now also implements IInfoMapReadModel (same singleton instance as IRustMapsMapCoordinator); the driver stores the RustMaps imageIconUrl directly instead of downloading bytes; InfoMapHostedService drops all posting/composing and just drives generation + publishes InfoMapReadyEvent once per (size, seed). Poster, #info channel locator, and the MapComposer byte fallback (ComposeStaticAsync) are deleted. - Features.Workspace: new ServerInfoMapMessageRenderer shows the RustMaps render via WithImageUrl (or a "preparing" footer) and always returns a non-empty embed so it claims the top slot on the first reconcile; WorkspaceHostedService gains an InfoMapReadyEvent consumer that reconciles the owning server. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(workspace): reconcile channel message order (map above the #info status embed) EnsureMessagesAsync only edited-in-place or posted-if-missing per spec, never reordering, so on an already-provisioned guild a newly-declared earlier message (server.info.map) posted after an existing later one (server.info) since Discord orders by creation time. Group specs by channel, detect a to-post message that precedes an already-live one, and delete+repost the trailing live messages so they re-materialize fresh, below it, in declaration order. In-order and single-message channels are unaffected (still edit-in-place). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(map): register #info map generation from the connection store each tick The RustMaps generation was registered only from ConnectionStatusChangedEvent, which the in-process bus delivers live-only (no replay). Because InfoMapHostedService is registered after Connections in Program.cs, the connection can publish the connect event before this service has subscribed, so it is missed — the (size,seed) key is never registered, PendingKeys() stays empty, generation never runs, and #info shows the 'preparing' placeholder forever. Register connected servers from IConnectionStore each tick (GetWorldAsync returns null for non-connected), independent of the event. Adds a regression test that generates with no connect event ever published. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(map): stop #info map flickering to a placeholder on every boot The RustMaps "ready" state lives only in memory (RustMapsMapCoordinator), so on every restart it is empty until generation re-completes ~20s later. The renderer previously returned a "preparing…" placeholder embed while not ready, which overwrote the already-posted image on each boot and swapped it back once generation finished — the visible flicker/refresh. Return an EMPTY payload until the render is ready instead. The reconciler skips empty payloads, so the existing map message (with its last image) is left untouched across restarts. Positioning above the status embed is still handled by the reconciler's channel-order repair when the map first becomes non-empty, so the message no longer needs to be non-empty on first reconcile. Removes the now-dead map.info.generating string (EN/FR) and updates the renderer + localization parity tests accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(map): correct the small oil rig monument token (oilrig_1 → oil_rig_small) The Rust+ protobuf token for the small oil rig is `oil_rig_small` (confirmed against rustplusplus Map.js/MapMarkers.js and rustplus-desktop), not `oilrig_1`. With the wrong token the small rig never matched anywhere, so: its icon never drew, no RigPlacement was created, and its CH47-proximity activation was never detected. Fixes the token in all three production sites that switch on it — the icon map, the map-composer rig placement, and the connection-supervisor rig-state detection — plus the doc-comment examples and the tests that used it. The large oil rig token (`large_oil_rig`) was already correct. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(map): centre grid cell labels (were top-left → "half a row too high") Grid labels were anchored at each cell's top-left corner. Because text flows right and down from its origin, column letters read as roughly in-column (fine) but row numbers hugged the cell's top edge, appearing half a row too high versus the in-game map — which centres its labels. Probe the cell centre ((col+0.5, row+0.5) cells) and centre the text on it in both axes, matching the in-game grid. Drops the now-unneeded LabelRowEpsilon boundary nudge (centre placement is always safely inside the cell). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * revert(map): keep grid labels at the cell top-left corner Reverts the label-centring change (507eef6): the top-left corner placement was the intended one and should not have been touched. The real grid displacement is a separate, still-open issue tracked below. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(map): render the partial edge grid cell so the grid matches RustMaps The grid dropped a row and a column versus RustMaps and the in-game map: a 1500 world shows 11×11 (A–K, rows 0–10), but we drew 10×10 (A–J, 0–9). A prior change snapped the world size to a whole multiple of the 146.25 cell size (rustplusplus getCorrectedMapSize), which *deletes* the partial edge cell — the "displacement" the user reported. Rust, the Rust+ companion app and RustMaps all keep that partial edge cell as a (narrower) labelled cell. So: - MapGrid.CellCount is now ceil(worldSize / CellSize) — the whole world, partial edge cell included — and CorrectedWorldSize is removed. - MapGrid.LabelFor bins straight off the raw world size from the SW corner (the actual Rust grid formula), so grid references match the in-game grid. - MapRenderer.DrawGrid covers [0, worldSize], clamping the final boundary to worldSize so the last cell is the partial one; labels stay at the cell top-left corner. Drops the now-unneeded LabelRowEpsilon nudge. Verified against RustMaps 1500/1234: 11×11, and monuments land in the same cells RustMaps shows (small oil rig F5, lighthouse I3, substation I2). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(map): anchor the grid at the world's north-west corner, lattice over the whole image The previous fix kept the partial edge cell but anchored rows at the SOUTH edge, leaving the remainder as a squished sliver row at the top — rows sat visibly displaced versus RustMaps and the companion app (columns were fine because they were already west-anchored). Rust, the app and RustMaps anchor the grid at the world's NW corner: rows step south from the top and the partial cell sits at the south/east, bleeding past the world edge so every visible cell looks full-size. They also draw the grid lattice across the WHOLE map image (ocean margin included), not clipped to the world square. - MapRenderer.DrawGrid: lattice anchored at the NW world corner, lines spanning the full image in all directions; labels (A0..) only for the world's ceil-count cells, top-left corner placement unchanged. - MapGrid.LabelFor: rows bin from the north edge (floor((worldSize - y) / CellSize)) so grid references match the visible grid; columns unchanged. - Regression tests lock the north anchoring (1500 world: y=1360 → A0, y=10 → partial row A10). Verified against RustMaps 1500/1234 (tmp/images/image.png) and the companion-app geometry with an ocean-margin base tile. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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> * fix(map): clip the grid to the labelled cell block, not the whole image Draw the grid lattice only over the labelled cells (A0 through the last ceil-count cell) instead of extending the lines across the entire image and its ocean margin. The anchoring (per grid style) and label placement are unchanged; the partial edge cell still bleeds past the world's south/east edge inside the block, so every labelled cell renders full-size. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(review): address Copilot comments on PR #45 - DiscordWorkspaceGateway.DeleteMessageAsync forwards the cancellation token via RequestOptions.CancelToken instead of ignoring it. - Correct the stale DI comment: with no RustMaps API key the #info map renderer returns an empty payload (no message), not a placeholder. - RustMapsParityTests orders the fixture enumeration so the selected fixture is deterministic across filesystems. - MapParity CLI uses TryParse for numeric args and prints the usage line on invalid input instead of throwing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(test): deflake InfoMapHostedService tests (publish-before-subscribe race) Ready_key_publishes_InfoMapReadyEvent_once_per_requester failed on the Linux CI runner with 0 events received. Root cause: the test collected events by subscribing to the real InMemoryEventBus from a Task.Run — that bus drops events published before a subscriber is active, and the service's first tick (10 ms poll) can announce the key before the background subscription starts on a loaded runner. The key is announced at most once, so the events are gone for good and the test times out at 0. Replace the racy live-bus collector with a small CapturingEventBus fake that records every published InfoMapReadyEvent synchronously — deterministic, and the tests now finish in ~170 ms instead of riding the 5 s deadline. The Connect_registers test keeps the real bus (it re-publishes in a loop, so the subscribe race self-heals there). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c049fb2 commit f9897fd

119 files changed

Lines changed: 4256 additions & 421 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.editorconfig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,7 @@ roslynator_compiler_diagnostic_fixes.enabled = true
392392
dotnet_diagnostic.CA1002.severity = none # Do not expose generic lists API uses List<T> intentionally for protobuf collections
393393
dotnet_diagnostic.CA1008.severity = none # Enums should have zero value
394394
dotnet_diagnostic.CA1031.severity = none # Do not catch general exception types (intentional for fail-safe CLI resilience)
395+
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
395396
dotnet_diagnostic.CA1056.severity = none # Uri properties should not be strings
396397
dotnet_diagnostic.CA1303.severity = none # Do not pass literals as localized parameters (literal strings in console output CLI tool not localized)
397398
dotnet_diagnostic.CA1308.severity = none # Normalize strings to uppercase

Directory.Packages.props

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
1414
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
1515
<PackageVersion Include="Persistord.Core" Version="1.0.0-beta.1" />
16-
<PackageVersion Include="RustPlusApi" Version="2.0.0-beta.3" />
17-
<PackageVersion Include="RustPlusApi.Fcm" Version="2.0.0-beta.3" />
16+
<PackageVersion Include="RustMapsApi" Version="1.0.0-beta.2" />
17+
<PackageVersion Include="RustPlusApi" Version="2.0.0-beta.4" />
18+
<PackageVersion Include="RustPlusApi.Fcm" Version="2.0.0-beta.4" />
1819
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
1920
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.1" />
2021
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />

RustPlusBot.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
</Folder>
2222
<Folder Name="/tools/">
2323
<Project Path="tools/RustPlusBot.ItemData.Generator/RustPlusBot.ItemData.Generator.csproj" />
24+
<Project Path="tools/RustPlusBot.MapParity/RustPlusBot.MapParity.csproj" />
2425
</Folder>
2526
<Folder Name="/tests/">
2627
<Project Path="tests/RustPlusBot.Abstractions.Tests/RustPlusBot.Abstractions.Tests.csproj" />

src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ public interface IRustServerQuery
4646
/// <returns>The map dimensions, or null when there is no live socket.</returns>
4747
Task<MapDimensions?> GetMapDimensionsAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken);
4848

49+
/// <summary>Gets the world size and seed of a connected server, or null when unavailable.</summary>
50+
/// <param name="guildId">The owning guild snowflake.</param>
51+
/// <param name="serverId">The target server id.</param>
52+
/// <param name="cancellationToken">A cancellation token.</param>
53+
/// <returns>The world snapshot, or null.</returns>
54+
Task<WorldSnapshot?> GetWorldAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken);
55+
4956
/// <summary>Gets the server's monuments, or an empty list when there is no live socket.</summary>
5057
/// <param name="guildId">The owning guild snowflake.</param>
5158
/// <param name="serverId">The target server id.</param>
Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
namespace RustPlusBot.Abstractions.Connections;
22

3-
/// <summary>The static-per-wipe map size needed to convert world coordinates to a grid reference.</summary>
4-
/// <param name="Width">Map image width.</param>
5-
/// <param name="Height">Map image height.</param>
6-
/// <param name="OceanMargin">The ocean margin around the playable area.</param>
7-
public sealed record MapDimensions(uint Width, uint Height, int OceanMargin);
3+
/// <summary>Dimensions of the server-rendered map tile plus the world size.</summary>
4+
/// <param name="Width">Width of the base map image, in pixels.</param>
5+
/// <param name="Height">Height of the base map image, in pixels.</param>
6+
/// <param name="OceanMargin">Ocean border baked into the base map image, in pixels.</param>
7+
/// <param name="WorldSize">Size of the playable world, in game units (from server info).</param>
8+
public sealed record MapDimensions(uint Width, uint Height, int OceanMargin, uint WorldSize);
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
using System.Globalization;
2+
using System.Text;
3+
4+
namespace RustPlusBot.Abstractions.Connections;
5+
6+
/// <summary>
7+
/// Rust map grid math shared by the map renderer and grid-reference formatting.
8+
/// One cell is 146.25 game units; the grid is anchored at the world's NORTH-WEST corner
9+
/// (Rust / RustMaps / companion-app behaviour): columns are lettered west→east from A, rows are
10+
/// numbered north→south from 0, and the partial edge cell (when the world size is not a whole
11+
/// multiple of the cell size) sits along the south and east edges.
12+
/// </summary>
13+
public static class MapGrid
14+
{
15+
/// <summary>Edge length of one (whole) grid cell, in game units.</summary>
16+
public const float CellSize = 146.25f;
17+
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+
31+
/// <summary>
32+
/// Number of grid cells per axis, covering the whole world size — <c>ceil(worldSize / CellSize)</c>.
33+
/// The last cell (east-most column / south-most row) is a partial edge cell whenever the world size
34+
/// is not a whole multiple of <see cref="CellSize"/>; it is still a labelled cell, matching how Rust,
35+
/// the Rust+ companion app and RustMaps draw the grid. (A 1500 world → 11 cells: A–K, rows 0–10.)
36+
/// </summary>
37+
/// <param name="worldSize">The world size in game units.</param>
38+
/// <returns>The cell count (at least 1).</returns>
39+
public static int CellCount(uint worldSize)
40+
{
41+
var full = (int)(worldSize / CellSize);
42+
var hasPartial = worldSize - (full * CellSize) > 0.5f;
43+
return Math.Max(1, hasPartial ? full + 1 : full);
44+
}
45+
46+
/// <summary>Formats a spreadsheet-style column label (0→A … 25→Z, 26→AA …).</summary>
47+
/// <param name="index">The zero-based column index.</param>
48+
/// <returns>The column letters.</returns>
49+
public static string ColumnLetters(int index)
50+
{
51+
var sb = new StringBuilder();
52+
var n = index;
53+
do
54+
{
55+
sb.Insert(0, (char)('A' + (n % 26)));
56+
n = (n / 26) - 1;
57+
} while (n >= 0);
58+
59+
return sb.ToString();
60+
}
61+
62+
/// <summary>Formats the grid label ("D7") for a world coordinate.</summary>
63+
/// <param name="x">World X (west→east).</param>
64+
/// <param name="y">World Y (south→north).</param>
65+
/// <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>
67+
/// <returns>The grid label, rows numbered from the top; out-of-world coordinates clamp to the edge cell.</returns>
68+
public static string LabelFor(float x, float y, uint worldSize, MapGridStyle style = MapGridStyle.InGame)
69+
{
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.
72+
var cells = CellCount(worldSize);
73+
var col = Math.Clamp((int)MathF.Floor(x / CellSize), 0, cells - 1);
74+
var row = Math.Clamp((int)MathF.Floor((worldSize - RowInset(style) - y) / CellSize), 0, cells - 1);
75+
return string.Create(CultureInfo.InvariantCulture, $"{ColumnLetters(col)}{row}");
76+
}
77+
}
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+
}
Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
namespace RustPlusBot.Abstractions.Connections;
22

3+
// Rotation defaults to null so existing call sites stay valid and "no heading" is distinguishable
4+
// from "heading north" once RustPlusApi beta.4 supplies values.
35
/// <summary>One map marker observed in a <c>GetMapMarkers</c> poll.</summary>
46
/// <param name="Id">The stable marker id (used to diff polls).</param>
57
/// <param name="Kind">The classified marker kind.</param>
68
/// <param name="X">World X coordinate.</param>
79
/// <param name="Y">World Y coordinate.</param>
810
/// <param name="Name">The marker name, if any.</param>
9-
public sealed record MapMarkerSnapshot(ulong Id, MarkerKind Kind, float X, float Y, string? Name);
11+
/// <param name="Rotation">Heading in degrees as sent by the server, or null when unavailable.</param>
12+
public sealed record MapMarkerSnapshot(
13+
ulong Id,
14+
MarkerKind Kind,
15+
float X,
16+
float Y,
17+
string? Name,
18+
float? Rotation = null);
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
namespace RustPlusBot.Abstractions.Connections;
22

33
/// <summary>One named monument observed in a <c>GetMap</c> response.</summary>
4-
/// <param name="Token">The monument token (e.g. <c>oilrig_1</c>, <c>large_oil_rig</c>).</param>
4+
/// <param name="Token">The monument token (e.g. <c>oil_rig_small</c>, <c>large_oil_rig</c>).</param>
55
/// <param name="X">World X coordinate.</param>
66
/// <param name="Y">World Y coordinate.</param>
77
public sealed record MonumentSnapshot(string Token, float X, float Y);
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace RustPlusBot.Abstractions.Connections;
2+
3+
/// <summary>The world identity of a connected server, used to resolve external map imagery.</summary>
4+
/// <param name="WorldSize">Size of the playable world, in game units.</param>
5+
/// <param name="Seed">Procedural map generation seed.</param>
6+
public sealed record WorldSnapshot(uint WorldSize, uint Seed);

0 commit comments

Comments
 (0)