Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions apps/counterstrikesharp/src/FiveStack.Services/MatchEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ EventData<Dictionary<string, object>> Payload
EventData<Dictionary<string, object>>
)>(new UnboundedChannelOptions { SingleReader = true });

private readonly MapStatusClock _mapStatusClock = new();

private readonly ILogger<MatchEvents> _logger;
private readonly MatchService _matchService;
private readonly EnvironmentService _environmentService;
Expand Down Expand Up @@ -130,14 +132,20 @@ public void PublishMapStatus(eMapStatus status, Guid? winningLineupId)
_logger.LogInformation(
$"PublishMapStatus status={status} winning_lineup_id={winningLineupId?.ToString() ?? "<null>"}"
);
PublishGameEvent(
"mapStatus",
new Dictionary<string, object>
{
{ "status", status.ToString() },
{ "winning_lineup_id", winningLineupId?.ToString() ?? "" },
}
);
var data = new Dictionary<string, object>
{
{ "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<string, object> Data)
Expand Down
41 changes: 41 additions & 0 deletions apps/counterstrikesharp/test/MapStatusClockTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
3 changes: 2 additions & 1 deletion apps/simulator/src/MapRun.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions apps/simulator/test/MatchFlowTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Published> statuses = sink.Events.Where(e => e.Name == "mapStatus").ToList();
Assert.All(statuses, status => Assert.Equal(mapId, status.Data["match_map_id"]));
List<DateTimeOffset> times = statuses
.Select(status => (DateTimeOffset)status.Data["time"])
.ToList();
Assert.Equal(times.Distinct().Order(), times);
}

[Fact]
public async Task EachRoundsStatsComeBeforeItsScore()
{
Expand Down
24 changes: 16 additions & 8 deletions apps/swiftly/src/FiveStack.Services/MatchEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ EventData<Dictionary<string, object>> Payload
EventData<Dictionary<string, object>>
)>(new UnboundedChannelOptions { SingleReader = true });

private readonly MapStatusClock _mapStatusClock = new();

private readonly ILogger<MatchEvents> _logger;
private readonly MatchService _matchService;
private readonly EnvironmentService _environmentService;
Expand Down Expand Up @@ -136,14 +138,20 @@ public void PublishMapStatus(eMapStatus status, Guid? winningLineupId)
return;
}

PublishGameEvent(
"mapStatus",
new Dictionary<string, object>
{
{ "status", status.ToString() },
{ "winning_lineup_id", winningLineupId?.ToString() ?? "" },
}
);
var data = new Dictionary<string, object>
{
{ "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<string, object> Data)
Expand Down
41 changes: 41 additions & 0 deletions apps/swiftly/test/MapStatusClockTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
25 changes: 25 additions & 0 deletions shared/dotnet/FiveStack.Utilities/MapStatusClock.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Loading