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:
parent
0efef6170c
commit
ec22348ebc
6 changed files with 425 additions and 0 deletions
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
83
src/OpenAC.MosswartMassacre/Radar/DungeonMapBuilder.cs
Normal file
83
src/OpenAC.MosswartMassacre/Radar/DungeonMapBuilder.cs
Normal 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
|
||||
}
|
||||
200
src/OpenAC.MosswartMassacre/Radar/RadarStream.cs
Normal file
200
src/OpenAC.MosswartMassacre/Radar/RadarStream.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>A settable <see cref="ITradeAutomation"/>: a test drives <see cref="Raise*"/> and inspects <see cref="Calls"/>.</summary>
|
||||
|
|
|
|||
|
|
@ -43,3 +43,12 @@ public sealed class FakeNetwork : INetworkAutomation
|
|||
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);
|
||||
}
|
||||
|
|
|
|||
119
tests/OpenAC.MosswartMassacre.Tests/Radar/RadarTests.cs
Normal file
119
tests/OpenAC.MosswartMassacre.Tests/Radar/RadarTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue