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 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-10 07:38:37 -04:00
parent 0bb0f46abc
commit 869f9ce13d
4 changed files with 121 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
namespace SVSim.Database.Services.Replay;
/// <summary>
/// Per-viewer battle context captured at start time (do_matching/start) and consumed
/// at finish time. Lives in <see cref="IBattleContextStore"/> for the duration of a
/// single battle. See docs/superpowers/specs/2026-06-10-replay-info-design.md.
/// </summary>
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);

View File

@@ -0,0 +1,17 @@
namespace SVSim.Database.Services.Replay;
/// <summary>
/// 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.
/// </summary>
public interface IBattleContextStore
{
/// <summary>Store the viewer's active battle context. Overwrites any prior entry.</summary>
void Set(long viewerId, BattleContext ctx);
/// <summary>Atomic read+clear. Returns null when no context (server restart,
/// non-tracked family, already taken). Finish handlers must tolerate null.</summary>
BattleContext? TakeFor(long viewerId);
}

View File

@@ -0,0 +1,20 @@
using System.Collections.Concurrent;
namespace SVSim.Database.Services.Replay;
/// <summary>
/// <see cref="ConcurrentDictionary{TKey, TValue}"/>-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).
/// </summary>
public sealed class InMemoryBattleContextStore : IBattleContextStore
{
private readonly ConcurrentDictionary<long, BattleContext> _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;
}

View File

@@ -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));
}
}