From 869f9ce13d8276db6addf60f342a9411f6e040ea Mon Sep 17 00:00:00 2001 From: gamer147 Date: Wed, 10 Jun 2026 07:38:37 -0400 Subject: [PATCH] feat(replay): add BattleContextStore for start->finish handoff Bridges the start-time -> finish-time gap. /finish carries neither battle_id nor opponent identity; this store holds both for the finish handler to compose a ViewerBattleHistory row. Co-Authored-By: Claude Opus 4.7 --- .../Services/Replay/BattleContext.cs | 26 +++++++++ .../Services/Replay/IBattleContextStore.cs | 17 ++++++ .../Replay/InMemoryBattleContextStore.cs | 20 +++++++ .../InMemoryBattleContextStoreTests.cs | 58 +++++++++++++++++++ 4 files changed, 121 insertions(+) create mode 100644 SVSim.Database/Services/Replay/BattleContext.cs create mode 100644 SVSim.Database/Services/Replay/IBattleContextStore.cs create mode 100644 SVSim.Database/Services/Replay/InMemoryBattleContextStore.cs create mode 100644 SVSim.UnitTests/Services/InMemoryBattleContextStoreTests.cs diff --git a/SVSim.Database/Services/Replay/BattleContext.cs b/SVSim.Database/Services/Replay/BattleContext.cs new file mode 100644 index 0000000..70460df --- /dev/null +++ b/SVSim.Database/Services/Replay/BattleContext.cs @@ -0,0 +1,26 @@ +namespace SVSim.Database.Services.Replay; + +/// +/// Per-viewer battle context captured at start time (do_matching/start) and consumed +/// at finish time. Lives in for the duration of a +/// single battle. See docs/superpowers/specs/2026-06-10-replay-info-design.md. +/// +public sealed record BattleContext( + long BattleId, + int BattleType, + int DeckFormat, + int TwoPickType, + int SelfClassId, + int SelfSubClassId, + int SelfCharaId, + string SelfRotationId, + int OpponentViewerId, + string OpponentName, + int OpponentClassId, + int OpponentSubClassId, + int OpponentCharaId, + string OpponentCountryCode, + long OpponentEmblemId, + long OpponentDegreeId, + string OpponentRotationId, + DateTime BattleStartTime); diff --git a/SVSim.Database/Services/Replay/IBattleContextStore.cs b/SVSim.Database/Services/Replay/IBattleContextStore.cs new file mode 100644 index 0000000..baf2013 --- /dev/null +++ b/SVSim.Database/Services/Replay/IBattleContextStore.cs @@ -0,0 +1,17 @@ +namespace SVSim.Database.Services.Replay; + +/// +/// In-memory per-viewer battle context store. Bridges the start-time → finish-time +/// gap: the /finish request body carries neither battle_id nor opponent identity, +/// so this stash holds everything the finish hook needs to compose a +/// ViewerBattleHistory row. +/// +public interface IBattleContextStore +{ + /// Store the viewer's active battle context. Overwrites any prior entry. + void Set(long viewerId, BattleContext ctx); + + /// Atomic read+clear. Returns null when no context (server restart, + /// non-tracked family, already taken). Finish handlers must tolerate null. + BattleContext? TakeFor(long viewerId); +} diff --git a/SVSim.Database/Services/Replay/InMemoryBattleContextStore.cs b/SVSim.Database/Services/Replay/InMemoryBattleContextStore.cs new file mode 100644 index 0000000..7d4f749 --- /dev/null +++ b/SVSim.Database/Services/Replay/InMemoryBattleContextStore.cs @@ -0,0 +1,20 @@ +using System.Collections.Concurrent; + +namespace SVSim.Database.Services.Replay; + +/// +/// -backed in-memory store. +/// Lives as a singleton in DI. Server restart drops in-flight contexts — +/// acceptable per spec (history is best-effort; finish handlers warn-log +/// and continue when context is missing). +/// +public sealed class InMemoryBattleContextStore : IBattleContextStore +{ + private readonly ConcurrentDictionary _contexts = new(); + + public void Set(long viewerId, BattleContext ctx) + => _contexts[viewerId] = ctx; + + public BattleContext? TakeFor(long viewerId) + => _contexts.TryRemove(viewerId, out var ctx) ? ctx : null; +} diff --git a/SVSim.UnitTests/Services/InMemoryBattleContextStoreTests.cs b/SVSim.UnitTests/Services/InMemoryBattleContextStoreTests.cs new file mode 100644 index 0000000..601345e --- /dev/null +++ b/SVSim.UnitTests/Services/InMemoryBattleContextStoreTests.cs @@ -0,0 +1,58 @@ +using SVSim.Database.Services.Replay; + +namespace SVSim.UnitTests.Services; + +public class InMemoryBattleContextStoreTests +{ + private static BattleContext MakeCtx(long battleId = 1, int oppViewer = 0) => new( + BattleId: battleId, + BattleType: 2, + DeckFormat: 0, + TwoPickType: 0, + SelfClassId: 1, + SelfSubClassId: 0, + SelfCharaId: 1, + SelfRotationId: "0", + OpponentViewerId: oppViewer, + OpponentName: "Opponent", + OpponentClassId: 2, + OpponentSubClassId: 0, + OpponentCharaId: 1, + OpponentCountryCode: "", + OpponentEmblemId: 0, + OpponentDegreeId: 0, + OpponentRotationId: "0", + BattleStartTime: DateTime.UtcNow); + + [Test] + public void Set_then_TakeFor_returns_ctx_and_clears() + { + var store = new InMemoryBattleContextStore(); + var ctx = MakeCtx(battleId: 42); + + store.Set(viewerId: 100, ctx); + var taken = store.TakeFor(100); + + Assert.That(taken, Is.Not.Null); + Assert.That(taken!.BattleId, Is.EqualTo(42)); + Assert.That(store.TakeFor(100), Is.Null, "second take must return null"); + } + + [Test] + public void TakeFor_missing_viewer_returns_null() + { + var store = new InMemoryBattleContextStore(); + Assert.That(store.TakeFor(999), Is.Null); + } + + [Test] + public void Set_overwrites_prior_context_for_same_viewer() + { + var store = new InMemoryBattleContextStore(); + store.Set(viewerId: 100, MakeCtx(battleId: 1)); + store.Set(viewerId: 100, MakeCtx(battleId: 2)); + + var taken = store.TakeFor(100); + Assert.That(taken!.BattleId, Is.EqualTo(2)); + } +}