From ec22348ebc9c3c083bcd11ab146d5fdb13ca785e Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 25 Sep 2026 12:28:07 +0200 Subject: [PATCH] Radar: nearby_objects every second and dungeon_map per dungeon landblock start_radar/stop_radar from the backend drive it, as before. nearby_objects keeps the original's shape: map coordinates on the surface, landblock-local raw positions with zero map coordinates in a dungeon, ids as signed ints, the same eight object classes, no wielded items or self. dungeon_map is built the original's way from the landblock's cells (x a multiple of 10, whole metres, 6-metre floors, rotation = the orientation's stored W) and sent once per landblock per session; the cells come through a small reader that the plugin API's dungeon-cell data will fill (until then the map is not sent). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/OpenAC.MosswartMassacre/FeatureCatalog.cs | 11 + .../Radar/DungeonMapBuilder.cs | 83 ++++++++ .../Radar/RadarStream.cs | 200 ++++++++++++++++++ .../Fakes/FakeAutomation.cs | 3 + .../Fakes/FakeNetwork.cs | 9 + .../Radar/RadarTests.cs | 119 +++++++++++ 6 files changed, 425 insertions(+) create mode 100644 src/OpenAC.MosswartMassacre/Radar/DungeonMapBuilder.cs create mode 100644 src/OpenAC.MosswartMassacre/Radar/RadarStream.cs create mode 100644 tests/OpenAC.MosswartMassacre.Tests/Radar/RadarTests.cs diff --git a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs index 6a6fd9c..35a62fa 100644 --- a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs +++ b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs @@ -36,5 +36,16 @@ internal static class FeatureCatalog session.AddConnectionListener(sharing); session.AddShareHandler(sharing); features.Add(new Sharing.VitalSharingOverlay(context, sharing)); + + // The radar the backend turns on and off. + var radar = new Radar.RadarStream(context, cells: null); + features.Add(radar); + session.RadarToggle = on => + { + if (on) + radar.Start(); + else + radar.Stop(); + }; } } diff --git a/src/OpenAC.MosswartMassacre/Radar/DungeonMapBuilder.cs b/src/OpenAC.MosswartMassacre/Radar/DungeonMapBuilder.cs new file mode 100644 index 0000000..0b31ba5 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Radar/DungeonMapBuilder.cs @@ -0,0 +1,83 @@ +using System.Globalization; + +namespace OpenAC.MosswartMassacre.Radar; + +/// +/// One indoor cell of a landblock as the game data stores it: the low half of +/// its environment id, its origin in the landblock (metres, as stored), and +/// the first stored component of its orientation, which the original sent as +/// the cell's rotation. +/// +internal readonly record struct DungeonCellRecord(ushort EnvironmentId, float X, float Y, float Z, float RotationW); + +/// Reads a landblock's indoor cells from the game data. +internal interface IDungeonCellSource +{ + /// + /// The landblock's indoor cells in cell order, or null when the host cannot + /// read them (the frame is then not sent). + /// + IReadOnlyList? ReadCells(uint landblock); +} + +/// +/// Builds the dungeon_map frame exactly as the original did from the +/// same cells: cells whose x is not a multiple of 10 dropped, coordinates +/// rounded to whole metres, grouped into 6-metre floors. +/// +internal static class DungeonMapBuilder +{ + public static object? Build(uint landblock, string characterName, IReadOnlyList records) + { + var cells = new List(); + int minX = int.MaxValue, maxX = int.MinValue; + int minY = int.MaxValue, maxY = int.MinValue; + foreach (DungeonCellRecord record in records) + { + int x = (int)Math.Round(record.X); + int y = (int)Math.Round(record.Y); + int z = (int)Math.Round(record.Z); + if (x % 10 != 0) + continue; + cells.Add(new CellData + { + env_id = record.EnvironmentId, + x = x, + y = y, + z = z, + rotation = record.RotationW, + }); + minX = Math.Min(minX, x); + maxX = Math.Max(maxX, x); + minY = Math.Min(minY, y); + maxY = Math.Max(maxY, y); + } + if (cells.Count == 0) + return null; + + var zLevels = cells + .GroupBy(cell => (int)(Math.Floor((cell.z + 3.0) / 6.0) * 6.0)) + .Select(group => new { z = group.Key, cells = group.ToList() }) + .OrderBy(group => group.z) + .ToList(); + + return new + { + type = "dungeon_map", + character_name = characterName, + landblock = string.Create(CultureInfo.InvariantCulture, $"0x{landblock:X8}"), + bounds = new { min_x = minX, max_x = maxX, min_y = minY, max_y = maxY }, + z_levels = zLevels, + }; + } + + // Public fields, in this order, are the wire shape the original sent. +#pragma warning disable CA1051, IDE1006, SA1307 + private sealed class CellData + { + public ushort env_id; + public int x, y, z; + public float rotation; + } +#pragma warning restore CA1051, IDE1006, SA1307 +} diff --git a/src/OpenAC.MosswartMassacre/Radar/RadarStream.cs b/src/OpenAC.MosswartMassacre/Radar/RadarStream.cs new file mode 100644 index 0000000..dc4f00e --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Radar/RadarStream.cs @@ -0,0 +1,200 @@ +using System.Globalization; +using AcDream.Plugin.Abstractions; +using OpenAC.MosswartMassacre.Backend; +using OpenAC.MosswartMassacre.Infrastructure; + +namespace OpenAC.MosswartMassacre.Radar; + +/// +/// The radar the backend turns on with start_radar: every second a +/// nearby_objects frame with the player and the monsters, players, +/// NPCs, vendors, portals, corpses, containers and doors around, and on +/// entering a dungeon landblock its dungeon_map, once per landblock +/// per session. +/// +internal sealed class RadarStream : IMmFeature +{ + public static readonly TimeSpan Interval = TimeSpan.FromSeconds(1); + + private readonly MmContext _context; + private readonly IDungeonCellSource? _cells; + private readonly HashSet _mapsSent = []; + private IDisposable? _timer; + private uint _lastLandblock; + private bool _inDungeon; + + public RadarStream(MmContext context, IDungeonCellSource? cells) + { + _context = context; + _cells = cells; + } + + public bool IsActive => _timer is not null; + + public void Start() + { + if (IsActive) + return; + _lastLandblock = 0; + _inDungeon = false; + _timer = _context.Scheduler.Every(Interval, OnTick); + _context.Verbose("[Radar] Started nearby objects tracker"); + } + + public void Stop() + { + if (!IsActive) + return; + _timer?.Dispose(); + _timer = null; + _context.Verbose("[Radar] Stopped nearby objects tracker"); + } + + public void OnLogin() + { + } + + public void OnLogoff() => Stop(); + + public void Dispose() => Stop(); + + internal void OnTick() + { + try + { + CheckDungeonTransition(); + if (BuildNearbyObjects() is { } payload) + _context.Send(payload); + } + catch (Exception ex) + { + _context.Verbose($"[Radar] Tick error: {ex.Message}"); + } + } + + private void CheckDungeonTransition() + { + uint landcell = _context.Automation.Navigation.Snapshot.Position.CellId; + uint landblock = landcell & 0xFFFF0000; + if (landblock == _lastLandblock) + return; + _lastLandblock = landblock; + _inDungeon = IsDungeon(landcell); + if (!_inDungeon || _cells is null || !_mapsSent.Add(landblock)) + return; + + IReadOnlyList? records = _cells.ReadCells(landblock); + if (records is null) + return; + if (DungeonMapBuilder.Build(landblock, _context.CharacterName, records) is { } map) + { + string json = Wire.Serialize(map); + _context.Verbose($"[Radar] Sending dungeon_map ({json.Length} bytes) for 0x{landblock:X8}"); + _context.SendRaw(json); + } + } + + /// The original's test: an indoor cell (index 0x100 and up) that does not see outside. + private bool IsDungeon(uint landcell) => + (landcell & 0x0000FFFF) >= 0x0100 && _context.Automation.DungeonMap.IsSealedDungeon(landcell); + + internal object? BuildNearbyObjects() + { + if (!_context.IsLoggedIn) + return null; + uint playerId = _context.Automation.Character.ObjectId; + PluginNavigationPosition here = _context.Automation.Navigation.Snapshot.Position; + MmCoordinates player = MmCoordinates.From(here); + Local playerRaw = Local.Of(here); + uint landblock = here.CellId & 0xFFFF0000; + + var objects = new List(); + foreach (PluginWorldObject wo in _context.Automation.Objects.CaptureObjects()) + { + if (wo.ObjectId == playerId || !wo.IsLandscape || !wo.HasPosition || wo.WielderObjectId != 0) + continue; + string? objectClass = Classify(wo.ObjectClass); + if (objectClass is null) + continue; + + if (_inDungeon) + { + Local raw = Local.Of(wo.Position); + if (raw.X == 0 && raw.Y == 0 && raw.Z == 0) + continue; + objects.Add(new + { + id = unchecked((int)wo.ObjectId), + name = wo.Name, + object_class = objectClass, + ew = 0.0, + ns = 0.0, + z = 0.0, + raw_x = Math.Round(raw.X, 2), + raw_y = Math.Round(raw.Y, 2), + raw_z = Math.Round(raw.Z, 2), + }); + } + else + { + double ew = wo.Position.EastWest; + double ns = wo.Position.NorthSouth; + if (ew == 0 && ns == 0) + continue; + objects.Add(new + { + id = unchecked((int)wo.ObjectId), + name = wo.Name, + object_class = objectClass, + ew = Math.Round(ew, 7), + ns = Math.Round(ns, 7), + z = Math.Round(Local.Of(wo.Position).Z, 2), + }); + } + } + + return new + { + type = "nearby_objects", + character_name = _context.CharacterName, + timestamp = Wire.Timestamp(_context.Clock), + player_ew = Math.Round(player.EW, 7), + player_ns = Math.Round(player.NS, 7), + player_z = Math.Round(player.Z, 2), + player_heading = Math.Round((double)here.HeadingDegrees, 1), + is_dungeon = _inDungeon, + landblock = string.Create(CultureInfo.InvariantCulture, $"0x{landblock:X8}"), + player_x = Math.Round(playerRaw.X, 2), + player_y = Math.Round(playerRaw.Y, 2), + player_raw_z = Math.Round(playerRaw.Z, 2), + objects, + }; + } + + private static string? Classify(PluginObjectClass objectClass) => objectClass switch + { + PluginObjectClass.Monster => "Monster", + PluginObjectClass.Player => "Player", + PluginObjectClass.Npc => "NPC", + PluginObjectClass.Vendor => "Vendor", + PluginObjectClass.Portal => "Portal", + PluginObjectClass.Corpse => "Corpse", + PluginObjectClass.Container => "Container", + PluginObjectClass.Door => "Door", + _ => null, + }; + + /// A position in its landblock's own frame, as the floats the original read from the physics object. + private readonly record struct Local(double X, double Y, double Z) + { + public static Local Of(in PluginNavigationPosition position) + { + uint blockX = (position.CellId >> 24) & 0xFFu; + uint blockY = (position.CellId >> 16) & 0xFFu; + float x = (float)((position.EastWest * 240d) + 84d - (((double)blockX - 127d) * 192d)); + float y = (float)((position.NorthSouth * 240d) + 84d - (((double)blockY - 127d) * 192d)); + float z = (float)(position.Elevation * 240d); + return new Local(x, y, z); + } + } +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs index be81fc6..bfe98d1 100644 --- a/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs +++ b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs @@ -42,6 +42,8 @@ public sealed class FakeAutomation : IAutomationSurface public FakeNetwork Network { get; } = new(); + public FakeDungeonMap DungeonMap { get; } = new(); + ICharacterInfo IAutomationSurface.Character => Character; ISpellCatalog IAutomationSurface.Spells => Spells; IMagicCommands IAutomationSurface.Magic => Magic; @@ -58,6 +60,7 @@ public sealed class FakeAutomation : IAutomationSurface IVendorAutomation IAutomationSurface.Vendor => Vendor; ILoginAutomation IAutomationSurface.Login => Login; INetworkAutomation IAutomationSurface.Network => Network; + IDungeonMapAutomation IAutomationSurface.DungeonMap => DungeonMap; } /// A settable : a test drives and inspects . diff --git a/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeNetwork.cs b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeNetwork.cs index e910dee..0ff33ee 100644 --- a/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeNetwork.cs +++ b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeNetwork.cs @@ -43,3 +43,12 @@ public sealed class FakeNetwork : INetworkAutomation return AcceptImports; } } + +/// A dungeon-map surface whose sealed landblocks a test names. +public sealed class FakeDungeonMap : IDungeonMapAutomation +{ + public HashSet SealedLandblocks { get; } = []; + + public bool IsSealedDungeon(uint cellId) => + (cellId & 0xFFFFu) >= 0x100u && SealedLandblocks.Contains(cellId & 0xFFFF0000u); +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Radar/RadarTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Radar/RadarTests.cs new file mode 100644 index 0000000..dda6d6f --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Radar/RadarTests.cs @@ -0,0 +1,119 @@ +using System.Globalization; +using AcDream.Plugin.Abstractions; +using OpenAC.MosswartMassacre.Backend; +using OpenAC.MosswartMassacre.Radar; +using OpenAC.MosswartMassacre.Tests.Streams; + +namespace OpenAC.MosswartMassacre.Tests.Radar; + +/// The nearby_objects and dungeon_map frames as the original built them. +public sealed class RadarTests +{ + private const string Stamp = "2026-09-25T08:30:15.1230000Z"; + + private static string D(double value) => value.ToString("R", CultureInfo.InvariantCulture); + + private static PluginWorldObject Landscape(uint id, string name, PluginObjectClass cls, PluginNavigationPosition position, uint wielder = 0) => + new(id, 1u, name, cls, 0u, 0u, wielder) { IsLandscape = true, HasPosition = true, Position = position }; + + [Fact] + public void Nearby_objects_on_the_surface() + { + // NearbyObjectsTracker.cs:124-231: objects with ew/ns to 7 places and + // z to 2; the player's own position both as map coordinates and as + // landblock-local metres; ids as signed ints; only the eight classes; + // no wielded items, no self. + using var harness = new PluginHarness("Mossy"); + harness.Host.Automation.Navigation.Snapshot = new PluginNavigationSnapshot( + true, false, 1u, StreamGoldenTests.Position(), false, false); + harness.Host.Automation.Objects.Objects.AddRange( + [ + Landscape(0x80001234, "Drudge Skulker", PluginObjectClass.Monster, StreamGoldenTests.Position(x: 108f, y: 120f, z: 44.25f)), + Landscape(0x80001235, "Sword", PluginObjectClass.MeleeWeapon, StreamGoldenTests.Position()), + Landscape(0x80001236, "Held", PluginObjectClass.Monster, StreamGoldenTests.Position(), wielder: 5), + Landscape(0x50000001, "Mossy", PluginObjectClass.Player, StreamGoldenTests.Position()), + ]); + harness.Login(); + + string json = Wire.Serialize(harness.Feature().BuildNearbyObjects()); + + double monsterEw = StreamGoldenTests.Position(x: 108f).EastWest; + double monsterNs = StreamGoldenTests.Position(y: 120f).NorthSouth; + Assert.Equal( + "{\"type\":\"nearby_objects\",\"character_name\":\"Mossy\",\"timestamp\":\"" + Stamp + "\"," + + "\"player_ew\":" + D(Math.Round((double)33.65f, 7)) + ",\"player_ns\":" + D(Math.Round((double)42.55f, 7)) + + ",\"player_z\":42.5,\"player_heading\":90.0,\"is_dungeon\":false,\"landblock\":\"0xA9B40000\"," + + "\"player_x\":96.0,\"player_y\":120.0,\"player_raw_z\":42.5," + + "\"objects\":[{\"id\":-2147478988,\"name\":\"Drudge Skulker\",\"object_class\":\"Monster\"," + + "\"ew\":" + D(Math.Round(monsterEw, 7)) + ",\"ns\":" + D(Math.Round(monsterNs, 7)) + ",\"z\":44.25}]}", + json); + } + + [Fact] + public void In_a_dungeon_objects_carry_raw_positions_and_the_map_goes_out_once() + { + const uint cell = 0x01D90105u; + var here = StreamGoldenTests.Position(x: 30f, y: 40f, z: -12f, cell: cell); + var cells = new FakeCells( + [ + new DungeonCellRecord(0x0123, 10.2f, 20f, 0.4f, 1f), + new DungeonCellRecord(0x0456, 15f, 20f, 0f, 0.7071f), + new DungeonCellRecord(0x0789, 30f, -10f, -6.4f, 0f), + ]); + using var harness = new PluginHarness("Mossy"); + harness.Host.Automation.Navigation.Snapshot = new PluginNavigationSnapshot(true, false, 1u, here, false, false); + harness.Host.Automation.DungeonMap.SealedLandblocks.Add(0x01D90000u); + harness.Host.Automation.Objects.Objects.Add( + Landscape(0x80000001, "Lich", PluginObjectClass.Monster, StreamGoldenTests.Position(x: 31.5f, y: 41.25f, z: -12f, cell: cell))); + harness.Login(); + var radar = new RadarStream(harness.Context, cells) ; + radar.Start(); + + radar.OnTick(); + radar.OnTick(); + + Assert.Equal( + "{\"type\":\"dungeon_map\",\"character_name\":\"Mossy\",\"landblock\":\"0x01D90000\"," + + "\"bounds\":{\"min_x\":10,\"max_x\":30,\"min_y\":-10,\"max_y\":20}," + + "\"z_levels\":[{\"z\":-6,\"cells\":[{\"env_id\":1929,\"x\":30,\"y\":-10,\"z\":-6,\"rotation\":0.0}]}," + + "{\"z\":0,\"cells\":[{\"env_id\":291,\"x\":10,\"y\":20,\"z\":0,\"rotation\":1.0}]}]}", + Assert.Single(harness.Frames, f => f.Contains("dungeon_map", StringComparison.Ordinal))); + string objects = harness.Frames.First(f => f.Contains("nearby_objects", StringComparison.Ordinal)); + Assert.Contains("\"is_dungeon\":true", objects, StringComparison.Ordinal); + Assert.Contains( + "{\"id\":-2147483647,\"name\":\"Lich\",\"object_class\":\"Monster\",\"ew\":0.0,\"ns\":0.0,\"z\":0.0," + + "\"raw_x\":31.5,\"raw_y\":41.25,\"raw_z\":-12.0}", + objects, + StringComparison.Ordinal); + } + + [Fact] + public void The_backend_turns_the_radar_on_and_off() + { + using var harness = new PluginHarness("Mossy"); + harness.Login(); + var radar = harness.Feature(); + + harness.Receive("{\"player_name\":\"Mossy\",\"command\":\"start_radar\"}"); + harness.Tick(0.01); + Assert.True(radar.IsActive); + + harness.Run(2.05); + Assert.Equal(2, harness.FramesOfType("nearby_objects").Count()); + + harness.Receive("{\"player_name\":\"Mossy\",\"command\":\"stop_radar\"}"); + harness.Tick(0.01); + Assert.False(radar.IsActive); + } + + private sealed class FakeCells(IReadOnlyList cells) : IDungeonCellSource + { + public int Reads { get; private set; } + + public IReadOnlyList? ReadCells(uint landblock) + { + Reads++; + return cells; + } + } +}