feat(home-dialog): per-session suppression tracker
Singleton keyed by ShortUdid; lock on per-viewer set to avoid cross-viewer contention. Process lifetime — restart re-fires. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -117,6 +117,10 @@ public class Program
|
|||||||
builder.Services.AddMemoryCache();
|
builder.Services.AddMemoryCache();
|
||||||
builder.Services.AddSingleton<IDeckCodeService, DeckCodeService>();
|
builder.Services.AddSingleton<IDeckCodeService, DeckCodeService>();
|
||||||
|
|
||||||
|
// Per-process per-viewer tracker for home_dialog_list suppression on /mypage/index.
|
||||||
|
// Restart re-fires once per viewer — documented trade in the design spec.
|
||||||
|
builder.Services.AddSingleton<IHomeDialogSessionTracker, HomeDialogSessionTracker>();
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
builder.Services.AddBattleNode(opt =>
|
builder.Services.AddBattleNode(opt =>
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace SVSim.EmulatedEntrypoint.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records which home_dialog_list entries have already been emitted to which viewer
|
||||||
|
/// during the current server-process lifetime. Used by MyPageController to suppress
|
||||||
|
/// re-firing the popup on subsequent /mypage/index calls.
|
||||||
|
///
|
||||||
|
/// Keyed by ShortUdid (stable for the viewer's lifetime), NOT the rotating SID.
|
||||||
|
/// Lifetime is the host process — restart re-fires once per viewer (documented trade
|
||||||
|
/// in docs/superpowers/specs/2026-06-08-home-dialog-list-design.md §1).
|
||||||
|
/// </summary>
|
||||||
|
public interface IHomeDialogSessionTracker
|
||||||
|
{
|
||||||
|
/// <summary>True iff this dialog has not yet been emitted for this viewer in this
|
||||||
|
/// process. Marks as fired on success.</summary>
|
||||||
|
bool TryReserve(long viewerShortUdid, int dialogId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class HomeDialogSessionTracker : IHomeDialogSessionTracker
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<long, HashSet<int>> _firedByViewer = new();
|
||||||
|
|
||||||
|
public bool TryReserve(long viewerShortUdid, int dialogId)
|
||||||
|
{
|
||||||
|
var set = _firedByViewer.GetOrAdd(viewerShortUdid, _ => new HashSet<int>());
|
||||||
|
// HashSet<int> is NOT thread-safe — lock on the per-viewer set instance so
|
||||||
|
// we don't serialize across viewers.
|
||||||
|
lock (set)
|
||||||
|
{
|
||||||
|
return set.Add(dialogId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
SVSim.UnitTests/Services/HomeDialogSessionTrackerTests.cs
Normal file
53
SVSim.UnitTests/Services/HomeDialogSessionTrackerTests.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using SVSim.EmulatedEntrypoint.Services;
|
||||||
|
|
||||||
|
namespace SVSim.UnitTests.Services;
|
||||||
|
|
||||||
|
public class HomeDialogSessionTrackerTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void TryReserve_returns_true_first_time_and_false_on_repeat_for_same_viewer_and_dialog()
|
||||||
|
{
|
||||||
|
var tracker = new HomeDialogSessionTracker();
|
||||||
|
|
||||||
|
Assert.That(tracker.TryReserve(viewerShortUdid: 100, dialogId: 1), Is.True);
|
||||||
|
Assert.That(tracker.TryReserve(viewerShortUdid: 100, dialogId: 1), Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TryReserve_is_independent_across_viewers()
|
||||||
|
{
|
||||||
|
var tracker = new HomeDialogSessionTracker();
|
||||||
|
|
||||||
|
Assert.That(tracker.TryReserve(100, 1), Is.True);
|
||||||
|
Assert.That(tracker.TryReserve(200, 1), Is.True, "viewer 200 must see the dialog even though viewer 100 already did");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TryReserve_is_independent_across_dialog_ids_for_one_viewer()
|
||||||
|
{
|
||||||
|
var tracker = new HomeDialogSessionTracker();
|
||||||
|
|
||||||
|
Assert.That(tracker.TryReserve(100, 1), Is.True);
|
||||||
|
Assert.That(tracker.TryReserve(100, 2), Is.True);
|
||||||
|
Assert.That(tracker.TryReserve(100, 1), Is.False);
|
||||||
|
Assert.That(tracker.TryReserve(100, 2), Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TryReserve_is_thread_safe_under_concurrent_calls_for_one_viewer()
|
||||||
|
{
|
||||||
|
var tracker = new HomeDialogSessionTracker();
|
||||||
|
const int dialogId = 42;
|
||||||
|
const int parallelism = 200;
|
||||||
|
int trueCount = 0;
|
||||||
|
|
||||||
|
Parallel.For(0, parallelism, _ =>
|
||||||
|
{
|
||||||
|
if (tracker.TryReserve(viewerShortUdid: 1, dialogId: dialogId))
|
||||||
|
Interlocked.Increment(ref trueCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.That(trueCount, Is.EqualTo(1),
|
||||||
|
"Exactly one thread must win the reservation; the rest must observe false.");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user