Skip to content

Commit a867786

Browse files
committed
feat(2a): add in-memory EventStateStore + IEventState read-seam
1 parent 2c66d25 commit a867786

4 files changed

Lines changed: 223 additions & 0 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using RustPlusBot.Features.Connections.Listening;
2+
3+
namespace RustPlusBot.Features.Events.State;
4+
5+
/// <summary>A marker currently present on a server's map.</summary>
6+
/// <param name="Id">The marker id.</param>
7+
/// <param name="Kind">The marker kind.</param>
8+
/// <param name="X">World X coordinate.</param>
9+
/// <param name="Y">World Y coordinate.</param>
10+
/// <param name="Dimensions">Map dimensions for grid rendering, or null.</param>
11+
/// <param name="SeenAtUtc">When the marker was first seen.</param>
12+
public sealed record ActiveMarker(
13+
ulong Id,
14+
MarkerKind Kind,
15+
float X,
16+
float Y,
17+
MapDimensions? Dimensions,
18+
DateTimeOffset SeenAtUtc);
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
using System.Collections.Concurrent;
2+
using RustPlusBot.Abstractions.Events;
3+
using RustPlusBot.Abstractions.Time;
4+
using RustPlusBot.Features.Connections.Listening;
5+
using RustPlusBot.Features.Events.Classifying;
6+
7+
namespace RustPlusBot.Features.Events.State;
8+
9+
/// <summary>In-memory per-(guild, server) active markers and a bounded recent-event ring. Cleared on disconnect.</summary>
10+
/// <param name="clock">Stamps when markers become active.</param>
11+
internal sealed class EventStateStore(IClock clock) : IEventState
12+
{
13+
private const int RecentCapacity = 10;
14+
private readonly ConcurrentDictionary<(ulong Guild, Guid Server), ServerState> _byServer = new();
15+
16+
/// <inheritdoc />
17+
public IReadOnlyList<ActiveMarker> GetActiveMarkers(ulong guildId, Guid serverId, MarkerKind kind)
18+
{
19+
if (!_byServer.TryGetValue((guildId, serverId), out var state))
20+
{
21+
return [];
22+
}
23+
24+
lock (state.Gate)
25+
{
26+
return state.Active.Values
27+
.Where(m => m.Kind == kind)
28+
.OrderByDescending(m => m.SeenAtUtc)
29+
.ToList();
30+
}
31+
}
32+
33+
/// <inheritdoc />
34+
public IReadOnlyList<RustMapEvent> GetRecentEvents(ulong guildId, Guid serverId)
35+
{
36+
if (!_byServer.TryGetValue((guildId, serverId), out var state))
37+
{
38+
return [];
39+
}
40+
41+
lock (state.Gate)
42+
{
43+
return state.Recent.ToList(); // already newest-first
44+
}
45+
}
46+
47+
/// <summary>Applies one marker-change delta and its classified events.</summary>
48+
/// <param name="delta">The raw marker delta (drives the active set).</param>
49+
/// <param name="events">The classified events (pushed onto the recent ring).</param>
50+
public void Apply(MapMarkersChangedEvent delta, IReadOnlyList<RustMapEvent> events)
51+
{
52+
ArgumentNullException.ThrowIfNull(delta);
53+
ArgumentNullException.ThrowIfNull(events);
54+
var state = _byServer.GetOrAdd((delta.GuildId, delta.ServerId), static _ => new ServerState());
55+
var now = clock.UtcNow;
56+
lock (state.Gate)
57+
{
58+
foreach (var m in delta.Added)
59+
{
60+
state.Active[m.Id] = new ActiveMarker(m.Id, m.Kind, m.X, m.Y, delta.Dimensions, now);
61+
}
62+
63+
foreach (var m in delta.Removed)
64+
{
65+
state.Active.Remove(m.Id);
66+
}
67+
68+
foreach (var e in events)
69+
{
70+
state.Recent.Insert(0, e);
71+
}
72+
73+
if (state.Recent.Count > RecentCapacity)
74+
{
75+
state.Recent.RemoveRange(RecentCapacity, state.Recent.Count - RecentCapacity);
76+
}
77+
}
78+
}
79+
80+
/// <summary>Clears all state for a server (called when its connection drops).</summary>
81+
/// <param name="guildId">The owning guild snowflake.</param>
82+
/// <param name="serverId">The target server id.</param>
83+
public void Clear(ulong guildId, Guid serverId) => _byServer.TryRemove((guildId, serverId), out _);
84+
85+
private sealed class ServerState
86+
{
87+
public object Gate { get; } = new();
88+
89+
public Dictionary<ulong, ActiveMarker> Active { get; } = [];
90+
91+
public List<RustMapEvent> Recent { get; } = [];
92+
}
93+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using RustPlusBot.Features.Connections.Listening;
2+
using RustPlusBot.Features.Events.Classifying;
3+
4+
namespace RustPlusBot.Features.Events.State;
5+
6+
/// <summary>Read access to current map markers and recent events (consumed by in-game command handlers).</summary>
7+
public interface IEventState
8+
{
9+
/// <summary>Gets the currently-active markers of a kind for a server (empty if none/unknown).</summary>
10+
/// <param name="guildId">The owning guild snowflake.</param>
11+
/// <param name="serverId">The target server id.</param>
12+
/// <param name="kind">The marker kind to filter by.</param>
13+
/// <returns>The active markers of that kind, newest-first.</returns>
14+
IReadOnlyList<ActiveMarker> GetActiveMarkers(ulong guildId, Guid serverId, MarkerKind kind);
15+
16+
/// <summary>Gets the recent events for a server, newest-first (empty if none/unknown).</summary>
17+
/// <param name="guildId">The owning guild snowflake.</param>
18+
/// <param name="serverId">The target server id.</param>
19+
/// <returns>The recent events, newest-first.</returns>
20+
IReadOnlyList<RustMapEvent> GetRecentEvents(ulong guildId, Guid serverId);
21+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
using NSubstitute;
2+
using RustPlusBot.Abstractions.Events;
3+
using RustPlusBot.Abstractions.Time;
4+
using RustPlusBot.Features.Connections.Listening;
5+
using RustPlusBot.Features.Events.Classifying;
6+
using RustPlusBot.Features.Events.State;
7+
8+
namespace RustPlusBot.Features.Events.Tests.State;
9+
10+
public sealed class EventStateStoreTests
11+
{
12+
private const ulong Guild = 1UL;
13+
private static readonly Guid Server = Guid.NewGuid();
14+
private static readonly DateTimeOffset Now = new(2026, 6, 17, 12, 0, 0, TimeSpan.Zero);
15+
16+
private static EventStateStore Build()
17+
{
18+
var clock = Substitute.For<IClock>();
19+
clock.UtcNow.Returns(Now);
20+
return new EventStateStore(clock);
21+
}
22+
23+
private static MapMarkersChangedEvent Delta(
24+
IReadOnlyList<MapMarkerSnapshot> added,
25+
IReadOnlyList<MapMarkerSnapshot> removed) =>
26+
new(Guild, Server, null, added, removed);
27+
28+
private static MapMarkersChangedEvent DeltaWithDims(
29+
IReadOnlyList<MapMarkerSnapshot> added,
30+
IReadOnlyList<MapMarkerSnapshot> removed,
31+
MapDimensions? dims) =>
32+
new(Guild, Server, dims, added, removed);
33+
34+
[Fact]
35+
public void Added_marker_becomes_active_and_carries_dimensions()
36+
{
37+
var store = Build();
38+
var dims = new MapDimensions(4000u, 4000u, 500);
39+
store.Apply(
40+
DeltaWithDims([new MapMarkerSnapshot(1, MarkerKind.CargoShip, 10f, 20f, null)], [], dims),
41+
[new RustMapEvent(MapEventKind.CargoEntered, 10f, 20f, dims, Now)]);
42+
43+
var active = store.GetActiveMarkers(Guild, Server, MarkerKind.CargoShip);
44+
Assert.Single(active);
45+
Assert.Equal(10f, active[0].X);
46+
Assert.Equal(dims, active[0].Dimensions);
47+
Assert.Equal(Now, active[0].SeenAtUtc);
48+
}
49+
50+
[Fact]
51+
public void Removed_marker_leaves_active_even_when_unalerted()
52+
{
53+
var store = Build();
54+
// A Crate marker produces NO classified event (core-3), but is still tracked in the active set...
55+
store.Apply(Delta([new MapMarkerSnapshot(4, MarkerKind.Crate, 0f, 0f, null)], []), []);
56+
Assert.Single(store.GetActiveMarkers(Guild, Server, MarkerKind.Crate));
57+
// ...and its (un-alerted) removal must still clear it.
58+
store.Apply(Delta([], [new MapMarkerSnapshot(4, MarkerKind.Crate, 0f, 0f, null)]), []);
59+
60+
Assert.Empty(store.GetActiveMarkers(Guild, Server, MarkerKind.Crate));
61+
}
62+
63+
[Fact]
64+
public void Recent_events_are_newest_first_and_bounded_to_ten()
65+
{
66+
var store = Build();
67+
for (var i = 0; i < 12; i++)
68+
{
69+
store.Apply(
70+
Delta([new MapMarkerSnapshot((ulong)i, MarkerKind.CargoShip, i, 0f, null)], []),
71+
[new RustMapEvent(MapEventKind.CargoEntered, i, 0f, null, Now.AddMinutes(i))]);
72+
}
73+
74+
var recent = store.GetRecentEvents(Guild, Server);
75+
Assert.Equal(10, recent.Count);
76+
Assert.Equal(Now.AddMinutes(11), recent[0].AtUtc); // newest first
77+
}
78+
79+
[Fact]
80+
public void Clear_drops_active_and_recent_for_that_server()
81+
{
82+
var store = Build();
83+
store.Apply(Delta([new MapMarkerSnapshot(1, MarkerKind.CargoShip, 0f, 0f, null)], []),
84+
[new RustMapEvent(MapEventKind.CargoEntered, 0f, 0f, null, Now)]);
85+
86+
store.Clear(Guild, Server);
87+
88+
Assert.Empty(store.GetActiveMarkers(Guild, Server, MarkerKind.CargoShip));
89+
Assert.Empty(store.GetRecentEvents(Guild, Server));
90+
}
91+
}

0 commit comments

Comments
 (0)