diff --git a/src/OpenAC.MosswartMassacre/Backend/FrameworkNumberConverter.cs b/src/OpenAC.MosswartMassacre/Backend/FrameworkNumberConverter.cs index 68a4aff..a4b7858 100644 --- a/src/OpenAC.MosswartMassacre/Backend/FrameworkNumberConverter.cs +++ b/src/OpenAC.MosswartMassacre/Backend/FrameworkNumberConverter.cs @@ -58,9 +58,28 @@ internal sealed class FrameworkNumberConverter : JsonConverter public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) => throw new NotSupportedException(); + /// + /// .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. + /// + 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); + } + /// .NET Framework's "R" for a double: G15 if it reads back exactly, else G17. 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 /// .NET Framework's "R" for a float: G7 if it reads back exactly, else G9. 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; diff --git a/src/OpenAC.MosswartMassacre/MmContext.cs b/src/OpenAC.MosswartMassacre/MmContext.cs index dd44b63..7cfbd9c 100644 --- a/src/OpenAC.MosswartMassacre/MmContext.cs +++ b/src/OpenAC.MosswartMassacre/MmContext.cs @@ -71,8 +71,13 @@ internal sealed class MmContext /// The character's vitae penalty in percent (0 when none), as the original's vitals frame carried it. public Func Vitae { get; set; } = static () => 0; - /// The per-process id the original put in every telemetry frame. - public static string ProcessSessionId { get; } = Guid.NewGuid().ToString("N"); + /// + /// 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. + /// + public string TelemetrySessionId { get; set; } = Guid.NewGuid().ToString("N"); /// /// Sees every frame the plugin hands to the socket, before the socket diff --git a/src/OpenAC.MosswartMassacre/MosswartMassacrePlugin.cs b/src/OpenAC.MosswartMassacre/MosswartMassacrePlugin.cs index 94aa757..8fe9da9 100644 --- a/src/OpenAC.MosswartMassacre/MosswartMassacrePlugin.cs +++ b/src/OpenAC.MosswartMassacre/MosswartMassacrePlugin.cs @@ -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!"); diff --git a/src/OpenAC.MosswartMassacre/Streams/ChatRouter.cs b/src/OpenAC.MosswartMassacre/Streams/ChatRouter.cs index 117be3d..df10b1a 100644 --- a/src/OpenAC.MosswartMassacre/Streams/ChatRouter.cs +++ b/src/OpenAC.MosswartMassacre/Streams/ChatRouter.cs @@ -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}"); } diff --git a/src/OpenAC.MosswartMassacre/Streams/SpawnPortalStream.cs b/src/OpenAC.MosswartMassacre/Streams/SpawnPortalStream.cs index c7e9023..8a576b1 100644 --- a/src/OpenAC.MosswartMassacre/Streams/SpawnPortalStream.cs +++ b/src/OpenAC.MosswartMassacre/Streams/SpawnPortalStream.cs @@ -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); } } diff --git a/src/OpenAC.MosswartMassacre/Streams/TelemetryStream.cs b/src/OpenAC.MosswartMassacre/Streams/TelemetryStream.cs index 8c01021..02f3a2d 100644 --- a/src/OpenAC.MosswartMassacre/Streams/TelemetryStream.cs +++ b/src/OpenAC.MosswartMassacre/Streams/TelemetryStream.cs @@ -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), diff --git a/tests/OpenAC.MosswartMassacre.Tests/Backend/WireNumberTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Backend/WireNumberTests.cs index 9928835..e784886 100644 --- a/tests/OpenAC.MosswartMassacre.Tests/Backend/WireNumberTests.cs +++ b/tests/OpenAC.MosswartMassacre.Tests/Backend/WireNumberTests.cs @@ -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() { diff --git a/tests/OpenAC.MosswartMassacre.Tests/PluginLifecycleTests.cs b/tests/OpenAC.MosswartMassacre.Tests/PluginLifecycleTests.cs index 17466ee..031c1a1 100644 --- a/tests/OpenAC.MosswartMassacre.Tests/PluginLifecycleTests.cs +++ b/tests/OpenAC.MosswartMassacre.Tests/PluginLifecycleTests.cs @@ -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() { diff --git a/tests/OpenAC.MosswartMassacre.Tests/Streams/StreamGoldenTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Streams/StreamGoldenTests.cs index f742034..bec08b3 100644 --- a/tests/OpenAC.MosswartMassacre.Tests/Streams/StreamGoldenTests.cs +++ b/tests/OpenAC.MosswartMassacre.Tests/Streams/StreamGoldenTests.cs @@ -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\":"; diff --git a/tools/FrameRecorder/Program.cs b/tools/FrameRecorder/Program.cs index 7bdaf4a..fc0cb02 100644 --- a/tools/FrameRecorder/Program.cs +++ b/tools/FrameRecorder/Program.cs @@ -59,6 +59,7 @@ static async Task 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 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 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 Clients = new(StringComparer.Ordinal); + private static readonly HashSet Subscribers = new(StringComparer.Ordinal); + + public static async Task 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 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); + } + } +}