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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-25 12:28:07 +02:00
parent 0efef6170c
commit ec22348ebc
6 changed files with 425 additions and 0 deletions

View file

@ -36,5 +36,16 @@ internal static class FeatureCatalog
session.AddConnectionListener(sharing); session.AddConnectionListener(sharing);
session.AddShareHandler(sharing); session.AddShareHandler(sharing);
features.Add(new Sharing.VitalSharingOverlay(context, 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();
};
} }
} }

View file

@ -0,0 +1,83 @@
using System.Globalization;
namespace OpenAC.MosswartMassacre.Radar;
/// <summary>
/// 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 <c>rotation</c>.
/// </summary>
internal readonly record struct DungeonCellRecord(ushort EnvironmentId, float X, float Y, float Z, float RotationW);
/// <summary>Reads a landblock's indoor cells from the game data.</summary>
internal interface IDungeonCellSource
{
/// <summary>
/// The landblock's indoor cells in cell order, or null when the host cannot
/// read them (the frame is then not sent).
/// </summary>
IReadOnlyList<DungeonCellRecord>? ReadCells(uint landblock);
}
/// <summary>
/// Builds the <c>dungeon_map</c> 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.
/// </summary>
internal static class DungeonMapBuilder
{
public static object? Build(uint landblock, string characterName, IReadOnlyList<DungeonCellRecord> records)
{
var cells = new List<CellData>();
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
}

View file

@ -0,0 +1,200 @@
using System.Globalization;
using AcDream.Plugin.Abstractions;
using OpenAC.MosswartMassacre.Backend;
using OpenAC.MosswartMassacre.Infrastructure;
namespace OpenAC.MosswartMassacre.Radar;
/// <summary>
/// The radar the backend turns on with <c>start_radar</c>: every second a
/// <c>nearby_objects</c> frame with the player and the monsters, players,
/// NPCs, vendors, portals, corpses, containers and doors around, and on
/// entering a dungeon landblock its <c>dungeon_map</c>, once per landblock
/// per session.
/// </summary>
internal sealed class RadarStream : IMmFeature
{
public static readonly TimeSpan Interval = TimeSpan.FromSeconds(1);
private readonly MmContext _context;
private readonly IDungeonCellSource? _cells;
private readonly HashSet<uint> _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<DungeonCellRecord>? 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);
}
}
/// <summary>The original's test: an indoor cell (index 0x100 and up) that does not see outside.</summary>
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<object>();
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,
};
/// <summary>A position in its landblock's own frame, as the floats the original read from the physics object.</summary>
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);
}
}
}

View file

@ -42,6 +42,8 @@ public sealed class FakeAutomation : IAutomationSurface
public FakeNetwork Network { get; } = new(); public FakeNetwork Network { get; } = new();
public FakeDungeonMap DungeonMap { get; } = new();
ICharacterInfo IAutomationSurface.Character => Character; ICharacterInfo IAutomationSurface.Character => Character;
ISpellCatalog IAutomationSurface.Spells => Spells; ISpellCatalog IAutomationSurface.Spells => Spells;
IMagicCommands IAutomationSurface.Magic => Magic; IMagicCommands IAutomationSurface.Magic => Magic;
@ -58,6 +60,7 @@ public sealed class FakeAutomation : IAutomationSurface
IVendorAutomation IAutomationSurface.Vendor => Vendor; IVendorAutomation IAutomationSurface.Vendor => Vendor;
ILoginAutomation IAutomationSurface.Login => Login; ILoginAutomation IAutomationSurface.Login => Login;
INetworkAutomation IAutomationSurface.Network => Network; INetworkAutomation IAutomationSurface.Network => Network;
IDungeonMapAutomation IAutomationSurface.DungeonMap => DungeonMap;
} }
/// <summary>A settable <see cref="ITradeAutomation"/>: a test drives <see cref="Raise*"/> and inspects <see cref="Calls"/>.</summary> /// <summary>A settable <see cref="ITradeAutomation"/>: a test drives <see cref="Raise*"/> and inspects <see cref="Calls"/>.</summary>

View file

@ -43,3 +43,12 @@ public sealed class FakeNetwork : INetworkAutomation
return AcceptImports; return AcceptImports;
} }
} }
/// <summary>A dungeon-map surface whose sealed landblocks a test names.</summary>
public sealed class FakeDungeonMap : IDungeonMapAutomation
{
public HashSet<uint> SealedLandblocks { get; } = [];
public bool IsSealedDungeon(uint cellId) =>
(cellId & 0xFFFFu) >= 0x100u && SealedLandblocks.Contains(cellId & 0xFFFF0000u);
}

View file

@ -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;
/// <summary>The nearby_objects and dungeon_map frames as the original built them.</summary>
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<RadarStream>().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<RadarStream>();
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<DungeonCellRecord> cells) : IDungeonCellSource
{
public int Reads { get; private set; }
public IReadOnlyList<DungeonCellRecord>? ReadCells(uint landblock)
{
Reads++;
return cells;
}
}
}