From 848db7fd3f7766e75b8df202d23090cbccd89acd Mon Sep 17 00:00:00 2001 From: = Date: Mon, 10 Aug 2026 18:55:43 +0200 Subject: [PATCH 01/11] docs: spec for heli crash reporting and off-map directions Report a patrol helicopter that disappears inside the map as a probable crash with its grid cell, and one that disappears at or beyond the border as having left toward a compass direction. Replace clamped edge-cell grid references for off-map markers with 8-point directions across cargo, heli and chinook announcements, commands and the #info embed. Co-Authored-By: Claude Opus 5 (1M context) --- ...heli-crash-and-offmap-directions-design.md | 257 ++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-10-patrol-heli-crash-and-offmap-directions-design.md diff --git a/docs/superpowers/specs/2026-08-10-patrol-heli-crash-and-offmap-directions-design.md b/docs/superpowers/specs/2026-08-10-patrol-heli-crash-and-offmap-directions-design.md new file mode 100644 index 00000000..2a9c669b --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-patrol-heli-crash-and-offmap-directions-design.md @@ -0,0 +1,257 @@ +# Patrol Helicopter Crash Reporting and Off-Map Directions — Design + +Date: 2026-08-10 +Status: Approved + +## Problem + +Two defects in live map-event reporting. + +**1. A downed patrol helicopter is reported as having left.** +`MarkerEventClassifier` maps every patrol-helicopter marker removal to `MapEventKind.HeliLeft` +(`src/RustPlusBot.Features.Events/Classifying/MarkerEventClassifier.cs:38-41`), rendered as +"🚁 Patrol Helicopter left (D7)". The heli disappears from the map for two very different reasons: +it was shot down, or it finished its patrol and flew off the map edge. The shoot-down case is the +one players care about — it marks loot on the ground — and it is currently indistinguishable from a +routine departure. + +**2. Off-map markers report a fake grid cell.** +`MapGrid.LabelFor` clamps out-of-world coordinates to the nearest edge cell +(`src/RustPlusBot.Abstractions/Connections/MapGrid.cs:73-74`). Cargo ships, helicopters and chinooks +spawn in the ocean *outside* the playable world, so their spawn announcement names a cell they are +not in and may never visit. Players describe these spawns by direction ("cargo spawned north-east"), +not by cell. + +## Goals + +- Report a heli that disappeared inside the map as a probable crash, with its grid cell. +- Report a heli that disappeared at or beyond the map border as having left, with a direction. +- Report any marker positioned outside the playable world by 8-point compass direction rather than a + clamped grid cell, across announcements, team-chat lines, commands and the `#info` embed. +- Keep both supported cultures (`en`, `fr`) naturally worded. + +## Non-goals + +- No change to marker polling, state storage, or map rendering. +- No change to team-member position formatting — players are always inside the world. +- No new user-facing settings. The border band is a constant, not a per-server option. + +## Decisions + +| Question | Decision | +| --- | --- | +| Crash vs left | Position-based with a tolerance band: inside the map by more than one grid cell → crashed; at or beyond the border → left. | +| Border band width | One grid cell, `MapGrid.CellSize` = 146.25 game units. A strict inside/outside test would misreport most normal departures as crashes, because the last marker update before removal usually lands slightly inside the border. | +| Direction basis | 8-point compass from the world centre, binned every 45°. | +| Scope | All map markers — cargo, patrol heli, chinook — in every surface that formats a marker position. | +| Off-map message content | Direction only. No raw coordinates, no "nearest" cell. | +| Wording | "probably crashed at D7" / "left the map to the north-east". | +| Map dimensions unavailable | Fall back to today's behaviour: `HeliLeft` with raw `(x, y)` coordinates. Neither a cell nor a direction is computable without a world size. | + +## Design + +### Map math (`RustPlusBot.Abstractions/Connections`) + +New `MapDirection` enum: `North, NorthEast, East, SouthEast, South, SouthWest, West, NorthWest`. + +New members on `MapGrid`, which already owns cell size and grid binning: + +- `MapDirection DirectionFrom(float x, float y, uint worldSize)` — bearing from the world centre + (`worldSize / 2` on both axes) to the point, binned into 45° sectors centred on each compass + point. Recall the world axes: X runs west→east, Y runs south→north. A point exactly at the centre + is degenerate; return `North` rather than throwing, since it cannot arise for a real off-map + marker. +- `bool IsOutsideWorld(float x, float y, uint worldSize)` — true when X or Y falls outside + `[0, worldSize]`. +- `bool IsAtOrBeyondBorder(float x, float y, uint worldSize)` — true when the point is outside the + world, or within `CellSize` of any edge. + +These are pure functions with no localization or grid-style dependency; direction does not vary by +`MapGridStyle`. + +### Location description (`RustPlusBot.Features.Events/Formatting`) + +New static `MapLocation` alongside the existing `GridReference`: + +```csharp +public readonly record struct MapLocationText(bool IsDirection, string Text); + +public static MapLocationText Describe( + ILocalizer localizer, string culture, + float x, float y, MapDimensions? dims, MapGridStyle style); +``` + +Behaviour: + +- `dims` null or `WorldSize == 0` → `(false, "(x, y)")`, matching `GridReference.From` exactly. +- Outside the world → `(true, )`. +- Otherwise → `(false, )`. + +Plus `DescribeDirection(localizer, culture, x, y, dims)` for the departure messages, which always +render a direction regardless of whether the last position was inside the border band. When `dims` +is null it returns `(false, "(x, y)")` like `Describe`, so the caller falls back to the plain +message key and today's raw-coordinate wording. + +`IsDirection` is therefore false for both a grid cell and a raw-coordinate fallback: it answers +"does this text name a direction", which is exactly the question the message-key suffix asks. + +`GridReference` is unchanged and stays in use by `ServerTeamMessageRenderer` and +`PlayerEventRenderer`. + +### Classification (`MarkerEventClassifier`) + +New `MapEventKind.HeliCrashed = 5` (appended; the enum is persisted only as in-memory recent-event +state, but appending keeps the existing numbering stable). + +Heli marker removal resolves as: + +- `evt.Dimensions` is null → `HeliLeft` (unchanged fallback). +- `MapGrid.IsAtOrBeyondBorder(m.X, m.Y, worldSize)` → `HeliLeft`. +- Otherwise → `HeliCrashed`. + +Cargo removal stays `CargoLeft`; chinook removal stays silent. + +### Rendering + +Location rule by message type: + +| Message | Location shown | +| --- | --- | +| `HeliCrashed` | Grid cell — inside the world by definition. | +| `HeliLeft`, `CargoLeft` | Direction always (`DescribeDirection`), falling back to raw coordinates only when dimensions are unavailable. | +| Arrivals (`CargoEntered`, `HeliEntered`, `ChinookSpawned`) | `Describe` — grid inside, direction outside. | +| Live position readouts (`!cargo`, `!heli`, `!chinook`, `#info` events embed, `!events` entries) | `Describe`. | + +Call sites to update: + +- `Features.Events/Rendering/EventEmbedRenderer.cs` — `Render` and `RenderLine`; add the + `HeliCrashed` arm to both key maps, and append `.dir` to the resolved key when the described + location is a direction. +- `Features.Commands/Handlers/EventsCommandHandler.cs` — same treatment for the `command.event.*` + keys. +- `Features.Commands/Handlers/MarkerReply.cs` — `.dir` suffix on `{prefix}.ok`. +- `Features.Events/Messages/ServerEventsMessageRenderer.cs` — swap `GridReference.From` for + `MapLocation.Describe`. No `.dir` variant: `server.events.out` is a compact + "Out · {0} · {1} ago" field where a direction word reads correctly on its own — "Out · north-east + · 3m ago", "Présent · le nord-est · il y a 3m". The French article is slightly redundant there; + that is the cost of one direction-word set instead of two, and it is confined to this one field. + +`MapEventKind` is exhaustively switched in `EventEmbedRenderer` (twice) and `EventsCommandHandler`, +each throwing `ArgumentOutOfRangeException` in the default arm — so a missed `HeliCrashed` arm +surfaces as a test failure rather than a silent fallback. + +### Strings + +Both `Strings.resx` and `Strings.fr.resx`. `StringsResourceParityTests` already fails the build on +any key present in one file but not the other. + +Every message key follows one uniform rule: the renderer appends `.dir` when the described location +is a direction, and uses the plain key otherwise. "Otherwise" covers both a grid cell and the +raw-coordinate fallback, so no existing value changes meaning and the no-dimensions path keeps +today's exact wording. Nothing is repurposed; every direction-worded message is a new `.dir` key. + +New direction words. English is bare and pairs with "to the {0}" / "from the {0}" in the message; +French carries its own article so a single message value ("vers {0}", "depuis {0}") works for all +eight, including the two that elide to "l'". + +| Key | en | fr | +| --- | --- | --- | +| `direction.n` | north | le nord | +| `direction.ne` | north-east | le nord-est | +| `direction.e` | east | l'est | +| `direction.se` | south-east | le sud-est | +| `direction.s` | south | le sud | +| `direction.sw` | south-west | le sud-ouest | +| `direction.w` | west | l'ouest | +| `direction.nw` | north-west | le nord-ouest | + +New crash messages: + +| Key | en | fr | +| --- | --- | --- | +| `event.heli.crashed` | 🚁 Patrol Helicopter probably crashed at {0} | 🚁 Hélicoptère de patrouille probablement abattu en {0} | +| `event.heli.crashed.line` | Patrol Helicopter probably crashed at {0} | Hélicoptère de patrouille probablement abattu en {0} | +| `command.event.helicrashed` | heli crashed in {0} | héli abattu en {0} | + +New departure `.dir` variants. The existing `event.*.left`, `event.*.left.line` and +`command.event.*left` keys keep their current values verbatim; they now render only on the +no-dimensions fallback path. + +| Key | en | fr | +| --- | --- | --- | +| `event.heli.left.dir` | 🚁 Patrol Helicopter left the map to the {0} | 🚁 Hélicoptère de patrouille parti vers {0} | +| `event.heli.left.line.dir` | Patrol Helicopter left the map to the {0} | Hélicoptère de patrouille parti vers {0} | +| `event.cargo.left.dir` | 🚢 Cargo Ship left the map to the {0} | 🚢 Cargo Ship parti vers {0} | +| `event.cargo.left.line.dir` | Cargo Ship left the map to the {0} | Cargo Ship parti vers {0} | +| `command.event.helileft.dir` | heli left to the {0} | héli parti vers {0} | +| `command.event.cargoleft.dir` | cargo left to the {0} | cargo parti vers {0} | + +New arrival and live-position `.dir` variants: + +| Key | en | fr | +| --- | --- | --- | +| `event.cargo.entered.dir` | 🚢 Cargo Ship entered from the {0} | 🚢 Cargo Ship arrivé depuis {0} | +| `event.cargo.entered.line.dir` | Cargo Ship entered from the {0} | Cargo Ship arrivé depuis {0} | +| `event.heli.entered.dir` | 🚁 Patrol Helicopter entered from the {0} | 🚁 Hélicoptère de patrouille arrivé depuis {0} | +| `event.heli.entered.line.dir` | Patrol Helicopter entered from the {0} | Hélicoptère de patrouille arrivé depuis {0} | +| `event.chinook.spawned.dir` | 🚁 Chinook spawned to the {0} | 🚁 Chinook apparu vers {0} | +| `event.chinook.spawned.line.dir` | Chinook spawned to the {0} | Chinook apparu vers {0} | +| `command.event.cargoentered.dir` | cargo from the {0} | cargo depuis {0} | +| `command.event.helientered.dir` | heli from the {0} | héli depuis {0} | +| `command.event.chinookspawned.dir` | chinook to the {0} | chinook vers {0} | +| `command.cargo.ok.dir` | Cargo Ship to the {0} ({1} ago) | Cargo vers {0} (il y a {1}) | +| `command.heli.ok.dir` | Patrol Helicopter to the {0} ({1} ago) | Hélicoptère vers {0} (il y a {1}) | +| `command.chinook.ok.dir` | Chinook to the {0} ({1} ago) | Chinook vers {0} (il y a {1}) | + +Rejected alternative: composing every message from a shared "at D7" / "to the north-east" fragment. +It roughly halves the key count but produces awkward French across differing verbs and forces a +second, bare direction form for the compact `#info` field — so each message keeps its own value. + +## Testing + +`RustPlusBot.Abstractions.Tests` — `MapGrid`: + +- `DirectionFrom` returns the expected compass point for one sample per sector, for points both + inside and outside the world. +- Sector boundaries: a point due north-east of centre is `NorthEast`; points a hair either side of a + 45° boundary fall in the adjacent sectors. +- `IsOutsideWorld` at exactly 0 and exactly `worldSize` (inside), and just past either (outside). +- `IsAtOrBeyondBorder` at exactly `CellSize` from an edge (inside), just under it (border), and + outside the world (border). + +`RustPlusBot.Features.Events.Tests` — `MarkerEventClassifier`: + +- Heli removed at map centre → `HeliCrashed`. +- Heli removed within one cell of an edge → `HeliLeft`. +- Heli removed outside the world → `HeliLeft`. +- Heli removed with null dimensions → `HeliLeft`. +- Cargo removal is still `CargoLeft`; the existing tests must keep passing. + +`RustPlusBot.Features.Events.Tests` — `EventEmbedRenderer`: + +- `HeliCrashed` renders the crash text with a grid cell, in `en` and `fr`. +- An arrival outside the world renders the `.dir` text with a direction word, not a clamped cell. +- An arrival inside the world still renders the plain key with a cell. +- `HeliLeft` renders the `.dir` text with a direction even when the position is inside the border + band. +- `HeliLeft` with null dimensions renders the plain key with raw coordinates — today's text, + unchanged. +- Every asserted string is the resolved value, not the key, so a missing resx entry fails rather + than silently rendering the key name. + +`RustPlusBot.Features.Commands.Tests` — `!events`, `!heli`, `!cargo` replies pick the `.dir` variants +for off-map positions and the plain keys otherwise. + +`RustPlusBot.Localization.Tests` — existing parity tests cover the new keys with no changes. + +## Risks + +- **Band width is a judgement call.** 146.25 units is one grid cell. A heli shot down while hugging + the map border is reported as having left. The opposite error — calling every routine departure a + crash — is both more likely and more annoying, so the band errs toward "left". +- **Message-key explosion.** ~29 new keys per language. The parity test catches omissions, and the + exhaustive `switch` arms catch a missed `HeliCrashed` case, so both failure modes are loud. +- **A `.dir` key that is never written is only caught at runtime.** `ILocalizer` resolves an unknown + key by returning the key itself rather than throwing, so a missing `.dir` variant would surface as + a literal key in a Discord message. The renderer tests assert the resolved text for both the plain + and `.dir` paths of every message that gains a variant. From e0c178037eaaa3202212a5358e056915bda0cb07 Mon Sep 17 00:00:00 2001 From: = Date: Mon, 10 Aug 2026 19:07:52 +0200 Subject: [PATCH 02/11] docs: implementation plan for heli crash and off-map directions Co-Authored-By: Claude Opus 5 (1M context) --- ...patrol-heli-crash-and-offmap-directions.md | 1202 +++++++++++++++++ 1 file changed, 1202 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-10-patrol-heli-crash-and-offmap-directions.md diff --git a/docs/superpowers/plans/2026-08-10-patrol-heli-crash-and-offmap-directions.md b/docs/superpowers/plans/2026-08-10-patrol-heli-crash-and-offmap-directions.md new file mode 100644 index 00000000..b7cdf622 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-patrol-heli-crash-and-offmap-directions.md @@ -0,0 +1,1202 @@ +# Patrol Helicopter Crash Reporting and Off-Map Directions Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Report a patrol helicopter that vanishes inside the map as a probable crash with its grid cell, and report any marker positioned outside the playable world by compass direction instead of a clamped edge grid cell. + +**Architecture:** Two pure helpers land in `MapGrid` (Abstractions) — an 8-point bearing from the world centre, and two predicates for "outside the world" / "at or beyond the one-cell border band". A new `MapLocation` formatter in `Features.Events/Formatting` turns a coordinate into either a grid cell or a localized direction word and reports which it chose. Every renderer appends `.dir` to its message key when the location is a direction; the plain key keeps today's wording and now also covers the no-dimensions raw-coordinate fallback. `MarkerEventClassifier` splits heli removal into `HeliCrashed` / `HeliLeft` on the border-band predicate. + +**Tech Stack:** .NET 10, C# with nullable reference types, xUnit + NSubstitute, RESX localization (`en` neutral + `fr` satellite), Discord.Net embeds. + +**Spec:** `docs/superpowers/specs/2026-08-10-patrol-heli-crash-and-offmap-directions-design.md` + +## Global Constraints + +- `Directory.Build.props` sets `TreatWarningsAsErrors=true`, `GenerateDocumentationFile=true`, `AnalysisLevel=latest-all`, `EnforceCodeStyleInBuild=true`, `Nullable=enable`. **Every public type and public member needs an XML doc comment**, including `` and ``, or the build fails. Internal members in this codebase are documented too — follow suit. +- Build and test with `dtk` (token-filtered `dotnet` wrapper): `dtk dotnet build`, `dtk dotnet test `. Exit codes are preserved. +- Both `src/RustPlusBot.Localization/Strings.resx` (English, neutral) and `src/RustPlusBot.Localization/Strings.fr.resx` must gain the same keys. `StringsResourceParityTests` fails the build otherwise. Keep `` entries in ordinal-alphabetical key order, matching the existing files. +- `ILocalizer.Get` returns the key itself when a key is missing (`ResxLocalizer.cs:25`) — it never throws. Assert on resolved text in tests, never on the key. +- Grid cell size is `MapGrid.CellSize` = 146.25 game units. Never hardcode 146.25 in production code. +- World axes: X runs west→east, Y runs south→north. Grid rows are numbered north→south. +- Commit messages follow conventional commits (`feat:`, `fix:`, `docs:`, `test:`). +- Do not change `GridReference`, `ServerTeamMessageRenderer`, or `PlayerEventRenderer`. Team members are always inside the world. + +--- + +### Task 1: Compass direction and border-band math + +**Files:** +- Create: `src/RustPlusBot.Abstractions/Connections/MapDirection.cs` +- Modify: `src/RustPlusBot.Abstractions/Connections/MapGrid.cs` (append members after `LabelFor`) +- Test: `tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs` (append) + +**Interfaces:** +- Consumes: `MapGrid.CellSize` (existing constant, 146.25f). +- Produces: + - `enum MapDirection { North = 0, NorthEast = 1, East = 2, SouthEast = 3, South = 4, SouthWest = 5, West = 6, NorthWest = 7 }` in namespace `RustPlusBot.Abstractions.Connections`. + - `static MapDirection MapGrid.DirectionFrom(float x, float y, uint worldSize)` + - `static bool MapGrid.IsOutsideWorld(float x, float y, uint worldSize)` + - `static bool MapGrid.IsAtOrBeyondBorder(float x, float y, uint worldSize)` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs`, inside the existing `MapGridTests` class (the file already has `using RustPlusBot.Abstractions.Connections;`): + +```csharp + [Theory] + [InlineData(2000f, 3900f, MapDirection.North)] + [InlineData(3900f, 3900f, MapDirection.NorthEast)] + [InlineData(3900f, 2000f, MapDirection.East)] + [InlineData(3900f, 100f, MapDirection.SouthEast)] + [InlineData(2000f, 100f, MapDirection.South)] + [InlineData(100f, 100f, MapDirection.SouthWest)] + [InlineData(100f, 2000f, MapDirection.West)] + [InlineData(100f, 3900f, MapDirection.NorthWest)] + public void DirectionFrom_bins_the_bearing_from_the_world_centre(float x, float y, MapDirection expected) => + Assert.Equal(expected, MapGrid.DirectionFrom(x, y, 4000u)); + + [Theory] + // Sectors are centred on each compass point, so the North/NorthEast split sits at 22.5° + // clockwise from north: dx/dy = tan(22.5°) = 0.4142. With dy = 1000, that is dx = 414.2. + [InlineData(2410f, 3000f, MapDirection.North)] + [InlineData(2420f, 3000f, MapDirection.NorthEast)] + public void DirectionFrom_splits_sectors_half_way_between_compass_points( + float x, float y, MapDirection expected) => + Assert.Equal(expected, MapGrid.DirectionFrom(x, y, 4000u)); + + [Fact] + public void DirectionFrom_works_outside_the_world() + { + // The whole point of the helper: ocean spawns sit beyond the world bounds. + Assert.Equal(MapDirection.NorthWest, MapGrid.DirectionFrom(-500f, 4500f, 4000u)); + } + + [Fact] + public void DirectionFrom_returns_north_at_the_exact_centre() => + Assert.Equal(MapDirection.North, MapGrid.DirectionFrom(2000f, 2000f, 4000u)); + + [Theory] + [InlineData(0f, 0f, false)] + [InlineData(4000f, 4000f, false)] + [InlineData(-0.1f, 2000f, true)] + [InlineData(2000f, 4000.1f, true)] + public void IsOutsideWorld_treats_the_exact_edges_as_inside(float x, float y, bool expected) => + Assert.Equal(expected, MapGrid.IsOutsideWorld(x, y, 4000u)); + + [Theory] + [InlineData(2000f, 2000f, false)] // dead centre + [InlineData(146.25f, 2000f, false)] // exactly one cell in from the west edge + [InlineData(146f, 2000f, true)] // a hair inside the band + [InlineData(2000f, 3854f, true)] // 4000 - 146.25 = 3853.75, so this is inside the north band + [InlineData(-50f, 2000f, true)] // outside the world entirely + public void IsAtOrBeyondBorder_covers_a_one_cell_band(float x, float y, bool expected) => + Assert.Equal(expected, MapGrid.IsAtOrBeyondBorder(x, y, 4000u)); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dtk dotnet test tests/RustPlusBot.Abstractions.Tests/RustPlusBot.Abstractions.Tests.csproj --filter "FullyQualifiedName~MapGridTests" +``` + +Expected: build failure — `MapDirection` does not exist, and `MapGrid` has no `DirectionFrom` / `IsOutsideWorld` / `IsAtOrBeyondBorder`. + +- [ ] **Step 3: Create the direction enum** + +Create `src/RustPlusBot.Abstractions/Connections/MapDirection.cs`: + +```csharp +namespace RustPlusBot.Abstractions.Connections; + +/// +/// An 8-point compass direction. Values are ordered clockwise from north so that a bearing can be +/// binned straight into this enum by integer division. +/// +public enum MapDirection +{ + /// Due north. + North = 0, + + /// North-east. + NorthEast = 1, + + /// Due east. + East = 2, + + /// South-east. + SouthEast = 3, + + /// Due south. + South = 4, + + /// South-west. + SouthWest = 5, + + /// Due west. + West = 6, + + /// North-west. + NorthWest = 7, +} +``` + +- [ ] **Step 4: Add the three helpers to MapGrid** + +Append inside the `MapGrid` class in `src/RustPlusBot.Abstractions/Connections/MapGrid.cs`, after `LabelFor`: + +```csharp + /// Tests whether a coordinate falls outside the playable world. + /// World X (west→east). + /// World Y (south→north). + /// The world size in game units. + /// True when either axis is beyond [0, worldSize]; the exact edges count as inside. + public static bool IsOutsideWorld(float x, float y, uint worldSize) => + x < 0f || y < 0f || x > worldSize || y > worldSize; + + /// + /// Tests whether a coordinate sits at the map border — outside the world, or within one grid cell + /// of any edge. Marker positions are sampled by polling, so a marker that has just crossed the + /// border is usually still reported slightly inside it; the one-cell band absorbs that lag. + /// + /// World X (west→east). + /// World Y (south→north). + /// The world size in game units. + /// True when the coordinate is outside the world or within of an edge. + public static bool IsAtOrBeyondBorder(float x, float y, uint worldSize) => + IsOutsideWorld(x, y, worldSize) + || x < CellSize + || y < CellSize + || x > worldSize - CellSize + || y > worldSize - CellSize; + + /// Bins the bearing from the world centre to a coordinate into an 8-point compass direction. + /// World X (west→east). + /// World Y (south→north). + /// The world size in game units. + /// + /// The compass sector containing the coordinate. Sectors are 45° wide and centred on each compass + /// point, so due north spans 337.5°–22.5°. A coordinate exactly at the centre yields + /// ; that cannot arise for a real off-map marker. + /// + public static MapDirection DirectionFrom(float x, float y, uint worldSize) + { + var centre = worldSize / 2f; + + // Atan2(east, north) gives a bearing measured clockwise from north, which is the order the + // MapDirection values are declared in. + var bearing = MathF.Atan2(x - centre, y - centre) * (180f / MathF.PI); + if (bearing < 0f) + { + bearing += 360f; + } + + // Shift by half a sector so the bins straddle each compass point rather than starting at it. + return (MapDirection)(int)MathF.Floor((bearing + 22.5f) % 360f / 45f); + } +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +dtk dotnet test tests/RustPlusBot.Abstractions.Tests/RustPlusBot.Abstractions.Tests.csproj --filter "FullyQualifiedName~MapGridTests" +``` + +Expected: PASS, including the pre-existing `CellCount` / `ColumnLetters` / `LabelFor` tests. + +- [ ] **Step 6: Commit** + +```bash +git add src/RustPlusBot.Abstractions/Connections/MapDirection.cs \ + src/RustPlusBot.Abstractions/Connections/MapGrid.cs \ + tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs +git commit -m "feat: add compass direction and border-band map grid helpers" +``` + +--- + +### Task 2: MapLocation formatter and direction words + +**Files:** +- Create: `src/RustPlusBot.Features.Events/Formatting/MapLocation.cs` +- Modify: `src/RustPlusBot.Localization/Strings.resx`, `src/RustPlusBot.Localization/Strings.fr.resx` +- Test: `tests/RustPlusBot.Features.Events.Tests/Formatting/MapLocationTests.cs` (create) + +**Interfaces:** +- Consumes: `MapGrid.DirectionFrom`, `MapGrid.IsOutsideWorld` (Task 1); existing `GridReference.From(float, float, MapDimensions?, MapGridStyle)`; `ILocalizer.Get(string key, string culture)`. +- Produces: + - `public readonly record struct MapLocationText(bool IsDirection, string Text)` in `RustPlusBot.Features.Events.Formatting`. + - `static MapLocationText MapLocation.Describe(ILocalizer localizer, string culture, float x, float y, MapDimensions? dims, MapGridStyle style = MapGridStyle.InGame)` + - `static MapLocationText MapLocation.DescribeDirection(ILocalizer localizer, string culture, float x, float y, MapDimensions? dims)` + - Resource keys `direction.n`, `direction.ne`, `direction.e`, `direction.se`, `direction.s`, `direction.sw`, `direction.w`, `direction.nw`. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/RustPlusBot.Features.Events.Tests/Formatting/MapLocationTests.cs`: + +```csharp +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Features.Events.Formatting; +using RustPlusBot.Localization; + +namespace RustPlusBot.Features.Events.Tests.Formatting; + +public sealed class MapLocationTests +{ + private static readonly ResxLocalizer Loc = new(); + private static readonly MapDimensions Dims = new(4000u, 4000u, 500, WorldSize: 4000u); + + [Fact] + public void Inside_the_world_describes_a_grid_cell() + { + var location = MapLocation.Describe(Loc, "en", 10f, 3990f, Dims); + + Assert.False(location.IsDirection); + Assert.Equal("A0", location.Text); + } + + [Fact] + public void Outside_the_world_describes_a_direction() + { + var location = MapLocation.Describe(Loc, "en", -500f, 4500f, Dims); + + Assert.True(location.IsDirection); + Assert.Equal("north-west", location.Text); + } + + [Fact] + public void Direction_words_are_localized() + { + // French direction words carry their article so one message value ("vers {0}") covers all eight. + Assert.Equal("le nord-ouest", MapLocation.Describe(Loc, "fr", -500f, 4500f, Dims).Text); + Assert.Equal("l'est", MapLocation.Describe(Loc, "fr", 4500f, 2000f, Dims).Text); + } + + [Fact] + public void Null_dimensions_fall_back_to_raw_coordinates() + { + var location = MapLocation.Describe(Loc, "en", 1234f, 5678f, dims: null); + + Assert.False(location.IsDirection); + Assert.Equal("(1234, 5678)", location.Text); + } + + [Fact] + public void DescribeDirection_uses_a_direction_even_inside_the_world() + { + var location = MapLocation.DescribeDirection(Loc, "en", 2000f, 3900f, Dims); + + Assert.True(location.IsDirection); + Assert.Equal("north", location.Text); + } + + [Fact] + public void DescribeDirection_falls_back_to_raw_coordinates_without_dimensions() + { + var location = MapLocation.DescribeDirection(Loc, "en", 1234f, 5678f, dims: null); + + Assert.False(location.IsDirection); + Assert.Equal("(1234, 5678)", location.Text); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dtk dotnet test tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj --filter "FullyQualifiedName~MapLocationTests" +``` + +Expected: build failure — `MapLocation` does not exist. + +- [ ] **Step 3: Add the direction words to both RESX files** + +In `src/RustPlusBot.Localization/Strings.resx`, insert in alphabetical position (after the `decay.*` / before the `event.*` entries — locate by searching for the first ` + east + + + north + + + north-east + + + north-west + + + south + + + south-east + + + south-west + + + west + +``` + +The same keys in `src/RustPlusBot.Localization/Strings.fr.resx`, in the same position: + +```xml + + l'est + + + le nord + + + le nord-est + + + le nord-ouest + + + le sud + + + le sud-est + + + le sud-ouest + + + l'ouest + +``` + +- [ ] **Step 4: Create the MapLocation formatter** + +Create `src/RustPlusBot.Features.Events/Formatting/MapLocation.cs`: + +```csharp +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Localization; + +namespace RustPlusBot.Features.Events.Formatting; + +/// A rendered marker location, and whether it names a compass direction rather than a grid cell. +/// +/// True when is a compass direction. False for a grid cell and for the +/// raw-coordinate fallback — it answers "does this text name a direction", which is the question +/// the .dir message-key suffix asks. +/// +/// The localized location text. +public readonly record struct MapLocationText(bool IsDirection, string Text); + +/// +/// Describes a marker position as a grid cell when it is on the map, and as a compass direction +/// when it is not. Markers spawn and despawn in the ocean outside the playable world, where a grid +/// reference would name a cell the marker is not in. +/// +public static class MapLocation +{ + /// Describes a position, preferring a grid cell and falling back to a direction off-map. + /// Resolves the direction word. + /// The guild culture ("en"/"fr"). + /// World X coordinate. + /// World Y coordinate. + /// Map dimensions, or null when unavailable. + /// Which grid convention to bin against. + /// A direction off-map, a grid cell on-map, or raw coordinates when dimensions are unavailable. + /// is null. + public static MapLocationText Describe( + ILocalizer localizer, + string culture, + float x, + float y, + MapDimensions? dims, + MapGridStyle style = MapGridStyle.InGame) + { + ArgumentNullException.ThrowIfNull(localizer); + if (dims is null || dims.WorldSize == 0) + { + return new MapLocationText(false, GridReference.From(x, y, dims, style)); + } + + return MapGrid.IsOutsideWorld(x, y, dims.WorldSize) + ? new MapLocationText(true, Word(localizer, culture, x, y, dims.WorldSize)) + : new MapLocationText(false, GridReference.From(x, y, dims, style)); + } + + /// + /// Describes a position as a compass direction regardless of whether it is on the map. Used by + /// departure messages, where the direction the marker headed matters more than the cell it was + /// last seen in. + /// + /// Resolves the direction word. + /// The guild culture ("en"/"fr"). + /// World X coordinate. + /// World Y coordinate. + /// Map dimensions, or null when unavailable. + /// A direction, or raw coordinates when dimensions are unavailable. + /// is null. + public static MapLocationText DescribeDirection( + ILocalizer localizer, + string culture, + float x, + float y, + MapDimensions? dims) + { + ArgumentNullException.ThrowIfNull(localizer); + return dims is null || dims.WorldSize == 0 + ? new MapLocationText(false, GridReference.From(x, y, dims)) + : new MapLocationText(true, Word(localizer, culture, x, y, dims.WorldSize)); + } + + private static string Word(ILocalizer localizer, string culture, float x, float y, uint worldSize) => + localizer.Get(Key(MapGrid.DirectionFrom(x, y, worldSize)), culture); + + private static string Key(MapDirection direction) => direction switch + { + MapDirection.North => "direction.n", + MapDirection.NorthEast => "direction.ne", + MapDirection.East => "direction.e", + MapDirection.SouthEast => "direction.se", + MapDirection.South => "direction.s", + MapDirection.SouthWest => "direction.sw", + MapDirection.West => "direction.w", + MapDirection.NorthWest => "direction.nw", + _ => throw new ArgumentOutOfRangeException(nameof(direction), direction, "Unsupported map direction."), + }; +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +dtk dotnet test tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj --filter "FullyQualifiedName~MapLocationTests" +dtk dotnet test tests/RustPlusBot.Localization.Tests/RustPlusBot.Localization.Tests.csproj +``` + +Expected: PASS both — including `StringsResourceParityTests`, which proves the eight keys landed in both files. + +- [ ] **Step 6: Commit** + +```bash +git add src/RustPlusBot.Features.Events/Formatting/MapLocation.cs \ + src/RustPlusBot.Localization/Strings.resx \ + src/RustPlusBot.Localization/Strings.fr.resx \ + tests/RustPlusBot.Features.Events.Tests/Formatting/MapLocationTests.cs +git commit -m "feat: describe off-map marker positions by compass direction" +``` + +--- + +### Task 3: Classify a downed helicopter as a crash + +**Files:** +- Modify: `src/RustPlusBot.Features.Events/Classifying/MapEventKind.cs` +- Modify: `src/RustPlusBot.Features.Events/Classifying/MarkerEventClassifier.cs:35-47` +- Test: `tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs` (append) + +**Interfaces:** +- Consumes: `MapGrid.IsAtOrBeyondBorder` (Task 1). +- Produces: `MapEventKind.HeliCrashed = 5`. Tasks 4 and 5 both switch on it; their `switch` expressions throw `ArgumentOutOfRangeException` on unhandled kinds, so a missing arm is a test failure, not a silent fallback. + +- [ ] **Step 1: Write the failing tests** + +Append to the `MarkerEventClassifierTests` class: + +```csharp + [Fact] + public void Heli_removed_inside_the_map_is_HeliCrashed() + { + // Dead centre of a 4000 world: nowhere near the border, so it came down here. + var result = Build().Classify(Evt([], + [new MapMarkerSnapshot(2, MarkerKind.PatrolHelicopter, 2000f, 2000f, null)])); + + Assert.Equal(MapEventKind.HeliCrashed, Assert.Single(result).Kind); + } + + [Fact] + public void Heli_removed_within_one_cell_of_the_edge_is_HeliLeft() + { + // One cell is 146.25 units, so x = 100 is inside the border band: a routine departure. + var result = Build().Classify(Evt([], + [new MapMarkerSnapshot(2, MarkerKind.PatrolHelicopter, 100f, 2000f, null)])); + + Assert.Equal(MapEventKind.HeliLeft, Assert.Single(result).Kind); + } + + [Fact] + public void Heli_removed_outside_the_world_is_HeliLeft() + { + var result = Build().Classify(Evt([], + [new MapMarkerSnapshot(2, MarkerKind.PatrolHelicopter, 4500f, 2000f, null)])); + + Assert.Equal(MapEventKind.HeliLeft, Assert.Single(result).Kind); + } + + [Fact] + public void Heli_removed_without_dimensions_is_HeliLeft() + { + // No world size means neither a cell nor a direction is computable: keep the old behaviour. + var evt = new MapMarkersChangedEvent(1UL, Server, null, [], + [new MapMarkerSnapshot(2, MarkerKind.PatrolHelicopter, 2000f, 2000f, null)], []); + + Assert.Equal(MapEventKind.HeliLeft, Assert.Single(Build().Classify(evt)).Kind); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dtk dotnet test tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj --filter "FullyQualifiedName~MarkerEventClassifierTests" +``` + +Expected: build failure — `MapEventKind.HeliCrashed` does not exist. + +- [ ] **Step 3: Add the enum member** + +Append to `src/RustPlusBot.Features.Events/Classifying/MapEventKind.cs`, inside the enum after `ChinookSpawned = 4`: + +```csharp + + /// A patrol helicopter disappeared inside the map — almost certainly shot down. + HeliCrashed = 5, +``` + +- [ ] **Step 4: Split heli removal in the classifier** + +In `src/RustPlusBot.Features.Events/Classifying/MarkerEventClassifier.cs`, add the `MapGrid` using if absent (`using RustPlusBot.Abstractions.Connections;` is already there), then change the removal loop's switch arm and add a private helper: + +```csharp + foreach (var m in evt.Removed) + { + MapEventKind? kind = m.Kind switch + { + MarkerKind.CargoShip => MapEventKind.CargoLeft, + MarkerKind.PatrolHelicopter => HeliRemoval(m, evt.Dimensions), + _ => null, // Chinook/Crate removal is silent. + }; + if (kind is { } k) + { + events.Add(new RustMapEvent(k, m.X, m.Y, evt.Dimensions, now)); + } + } + + return events; + } + + // The heli marker vanishes either because players shot it down or because it finished its patrol + // and flew off the map. Polling samples position, so a heli that has just crossed the border is + // usually still reported slightly inside it — hence the one-cell band rather than a strict + // inside/outside test, which would misreport most routine departures as crashes. + private static MapEventKind HeliRemoval(MapMarkerSnapshot marker, MapDimensions? dims) => + dims is null || dims.WorldSize == 0 || MapGrid.IsAtOrBeyondBorder(marker.X, marker.Y, dims.WorldSize) + ? MapEventKind.HeliLeft + : MapEventKind.HeliCrashed; +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +dtk dotnet test tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj --filter "FullyQualifiedName~MarkerEventClassifierTests" +``` + +Expected: PASS, including the pre-existing `Heli_added_and_removed_map_to_entered_and_left` and `Multiple_deltas_produce_multiple_events` — both remove the heli at (0, 0), which is at the border and so stays `HeliLeft`. + +- [ ] **Step 6: Commit** + +```bash +git add src/RustPlusBot.Features.Events/Classifying/MapEventKind.cs \ + src/RustPlusBot.Features.Events/Classifying/MarkerEventClassifier.cs \ + tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs +git commit -m "feat: classify a helicopter lost inside the map as a crash" +``` + +--- + +### Task 4: Announcement embeds and team-chat lines + +**Files:** +- Modify: `src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs:20-39,76-90` +- Modify: `src/RustPlusBot.Localization/Strings.resx`, `src/RustPlusBot.Localization/Strings.fr.resx` +- Test: `tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs` (append) + +**Interfaces:** +- Consumes: `MapLocation.Describe` / `MapLocation.DescribeDirection` / `MapLocationText` (Task 2), `MapEventKind.HeliCrashed` (Task 3), existing `GridReference.From`. +- Produces: nothing new for later tasks. `EventEmbedRenderer.Render` and `RenderLine` keep their existing signatures. + +**Key convention (applies here and in Task 5):** the renderer resolves a base key, then appends `.dir` when the described location is a direction. Line keys are `.line` and `.line.dir`. `HeliCrashed` always uses a grid cell, so it needs no `.dir` variant. + +- [ ] **Step 1: Write the failing tests** + +Append to the `EventEmbedRendererTests` class: + +```csharp + private static readonly MapDimensions Dims4000 = new(4000u, 4000u, 500, WorldSize: 4000u); + + [Fact] + public void Heli_crashed_renders_a_grid_cell() + { + var embed = Build().Render(new RustMapEvent(MapEventKind.HeliCrashed, 2000f, 2000f, Dims4000, Now), "en"); + + Assert.Equal("🚁 Patrol Helicopter probably crashed at N13", embed.Description); + } + + [Fact] + public void Heli_crashed_renders_french() + { + var embed = Build().Render(new RustMapEvent(MapEventKind.HeliCrashed, 2000f, 2000f, Dims4000, Now), "fr"); + + Assert.Contains("probablement abattu en", embed.Description, StringComparison.Ordinal); + } + + [Fact] + public void Heli_left_renders_a_direction() + { + var embed = Build().Render(new RustMapEvent(MapEventKind.HeliLeft, 100f, 2000f, Dims4000, Now), "en"); + + Assert.Equal("🚁 Patrol Helicopter left the map to the west", embed.Description); + } + + [Fact] + public void Cargo_entered_off_map_renders_a_direction_not_a_clamped_cell() + { + // The bug being fixed: an ocean spawn outside the world used to report the clamped edge cell. + var embed = Build().Render(new RustMapEvent(MapEventKind.CargoEntered, 4500f, 4500f, Dims4000, Now), "en"); + + Assert.Equal("🚢 Cargo Ship entered from the north-east", embed.Description); + } + + [Fact] + public void Cargo_entered_on_map_still_renders_a_cell() + { + var embed = Build().Render(new RustMapEvent(MapEventKind.CargoEntered, 10f, 3990f, Dims4000, Now), "en"); + + Assert.Equal("🚢 Cargo Ship entered at A0", embed.Description); + } + + [Fact] + public void Left_without_dimensions_keeps_the_raw_coordinate_wording() + { + var embed = Build().Render(new RustMapEvent(MapEventKind.HeliLeft, 1234f, 5678f, null, Now), "en"); + + Assert.Equal("🚁 Patrol Helicopter left ((1234, 5678))", embed.Description); + } + + [Fact] + public void Lines_follow_the_same_direction_split() + { + var renderer = Build(); + + Assert.Equal("Patrol Helicopter probably crashed at N13", + renderer.RenderLine(new RustMapEvent(MapEventKind.HeliCrashed, 2000f, 2000f, Dims4000, Now), "en")); + Assert.Equal("Chinook spawned to the south-west", + renderer.RenderLine(new RustMapEvent(MapEventKind.ChinookSpawned, -100f, -100f, Dims4000, Now), "en")); + Assert.Equal("Cargo Ship left the map to the north-east", + renderer.RenderLine(new RustMapEvent(MapEventKind.CargoLeft, 4500f, 4500f, Dims4000, Now), "en")); + } +``` + +Where `N13` comes from: a 4000 world has 28 cells (`4000 / 146.25 = 27.35` → 27 whole + 1 partial edge cell). Centre (2000, 2000) → column `floor(2000 / 146.25) = 13` → "N"; row `floor((4000 - 0 - 2000) / 146.25) = 13` (in-game style has no row inset). + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dtk dotnet test tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj --filter "FullyQualifiedName~EventEmbedRendererTests" +``` + +Expected: FAIL — the crashed tests fail to build or report the raw key, and the direction tests report clamped cells. + +- [ ] **Step 3: Add the event strings to both RESX files** + +In `src/RustPlusBot.Localization/Strings.resx`, alongside the existing `event.*` entries, keeping alphabetical order: + +```xml + + 🚢 Cargo Ship entered from the {0} + + + Cargo Ship entered from the {0} + + + 🚢 Cargo Ship left the map to the {0} + + + Cargo Ship left the map to the {0} + + + 🚁 Chinook spawned to the {0} + + + Chinook spawned to the {0} + + + 🚁 Patrol Helicopter probably crashed at {0} + + + Patrol Helicopter probably crashed at {0} + + + 🚁 Patrol Helicopter entered from the {0} + + + Patrol Helicopter entered from the {0} + + + 🚁 Patrol Helicopter left the map to the {0} + + + Patrol Helicopter left the map to the {0} + +``` + +The same keys in `src/RustPlusBot.Localization/Strings.fr.resx`: + +```xml + + 🚢 Cargo Ship arrivé depuis {0} + + + Cargo Ship arrivé depuis {0} + + + 🚢 Cargo Ship parti vers {0} + + + Cargo Ship parti vers {0} + + + 🚁 Chinook apparu vers {0} + + + Chinook apparu vers {0} + + + 🚁 Hélicoptère de patrouille probablement abattu en {0} + + + Hélicoptère de patrouille probablement abattu en {0} + + + 🚁 Hélicoptère de patrouille arrivé depuis {0} + + + Hélicoptère de patrouille arrivé depuis {0} + + + 🚁 Hélicoptère de patrouille parti vers {0} + + + Hélicoptère de patrouille parti vers {0} + +``` + +Leave the existing `event.cargo.left`, `event.cargo.left.line`, `event.heli.left`, `event.heli.left.line` values untouched — they now render only when map dimensions are unavailable. + +- [ ] **Step 4: Route the renderer through MapLocation** + +In `src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs`, replace the bodies of `Render` and `RenderLine` and add a private `Locate` helper. `Render` becomes: + +```csharp + public Embed Render(RustMapEvent evt, string culture, MapGridStyle gridStyle = MapGridStyle.InGame) + { + ArgumentNullException.ThrowIfNull(evt); + var (key, suffix, text) = Locate(evt, culture, gridStyle); + + return new EmbedBuilder() + .WithAuthor(localizer.Get("event.title", culture)) + .WithDescription(localizer.Get(key + suffix, culture, text)) + .WithTimestamp(evt.AtUtc) + .Build(); + } +``` + +`RenderLine` becomes: + +```csharp + public string RenderLine(RustMapEvent evt, string culture, MapGridStyle gridStyle = MapGridStyle.InGame) + { + ArgumentNullException.ThrowIfNull(evt); + var (key, suffix, text) = Locate(evt, culture, gridStyle); + return localizer.Get(key + ".line" + suffix, culture, text); + } +``` + +And add, next to the existing private `RigKey`: + +```csharp + // Departures report the direction the marker headed, which outlives the cell it was last seen in; + // a crash always happened inside the map, so it reports a cell. Everything else prefers a cell and + // falls back to a direction only when the marker is outside the world. The ".dir" suffix picks the + // matching message wording — with no map dimensions the location is raw coordinates, IsDirection is + // false, and the plain key keeps today's text. + private (string Key, string Suffix, string Text) Locate(RustMapEvent evt, string culture, MapGridStyle style) + { + var location = evt.Kind switch + { + MapEventKind.CargoLeft or MapEventKind.HeliLeft => + MapLocation.DescribeDirection(localizer, culture, evt.X, evt.Y, evt.Dimensions), + MapEventKind.HeliCrashed => + new MapLocationText(false, GridReference.From(evt.X, evt.Y, evt.Dimensions, style)), + _ => MapLocation.Describe(localizer, culture, evt.X, evt.Y, evt.Dimensions, style), + }; + + var key = evt.Kind switch + { + MapEventKind.CargoEntered => "event.cargo.entered", + MapEventKind.CargoLeft => "event.cargo.left", + MapEventKind.HeliEntered => "event.heli.entered", + MapEventKind.HeliLeft => "event.heli.left", + MapEventKind.HeliCrashed => "event.heli.crashed", + MapEventKind.ChinookSpawned => "event.chinook.spawned", + _ => throw new ArgumentOutOfRangeException(nameof(evt), evt.Kind, "Unsupported map event kind."), + }; + + return (key, location.IsDirection ? ".dir" : string.Empty, location.Text); + } +``` + +Delete the now-unused `grid` locals and per-method key switches from `Render` and `RenderLine`. Leave `RenderRig` and `RenderRigLine` alone. Keep the `` doc tags on both methods — `Locate` still throws for an unsupported kind. + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +dtk dotnet test tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj +dtk dotnet test tests/RustPlusBot.Localization.Tests/RustPlusBot.Localization.Tests.csproj +``` + +Expected: PASS. The pre-existing `Cargo_entered_renders_english_with_grid`, `Chinook_spawned_renders_french` and `Null_dimensions_render_raw_coordinates` must still pass unchanged. + +- [ ] **Step 6: Commit** + +```bash +git add src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs \ + src/RustPlusBot.Localization/Strings.resx \ + src/RustPlusBot.Localization/Strings.fr.resx \ + tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs +git commit -m "feat: announce heli crashes and off-map directions in event embeds" +``` + +--- + +### Task 5: Commands (`!events`, `!cargo`, `!heli`, `!chinook`) + +**Files:** +- Modify: `src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs:35-50` +- Modify: `src/RustPlusBot.Features.Commands/Handlers/MarkerReply.cs:44-47` +- Modify: `src/RustPlusBot.Localization/Strings.resx`, `src/RustPlusBot.Localization/Strings.fr.resx` +- Test: `tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs` (append) + +**Interfaces:** +- Consumes: `MapLocation.Describe` / `MapLocation.DescribeDirection` (Task 2), `MapEventKind.HeliCrashed` (Task 3). `MarkerReply.ForAsync` keeps its existing signature and its `{prefix}.ok` / `{prefix}.none` key convention, now with a `{prefix}.ok.dir` variant. +- Produces: nothing for later tasks. + +- [ ] **Step 1: Write the failing tests** + +Append to the `EventHandlersTests` class: + +```csharp + private static readonly MapDimensions Dims4000 = new(4000u, 4000u, 500, WorldSize: 4000u); + + [Fact] + public async Task Heli_off_the_map_reports_a_direction() + { + var (clock, loc) = Deps(); + var state = Substitute.For(); + state.GetActiveMarkers(Guild, Server, MarkerKind.PatrolHelicopter).Returns( + [ + new ActiveMarker(1, MarkerKind.PatrolHelicopter, 4500f, 4500f, Dims4000, Now.AddMinutes(-5), + [new TrailPoint(4500f, 4500f)], null) + ]); + + var reply = await new HeliCommandHandler(state, loc, clock, Settings()).ExecuteAsync(Ctx(), + CancellationToken.None); + + Assert.Equal("Patrol Helicopter to the north-east (5m ago)", reply); + } + + [Fact] + public async Task Events_reports_a_crash_and_an_off_map_spawn() + { + var (_, loc) = Deps(); + var state = Substitute.For(); + state.GetRecentEvents(Guild, Server).Returns( + [ + new RustMapEvent(MapEventKind.HeliCrashed, 2000f, 2000f, Dims4000, Now), + new RustMapEvent(MapEventKind.CargoEntered, 4500f, 4500f, Dims4000, Now) + ]); + + var reply = await new EventsCommandHandler(state, loc, Settings()).ExecuteAsync(Ctx(), + CancellationToken.None); + + Assert.NotNull(reply); + Assert.Contains("heli crashed in", reply, StringComparison.Ordinal); + Assert.Contains("cargo from the north-east", reply, StringComparison.Ordinal); + } + + [Fact] + public async Task Events_off_map_departure_reports_a_direction() + { + var (_, loc) = Deps(); + var state = Substitute.For(); + state.GetRecentEvents(Guild, Server).Returns( + [ + new RustMapEvent(MapEventKind.CargoLeft, -500f, 100f, Dims4000, Now) + ]); + + var reply = await new EventsCommandHandler(state, loc, Settings()).ExecuteAsync(Ctx(), + CancellationToken.None); + + Assert.NotNull(reply); + Assert.Contains("cargo left to the south-west", reply, StringComparison.Ordinal); + } +``` + +The `5m ago` comes from `DurationFormat.Compact(TimeSpan.FromMinutes(5))`, which renders sub-hour spans as `"{totalMinutes}m"` — the marker is stamped `Now.AddMinutes(-5)` and the fixture clock returns `Now`. + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dtk dotnet test tests/RustPlusBot.Features.Commands.Tests/RustPlusBot.Features.Commands.Tests.csproj --filter "FullyQualifiedName~EventHandlersTests" +``` + +Expected: FAIL — clamped grid cells instead of directions, and a literal `command.event.helicrashed` key or an `ArgumentOutOfRangeException` for the crash event. + +- [ ] **Step 3: Add the command strings to both RESX files** + +`src/RustPlusBot.Localization/Strings.resx`, in alphabetical position among the existing `command.*` entries: + +```xml + + Cargo Ship to the {0} ({1} ago) + + + Chinook to the {0} ({1} ago) + + + cargo from the {0} + + + cargo left to the {0} + + + chinook to the {0} + + + heli crashed in {0} + + + heli from the {0} + + + heli left to the {0} + + + Patrol Helicopter to the {0} ({1} ago) + +``` + +`src/RustPlusBot.Localization/Strings.fr.resx`: + +```xml + + Cargo vers {0} (il y a {1}) + + + Chinook vers {0} (il y a {1}) + + + cargo depuis {0} + + + cargo parti vers {0} + + + chinook vers {0} + + + héli abattu en {0} + + + héli depuis {0} + + + héli parti vers {0} + + + Hélicoptère vers {0} (il y a {1}) + +``` + +- [ ] **Step 4: Route both handlers through MapLocation** + +In `src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs`, replace the `parts` projection: + +```csharp + var parts = events.Select(e => + { + // Departures report the direction the marker headed; everything else prefers a grid cell + // and falls back to a direction only when the marker is outside the world. + var location = e.Kind is MapEventKind.CargoLeft or MapEventKind.HeliLeft + ? MapLocation.DescribeDirection(localizer, context.Culture, e.X, e.Y, e.Dimensions) + : MapLocation.Describe(localizer, context.Culture, e.X, e.Y, e.Dimensions, settings.GridStyle); + + var key = e.Kind switch + { + MapEventKind.CargoEntered => "command.event.cargoentered", + MapEventKind.CargoLeft => "command.event.cargoleft", + MapEventKind.HeliEntered => "command.event.helientered", + MapEventKind.HeliLeft => "command.event.helileft", + MapEventKind.HeliCrashed => "command.event.helicrashed", + MapEventKind.ChinookSpawned => "command.event.chinookspawned", + _ => throw new ArgumentOutOfRangeException(nameof(e), e.Kind, "Unsupported map event kind."), + }; + + return localizer.Get(key + (location.IsDirection ? ".dir" : string.Empty), context.Culture, + location.Text); + }); +``` + +`MapEventKind.HeliCrashed` needs no `.dir` variant: a crash is inside the map by construction, so `Describe` returns a cell. + +In `src/RustPlusBot.Features.Commands/Handlers/MarkerReply.cs`, replace the grid lookup and the return: + +```csharp + var m = markers[0]; + var location = MapLocation.Describe(localizer, context.Culture, m.X, m.Y, m.Dimensions, settings.GridStyle); + var ago = DurationFormat.Compact(clock.UtcNow - m.SeenAtUtc); + return localizer.Get($"{prefix}.ok{(location.IsDirection ? ".dir" : string.Empty)}", context.Culture, + location.Text, ago); +``` + +Both files already import `RustPlusBot.Features.Events.Formatting`, so no new usings are needed. Remove the now-unused `GridReference` references if the compiler flags the import as unused. + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +dtk dotnet test tests/RustPlusBot.Features.Commands.Tests/RustPlusBot.Features.Commands.Tests.csproj +dtk dotnet test tests/RustPlusBot.Localization.Tests/RustPlusBot.Localization.Tests.csproj +``` + +Expected: PASS. The pre-existing `Cargo_with_active_marker_reports_grid` uses (10, 3990) in a 4000 world — inside, so it still reports a cell. + +- [ ] **Step 6: Commit** + +```bash +git add src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs \ + src/RustPlusBot.Features.Commands/Handlers/MarkerReply.cs \ + src/RustPlusBot.Localization/Strings.resx \ + src/RustPlusBot.Localization/Strings.fr.resx \ + tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs +git commit -m "feat: report crashes and off-map directions in marker commands" +``` + +--- + +### Task 6: `#info` events embed and README + +**Files:** +- Modify: `src/RustPlusBot.Features.Events/Messages/ServerEventsMessageRenderer.cs:78-91` +- Modify: `README.md:62` +- Test: `tests/RustPlusBot.Features.Events.Tests/Messages/ServerEventsMessageRendererTests.cs` (append) + +**Interfaces:** +- Consumes: `MapLocation.Describe` (Task 2). +- Produces: nothing. `server.events.out` needs no `.dir` variant — it is a compact "Out · {0} · {1} ago" field where a direction word reads correctly on its own. + +- [ ] **Step 1: Write the failing test** + +Append to the `ServerEventsMessageRendererTests` class: + +```csharp + [Fact] + public async Task Off_map_marker_row_shows_a_direction() + { + var dims = new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u); + var events = Substitute.For(); + events.GetActiveMarkers(1, ServerId, Arg.Any()).Returns([]); + events.GetActiveMarkers(1, ServerId, MarkerKind.CargoShip).Returns( + [ + new ActiveMarker(1, MarkerKind.CargoShip, 4500f, 4500f, dims, Now.AddMinutes(-3), + [new TrailPoint(4500f, 4500f)], null) + ]); + var rigs = Substitute.For(); + rigs.Get(1, ServerId, Arg.Any()).Returns(new RigState(RigStatus.Online, null)); + + var payload = await Build(events, rigs).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + Assert.NotNull(payload.Embed); + var cargo = payload.Embed.Fields[0].Value; + Assert.Contains("north-east", cargo, StringComparison.Ordinal); + } +``` + +The cargo row is the first field — `RenderAsync` adds cargo, heli, chinook, small rig, large rig in that order. + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +dtk dotnet test tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj --filter "FullyQualifiedName~ServerEventsMessageRendererTests" +``` + +Expected: FAIL — the row shows the clamped edge cell instead of "north-east". + +- [ ] **Step 3: Route the marker row through MapLocation** + +In `src/RustPlusBot.Features.Events/Messages/ServerEventsMessageRenderer.cs`, change the `Marker` helper's return: + +```csharp + // Newest-first: the freshest sighting is the one worth reporting. + var marker = active[0]; + var location = MapLocation.Describe(localizer, culture, marker.X, marker.Y, marker.Dimensions, style); + return localizer.Get("server.events.out", culture, + location.Text, + DurationFormat.Compact(clock.UtcNow - marker.SeenAtUtc)); +``` + +`RustPlusBot.Features.Events.Formatting` is already imported. Drop the `GridReference` call it replaces. + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +dtk dotnet test tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj --filter "FullyQualifiedName~ServerEventsMessageRendererTests" +``` + +Expected: PASS, including the pre-existing `Renders_all_five_rows`. + +- [ ] **Step 5: Update the README feature description** + +In `README.md`, replace line 62's event bullet with: + +```markdown +- Per-server `#events` feed (and an in-game team-chat mirror) for **Cargo Ship**, **Patrol Helicopter**, and **Chinook (CH47)** entering/leaving — a helicopter that disappears inside the map is reported as a probable crash with its grid cell, and markers outside the playable world are reported by compass direction rather than a map-edge grid cell — plus **small / large oil rig** activation, "crate lootable", and respawn, derived from polling the Rust+ map markers and monuments. +``` + +- [ ] **Step 6: Run the full test suite and build** + +```bash +dtk dotnet build RustPlusBot.slnx +dtk dotnet test RustPlusBot.slnx +``` + +Expected: build succeeds with zero warnings (warnings are errors), all tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/RustPlusBot.Features.Events/Messages/ServerEventsMessageRenderer.cs \ + README.md \ + tests/RustPlusBot.Features.Events.Tests/Messages/ServerEventsMessageRendererTests.cs +git commit -m "feat: show off-map directions in the #info events embed" +``` + +--- + +## Verification + +After Task 6, the whole feature is in. Confirm against the spec: + +- A heli marker removed at map centre produces "🚁 Patrol Helicopter probably crashed at N13" — Task 3 + Task 4. +- A heli marker removed within 146.25 units of an edge produces "🚁 Patrol Helicopter left the map to the west" — Task 3 + Task 4. +- A cargo marker added outside the world produces "🚢 Cargo Ship entered from the north-east", never a clamped cell — Task 4. +- `!heli` on an off-map heli replies "Patrol Helicopter to the north-east (5m ago)" — Task 5. +- The `#info` events embed shows "Out · north-east · 3m ago" for an off-map cargo — Task 6. +- With no map dimensions, every message keeps today's raw-coordinate wording — Tasks 3–5. +- `StringsResourceParityTests` passes, proving all 29 new keys exist in both languages — Tasks 2, 4, 5. From eac7ec0e96e0ca5665a96fc7e6e847b960aa7862 Mon Sep 17 00:00:00 2001 From: = Date: Mon, 10 Aug 2026 19:15:00 +0200 Subject: [PATCH 03/11] feat: add compass direction and border-band map grid helpers --- .../Connections/MapDirection.cs | 32 ++++++++++++ .../Connections/MapGrid.cs | 49 +++++++++++++++++++ .../Connections/MapGridTests.cs | 49 +++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 src/RustPlusBot.Abstractions/Connections/MapDirection.cs diff --git a/src/RustPlusBot.Abstractions/Connections/MapDirection.cs b/src/RustPlusBot.Abstractions/Connections/MapDirection.cs new file mode 100644 index 00000000..23fdbe01 --- /dev/null +++ b/src/RustPlusBot.Abstractions/Connections/MapDirection.cs @@ -0,0 +1,32 @@ +namespace RustPlusBot.Abstractions.Connections; + +/// +/// An 8-point compass direction. Values are ordered clockwise from north so that a bearing can be +/// binned straight into this enum by integer division. +/// +public enum MapDirection +{ + /// Due north. + North = 0, + + /// North-east. + NorthEast = 1, + + /// Due east. + East = 2, + + /// South-east. + SouthEast = 3, + + /// Due south. + South = 4, + + /// South-west. + SouthWest = 5, + + /// Due west. + West = 6, + + /// North-west. + NorthWest = 7, +} diff --git a/src/RustPlusBot.Abstractions/Connections/MapGrid.cs b/src/RustPlusBot.Abstractions/Connections/MapGrid.cs index 2b78a069..69b9aa26 100644 --- a/src/RustPlusBot.Abstractions/Connections/MapGrid.cs +++ b/src/RustPlusBot.Abstractions/Connections/MapGrid.cs @@ -74,4 +74,53 @@ public static string LabelFor(float x, float y, uint worldSize, MapGridStyle sty var row = Math.Clamp((int)MathF.Floor((worldSize - RowInset(style) - y) / CellSize), 0, cells - 1); return string.Create(CultureInfo.InvariantCulture, $"{ColumnLetters(col)}{row}"); } + + /// Tests whether a coordinate falls outside the playable world. + /// World X (west→east). + /// World Y (south→north). + /// The world size in game units. + /// True when either axis is beyond [0, worldSize]; the exact edges count as inside. + public static bool IsOutsideWorld(float x, float y, uint worldSize) => + x < 0f || y < 0f || x > worldSize || y > worldSize; + + /// + /// Tests whether a coordinate sits at the map border — outside the world, or within one grid cell + /// of any edge. Marker positions are sampled by polling, so a marker that has just crossed the + /// border is usually still reported slightly inside it; the one-cell band absorbs that lag. + /// + /// World X (west→east). + /// World Y (south→north). + /// The world size in game units. + /// True when the coordinate is outside the world or within of an edge. + public static bool IsAtOrBeyondBorder(float x, float y, uint worldSize) => + IsOutsideWorld(x, y, worldSize) + || x < CellSize + || y < CellSize + || x > worldSize - CellSize + || y > worldSize - CellSize; + + /// Bins the bearing from the world centre to a coordinate into an 8-point compass direction. + /// World X (west→east). + /// World Y (south→north). + /// The world size in game units. + /// + /// The compass sector containing the coordinate. Sectors are 45° wide and centred on each compass + /// point, so due north spans 337.5°–22.5°. A coordinate exactly at the centre yields + /// ; that cannot arise for a real off-map marker. + /// + public static MapDirection DirectionFrom(float x, float y, uint worldSize) + { + var centre = worldSize / 2f; + + // Atan2(east, north) gives a bearing measured clockwise from north, which is the order the + // MapDirection values are declared in. + var bearing = MathF.Atan2(x - centre, y - centre) * (180f / MathF.PI); + if (bearing < 0f) + { + bearing += 360f; + } + + // Shift by half a sector so the bins straddle each compass point rather than starting at it. + return (MapDirection)(int)MathF.Floor((bearing + 22.5f) % 360f / 45f); + } } diff --git a/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs b/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs index 952867cc..c7b8cf02 100644 --- a/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs +++ b/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs @@ -81,4 +81,53 @@ public void LabelFor_rustplus_style_clamps_the_top_strip_to_row_0() [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)); + + [Theory] + [InlineData(2000f, 3900f, MapDirection.North)] + [InlineData(3900f, 3900f, MapDirection.NorthEast)] + [InlineData(3900f, 2000f, MapDirection.East)] + [InlineData(3900f, 100f, MapDirection.SouthEast)] + [InlineData(2000f, 100f, MapDirection.South)] + [InlineData(100f, 100f, MapDirection.SouthWest)] + [InlineData(100f, 2000f, MapDirection.West)] + [InlineData(100f, 3900f, MapDirection.NorthWest)] + public void DirectionFrom_bins_the_bearing_from_the_world_centre(float x, float y, MapDirection expected) => + Assert.Equal(expected, MapGrid.DirectionFrom(x, y, 4000u)); + + [Theory] + // Sectors are centred on each compass point, so the North/NorthEast split sits at 22.5° + // clockwise from north: dx/dy = tan(22.5°) = 0.4142. With dy = 1000, that is dx = 414.2. + [InlineData(2410f, 3000f, MapDirection.North)] + [InlineData(2420f, 3000f, MapDirection.NorthEast)] + public void DirectionFrom_splits_sectors_half_way_between_compass_points( + float x, float y, MapDirection expected) => + Assert.Equal(expected, MapGrid.DirectionFrom(x, y, 4000u)); + + [Fact] + public void DirectionFrom_works_outside_the_world() + { + // The whole point of the helper: ocean spawns sit beyond the world bounds. + Assert.Equal(MapDirection.NorthWest, MapGrid.DirectionFrom(-500f, 4500f, 4000u)); + } + + [Fact] + public void DirectionFrom_returns_north_at_the_exact_centre() => + Assert.Equal(MapDirection.North, MapGrid.DirectionFrom(2000f, 2000f, 4000u)); + + [Theory] + [InlineData(0f, 0f, false)] + [InlineData(4000f, 4000f, false)] + [InlineData(-0.1f, 2000f, true)] + [InlineData(2000f, 4000.1f, true)] + public void IsOutsideWorld_treats_the_exact_edges_as_inside(float x, float y, bool expected) => + Assert.Equal(expected, MapGrid.IsOutsideWorld(x, y, 4000u)); + + [Theory] + [InlineData(2000f, 2000f, false)] // dead centre + [InlineData(146.25f, 2000f, false)] // exactly one cell in from the west edge + [InlineData(146f, 2000f, true)] // a hair inside the band + [InlineData(2000f, 3854f, true)] // 4000 - 146.25 = 3853.75, so this is inside the north band + [InlineData(-50f, 2000f, true)] // outside the world entirely + public void IsAtOrBeyondBorder_covers_a_one_cell_band(float x, float y, bool expected) => + Assert.Equal(expected, MapGrid.IsAtOrBeyondBorder(x, y, 4000u)); } From 2ea123368c68a8c66949a86428e15355ef00451a Mon Sep 17 00:00:00 2001 From: = Date: Mon, 10 Aug 2026 19:20:57 +0200 Subject: [PATCH 04/11] feat: describe off-map marker positions by compass direction --- .../Formatting/MapLocation.cs | 90 +++++++++++++++++++ src/RustPlusBot.Localization/Strings.fr.resx | 24 +++++ src/RustPlusBot.Localization/Strings.resx | 24 +++++ .../Formatting/MapLocationTests.cs | 64 +++++++++++++ .../StringsResourceParityTests.cs | 2 +- 5 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 src/RustPlusBot.Features.Events/Formatting/MapLocation.cs create mode 100644 tests/RustPlusBot.Features.Events.Tests/Formatting/MapLocationTests.cs diff --git a/src/RustPlusBot.Features.Events/Formatting/MapLocation.cs b/src/RustPlusBot.Features.Events/Formatting/MapLocation.cs new file mode 100644 index 00000000..c612b788 --- /dev/null +++ b/src/RustPlusBot.Features.Events/Formatting/MapLocation.cs @@ -0,0 +1,90 @@ +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Localization; + +namespace RustPlusBot.Features.Events.Formatting; + +/// A rendered marker location, and whether it names a compass direction rather than a grid cell. +/// +/// True when is a compass direction. False for a grid cell and for the +/// raw-coordinate fallback — it answers "does this text name a direction", which is the question +/// the .dir message-key suffix asks. +/// +/// The localized location text. +public readonly record struct MapLocationText(bool IsDirection, string Text); + +/// +/// Describes a marker position as a grid cell when it is on the map, and as a compass direction +/// when it is not. Markers spawn and despawn in the ocean outside the playable world, where a grid +/// reference would name a cell the marker is not in. +/// +public static class MapLocation +{ + /// Describes a position, preferring a grid cell and falling back to a direction off-map. + /// Resolves the direction word. + /// The guild culture ("en"/"fr"). + /// World X coordinate. + /// World Y coordinate. + /// Map dimensions, or null when unavailable. + /// Which grid convention to bin against. + /// A direction off-map, a grid cell on-map, or raw coordinates when dimensions are unavailable. + /// is null. + public static MapLocationText Describe( + ILocalizer localizer, + string culture, + float x, + float y, + MapDimensions? dims, + MapGridStyle style = MapGridStyle.InGame) + { + ArgumentNullException.ThrowIfNull(localizer); + if (dims is null || dims.WorldSize == 0) + { + return new MapLocationText(false, GridReference.From(x, y, dims, style)); + } + + return MapGrid.IsOutsideWorld(x, y, dims.WorldSize) + ? new MapLocationText(true, Word(localizer, culture, x, y, dims.WorldSize)) + : new MapLocationText(false, GridReference.From(x, y, dims, style)); + } + + /// + /// Describes a position as a compass direction regardless of whether it is on the map. Used by + /// departure messages, where the direction the marker headed matters more than the cell it was + /// last seen in. + /// + /// Resolves the direction word. + /// The guild culture ("en"/"fr"). + /// World X coordinate. + /// World Y coordinate. + /// Map dimensions, or null when unavailable. + /// A direction, or raw coordinates when dimensions are unavailable. + /// is null. + public static MapLocationText DescribeDirection( + ILocalizer localizer, + string culture, + float x, + float y, + MapDimensions? dims) + { + ArgumentNullException.ThrowIfNull(localizer); + return dims is null || dims.WorldSize == 0 + ? new MapLocationText(false, GridReference.From(x, y, dims)) + : new MapLocationText(true, Word(localizer, culture, x, y, dims.WorldSize)); + } + + private static string Word(ILocalizer localizer, string culture, float x, float y, uint worldSize) => + localizer.Get(Key(MapGrid.DirectionFrom(x, y, worldSize)), culture); + + private static string Key(MapDirection direction) => direction switch + { + MapDirection.North => "direction.n", + MapDirection.NorthEast => "direction.ne", + MapDirection.East => "direction.e", + MapDirection.SouthEast => "direction.se", + MapDirection.South => "direction.s", + MapDirection.SouthWest => "direction.sw", + MapDirection.West => "direction.w", + MapDirection.NorthWest => "direction.nw", + _ => throw new ArgumentOutOfRangeException(nameof(direction), direction, "Unsupported map direction."), + }; +} diff --git a/src/RustPlusBot.Localization/Strings.fr.resx b/src/RustPlusBot.Localization/Strings.fr.resx index 75ff46e8..51cee765 100644 --- a/src/RustPlusBot.Localization/Strings.fr.resx +++ b/src/RustPlusBot.Localization/Strings.fr.resx @@ -471,6 +471,30 @@ {0} + + l'est + + + le nord + + + le nord-est + + + le nord-ouest + + + le sud + + + le sud-est + + + le sud-ouest + + + l'ouest + 🚢 Cargo Ship arrivé en {0} diff --git a/src/RustPlusBot.Localization/Strings.resx b/src/RustPlusBot.Localization/Strings.resx index a6f4182e..4d403937 100644 --- a/src/RustPlusBot.Localization/Strings.resx +++ b/src/RustPlusBot.Localization/Strings.resx @@ -471,6 +471,30 @@ {0} + + east + + + north + + + north-east + + + north-west + + + south + + + south-east + + + south-west + + + west + 🚢 Cargo Ship entered at {0} diff --git a/tests/RustPlusBot.Features.Events.Tests/Formatting/MapLocationTests.cs b/tests/RustPlusBot.Features.Events.Tests/Formatting/MapLocationTests.cs new file mode 100644 index 00000000..034963da --- /dev/null +++ b/tests/RustPlusBot.Features.Events.Tests/Formatting/MapLocationTests.cs @@ -0,0 +1,64 @@ +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Features.Events.Formatting; +using RustPlusBot.Localization; + +namespace RustPlusBot.Features.Events.Tests.Formatting; + +public sealed class MapLocationTests +{ + private static readonly ResxLocalizer Loc = new(); + private static readonly MapDimensions Dims = new(4000u, 4000u, 500, WorldSize: 4000u); + + [Fact] + public void Inside_the_world_describes_a_grid_cell() + { + var location = MapLocation.Describe(Loc, "en", 10f, 3990f, Dims); + + Assert.False(location.IsDirection); + Assert.Equal("A0", location.Text); + } + + [Fact] + public void Outside_the_world_describes_a_direction() + { + var location = MapLocation.Describe(Loc, "en", -500f, 4500f, Dims); + + Assert.True(location.IsDirection); + Assert.Equal("north-west", location.Text); + } + + [Fact] + public void Direction_words_are_localized() + { + // French direction words carry their article so one message value ("vers {0}") covers all eight. + Assert.Equal("le nord-ouest", MapLocation.Describe(Loc, "fr", -500f, 4500f, Dims).Text); + Assert.Equal("l'est", MapLocation.Describe(Loc, "fr", 4500f, 2000f, Dims).Text); + } + + [Fact] + public void Null_dimensions_fall_back_to_raw_coordinates() + { + var location = MapLocation.Describe(Loc, "en", 1234f, 5678f, dims: null); + + Assert.False(location.IsDirection); + Assert.Equal("(1234, 5678)", location.Text); + } + + [Fact] + public void DescribeDirection_uses_a_direction_even_inside_the_world() + { + var location = MapLocation.DescribeDirection(Loc, "en", 2000f, 3900f, Dims); + + Assert.True(location.IsDirection); + Assert.Equal("north", location.Text); + } + + [Fact] + public void DescribeDirection_falls_back_to_raw_coordinates_without_dimensions() + { + var location = MapLocation.DescribeDirection(Loc, "en", 1234f, 5678f, dims: null); + + Assert.False(location.IsDirection); + Assert.Equal("(1234, 5678)", location.Text); + } +} diff --git a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs index d84f322f..78215017 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(366, EnglishKeys().Count); + Assert.Equal(374, EnglishKeys().Count); } } From a4fa92dd55363f6f46056907c3e5759d1923efce Mon Sep 17 00:00:00 2001 From: = Date: Mon, 10 Aug 2026 19:26:05 +0200 Subject: [PATCH 05/11] feat: classify a helicopter lost inside the map as a crash --- .../Classifying/MapEventKind.cs | 3 ++ .../Classifying/MarkerEventClassifier.cs | 18 ++++++++- .../Classifying/MarkerEventClassifierTests.cs | 39 +++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/RustPlusBot.Features.Events/Classifying/MapEventKind.cs b/src/RustPlusBot.Features.Events/Classifying/MapEventKind.cs index 76c4588d..79676d85 100644 --- a/src/RustPlusBot.Features.Events/Classifying/MapEventKind.cs +++ b/src/RustPlusBot.Features.Events/Classifying/MapEventKind.cs @@ -17,4 +17,7 @@ public enum MapEventKind /// A Chinook spawned. ChinookSpawned = 4, + + /// A patrol helicopter disappeared inside the map — almost certainly shot down. + HeliCrashed = 5, } diff --git a/src/RustPlusBot.Features.Events/Classifying/MarkerEventClassifier.cs b/src/RustPlusBot.Features.Events/Classifying/MarkerEventClassifier.cs index 85161b24..88994527 100644 --- a/src/RustPlusBot.Features.Events/Classifying/MarkerEventClassifier.cs +++ b/src/RustPlusBot.Features.Events/Classifying/MarkerEventClassifier.cs @@ -37,7 +37,7 @@ public IReadOnlyList Classify(MapMarkersChangedEvent evt) MapEventKind? kind = m.Kind switch { MarkerKind.CargoShip => MapEventKind.CargoLeft, - MarkerKind.PatrolHelicopter => MapEventKind.HeliLeft, + MarkerKind.PatrolHelicopter => HeliRemoval(m, evt.Dimensions), _ => null, // Chinook/Crate removal is silent. }; if (kind is { } k) @@ -48,4 +48,20 @@ public IReadOnlyList Classify(MapMarkersChangedEvent evt) return events; } + + /// + /// Classifies heli marker removal: crashed if it vanished well inside the map, left if at or beyond the border. + /// + /// The removed marker snapshot. + /// The world dimensions, or null if unavailable. + /// + /// The heli marker vanishes either because players shot it down or because it finished its patrol + /// and flew off the map. Polling samples position, so a heli that has just crossed the border is + /// usually still reported slightly inside it — hence the one-cell band rather than a strict + /// inside/outside test, which would misreport most routine departures as crashes. + /// + private static MapEventKind HeliRemoval(MapMarkerSnapshot marker, MapDimensions? dims) => + dims is null || dims.WorldSize == 0 || MapGrid.IsAtOrBeyondBorder(marker.X, marker.Y, dims.WorldSize) + ? MapEventKind.HeliLeft + : MapEventKind.HeliCrashed; } diff --git a/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs b/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs index 153de6a1..ee4397d9 100644 --- a/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs @@ -87,4 +87,43 @@ public void Multiple_deltas_produce_multiple_events() [new MapMarkerSnapshot(2, MarkerKind.PatrolHelicopter, 0f, 0f, null)])); Assert.Equal(3, result.Count); } + + [Fact] + public void Heli_removed_inside_the_map_is_HeliCrashed() + { + // Dead centre of a 4000 world: nowhere near the border, so it came down here. + var result = Build().Classify(Evt([], + [new MapMarkerSnapshot(2, MarkerKind.PatrolHelicopter, 2000f, 2000f, null)])); + + Assert.Equal(MapEventKind.HeliCrashed, Assert.Single(result).Kind); + } + + [Fact] + public void Heli_removed_within_one_cell_of_the_edge_is_HeliLeft() + { + // One cell is 146.25 units, so x = 100 is inside the border band: a routine departure. + var result = Build().Classify(Evt([], + [new MapMarkerSnapshot(2, MarkerKind.PatrolHelicopter, 100f, 2000f, null)])); + + Assert.Equal(MapEventKind.HeliLeft, Assert.Single(result).Kind); + } + + [Fact] + public void Heli_removed_outside_the_world_is_HeliLeft() + { + var result = Build().Classify(Evt([], + [new MapMarkerSnapshot(2, MarkerKind.PatrolHelicopter, 4500f, 2000f, null)])); + + Assert.Equal(MapEventKind.HeliLeft, Assert.Single(result).Kind); + } + + [Fact] + public void Heli_removed_without_dimensions_is_HeliLeft() + { + // No world size means neither a cell nor a direction is computable: keep the old behaviour. + var evt = new MapMarkersChangedEvent(1UL, Server, null, [], + [new MapMarkerSnapshot(2, MarkerKind.PatrolHelicopter, 2000f, 2000f, null)], []); + + Assert.Equal(MapEventKind.HeliLeft, Assert.Single(Build().Classify(evt)).Kind); + } } From 27c8f547d0c2d47ed6ad1e03f1e05b9d40908930 Mon Sep 17 00:00:00 2001 From: = Date: Mon, 10 Aug 2026 19:33:18 +0200 Subject: [PATCH 06/11] feat: announce heli crashes and off-map directions in event embeds --- .../Rendering/EventEmbedRenderer.cs | 55 ++++++++++------ src/RustPlusBot.Localization/Strings.fr.resx | 36 +++++++++++ src/RustPlusBot.Localization/Strings.resx | 36 +++++++++++ .../Rendering/EventEmbedRendererTests.cs | 64 +++++++++++++++++++ .../StringsResourceParityTests.cs | 2 +- 5 files changed, 174 insertions(+), 19 deletions(-) diff --git a/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs b/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs index 37863b85..98f5ac6b 100644 --- a/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs +++ b/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs @@ -20,20 +20,11 @@ internal sealed class EventEmbedRenderer(ILocalizer localizer) public Embed Render(RustMapEvent evt, string culture, MapGridStyle gridStyle = MapGridStyle.InGame) { ArgumentNullException.ThrowIfNull(evt); - var grid = GridReference.From(evt.X, evt.Y, evt.Dimensions, gridStyle); - var key = evt.Kind switch - { - MapEventKind.CargoEntered => "event.cargo.entered", - MapEventKind.CargoLeft => "event.cargo.left", - MapEventKind.HeliEntered => "event.heli.entered", - MapEventKind.HeliLeft => "event.heli.left", - MapEventKind.ChinookSpawned => "event.chinook.spawned", - _ => throw new ArgumentOutOfRangeException(nameof(evt), evt.Kind, "Unsupported map event kind."), - }; + var (key, suffix, text) = Locate(evt, culture, gridStyle); return new EmbedBuilder() .WithAuthor(localizer.Get("event.title", culture)) - .WithDescription(localizer.Get(key, culture, grid)) + .WithDescription(localizer.Get(key + suffix, culture, text)) .WithTimestamp(evt.AtUtc) .Build(); } @@ -76,17 +67,45 @@ public string RenderRigLine(RigStateChangedEvent evt, public string RenderLine(RustMapEvent evt, string culture, MapGridStyle gridStyle = MapGridStyle.InGame) { ArgumentNullException.ThrowIfNull(evt); - var grid = GridReference.From(evt.X, evt.Y, evt.Dimensions, gridStyle); + var (key, suffix, text) = Locate(evt, culture, gridStyle); + return localizer.Get(key + ".line" + suffix, culture, text); + } + + /// + /// Departures report the direction the marker headed, which outlives the cell it was last seen in; + /// a crash always happened inside the map, so it reports a cell. Everything else prefers a cell and + /// falls back to a direction only when the marker is outside the world. The ".dir" suffix picks the + /// matching message wording — with no map dimensions the location is raw coordinates, IsDirection is + /// false, and the plain key keeps today's text. + /// + /// The map event. + /// The guild culture. + /// Which grid convention the reference uses. + /// The base localization key, the ".dir" suffix (or empty), and the location text to interpolate. + /// The event kind is not a supported . + private (string Key, string Suffix, string Text) Locate(RustMapEvent evt, string culture, MapGridStyle style) + { + var location = evt.Kind switch + { + MapEventKind.CargoLeft or MapEventKind.HeliLeft => + MapLocation.DescribeDirection(localizer, culture, evt.X, evt.Y, evt.Dimensions), + MapEventKind.HeliCrashed => + new MapLocationText(false, GridReference.From(evt.X, evt.Y, evt.Dimensions, style)), + _ => MapLocation.Describe(localizer, culture, evt.X, evt.Y, evt.Dimensions, style), + }; + var key = evt.Kind switch { - MapEventKind.CargoEntered => "event.cargo.entered.line", - MapEventKind.CargoLeft => "event.cargo.left.line", - MapEventKind.HeliEntered => "event.heli.entered.line", - MapEventKind.HeliLeft => "event.heli.left.line", - MapEventKind.ChinookSpawned => "event.chinook.spawned.line", + MapEventKind.CargoEntered => "event.cargo.entered", + MapEventKind.CargoLeft => "event.cargo.left", + MapEventKind.HeliEntered => "event.heli.entered", + MapEventKind.HeliLeft => "event.heli.left", + MapEventKind.HeliCrashed => "event.heli.crashed", + MapEventKind.ChinookSpawned => "event.chinook.spawned", _ => throw new ArgumentOutOfRangeException(nameof(evt), evt.Kind, "Unsupported map event kind."), }; - return localizer.Get(key, culture, grid); + + return (key, location.IsDirection ? ".dir" : string.Empty, location.Text); } private static string RigKey(RigStateChangedEvent evt) diff --git a/src/RustPlusBot.Localization/Strings.fr.resx b/src/RustPlusBot.Localization/Strings.fr.resx index 51cee765..c66bc147 100644 --- a/src/RustPlusBot.Localization/Strings.fr.resx +++ b/src/RustPlusBot.Localization/Strings.fr.resx @@ -498,33 +498,69 @@ 🚢 Cargo Ship arrivé en {0} + + 🚢 Cargo Ship arrivé depuis {0} + Cargo Ship arrivé en {0} + + Cargo Ship arrivé depuis {0} + 🚢 Cargo Ship parti ({0}) + + 🚢 Cargo Ship parti vers {0} + Cargo Ship parti ({0}) + + Cargo Ship parti vers {0} + 🚁 Chinook apparu en {0} + + 🚁 Chinook apparu vers {0} + Chinook apparu en {0} + + Chinook apparu vers {0} + + + 🚁 Hélicoptère de patrouille probablement abattu en {0} + + + Hélicoptère de patrouille probablement abattu en {0} + 🚁 Hélicoptère de patrouille arrivé en {0} + + 🚁 Hélicoptère de patrouille arrivé depuis {0} + Hélicoptère de patrouille arrivé en {0} + + Hélicoptère de patrouille arrivé depuis {0} + 🚁 Hélicoptère de patrouille parti ({0}) + + 🚁 Hélicoptère de patrouille parti vers {0} + Hélicoptère de patrouille parti ({0}) + + Hélicoptère de patrouille parti vers {0} + 🛢️ Grande plateforme pétrolière activée — phase de combat, caisse bientôt lootable ({0}) diff --git a/src/RustPlusBot.Localization/Strings.resx b/src/RustPlusBot.Localization/Strings.resx index 4d403937..560a63ea 100644 --- a/src/RustPlusBot.Localization/Strings.resx +++ b/src/RustPlusBot.Localization/Strings.resx @@ -498,33 +498,69 @@ 🚢 Cargo Ship entered at {0} + + 🚢 Cargo Ship entered from the {0} + Cargo Ship entered at {0} + + Cargo Ship entered from the {0} + 🚢 Cargo Ship left ({0}) + + 🚢 Cargo Ship left the map to the {0} + Cargo Ship left ({0}) + + Cargo Ship left the map to the {0} + 🚁 Chinook spawned at {0} + + 🚁 Chinook spawned to the {0} + Chinook spawned at {0} + + Chinook spawned to the {0} + + + 🚁 Patrol Helicopter probably crashed at {0} + + + Patrol Helicopter probably crashed at {0} + 🚁 Patrol Helicopter entered at {0} + + 🚁 Patrol Helicopter entered from the {0} + Patrol Helicopter entered at {0} + + Patrol Helicopter entered from the {0} + 🚁 Patrol Helicopter left ({0}) + + 🚁 Patrol Helicopter left the map to the {0} + Patrol Helicopter left ({0}) + + Patrol Helicopter left the map to the {0} + 🛢️ Large Oil Rig activated — combat phase, crate lootable soon ({0}) diff --git a/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs b/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs index 5c4c41aa..e9a3a78c 100644 --- a/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs @@ -35,4 +35,68 @@ public void Null_dimensions_render_raw_coordinates() var embed = Build().Render(new RustMapEvent(MapEventKind.HeliEntered, 1234f, 5678f, null, Now), "en"); Assert.Contains("(1234, 5678)", embed.Description, StringComparison.Ordinal); } + + private static readonly MapDimensions Dims4000 = new(4000u, 4000u, 500, WorldSize: 4000u); + + [Fact] + public void Heli_crashed_renders_a_grid_cell() + { + var embed = Build().Render(new RustMapEvent(MapEventKind.HeliCrashed, 2000f, 2000f, Dims4000, Now), "en"); + + Assert.Equal("🚁 Patrol Helicopter probably crashed at N13", embed.Description); + } + + [Fact] + public void Heli_crashed_renders_french() + { + var embed = Build().Render(new RustMapEvent(MapEventKind.HeliCrashed, 2000f, 2000f, Dims4000, Now), "fr"); + + Assert.Contains("probablement abattu en", embed.Description, StringComparison.Ordinal); + } + + [Fact] + public void Heli_left_renders_a_direction() + { + var embed = Build().Render(new RustMapEvent(MapEventKind.HeliLeft, 100f, 2000f, Dims4000, Now), "en"); + + Assert.Equal("🚁 Patrol Helicopter left the map to the west", embed.Description); + } + + [Fact] + public void Cargo_entered_off_map_renders_a_direction_not_a_clamped_cell() + { + // The bug being fixed: an ocean spawn outside the world used to report the clamped edge cell. + var embed = Build().Render(new RustMapEvent(MapEventKind.CargoEntered, 4500f, 4500f, Dims4000, Now), "en"); + + Assert.Equal("🚢 Cargo Ship entered from the north-east", embed.Description); + } + + [Fact] + public void Cargo_entered_on_map_still_renders_a_cell() + { + var embed = Build().Render(new RustMapEvent(MapEventKind.CargoEntered, 10f, 3990f, Dims4000, Now), "en"); + + Assert.Equal("🚢 Cargo Ship entered at A0", embed.Description); + } + + [Fact] + public void Left_without_dimensions_keeps_the_raw_coordinate_wording() + { + var embed = Build().Render(new RustMapEvent(MapEventKind.HeliLeft, 1234f, 5678f, null, Now), "en"); + + Assert.Equal("🚁 Patrol Helicopter left ((1234, 5678))", embed.Description); + } + + [Fact] + public void Lines_follow_the_same_direction_split() + { + var renderer = Build(); + + Assert.Equal("Patrol Helicopter probably crashed at N13", + renderer.RenderLine(new RustMapEvent(MapEventKind.HeliCrashed, 2000f, 2000f, Dims4000, Now), "en")); + Assert.Equal("Chinook spawned to the south-west", + renderer.RenderLine(new RustMapEvent(MapEventKind.ChinookSpawned, -100f, -100f, Dims4000, Now), "en")); + Assert.Equal("Cargo Ship left the map to the north-east", + renderer.RenderLine(new RustMapEvent(MapEventKind.CargoLeft, 4500f, 4500f, Dims4000, Now), "en")); + } } diff --git a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs index 78215017..13498f5e 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(374, EnglishKeys().Count); + Assert.Equal(386, EnglishKeys().Count); } } From 51bc61e06ff7ebe4b172d3679e51f95dfe8a148c Mon Sep 17 00:00:00 2001 From: = Date: Mon, 10 Aug 2026 19:45:06 +0200 Subject: [PATCH 07/11] feat: report crashes and off-map directions in marker commands --- .../Handlers/EventsCommandHandler.cs | 12 +++- .../Handlers/MarkerReply.cs | 5 +- src/RustPlusBot.Localization/Strings.fr.resx | 27 +++++++++ src/RustPlusBot.Localization/Strings.resx | 27 +++++++++ .../Handlers/EventHandlersTests.cs | 55 +++++++++++++++++++ .../StringsResourceParityTests.cs | 2 +- 6 files changed, 123 insertions(+), 5 deletions(-) diff --git a/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs index b79904e0..086526e8 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs @@ -34,17 +34,25 @@ internal sealed class EventsCommandHandler( .ConfigureAwait(false); var parts = events.Select(e => { - var grid = GridReference.From(e.X, e.Y, e.Dimensions, settings.GridStyle); + // Departures report the direction the marker headed; everything else prefers a grid cell + // and falls back to a direction only when the marker is outside the world. + var location = e.Kind is MapEventKind.CargoLeft or MapEventKind.HeliLeft + ? MapLocation.DescribeDirection(localizer, context.Culture, e.X, e.Y, e.Dimensions) + : MapLocation.Describe(localizer, context.Culture, e.X, e.Y, e.Dimensions, settings.GridStyle); + var key = e.Kind switch { MapEventKind.CargoEntered => "command.event.cargoentered", MapEventKind.CargoLeft => "command.event.cargoleft", MapEventKind.HeliEntered => "command.event.helientered", MapEventKind.HeliLeft => "command.event.helileft", + MapEventKind.HeliCrashed => "command.event.helicrashed", MapEventKind.ChinookSpawned => "command.event.chinookspawned", _ => throw new ArgumentOutOfRangeException(nameof(e), e.Kind, "Unsupported map event kind."), }; - return localizer.Get(key, context.Culture, grid); + + return localizer.Get(key + (location.IsDirection ? ".dir" : string.Empty), context.Culture, + location.Text); }); return localizer.Get("command.events.ok", context.Culture, string.Join(", ", parts)); diff --git a/src/RustPlusBot.Features.Commands/Handlers/MarkerReply.cs b/src/RustPlusBot.Features.Commands/Handlers/MarkerReply.cs index e0e1d1bd..e63d6054 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/MarkerReply.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/MarkerReply.cs @@ -41,8 +41,9 @@ public static async Task ForAsync( 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, settings.GridStyle); + var location = MapLocation.Describe(localizer, context.Culture, 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); + return localizer.Get($"{prefix}.ok{(location.IsDirection ? ".dir" : string.Empty)}", context.Culture, + location.Text, ago); } } diff --git a/src/RustPlusBot.Localization/Strings.fr.resx b/src/RustPlusBot.Localization/Strings.fr.resx index c66bc147..b4954608 100644 --- a/src/RustPlusBot.Localization/Strings.fr.resx +++ b/src/RustPlusBot.Localization/Strings.fr.resx @@ -288,27 +288,51 @@ Cargo en {0} (il y a {1}) + + Cargo vers {0} (il y a {1}) + Aucun chinook sur la carte. Chinook en {0} (il y a {1}) + + Chinook vers {0} (il y a {1}) + cargo en {0} + + cargo depuis {0} + cargo parti de {0} + + cargo parti vers {0} + chinook en {0} + + chinook vers {0} + + + héli abattu en {0} + heli en {0} + + héli depuis {0} + heli parti de {0} + + héli parti vers {0} + Aucun événement récent. @@ -321,6 +345,9 @@ Hélicoptère en {0} (il y a {1}) + + Hélicoptère vers {0} (il y a {1}) + Grande plateforme : phase de combat — caisse lootable dans {0}. diff --git a/src/RustPlusBot.Localization/Strings.resx b/src/RustPlusBot.Localization/Strings.resx index 560a63ea..a7e6e2b0 100644 --- a/src/RustPlusBot.Localization/Strings.resx +++ b/src/RustPlusBot.Localization/Strings.resx @@ -288,27 +288,51 @@ Cargo Ship at {0} ({1} ago) + + Cargo Ship to the {0} ({1} ago) + No chinook on the map. Chinook at {0} ({1} ago) + + Chinook to the {0} ({1} ago) + cargo in {0} + + cargo from the {0} + cargo left {0} + + cargo left to the {0} + chinook in {0} + + chinook to the {0} + + + heli crashed in {0} + heli in {0} + + heli from the {0} + heli left {0} + + heli left to the {0} + No recent events. @@ -321,6 +345,9 @@ Patrol Helicopter at {0} ({1} ago) + + Patrol Helicopter to the {0} ({1} ago) + Large Oil Rig: combat phase — crate lootable in {0}. diff --git a/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs index 63f696ad..d000af2f 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs @@ -92,4 +92,59 @@ public async Task Handlers_expose_expected_names() Assert.Equal("chinook", new ChinookCommandHandler(state, loc, clock, Settings()).Name); Assert.Equal("events", new EventsCommandHandler(state, loc, Settings()).Name); } + + private static readonly MapDimensions Dims4000 = new(4000u, 4000u, 500, WorldSize: 4000u); + + [Fact] + public async Task Heli_off_the_map_reports_a_direction() + { + var (clock, loc) = Deps(); + var state = Substitute.For(); + state.GetActiveMarkers(Guild, Server, MarkerKind.PatrolHelicopter).Returns( + [ + new ActiveMarker(1, MarkerKind.PatrolHelicopter, 4500f, 4500f, Dims4000, Now.AddMinutes(-5), + [new TrailPoint(4500f, 4500f)], null) + ]); + + var reply = await new HeliCommandHandler(state, loc, clock, Settings()).ExecuteAsync(Ctx(), + CancellationToken.None); + + Assert.Equal("Patrol Helicopter to the north-east (5m ago)", reply); + } + + [Fact] + public async Task Events_reports_a_crash_and_an_off_map_spawn() + { + var (_, loc) = Deps(); + var state = Substitute.For(); + state.GetRecentEvents(Guild, Server).Returns( + [ + new RustMapEvent(MapEventKind.HeliCrashed, 2000f, 2000f, Dims4000, Now), + new RustMapEvent(MapEventKind.CargoEntered, 4500f, 4500f, Dims4000, Now) + ]); + + var reply = await new EventsCommandHandler(state, loc, Settings()).ExecuteAsync(Ctx(), + CancellationToken.None); + + Assert.NotNull(reply); + Assert.Contains("heli crashed in", reply, StringComparison.Ordinal); + Assert.Contains("cargo from the north-east", reply, StringComparison.Ordinal); + } + + [Fact] + public async Task Events_off_map_departure_reports_a_direction() + { + var (_, loc) = Deps(); + var state = Substitute.For(); + state.GetRecentEvents(Guild, Server).Returns( + [ + new RustMapEvent(MapEventKind.CargoLeft, -500f, 100f, Dims4000, Now) + ]); + + var reply = await new EventsCommandHandler(state, loc, Settings()).ExecuteAsync(Ctx(), + CancellationToken.None); + + Assert.NotNull(reply); + Assert.Contains("cargo left to the south-west", reply, StringComparison.Ordinal); + } } diff --git a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs index 13498f5e..0d6d7dbb 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(386, EnglishKeys().Count); + Assert.Equal(395, EnglishKeys().Count); } } From 292d607ac8f57f7b1e0f97d84fe340d9bf62fc0d Mon Sep 17 00:00:00 2001 From: = Date: Mon, 10 Aug 2026 19:50:52 +0200 Subject: [PATCH 08/11] feat: show off-map directions in the #info events embed --- README.md | 2 +- .../Messages/ServerEventsMessageRenderer.cs | 3 ++- .../ServerEventsMessageRendererTests.cs | 21 +++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2999459d..ecb78e59 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Plus a guild-global `RustPlusBot` category with `#information`, `#setup`, and `# ### Live map events -- Per-server `#events` feed (and an in-game team-chat mirror) for **Cargo Ship**, **Patrol Helicopter**, and **Chinook (CH47)** entering/leaving, plus **small / large oil rig** activation, "crate lootable", and respawn — derived from polling the Rust+ map markers and monuments. +- Per-server `#events` feed (and an in-game team-chat mirror) for **Cargo Ship**, **Patrol Helicopter**, and **Chinook (CH47)** entering/leaving — a helicopter that disappears inside the map is reported as a probable crash with its grid cell, and markers outside the playable world are reported by compass direction rather than a map-edge grid cell — plus **small / large oil rig** activation, "crate lootable", and respawn, derived from polling the Rust+ map markers and monuments. ### Map rendering diff --git a/src/RustPlusBot.Features.Events/Messages/ServerEventsMessageRenderer.cs b/src/RustPlusBot.Features.Events/Messages/ServerEventsMessageRenderer.cs index d8b57f2d..70ee6cde 100644 --- a/src/RustPlusBot.Features.Events/Messages/ServerEventsMessageRenderer.cs +++ b/src/RustPlusBot.Features.Events/Messages/ServerEventsMessageRenderer.cs @@ -85,8 +85,9 @@ private string Marker(ulong guildId, Guid serverId, MarkerKind kind, MapGridStyl // Newest-first: the freshest sighting is the one worth reporting. var marker = active[0]; + var location = MapLocation.Describe(localizer, culture, marker.X, marker.Y, marker.Dimensions, style); return localizer.Get("server.events.out", culture, - GridReference.From(marker.X, marker.Y, marker.Dimensions, style), + location.Text, DurationFormat.Compact(clock.UtcNow - marker.SeenAtUtc)); } diff --git a/tests/RustPlusBot.Features.Events.Tests/Messages/ServerEventsMessageRendererTests.cs b/tests/RustPlusBot.Features.Events.Tests/Messages/ServerEventsMessageRendererTests.cs index 0b9924de..d4c98c56 100644 --- a/tests/RustPlusBot.Features.Events.Tests/Messages/ServerEventsMessageRendererTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/Messages/ServerEventsMessageRendererTests.cs @@ -132,4 +132,25 @@ public async Task Disconnected_says_so_instead_of_reporting_stale_state() // "Not out / Online" wall would read as a confident live report. Assert.Equal("Not connected to the server.", payload.Embed!.Description); } + + [Fact] + public async Task Off_map_marker_row_shows_a_direction() + { + var dims = new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u); + var events = Substitute.For(); + events.GetActiveMarkers(1, ServerId, Arg.Any()).Returns([]); + events.GetActiveMarkers(1, ServerId, MarkerKind.CargoShip).Returns( + [ + new ActiveMarker(1, MarkerKind.CargoShip, 4500f, 4500f, dims, Now.AddMinutes(-3), + [new TrailPoint(4500f, 4500f)], null) + ]); + var rigs = Substitute.For(); + rigs.Get(1, ServerId, Arg.Any()).Returns(new RigState(RigStatus.Online, null)); + + var payload = await Build(events, rigs).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + Assert.NotNull(payload.Embed); + var cargo = payload.Embed.Fields[0].Value; + Assert.Contains("north-east", cargo, StringComparison.Ordinal); + } } From 7b8b86102d8aad2684dc65a96889fc423376d578 Mon Sep 17 00:00:00 2001 From: = Date: Mon, 10 Aug 2026 20:06:13 +0200 Subject: [PATCH 09/11] fix: close the helicrashed key hole and border/classifier edge gaps Route MapEventKind.HeliCrashed in EventsCommandHandler through GridReference.From directly, mirroring EventEmbedRenderer.Locate, so a future out-of-bounds crash can never resolve the nonexistent "command.event.helicrashed.dir" key. Also fixes an accent inconsistency in the French heli entered/left strings, adds test coverage for the border band's east/south edges and the classifier's WorldSize == 0 guard, and documents the small-world edge case on MapGrid.IsAtOrBeyondBorder. Co-Authored-By: Claude Opus 5 (1M context) --- .../Connections/MapGrid.cs | 7 ++++++- .../Handlers/EventsCommandHandler.cs | 16 ++++++++++----- src/RustPlusBot.Localization/Strings.fr.resx | 4 ++-- .../Connections/MapGridTests.cs | 2 ++ .../Handlers/EventHandlersTests.cs | 20 +++++++++++++++++++ .../Classifying/MarkerEventClassifierTests.cs | 11 ++++++++++ 6 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/RustPlusBot.Abstractions/Connections/MapGrid.cs b/src/RustPlusBot.Abstractions/Connections/MapGrid.cs index 69b9aa26..03273fe9 100644 --- a/src/RustPlusBot.Abstractions/Connections/MapGrid.cs +++ b/src/RustPlusBot.Abstractions/Connections/MapGrid.cs @@ -91,7 +91,12 @@ public static bool IsOutsideWorld(float x, float y, uint worldSize) => /// World X (west→east). /// World Y (south→north). /// The world size in game units. - /// True when the coordinate is outside the world or within of an edge. + /// + /// True when the coordinate is outside the world or within of an edge. On a + /// world smaller than 2 * CellSize (292.5 units) the north/south and east/west bands overlap + /// and every position reports as border; Rust's minimum map size is 1000, so this is unreachable in + /// practice, and it fails safe — it can only misclassify a crash as a departure, never the reverse. + /// public static bool IsAtOrBeyondBorder(float x, float y, uint worldSize) => IsOutsideWorld(x, y, worldSize) || x < CellSize diff --git a/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs index 086526e8..db2790e3 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs @@ -34,11 +34,17 @@ internal sealed class EventsCommandHandler( .ConfigureAwait(false); var parts = events.Select(e => { - // Departures report the direction the marker headed; everything else prefers a grid cell - // and falls back to a direction only when the marker is outside the world. - var location = e.Kind is MapEventKind.CargoLeft or MapEventKind.HeliLeft - ? MapLocation.DescribeDirection(localizer, context.Culture, e.X, e.Y, e.Dimensions) - : MapLocation.Describe(localizer, context.Culture, e.X, e.Y, e.Dimensions, settings.GridStyle); + // Departures report the direction the marker headed; a crash always happened inside the + // map, so it reports a cell; everything else prefers a grid cell and falls back to a + // direction only when the marker is outside the world. + var location = e.Kind switch + { + MapEventKind.CargoLeft or MapEventKind.HeliLeft => + MapLocation.DescribeDirection(localizer, context.Culture, e.X, e.Y, e.Dimensions), + MapEventKind.HeliCrashed => + new MapLocationText(false, GridReference.From(e.X, e.Y, e.Dimensions, settings.GridStyle)), + _ => MapLocation.Describe(localizer, context.Culture, e.X, e.Y, e.Dimensions, settings.GridStyle), + }; var key = e.Kind switch { diff --git a/src/RustPlusBot.Localization/Strings.fr.resx b/src/RustPlusBot.Localization/Strings.fr.resx index b4954608..8979deea 100644 --- a/src/RustPlusBot.Localization/Strings.fr.resx +++ b/src/RustPlusBot.Localization/Strings.fr.resx @@ -322,13 +322,13 @@ héli abattu en {0} - heli en {0} + héli en {0} héli depuis {0} - heli parti de {0} + héli parti de {0} héli parti vers {0} diff --git a/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs b/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs index c7b8cf02..73c6da63 100644 --- a/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs +++ b/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs @@ -127,6 +127,8 @@ public void IsOutsideWorld_treats_the_exact_edges_as_inside(float x, float y, bo [InlineData(146.25f, 2000f, false)] // exactly one cell in from the west edge [InlineData(146f, 2000f, true)] // a hair inside the band [InlineData(2000f, 3854f, true)] // 4000 - 146.25 = 3853.75, so this is inside the north band + [InlineData(3854f, 2000f, true)] // 4000 - 146.25 = 3853.75, so this is inside the east band + [InlineData(2000f, 146f, true)] // a hair inside the south band [InlineData(-50f, 2000f, true)] // outside the world entirely public void IsAtOrBeyondBorder_covers_a_one_cell_band(float x, float y, bool expected) => Assert.Equal(expected, MapGrid.IsAtOrBeyondBorder(x, y, 4000u)); diff --git a/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs index d000af2f..253390d9 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs @@ -131,6 +131,26 @@ public async Task Events_reports_a_crash_and_an_off_map_spawn() Assert.Contains("cargo from the north-east", reply, StringComparison.Ordinal); } + [Fact] + public async Task Events_crash_off_map_reports_a_grid_cell_not_a_direction() + { + var (_, loc) = Deps(); + var state = Substitute.For(); + state.GetRecentEvents(Guild, Server).Returns( + [ + new RustMapEvent(MapEventKind.HeliCrashed, 4500f, 4500f, Dims4000, Now) + ]); + + var reply = await new EventsCommandHandler(state, loc, Settings()).ExecuteAsync(Ctx(), + CancellationToken.None); + + Assert.NotNull(reply); + // A crash always reports a grid cell, even when the coordinates are outside the world (should + // not be reachable from the classifier today, but the message key must still resolve). + Assert.Contains("heli crashed in", reply, StringComparison.Ordinal); + Assert.DoesNotContain("command.event.", reply, StringComparison.Ordinal); + } + [Fact] public async Task Events_off_map_departure_reports_a_direction() { diff --git a/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs b/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs index ee4397d9..ee3beabf 100644 --- a/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs @@ -126,4 +126,15 @@ public void Heli_removed_without_dimensions_is_HeliLeft() Assert.Equal(MapEventKind.HeliLeft, Assert.Single(Build().Classify(evt)).Kind); } + + [Fact] + public void Heli_removed_with_zero_world_size_is_HeliLeft() + { + // A zero world size is as unusable as no dimensions at all: keep the old behaviour, even at a + // position that would otherwise read as dead centre and classify as a crash. + var evt = new MapMarkersChangedEvent(1UL, Server, new MapDimensions(0u, 0u, 0, WorldSize: 0u), [], + [new MapMarkerSnapshot(2, MarkerKind.PatrolHelicopter, 2000f, 2000f, null)], []); + + Assert.Equal(MapEventKind.HeliLeft, Assert.Single(Build().Classify(evt)).Kind); + } } From 5d6f1dd887cfa773f965cda300ef1b333fa88635 Mon Sep 17 00:00:00 2001 From: = Date: Mon, 10 Aug 2026 20:12:27 +0200 Subject: [PATCH 10/11] docs: record why the border band loses no useful crash location The outer band of a Rust map is ocean, so a helicopter downed there leaves no lootable debris and its grid cell would name water. The band erring toward "left" costs nothing players can act on. Co-Authored-By: Claude Opus 5 (1M context) --- ...08-10-patrol-heli-crash-and-offmap-directions-design.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-08-10-patrol-heli-crash-and-offmap-directions-design.md b/docs/superpowers/specs/2026-08-10-patrol-heli-crash-and-offmap-directions-design.md index 2a9c669b..dacf01e2 100644 --- a/docs/superpowers/specs/2026-08-10-patrol-heli-crash-and-offmap-directions-design.md +++ b/docs/superpowers/specs/2026-08-10-patrol-heli-crash-and-offmap-directions-design.md @@ -247,8 +247,11 @@ for off-map positions and the plain keys otherwise. ## Risks - **Band width is a judgement call.** 146.25 units is one grid cell. A heli shot down while hugging - the map border is reported as having left. The opposite error — calling every routine departure a - crash — is both more likely and more annoying, so the band errs toward "left". + the map border is reported as having left, and the departure message shows only a direction, so + that report also omits the cell. This is not a practical loss: the outer band of a Rust map is + ocean, well outside the land mass, so a helicopter downed there leaves no lootable debris and the + cell would name water. The opposite error — calling every routine departure a crash — is both more + likely and more annoying, so the band errs toward "left". - **Message-key explosion.** ~29 new keys per language. The parity test catches omissions, and the exhaustive `switch` arms catch a missed `HeliCrashed` case, so both failure modes are loud. - **A `.dir` key that is never written is only caught at runtime.** `ILocalizer` resolves an unknown From 60767630456d3aad61c5b72c14d078f604fa0022 Mon Sep 17 00:00:00 2001 From: = Date: Mon, 10 Aug 2026 20:14:29 +0200 Subject: [PATCH 11/11] style: apply ReSharper formatting to the new tests Field declarations to the top of their class and one wrapped parameter list, per the pre-push formatter. Co-Authored-By: Claude Opus 5 (1M context) --- .../Connections/MapGridTests.cs | 4 +++- .../Handlers/EventHandlersTests.cs | 4 ++-- .../Rendering/EventEmbedRendererTests.cs | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs b/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs index 73c6da63..09ccd148 100644 --- a/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs +++ b/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs @@ -100,7 +100,9 @@ public void DirectionFrom_bins_the_bearing_from_the_world_centre(float x, float [InlineData(2410f, 3000f, MapDirection.North)] [InlineData(2420f, 3000f, MapDirection.NorthEast)] public void DirectionFrom_splits_sectors_half_way_between_compass_points( - float x, float y, MapDirection expected) => + float x, + float y, + MapDirection expected) => Assert.Equal(expected, MapGrid.DirectionFrom(x, y, 4000u)); [Fact] diff --git a/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs index 253390d9..b7baa040 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs @@ -16,6 +16,8 @@ public sealed class EventHandlersTests private static readonly Guid Server = Guid.NewGuid(); private static readonly DateTimeOffset Now = new(2026, 6, 17, 12, 5, 0, TimeSpan.Zero); + private static readonly MapDimensions Dims4000 = new(4000u, 4000u, 500, WorldSize: 4000u); + private static (IClock Clock, ILocalizer Loc) Deps() { var clock = Substitute.For(); @@ -93,8 +95,6 @@ public async Task Handlers_expose_expected_names() Assert.Equal("events", new EventsCommandHandler(state, loc, Settings()).Name); } - private static readonly MapDimensions Dims4000 = new(4000u, 4000u, 500, WorldSize: 4000u); - [Fact] public async Task Heli_off_the_map_reports_a_direction() { diff --git a/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs b/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs index e9a3a78c..7421bda8 100644 --- a/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs @@ -9,6 +9,8 @@ public sealed class EventEmbedRendererTests { private static readonly DateTimeOffset Now = new(2026, 6, 17, 12, 0, 0, TimeSpan.Zero); + private static readonly MapDimensions Dims4000 = new(4000u, 4000u, 500, WorldSize: 4000u); + private static EventEmbedRenderer Build() => new(new ResxLocalizer()); @@ -36,8 +38,6 @@ public void Null_dimensions_render_raw_coordinates() Assert.Contains("(1234, 5678)", embed.Description, StringComparison.Ordinal); } - private static readonly MapDimensions Dims4000 = new(4000u, 4000u, 500, WorldSize: 4000u); - [Fact] public void Heli_crashed_renders_a_grid_cell() {