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/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.
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..dacf01e2
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-10-patrol-heli-crash-and-offmap-directions-design.md
@@ -0,0 +1,260 @@
+# 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, 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
+ 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.
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..03273fe9 100644
--- a/src/RustPlusBot.Abstractions/Connections/MapGrid.cs
+++ b/src/RustPlusBot.Abstractions/Connections/MapGrid.cs
@@ -74,4 +74,58 @@ 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. 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
+ || 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/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs
index b79904e0..db2790e3 100644
--- a/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs
+++ b/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs
@@ -34,17 +34,31 @@ 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; 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
{
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.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/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.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/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 75ff46e8..8979deea 100644
--- a/src/RustPlusBot.Localization/Strings.fr.resx
+++ b/src/RustPlusBot.Localization/Strings.fr.resx
@@ -288,26 +288,50 @@
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 en {0}
+
+
+ héli depuis {0}
- heli parti de {0}
+ héli 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}.
@@ -471,36 +498,96 @@
{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}
+
+ 🚢 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 a6f4182e..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}.
@@ -471,36 +498,96 @@
{0}
+
+ east
+
+
+ north
+
+
+ north-east
+
+
+ north-west
+
+
+ south
+
+
+ south-east
+
+
+ south-west
+
+
+ west
+
🚢 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.Abstractions.Tests/Connections/MapGridTests.cs b/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs
index 952867cc..09ccd148 100644
--- a/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs
+++ b/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs
@@ -81,4 +81,57 @@ 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(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 63f696ad..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();
@@ -92,4 +94,77 @@ 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);
}
+
+ [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_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()
+ {
+ 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.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs b/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs
index 153de6a1..ee3beabf 100644
--- a/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs
+++ b/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs
@@ -87,4 +87,54 @@ 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);
+ }
+
+ [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);
+ }
}
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.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);
+ }
}
diff --git a/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs b/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs
index 5c4c41aa..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());
@@ -35,4 +37,66 @@ 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);
}
+
+ [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 d84f322f..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(366, EnglishKeys().Count);
+ Assert.Equal(395, EnglishKeys().Count);
}
}