Review fixes: telemetry session id per login; Framework number text
F1: the telemetry session id is new at every login. The original made one per load of its assembly and its loader reloaded it at every login; the backend counts new kills per (session id, character), so reusing one id across a relog under-counted kills until the reset counter passed the old total. F5/F6: .NET Framework never wrote a negative zero, and its fixed-point formats (F7, F2, F0) rounded at fifteen significant digits, half away from zero. Spawn and portal coordinates, kills_per_hour and the !report line now format that way; the round-trip converter maps -0 to 0. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9b5371c2e4
commit
71f14473c6
10 changed files with 157 additions and 10 deletions
|
|
@ -58,9 +58,28 @@ internal sealed class FrameworkNumberConverter : JsonConverter
|
|||
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
/// <summary>
|
||||
/// .NET Framework's fixed-point and number formats ("F7", "F2", "F0",
|
||||
/// "N0" ...) for a double: the value taken to fifteen significant digits,
|
||||
/// then rounded half away from zero to the format's decimals, and never a
|
||||
/// negative zero. The decimal type formats exactly that way.
|
||||
/// </summary>
|
||||
internal static string FrameworkFixed(double value, string format, IFormatProvider provider)
|
||||
{
|
||||
if (!double.IsFinite(value))
|
||||
return value.ToString(format, provider);
|
||||
string digits = value.ToString("G15", CultureInfo.InvariantCulture);
|
||||
return decimal.TryParse(digits, NumberStyles.Float, CultureInfo.InvariantCulture, out decimal exact)
|
||||
? exact.ToString(format, provider)
|
||||
: value.ToString(format, provider);
|
||||
}
|
||||
|
||||
/// <summary>.NET Framework's "R" for a double: G15 if it reads back exactly, else G17.</summary>
|
||||
internal static string FrameworkRoundTrip(double value)
|
||||
{
|
||||
// The Framework printed a negative zero as "0".
|
||||
if (value == 0d)
|
||||
value = 0d;
|
||||
string text = value.ToString("G15", CultureInfo.InvariantCulture);
|
||||
if (double.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture) == value)
|
||||
return text;
|
||||
|
|
@ -70,6 +89,8 @@ internal sealed class FrameworkNumberConverter : JsonConverter
|
|||
/// <summary>.NET Framework's "R" for a float: G7 if it reads back exactly, else G9.</summary>
|
||||
internal static string FrameworkRoundTrip(float value)
|
||||
{
|
||||
if (value == 0f)
|
||||
value = 0f;
|
||||
string text = value.ToString("G7", CultureInfo.InvariantCulture);
|
||||
if (float.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture) == value)
|
||||
return text;
|
||||
|
|
|
|||
|
|
@ -71,8 +71,13 @@ internal sealed class MmContext
|
|||
/// <summary>The character's vitae penalty in percent (0 when none), as the original's vitals frame carried it.</summary>
|
||||
public Func<int> Vitae { get; set; } = static () => 0;
|
||||
|
||||
/// <summary>The per-process id the original put in every telemetry frame.</summary>
|
||||
public static string ProcessSessionId { get; } = Guid.NewGuid().ToString("N");
|
||||
/// <summary>
|
||||
/// The telemetry session id: a fresh GUID at every login. The original
|
||||
/// made one per load of its assembly, and its loader reloaded the assembly
|
||||
/// at every login; the backend counts new kills per (session id,
|
||||
/// character), so a login that resets the kill counter needs a new id.
|
||||
/// </summary>
|
||||
public string TelemetrySessionId { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
/// <summary>
|
||||
/// Sees every frame the plugin hands to the socket, before the socket
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ public sealed class MosswartMassacrePlugin : IAcDreamPlugin
|
|||
|
||||
string name = _host.Automation.Character.Name;
|
||||
_context.CharacterName = name;
|
||||
_context.TelemetrySessionId = Guid.NewGuid().ToString("N");
|
||||
_context.Settings.Load(name);
|
||||
_context.IsLoggedIn = true;
|
||||
_context.Chat.Write("Mosswart Massacre has started!");
|
||||
|
|
|
|||
|
|
@ -71,8 +71,11 @@ internal sealed class ChatRouter : IMmFeature
|
|||
TimeSpan elapsed = _context.Clock.Now - _kills.StatsStartTime;
|
||||
string report = string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
"Total Kills: {0}, Kills per Hour: {1:F2}, Elapsed Time: {2:dd\\.hh\\:mm\\:ss}, Rares Found: {3}",
|
||||
_kills.TotalKills, _kills.KillsPerHour, elapsed, _rares.RareCount);
|
||||
"Total Kills: {0}, Kills per Hour: {1}, Elapsed Time: {2:dd\\.hh\\:mm\\:ss}, Rares Found: {3}",
|
||||
_kills.TotalKills,
|
||||
FrameworkNumberConverter.FrameworkFixed(_kills.KillsPerHour, "F2", CultureInfo.CurrentCulture),
|
||||
elapsed,
|
||||
_rares.RareCount);
|
||||
_context.Host.Log.Info($"[Mosswart Massacre] Reporting to allegiance: {report}");
|
||||
_context.Automation.Chat.Submit($"/a {report}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,9 +102,10 @@ internal sealed class SpawnPortalStream : IMmFeature
|
|||
private static (string Ns, string Ew, string Z) Format(in PluginNavigationPosition position)
|
||||
{
|
||||
const string fmt = "F7";
|
||||
string ns = position.NorthSouth.ToString(fmt, CultureInfo.InvariantCulture);
|
||||
string ew = position.EastWest.ToString(fmt, CultureInfo.InvariantCulture);
|
||||
string z = ((float)(position.Elevation * 240d)).ToString("F2", CultureInfo.InvariantCulture);
|
||||
string ns = FrameworkNumberConverter.FrameworkFixed(position.NorthSouth, fmt, CultureInfo.InvariantCulture);
|
||||
string ew = FrameworkNumberConverter.FrameworkFixed(position.EastWest, fmt, CultureInfo.InvariantCulture);
|
||||
// The original read the height as a float and formatted it widened to a double.
|
||||
string z = FrameworkNumberConverter.FrameworkFixed((double)(float)(position.Elevation * 240d), "F2", CultureInfo.InvariantCulture);
|
||||
return (ns, ew, z);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,13 +64,13 @@ internal sealed class TelemetryStream : IMmFeature, IBackendConnectionListener
|
|||
type = "telemetry",
|
||||
character_name = _context.CharacterName,
|
||||
char_tag = _context.Settings.Current.CharTag ?? "",
|
||||
session_id = MmContext.ProcessSessionId,
|
||||
session_id = _context.TelemetrySessionId,
|
||||
timestamp = Wire.Timestamp(_context.Clock),
|
||||
ew = coords.EW,
|
||||
ns = coords.NS,
|
||||
z = coords.Z,
|
||||
kills = kills.TotalKills,
|
||||
kills_per_hour = kills.KillsPerHour.ToString("F0", CultureInfo.CurrentCulture),
|
||||
kills_per_hour = FrameworkNumberConverter.FrameworkFixed(kills.KillsPerHour, "F0", CultureInfo.CurrentCulture),
|
||||
onlinetime = (_context.Clock.Now - kills.StatsStartTime).ToString(@"dd\.hh\:mm\:ss", CultureInfo.InvariantCulture),
|
||||
deaths = kills.SessionDeaths.ToString(CultureInfo.CurrentCulture),
|
||||
total_deaths = kills.TotalDeaths.ToString(CultureInfo.CurrentCulture),
|
||||
|
|
|
|||
|
|
@ -43,6 +43,25 @@ public sealed class WireNumberTests
|
|||
public void Floats_use_the_frameworks_round_trip_digits(float value, string expected) =>
|
||||
Assert.Equal("{\"v\":" + expected + "}", Wire.Serialize(new { v = value }));
|
||||
|
||||
[Fact]
|
||||
public void Negative_zero_is_written_as_zero_as_the_framework_did()
|
||||
{
|
||||
Assert.Equal("{\"v\":0.0}", Wire.Serialize(new { v = Math.Round(-0.001, 2) }));
|
||||
Assert.Equal("{\"v\":0.0}", Wire.Serialize(new { v = -0f }));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// Rounded half away from zero at fifteen significant digits, as the
|
||||
// Framework did; the modern exact-binary rounding differs on these.
|
||||
[InlineData(0.125, "F2", "0.13")]
|
||||
[InlineData(2.675, "F2", "2.68")]
|
||||
[InlineData(-0.001, "F2", "0.00")]
|
||||
[InlineData(-0.0, "F7", "0.0000000")]
|
||||
[InlineData(12.5, "F0", "13")]
|
||||
[InlineData(33.650001525878906, "F7", "33.6500015")]
|
||||
public void Fixed_formats_round_as_the_framework_did(double value, string format, string expected) =>
|
||||
Assert.Equal(expected, FrameworkNumberConverter.FrameworkFixed(value, format, System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
[Fact]
|
||||
public void Nulls_and_non_finite_values_keep_newtonsofts_handling()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -42,6 +42,21 @@ public sealed class PluginLifecycleTests
|
|||
Assert.Equal("Horan", harness.Context.Settings.CharacterName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Each_login_gets_a_new_telemetry_session_id()
|
||||
{
|
||||
// The backend counts new kills per (session id, character); the kill
|
||||
// counter restarts at every login, so the id must too.
|
||||
using var harness = new PluginHarness("Mossy");
|
||||
harness.Login();
|
||||
string first = harness.Context.TelemetrySessionId;
|
||||
|
||||
harness.Plugin.OnLogin();
|
||||
|
||||
Assert.NotEqual(first, harness.Context.TelemetrySessionId);
|
||||
Assert.Equal(32, harness.Context.TelemetrySessionId.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mm_without_a_subcommand_prints_usage_and_unknown_is_reported()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ public sealed class StreamGoldenTests
|
|||
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\","
|
||||
+ harness.Context.TelemetrySessionId + "\",\"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\":";
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ static async Task<int> Record(int port, string outPath)
|
|||
{
|
||||
var buffer = new byte[65536];
|
||||
using var message = new MemoryStream();
|
||||
string? name = null;
|
||||
try
|
||||
{
|
||||
while (ws.WebSocket.State == WebSocketState.Open && !cts.IsCancellationRequested)
|
||||
|
|
@ -71,6 +72,7 @@ static async Task<int> Record(int port, string outPath)
|
|||
continue;
|
||||
string frame = Encoding.UTF8.GetString(message.ToArray());
|
||||
message.SetLength(0);
|
||||
name = await Relay.Handle(frame, ws.WebSocket, name);
|
||||
await writeLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
|
|
@ -86,6 +88,7 @@ static async Task<int> Record(int port, string outPath)
|
|||
catch (Exception ex) when (ex is OperationCanceledException or WebSocketException)
|
||||
{
|
||||
}
|
||||
Relay.Disconnected(name);
|
||||
Console.WriteLine("client disconnected");
|
||||
});
|
||||
}
|
||||
|
|
@ -170,3 +173,82 @@ static bool IsDataMap(string name) => name is "monsters" or "properties" or "ski
|
|||
or "vitals" or "IntValues" or "DoubleValues" or "BoolValues" or "StringValues" or "offense" or "defense";
|
||||
|
||||
static bool LooksLikeData(string key) => key.Length > 0 && (char.IsDigit(key[0]) || key.Contains(' '));
|
||||
|
||||
// The backend's vital-sharing relay: a client registers under its character
|
||||
// name; share_subscribe / share_unsubscribe opt it in and out; every other
|
||||
// share_* frame goes to every other opted-in client, unchanged.
|
||||
static class Relay
|
||||
{
|
||||
private static readonly object Gate = new();
|
||||
private static readonly Dictionary<string, WebSocket> Clients = new(StringComparer.Ordinal);
|
||||
private static readonly HashSet<string> Subscribers = new(StringComparer.Ordinal);
|
||||
|
||||
public static async Task<string?> Handle(string frame, WebSocket socket, string? name)
|
||||
{
|
||||
string? type;
|
||||
string? character;
|
||||
try
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(frame);
|
||||
JsonElement root = doc.RootElement;
|
||||
type = root.TryGetProperty("type", out JsonElement t) ? t.GetString() : null;
|
||||
character = root.TryGetProperty("character_name", out JsonElement c) ? c.GetString()
|
||||
: root.TryGetProperty("player_name", out JsonElement p) ? p.GetString() : null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return name;
|
||||
}
|
||||
|
||||
List<WebSocket> targets = [];
|
||||
lock (Gate)
|
||||
{
|
||||
if (type == "register" && character is not null)
|
||||
{
|
||||
Clients[character] = socket;
|
||||
return character;
|
||||
}
|
||||
if (type == "share_subscribe" && character is not null)
|
||||
{
|
||||
Subscribers.Add(character);
|
||||
return name;
|
||||
}
|
||||
if (type == "share_unsubscribe" && character is not null)
|
||||
{
|
||||
Subscribers.Remove(character);
|
||||
return name;
|
||||
}
|
||||
if (type is null || !type.StartsWith("share_", StringComparison.Ordinal))
|
||||
return name;
|
||||
foreach ((string client, WebSocket other) in Clients)
|
||||
{
|
||||
if (client != character && Subscribers.Contains(client) && other.State == WebSocketState.Open)
|
||||
targets.Add(other);
|
||||
}
|
||||
}
|
||||
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(frame);
|
||||
foreach (WebSocket target in targets)
|
||||
{
|
||||
try
|
||||
{
|
||||
await target.SendAsync(bytes, WebSocketMessageType.Text, true, CancellationToken.None);
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
}
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
public static void Disconnected(string? name)
|
||||
{
|
||||
if (name is null)
|
||||
return;
|
||||
lock (Gate)
|
||||
{
|
||||
Clients.Remove(name);
|
||||
Subscribers.Remove(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue