diff --git a/apps/counterstrikesharp/src/FiveStack.Services/MatchEvents.cs b/apps/counterstrikesharp/src/FiveStack.Services/MatchEvents.cs index 2b3e5be3..f45f2fe6 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/MatchEvents.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/MatchEvents.cs @@ -40,6 +40,8 @@ EventData> Payload EventData> )>(new UnboundedChannelOptions { SingleReader = true }); + private readonly MapStatusClock _mapStatusClock = new(); + private readonly ILogger _logger; private readonly MatchService _matchService; private readonly EnvironmentService _environmentService; @@ -130,14 +132,20 @@ public void PublishMapStatus(eMapStatus status, Guid? winningLineupId) _logger.LogInformation( $"PublishMapStatus status={status} winning_lineup_id={winningLineupId?.ToString() ?? ""}" ); - PublishGameEvent( - "mapStatus", - new Dictionary - { - { "status", status.ToString() }, - { "winning_lineup_id", winningLineupId?.ToString() ?? "" }, - } - ); + var data = new Dictionary + { + { "status", status.ToString() }, + { "winning_lineup_id", winningLineupId?.ToString() ?? "" }, + { "time", _mapStatusClock.Next(DateTime.UtcNow) }, + }; + + Guid? mapId = _matchService.GetCurrentMatch()?.GetActiveMapId(); + if (mapId != null) + { + data["match_map_id"] = mapId.Value.ToString(); + } + + PublishGameEvent("mapStatus", data); } public void PublishGameEvent(string Event, Dictionary Data) diff --git a/apps/counterstrikesharp/test/MapStatusClockTests.cs b/apps/counterstrikesharp/test/MapStatusClockTests.cs new file mode 100644 index 00000000..26c515f9 --- /dev/null +++ b/apps/counterstrikesharp/test/MapStatusClockTests.cs @@ -0,0 +1,41 @@ +using FiveStack.Utilities; +using Xunit; + +// The api keeps only the newest status of a map, by this time, since a resent +// status can arrive after a newer one. +public class MapStatusClockTests +{ + private static readonly DateTime Now = new(2026, 10, 30, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void StampsToTheMillisecondInUtc() + { + DateTime stamp = new MapStatusClock().Next(Now.AddTicks(1234)); + + Assert.Equal(Now, stamp); + Assert.Equal(DateTimeKind.Utc, stamp.Kind); + } + + [Fact] + public void KeepsTwoStatusesInTheSameMillisecondInOrder() + { + var clock = new MapStatusClock(); + + DateTime warmup = clock.Next(Now); + DateTime live = clock.Next(Now.AddTicks(10)); + + Assert.Equal(Now.AddMilliseconds(1), live); + Assert.True(live > warmup); + } + + [Fact] + public void NeverGoesBackWhenTheClockDoes() + { + var clock = new MapStatusClock(); + + DateTime first = clock.Next(Now); + DateTime second = clock.Next(Now.AddSeconds(-5)); + + Assert.True(second > first); + } +} diff --git a/apps/simulator/src/MapRun.cs b/apps/simulator/src/MapRun.cs index c79f6991..4bf9fe4d 100644 --- a/apps/simulator/src/MapRun.cs +++ b/apps/simulator/src/MapRun.cs @@ -402,8 +402,9 @@ or eMapStatus.Scheduled { { "status", status.ToString() }, { "winning_lineup_id", winningLineupId?.ToString() ?? "" }, + { "match_map_id", _map.id.ToString() }, }, - stamped: false + stamped: true ); Status = status; diff --git a/apps/simulator/test/MatchFlowTests.cs b/apps/simulator/test/MatchFlowTests.cs index aaa190b3..04324ff9 100644 --- a/apps/simulator/test/MatchFlowTests.cs +++ b/apps/simulator/test/MatchFlowTests.cs @@ -82,6 +82,24 @@ public async Task A5v5MapGoesFromWarmupToFinishedAsThePluginReportsIt() Assert.True(events.IndexOf(last) < events.IndexOf(waiting)); } + // The api keeps only the newest status of a map, since a resent one can + // arrive after it. + [Fact] + public async Task EachMapStatusNamesItsMapAndComesAfterTheLast() + { + MatchData built = Matches.Build(); + string mapId = built.current_match_map_id!.Value.ToString(); + + (RecordingSink sink, _) = await PlayMap(built); + + List statuses = sink.Events.Where(e => e.Name == "mapStatus").ToList(); + Assert.All(statuses, status => Assert.Equal(mapId, status.Data["match_map_id"])); + List times = statuses + .Select(status => (DateTimeOffset)status.Data["time"]) + .ToList(); + Assert.Equal(times.Distinct().Order(), times); + } + [Fact] public async Task EachRoundsStatsComeBeforeItsScore() { diff --git a/apps/swiftly/src/FiveStack.Services/MatchEvents.cs b/apps/swiftly/src/FiveStack.Services/MatchEvents.cs index fd31e3b5..6fc615ad 100644 --- a/apps/swiftly/src/FiveStack.Services/MatchEvents.cs +++ b/apps/swiftly/src/FiveStack.Services/MatchEvents.cs @@ -40,6 +40,8 @@ EventData> Payload EventData> )>(new UnboundedChannelOptions { SingleReader = true }); + private readonly MapStatusClock _mapStatusClock = new(); + private readonly ILogger _logger; private readonly MatchService _matchService; private readonly EnvironmentService _environmentService; @@ -136,14 +138,20 @@ public void PublishMapStatus(eMapStatus status, Guid? winningLineupId) return; } - PublishGameEvent( - "mapStatus", - new Dictionary - { - { "status", status.ToString() }, - { "winning_lineup_id", winningLineupId?.ToString() ?? "" }, - } - ); + var data = new Dictionary + { + { "status", status.ToString() }, + { "winning_lineup_id", winningLineupId?.ToString() ?? "" }, + { "time", _mapStatusClock.Next(DateTime.UtcNow) }, + }; + + Guid? mapId = _matchService.GetCurrentMatch()?.GetActiveMapId(); + if (mapId != null) + { + data["match_map_id"] = mapId.Value.ToString(); + } + + PublishGameEvent("mapStatus", data); } public void PublishGameEvent(string Event, Dictionary Data) diff --git a/apps/swiftly/test/MapStatusClockTests.cs b/apps/swiftly/test/MapStatusClockTests.cs new file mode 100644 index 00000000..26c515f9 --- /dev/null +++ b/apps/swiftly/test/MapStatusClockTests.cs @@ -0,0 +1,41 @@ +using FiveStack.Utilities; +using Xunit; + +// The api keeps only the newest status of a map, by this time, since a resent +// status can arrive after a newer one. +public class MapStatusClockTests +{ + private static readonly DateTime Now = new(2026, 10, 30, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void StampsToTheMillisecondInUtc() + { + DateTime stamp = new MapStatusClock().Next(Now.AddTicks(1234)); + + Assert.Equal(Now, stamp); + Assert.Equal(DateTimeKind.Utc, stamp.Kind); + } + + [Fact] + public void KeepsTwoStatusesInTheSameMillisecondInOrder() + { + var clock = new MapStatusClock(); + + DateTime warmup = clock.Next(Now); + DateTime live = clock.Next(Now.AddTicks(10)); + + Assert.Equal(Now.AddMilliseconds(1), live); + Assert.True(live > warmup); + } + + [Fact] + public void NeverGoesBackWhenTheClockDoes() + { + var clock = new MapStatusClock(); + + DateTime first = clock.Next(Now); + DateTime second = clock.Next(Now.AddSeconds(-5)); + + Assert.True(second > first); + } +} diff --git a/shared/dotnet/FiveStack.Utilities/MapStatusClock.cs b/shared/dotnet/FiveStack.Utilities/MapStatusClock.cs new file mode 100644 index 00000000..98209b74 --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/MapStatusClock.cs @@ -0,0 +1,25 @@ +namespace FiveStack.Utilities; + +// When a map status was decided. A status is resent until the api acknowledges +// it, in no particular order, so the api keeps only the newest by this time. +// To the millisecond, which the api compares exactly, and always after the last +// one, so two statuses in the same millisecond still keep their order. +public class MapStatusClock +{ + private readonly object _gate = new(); + private DateTime _last = DateTime.MinValue; + + public DateTime Next(DateTime utcNow) + { + DateTime now = new( + utcNow.Ticks - utcNow.Ticks % TimeSpan.TicksPerMillisecond, + DateTimeKind.Utc + ); + + lock (_gate) + { + _last = now > _last ? now : _last.AddMilliseconds(1); + return _last; + } + } +}