diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index 31ff1b6..4553aa0 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -117,6 +117,10 @@ public class Program builder.Services.AddMemoryCache(); builder.Services.AddSingleton(); + // 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(); + #endregion builder.Services.AddBattleNode(opt => diff --git a/SVSim.EmulatedEntrypoint/Services/HomeDialogSessionTracker.cs b/SVSim.EmulatedEntrypoint/Services/HomeDialogSessionTracker.cs new file mode 100644 index 0000000..44cc670 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Services/HomeDialogSessionTracker.cs @@ -0,0 +1,35 @@ +using System.Collections.Concurrent; + +namespace SVSim.EmulatedEntrypoint.Services; + +/// +/// 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). +/// +public interface IHomeDialogSessionTracker +{ + /// True iff this dialog has not yet been emitted for this viewer in this + /// process. Marks as fired on success. + bool TryReserve(long viewerShortUdid, int dialogId); +} + +public sealed class HomeDialogSessionTracker : IHomeDialogSessionTracker +{ + private readonly ConcurrentDictionary> _firedByViewer = new(); + + public bool TryReserve(long viewerShortUdid, int dialogId) + { + var set = _firedByViewer.GetOrAdd(viewerShortUdid, _ => new HashSet()); + // HashSet is NOT thread-safe โ€” lock on the per-viewer set instance so + // we don't serialize across viewers. + lock (set) + { + return set.Add(dialogId); + } + } +} diff --git a/SVSim.UnitTests/Services/HomeDialogSessionTrackerTests.cs b/SVSim.UnitTests/Services/HomeDialogSessionTrackerTests.cs new file mode 100644 index 0000000..609390b --- /dev/null +++ b/SVSim.UnitTests/Services/HomeDialogSessionTrackerTests.cs @@ -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."); + } +}