Resolved FeatureCatalog (both blocks kept; the main window's Tools buttons now open the Mossy Tracker and Metas panels). Tests from both sides adjusted to the combined plugin: quest tests look at quest frames only (chat is streamed too), and the stream tests ignore the /myquests request sent at login. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
331 lines
15 KiB
C#
331 lines
15 KiB
C#
using System.Globalization;
|
|
using AcDream.Plugin.Abstractions;
|
|
using OpenAC.MosswartMassacre.Streams;
|
|
using OpenAC.MosswartMassacre.Trackers;
|
|
|
|
namespace OpenAC.MosswartMassacre.Tests.Streams;
|
|
|
|
/// <summary>
|
|
/// The exact frames the original plugin sent, one per type in this slice. Each
|
|
/// expected string is derived by hand from the original source (file and lines
|
|
/// cited per test); field order, JSON types and number formats are the
|
|
/// contract with the backend.
|
|
/// </summary>
|
|
public sealed class StreamGoldenTests
|
|
{
|
|
// Clock of the harness: UTC 2026-09-25 08:30:15.123, local 10:30:15.123.
|
|
private const string Stamp = "2026-09-25T08:30:15.1230000Z";
|
|
|
|
/// <summary>
|
|
/// A position in landblock 0xA9B4 whose local floats are exact binary
|
|
/// fractions, so the original's float arithmetic is easy to follow:
|
|
/// x = 96 -> EW = ((96/24) + 169*8 - 1019.5) / 10 = 33.65 as a float;
|
|
/// y = 120 -> NS = ((120/24) + 180*8 - 1019.5) / 10 = 42.55 as a float;
|
|
/// z = 42.5.
|
|
/// </summary>
|
|
internal static PluginNavigationPosition Position(float x = 96f, float y = 120f, float z = 42.5f, uint cell = 0xA9B40019u)
|
|
{
|
|
uint bx = (cell >> 24) & 0xFFu;
|
|
uint by = (cell >> 16) & 0xFFu;
|
|
return new PluginNavigationPosition(
|
|
cell,
|
|
(((double)bx - 127d) * 192d + x - 84d) / 240d,
|
|
(((double)by - 127d) * 192d + y - 84d) / 240d,
|
|
z / 240d,
|
|
90f,
|
|
true);
|
|
}
|
|
|
|
private static string D(float value) => ((double)value).ToString("R", CultureInfo.InvariantCulture);
|
|
|
|
private static PluginHarness LoggedIn(Action<PluginHarness>? arrange = null)
|
|
{
|
|
var harness = new PluginHarness("Mossy");
|
|
harness.Host.Automation.Navigation.Snapshot = new PluginNavigationSnapshot(true, false, 1u, Position(), false, false);
|
|
arrange?.Invoke(harness);
|
|
harness.Login();
|
|
harness.Frames.Clear();
|
|
// Login itself submits /myquests; these tests count only their own commands.
|
|
harness.Host.Automation.Chat.Submitted.Clear();
|
|
return harness;
|
|
}
|
|
|
|
[Fact]
|
|
public void Coordinates_follow_the_originals_float_arithmetic()
|
|
{
|
|
var coords = OpenAC.MosswartMassacre.Infrastructure.MmCoordinates.From(Position());
|
|
|
|
Assert.Equal((double)33.65f, coords.EW);
|
|
Assert.Equal((double)42.55f, coords.NS);
|
|
Assert.Equal(42.5, coords.Z);
|
|
// A float widened to double, not the double 33.65.
|
|
Assert.NotEqual(33.65, coords.EW);
|
|
}
|
|
|
|
[Fact]
|
|
public void Coordinates_recover_the_float_local_position_for_awkward_values()
|
|
{
|
|
// 97.123f is not an exact binary fraction; the original computed from
|
|
// exactly this float, and so must the port.
|
|
var coords = OpenAC.MosswartMassacre.Infrastructure.MmCoordinates.From(Position(x: 97.123f, y: 3.3f));
|
|
|
|
Assert.Equal((double)OpenAC.MosswartMassacre.Infrastructure.MmCoordinates.LandblockToEW(0xA9B40019u, 97.123f), coords.EW);
|
|
Assert.Equal((double)OpenAC.MosswartMassacre.Infrastructure.MmCoordinates.LandblockToNS(0xA9B40019u, 3.3f), coords.NS);
|
|
}
|
|
|
|
[Fact]
|
|
public void Vitals_frame()
|
|
{
|
|
// PluginCore.cs:1062-1106 (SendVitalsUpdate): anonymous type in this
|
|
// order; percentages Math.Round(.., 1) as double (0 -> 0.0 by the
|
|
// ternary's double type); vitae an int.
|
|
using var harness = LoggedIn(h =>
|
|
{
|
|
var c = h.Host.Automation.Character;
|
|
c.CurrentHealth = 150; c.MaxHealth = 200;
|
|
c.CurrentStamina = 0; c.MaxStamina = 0;
|
|
c.CurrentMana = 333; c.MaxMana = 999;
|
|
});
|
|
|
|
harness.Feature<VitalsStream>().SendNow();
|
|
|
|
Assert.Equal(
|
|
"{\"type\":\"vitals\",\"timestamp\":\"" + Stamp + "\",\"character_name\":\"Mossy\","
|
|
+ "\"health_current\":150,\"health_max\":200,\"health_percentage\":75.0,"
|
|
+ "\"stamina_current\":0,\"stamina_max\":0,\"stamina_percentage\":0.0,"
|
|
+ "\"mana_current\":333,\"mana_max\":999,\"mana_percentage\":33.3,"
|
|
+ "\"vitae\":0}",
|
|
Assert.Single(harness.Frames));
|
|
}
|
|
|
|
[Fact]
|
|
public void Vitals_are_sent_every_five_seconds()
|
|
{
|
|
using var harness = LoggedIn();
|
|
|
|
harness.Run(10.05);
|
|
|
|
Assert.Equal(2, harness.FramesOfType("vitals").Count());
|
|
}
|
|
|
|
[Fact]
|
|
public void Telemetry_frame()
|
|
{
|
|
// WebSocket.cs:476-507 (BuildPayloadJson): ew/ns/z doubles from
|
|
// Coordinates.Me (float values); kills an int; kills_per_hour F0
|
|
// string; onlinetime dd.hh:mm:ss; deaths/total_deaths/taper strings;
|
|
// vt_state string; mem_mb a long (bytes); cpu_pct a float;
|
|
// mem_handles an int.
|
|
using var harness = LoggedIn(h =>
|
|
h.Host.Automation.Objects.Properties[0x50000001] = new PluginItemProperties(
|
|
new Dictionary<uint, int> { [43] = 7 },
|
|
new Dictionary<uint, long>(),
|
|
new Dictionary<uint, bool>(),
|
|
new Dictionary<uint, double>(),
|
|
new Dictionary<uint, string>(),
|
|
new Dictionary<uint, uint>(),
|
|
new Dictionary<uint, uint>()));
|
|
harness.Context.PrismaticTaperCount = () => 1234;
|
|
harness.Context.MetaState = () => "Combat";
|
|
harness.Context.Settings.Current.CharTag = "tank";
|
|
harness.Clock.Advance(TimeSpan.FromMinutes(90));
|
|
|
|
string json = Newtonsoft.Json.JsonConvert.SerializeObject(harness.Feature<TelemetryStream>().Build());
|
|
var frame = Newtonsoft.Json.Linq.JObject.Parse(json);
|
|
|
|
string[] expectedOrder =
|
|
[
|
|
"type", "character_name", "char_tag", "session_id", "timestamp", "ew", "ns", "z", "kills",
|
|
"kills_per_hour", "onlinetime", "deaths", "total_deaths", "prismatic_taper_count", "vt_state",
|
|
"mem_mb", "cpu_pct", "mem_handles",
|
|
];
|
|
Assert.Equal(expectedOrder, frame.Properties().Select(p => p.Name));
|
|
|
|
string head = "{\"type\":\"telemetry\",\"character_name\":\"Mossy\",\"char_tag\":\"tank\",\"session_id\":\""
|
|
+ MmContext.ProcessSessionId + "\",\"timestamp\":\"2026-09-25T10:00:15.1230000Z\","
|
|
+ "\"ew\":" + D(33.65f) + ",\"ns\":" + D(42.55f) + ",\"z\":42.5,\"kills\":0,"
|
|
+ "\"kills_per_hour\":\"0\",\"onlinetime\":\"00.01:30:00\",\"deaths\":\"0\",\"total_deaths\":\"7\","
|
|
+ "\"prismatic_taper_count\":\"1234\",\"vt_state\":\"Combat\",\"mem_mb\":";
|
|
Assert.StartsWith(head, json);
|
|
Assert.Equal(Newtonsoft.Json.Linq.JTokenType.Integer, frame["mem_mb"]!.Type);
|
|
Assert.Equal(Newtonsoft.Json.Linq.JTokenType.Float, frame["cpu_pct"]!.Type);
|
|
Assert.Equal(Newtonsoft.Json.Linq.JTokenType.Integer, frame["mem_handles"]!.Type);
|
|
Assert.Equal(32, ((string)frame["session_id"]!).Length);
|
|
}
|
|
|
|
[Fact]
|
|
public void Telemetry_goes_out_on_connect_and_then_every_five_seconds()
|
|
{
|
|
using var harness = LoggedIn();
|
|
|
|
harness.SimulateConnected();
|
|
Assert.Single(harness.FramesOfType("telemetry"));
|
|
|
|
harness.Run(5.05);
|
|
Assert.Equal(2, harness.FramesOfType("telemetry").Count());
|
|
}
|
|
|
|
[Fact]
|
|
public void Chat_frame_normalises_the_line()
|
|
{
|
|
// ChatEventRouter.cs:68-91 (AllChatText + NormalizeChatLine): tags
|
|
// stripped, trailing CR/LF trimmed, runs of spaces collapsed; color is
|
|
// the line's text class.
|
|
using var harness = LoggedIn();
|
|
|
|
harness.Host.Automation.Chat.Deliver("Hello there", kind: 0, sender: "Horan", logTextType: 2, senderObjectId: 0x50000002);
|
|
|
|
Assert.Equal(
|
|
"{\"type\":\"chat\",\"timestamp\":\"" + Stamp + "\",\"character_name\":\"Mossy\","
|
|
+ "\"text\":\"Horan says, \\\"Hello there\\\"\",\"color\":2}",
|
|
Assert.Single(harness.Frames));
|
|
}
|
|
|
|
[Fact]
|
|
public void Spawn_frame_has_string_coordinates()
|
|
{
|
|
// PluginCore.cs:838-895 + WebSocket.cs:290-304: ns/ew F7 invariant
|
|
// strings, z F2 string (landblock-local metres).
|
|
using var harness = LoggedIn();
|
|
|
|
harness.Host.Events.RaiseObjectChanged(new PluginObjectChange(0x80001234, PluginObjectChangeKind.Created)
|
|
{
|
|
Current = new PluginWorldObject(0x80001234, 1, "Drudge Skulker", PluginObjectClass.Monster, 16, 0, 0)
|
|
{
|
|
HasPosition = true,
|
|
Position = Position(x: 96f, y: 120f, z: 12.25f),
|
|
},
|
|
});
|
|
|
|
Assert.Equal(
|
|
"{\"type\":\"spawn\",\"timestamp\":\"" + Stamp + "\",\"character_name\":\"Mossy\","
|
|
+ "\"mob\":\"Drudge Skulker\",\"ns\":\"42.5500000\",\"ew\":\"33.6500000\",\"z\":\"12.25\"}",
|
|
Assert.Single(harness.Frames));
|
|
}
|
|
|
|
[Fact]
|
|
public void Portal_frame_only_within_240_metres()
|
|
{
|
|
// PluginCore.cs:897-961: flat distance > 240 m -> skipped.
|
|
using var harness = LoggedIn();
|
|
PluginWorldObject Portal(uint id, PluginNavigationPosition p) =>
|
|
new(id, 1, "Portal to Holtburg", PluginObjectClass.Portal, 0x10000, 0, 0) { HasPosition = true, Position = p };
|
|
|
|
harness.Host.Events.RaiseObjectChanged(new PluginObjectChange(1, PluginObjectChangeKind.Created)
|
|
{
|
|
Current = Portal(1, Position(x: 180f, y: 120f, z: 1f)),
|
|
});
|
|
harness.Host.Events.RaiseObjectChanged(new PluginObjectChange(2, PluginObjectChangeKind.Created)
|
|
{
|
|
Current = Portal(2, Position(x: 96f, y: 120f, z: 1f, cell: 0xABB40019u)),
|
|
});
|
|
|
|
Assert.Equal(
|
|
"{\"type\":\"portal\",\"timestamp\":\"" + Stamp + "\",\"character_name\":\"Mossy\","
|
|
+ "\"portal_name\":\"Portal to Holtburg\",\"ns\":\"42.5500000\",\"ew\":\"34.0000000\",\"z\":\"1.00\"}",
|
|
Assert.Single(harness.Frames));
|
|
}
|
|
|
|
[Fact]
|
|
public void Rare_frame_has_number_coordinates_and_announces_in_allegiance_later()
|
|
{
|
|
// RareTracker.cs:31-47 + WebSocket.cs:321-337: name = the item text,
|
|
// ew/ns/z numbers from Coordinates.Me; "/a <item>" 3 s later.
|
|
using var harness = LoggedIn();
|
|
|
|
harness.Host.Automation.Chat.Deliver("Mossy has discovered the Pearl of Blood Drinking!", kind: 4, logTextType: 0);
|
|
|
|
Assert.Equal(
|
|
"{\"type\":\"rare\",\"timestamp\":\"" + Stamp + "\",\"character_name\":\"Mossy\","
|
|
+ "\"name\":\"Pearl of Blood Drinking\",\"ew\":" + D(33.65f) + ",\"ns\":" + D(42.55f) + ",\"z\":42.5}",
|
|
harness.Frames[0]);
|
|
Assert.Equal("chat", (string?)Newtonsoft.Json.Linq.JObject.Parse(harness.Frames[1])["type"]);
|
|
|
|
harness.Run(2.9);
|
|
Assert.Empty(harness.Host.Automation.Chat.Submitted);
|
|
harness.Run(0.2);
|
|
Assert.Equal(["/a Pearl of Blood Drinking"], harness.Host.Automation.Chat.Submitted);
|
|
}
|
|
|
|
[Fact]
|
|
public void Another_players_rare_is_not_counted()
|
|
{
|
|
using var harness = LoggedIn();
|
|
|
|
harness.Host.Automation.Chat.Deliver("Horan has discovered the Pearl of Blood Drinking!", kind: 4);
|
|
|
|
Assert.Empty(harness.FramesOfType("rare"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Combat_stats_frame()
|
|
{
|
|
// CombatStatsTracker.cs:520-550 + CombatInfo.cs: session is the
|
|
// CombatSessionState fields (snake_case) then Newtonsoft also writes
|
|
// each monster's public aggregate getters (PascalCase); enum keys by
|
|
// name; lifetime null; session_id the per-connection id.
|
|
using var harness = LoggedIn();
|
|
harness.SimulateConnected();
|
|
harness.Frames.Clear();
|
|
var tracker = harness.Feature<CombatStatsTracker>();
|
|
|
|
harness.Host.Automation.Chat.Deliver("You slash Drudge Skulker for 25 points of slashing damage!", kind: 8, logTextType: 6);
|
|
harness.Frames.Clear();
|
|
tracker.OnSendTick();
|
|
|
|
string sessionStart = tracker.Session.SessionStart;
|
|
Assert.Equal(
|
|
"{\"type\":\"combat_stats\",\"timestamp\":\"" + Stamp + "\",\"character_name\":\"Mossy\","
|
|
+ "\"session_id\":\"Mossy-20260925-083015\","
|
|
+ "\"session\":{\"total_damage_given\":25,\"total_damage_received\":0,\"total_kills\":0,"
|
|
+ "\"total_aetheria_surges\":0,\"total_cloak_surges\":0,\"session_start\":\"" + sessionStart + "\","
|
|
+ "\"monsters\":{\"Drudge Skulker\":{\"name\":\"Drudge Skulker\",\"kill_count\":0,\"damage_given\":25,"
|
|
+ "\"damage_received\":0,\"aetheria_surges\":0,\"cloak_surges\":0,"
|
|
+ "\"offense\":{\"MeleeMissile\":{\"Slash\":{\"total_attacks\":1,\"failed_attacks\":0,\"crits\":0,"
|
|
+ "\"total_normal_damage\":25,\"max_normal_damage\":25,\"total_crit_damage\":0,\"max_crit_damage\":0,"
|
|
+ "\"killing_blows\":0,\"damage\":25}}},\"defense\":{},"
|
|
+ "\"TotalOffenseAttacks\":1,\"TotalOffenseFailedAttacks\":0,\"TotalOffenseCrits\":0,"
|
|
+ "\"TotalOffenseNormalDamage\":25,\"MaxOffenseNormalDamage\":25,\"TotalOffenseCritDamage\":0,"
|
|
+ "\"MaxOffenseCritDamage\":0,\"TotalDefenseAttacks\":0,\"TotalDefenseFailedAttacks\":0}}},"
|
|
+ "\"lifetime\":null}",
|
|
Assert.Single(harness.Frames));
|
|
|
|
// Nothing changed since: no frame.
|
|
harness.Frames.Clear();
|
|
tracker.OnSendTick();
|
|
Assert.Empty(harness.Frames);
|
|
}
|
|
|
|
[Fact]
|
|
public void Kills_count_from_kill_messages_and_the_report_answers_in_allegiance()
|
|
{
|
|
using var harness = LoggedIn();
|
|
var stats = harness.Feature<SessionStats>();
|
|
|
|
harness.Host.Automation.Chat.Deliver("You killed Drudge Skulker!", kind: 8, logTextType: 6);
|
|
harness.Host.Automation.Chat.Deliver("Drudge Skulker is torn to ribbons by your assault!", kind: 8, logTextType: 6);
|
|
Assert.Equal(2, stats.Kills.TotalKills);
|
|
|
|
harness.Host.Automation.Chat.Deliver("!report", kind: 2, sender: "Horan", channelName: "Allegiance", logTextType: 18);
|
|
|
|
string report = Assert.Single(harness.Host.Automation.Chat.Submitted);
|
|
Assert.StartsWith("/a Total Kills: 2, Kills per Hour: ", report);
|
|
Assert.EndsWith(", Elapsed Time: 00.00:00:00, Rares Found: 0", report);
|
|
}
|
|
|
|
[Fact]
|
|
public void Death_counts_a_session_death_and_rereads_total_deaths()
|
|
{
|
|
using var harness = LoggedIn();
|
|
var stats = harness.Feature<SessionStats>();
|
|
harness.Host.Automation.Objects.Properties[0x50000001] = new PluginItemProperties(
|
|
new Dictionary<uint, int> { [43] = 12 },
|
|
new Dictionary<uint, long>(), new Dictionary<uint, bool>(), new Dictionary<uint, double>(),
|
|
new Dictionary<uint, string>(), new Dictionary<uint, uint>(), new Dictionary<uint, uint>());
|
|
|
|
harness.Host.Events.RaiseLocalPlayerDied("You died.");
|
|
|
|
Assert.Equal(1, stats.Kills.SessionDeaths);
|
|
Assert.Equal(12, stats.Kills.TotalDeaths);
|
|
}
|
|
}
|