From 2b6c7bd6a415ba3fc30d4016e2c919000b49b6d0 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Wed, 10 Jun 2026 07:43:50 -0400 Subject: [PATCH] feat(replay): add BattleHistoryWriter with 50-row per-viewer retention Idempotent on (ViewerId, BattleId); evicts oldest CreateTime row when at cap. No-op when ctx is null (server-restart safety). Co-Authored-By: Claude Opus 4.7 --- .../Services/Replay/BattleHistoryWriter.cs | 73 ++++++++++++++ .../Services/Replay/IBattleHistoryWriter.cs | 16 ++++ SVSim.EmulatedEntrypoint/Program.cs | 4 + .../Services/BattleHistoryWriterTests.cs | 95 +++++++++++++++++++ 4 files changed, 188 insertions(+) create mode 100644 SVSim.Database/Services/Replay/BattleHistoryWriter.cs create mode 100644 SVSim.Database/Services/Replay/IBattleHistoryWriter.cs create mode 100644 SVSim.UnitTests/Services/BattleHistoryWriterTests.cs diff --git a/SVSim.Database/Services/Replay/BattleHistoryWriter.cs b/SVSim.Database/Services/Replay/BattleHistoryWriter.cs new file mode 100644 index 0000000..7626964 --- /dev/null +++ b/SVSim.Database/Services/Replay/BattleHistoryWriter.cs @@ -0,0 +1,73 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SVSim.Database.Models; + +namespace SVSim.Database.Services.Replay; + +public sealed class BattleHistoryWriter : IBattleHistoryWriter +{ + internal const int RetentionCap = 50; + + private readonly SVSimDbContext _db; + private readonly ILogger _log; + + public BattleHistoryWriter(SVSimDbContext db, ILogger log) + { + _db = db; + _log = log; + } + + public async Task RecordAsync(long viewerId, BattleContext? ctx, bool isWin, CancellationToken ct) + { + if (ctx is null) + { + _log.LogWarning( + "BattleHistoryWriter.RecordAsync called with null context for viewer {ViewerId} - " + + "likely missed start-time Set (server restart or non-tracked family). Skipping.", + viewerId); + return; + } + + var existing = await _db.ViewerBattleHistories + .AnyAsync(h => h.ViewerId == viewerId && h.BattleId == ctx.BattleId, ct); + if (existing) return; // idempotent + + var count = await _db.ViewerBattleHistories + .CountAsync(h => h.ViewerId == viewerId, ct); + if (count >= RetentionCap) + { + var oldest = await _db.ViewerBattleHistories + .Where(h => h.ViewerId == viewerId) + .OrderBy(h => h.CreateTime) + .FirstAsync(ct); + _db.ViewerBattleHistories.Remove(oldest); + } + + _db.ViewerBattleHistories.Add(new ViewerBattleHistory + { + ViewerId = viewerId, + BattleId = ctx.BattleId, + BattleType = ctx.BattleType, + DeckFormat = ctx.DeckFormat, + TwoPickType = ctx.TwoPickType, + IsLimitTurn = 0, + SelfClassId = ctx.SelfClassId, + SelfSubClassId = ctx.SelfSubClassId, + SelfCharaId = ctx.SelfCharaId, + SelfRotationId = ctx.SelfRotationId, + OpponentClassId = ctx.OpponentClassId, + OpponentSubClassId = ctx.OpponentSubClassId, + OpponentCharaId = ctx.OpponentCharaId, + OpponentName = ctx.OpponentName, + OpponentCountryCode = ctx.OpponentCountryCode, + OpponentEmblemId = ctx.OpponentEmblemId, + OpponentDegreeId = ctx.OpponentDegreeId, + OpponentRotationId = ctx.OpponentRotationId, + IsWin = isWin, + BattleStartTime = ctx.BattleStartTime, + CreateTime = DateTime.UtcNow, + }); + + await _db.SaveChangesAsync(ct); + } +} diff --git a/SVSim.Database/Services/Replay/IBattleHistoryWriter.cs b/SVSim.Database/Services/Replay/IBattleHistoryWriter.cs new file mode 100644 index 0000000..a7b5750 --- /dev/null +++ b/SVSim.Database/Services/Replay/IBattleHistoryWriter.cs @@ -0,0 +1,16 @@ +namespace SVSim.Database.Services.Replay; + +/// +/// Persists battle finishes to ViewerBattleHistory for the /replay/info list view. +/// +public interface IBattleHistoryWriter +{ + /// + /// Insert a history row for (viewerId, ctx.BattleId). No-op when ctx is null + /// (missing context = server restart mid-battle; warn-log and continue). + /// Idempotent on the composite PK — duplicate calls skip silently. + /// Enforces 50-row per-viewer retention by evicting the oldest CreateTime row + /// when at cap before insert. + /// + Task RecordAsync(long viewerId, BattleContext? ctx, bool isWin, CancellationToken ct); +} diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index 03a6399..d8de41b 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -12,6 +12,7 @@ using SVSim.Database.Repositories.Story; using SVSim.Database.Repositories.Viewer; using SVSim.Database.Services; using SVSim.Database.Services.Friend; +using SVSim.Database.Services.Replay; using SVSim.EmulatedEntrypoint.Configuration; using SVSim.EmulatedEntrypoint.Extensions; using SVSim.EmulatedEntrypoint.Matching; @@ -116,6 +117,9 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddSingleton(); + builder.Services.AddScoped(); + // Deck-code mint/resolve uses IMemoryCache for ephemeral (3-min TTL) storage; no DB // row, no migration. Singleton because the cache + RNG seam are process-wide. builder.Services.AddMemoryCache(); diff --git a/SVSim.UnitTests/Services/BattleHistoryWriterTests.cs b/SVSim.UnitTests/Services/BattleHistoryWriterTests.cs new file mode 100644 index 0000000..52d2bd7 --- /dev/null +++ b/SVSim.UnitTests/Services/BattleHistoryWriterTests.cs @@ -0,0 +1,95 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Services.Replay; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Services; + +public class BattleHistoryWriterTests +{ + private static BattleContext MakeCtx(long battleId) => new( + BattleId: battleId, + BattleType: 2, DeckFormat: 0, TwoPickType: 0, + SelfClassId: 1, SelfSubClassId: 0, SelfCharaId: 1, SelfRotationId: "0", + OpponentViewerId: 0, OpponentName: "Bot", OpponentClassId: 2, + OpponentSubClassId: 0, OpponentCharaId: 1, OpponentCountryCode: "", + OpponentEmblemId: 0, OpponentDegreeId: 0, OpponentRotationId: "0", + BattleStartTime: DateTime.UtcNow); + + [Test] + public async Task RecordAsync_writes_row() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_001UL); + + using var scope = factory.Services.CreateScope(); + var writer = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); + + await writer.RecordAsync(viewerId, MakeCtx(battleId: 42), isWin: true, default); + + var row = await db.ViewerBattleHistories + .SingleAsync(h => h.ViewerId == viewerId && h.BattleId == 42); + Assert.That(row.IsWin, Is.True); + Assert.That(row.OpponentName, Is.EqualTo("Bot")); + } + + [Test] + public async Task RecordAsync_null_ctx_is_noop() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_001UL); + + using var scope = factory.Services.CreateScope(); + var writer = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); + + await writer.RecordAsync(viewerId, ctx: null, isWin: true, default); + + Assert.That(await db.ViewerBattleHistories.CountAsync(), Is.EqualTo(0)); + } + + [Test] + public async Task RecordAsync_duplicate_battle_id_skips_silently() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_001UL); + + using var scope = factory.Services.CreateScope(); + var writer = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); + + await writer.RecordAsync(viewerId, MakeCtx(battleId: 42), isWin: true, default); + await writer.RecordAsync(viewerId, MakeCtx(battleId: 42), isWin: false, default); + + // Original row is preserved; the second call is a no-op. + var row = await db.ViewerBattleHistories + .SingleAsync(h => h.ViewerId == viewerId && h.BattleId == 42); + Assert.That(row.IsWin, Is.True); + } + + [Test] + public async Task RecordAsync_evicts_oldest_when_over_retention_cap() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_001UL); + + using var scope = factory.Services.CreateScope(); + var writer = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Write 51 rows. The first (oldest CreateTime) should be evicted on row 51's insert. + for (long i = 1; i <= 51; i++) + { + await writer.RecordAsync(viewerId, MakeCtx(battleId: i), isWin: false, default); + } + + var count = await db.ViewerBattleHistories.CountAsync(h => h.ViewerId == viewerId); + Assert.That(count, Is.EqualTo(50)); + + var hasOldest = await db.ViewerBattleHistories + .AnyAsync(h => h.ViewerId == viewerId && h.BattleId == 1); + Assert.That(hasOldest, Is.False, "battle_id=1 should have been evicted as the oldest"); + } +}