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 <noreply@anthropic.com>
This commit is contained in:
73
SVSim.Database/Services/Replay/BattleHistoryWriter.cs
Normal file
73
SVSim.Database/Services/Replay/BattleHistoryWriter.cs
Normal file
@@ -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<BattleHistoryWriter> _log;
|
||||||
|
|
||||||
|
public BattleHistoryWriter(SVSimDbContext db, ILogger<BattleHistoryWriter> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
SVSim.Database/Services/Replay/IBattleHistoryWriter.cs
Normal file
16
SVSim.Database/Services/Replay/IBattleHistoryWriter.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace SVSim.Database.Services.Replay;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists battle finishes to ViewerBattleHistory for the /replay/info list view.
|
||||||
|
/// </summary>
|
||||||
|
public interface IBattleHistoryWriter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
Task RecordAsync(long viewerId, BattleContext? ctx, bool isWin, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ using SVSim.Database.Repositories.Story;
|
|||||||
using SVSim.Database.Repositories.Viewer;
|
using SVSim.Database.Repositories.Viewer;
|
||||||
using SVSim.Database.Services;
|
using SVSim.Database.Services;
|
||||||
using SVSim.Database.Services.Friend;
|
using SVSim.Database.Services.Friend;
|
||||||
|
using SVSim.Database.Services.Replay;
|
||||||
using SVSim.EmulatedEntrypoint.Configuration;
|
using SVSim.EmulatedEntrypoint.Configuration;
|
||||||
using SVSim.EmulatedEntrypoint.Extensions;
|
using SVSim.EmulatedEntrypoint.Extensions;
|
||||||
using SVSim.EmulatedEntrypoint.Matching;
|
using SVSim.EmulatedEntrypoint.Matching;
|
||||||
@@ -116,6 +117,9 @@ public class Program
|
|||||||
builder.Services.AddScoped<IFriendService, FriendService>();
|
builder.Services.AddScoped<IFriendService, FriendService>();
|
||||||
builder.Services.AddScoped<IPlayedTogetherWriter, FriendService>();
|
builder.Services.AddScoped<IPlayedTogetherWriter, FriendService>();
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<IBattleContextStore, InMemoryBattleContextStore>();
|
||||||
|
builder.Services.AddScoped<IBattleHistoryWriter, BattleHistoryWriter>();
|
||||||
|
|
||||||
// Deck-code mint/resolve uses IMemoryCache for ephemeral (3-min TTL) storage; no DB
|
// 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.
|
// row, no migration. Singleton because the cache + RNG seam are process-wide.
|
||||||
builder.Services.AddMemoryCache();
|
builder.Services.AddMemoryCache();
|
||||||
|
|||||||
95
SVSim.UnitTests/Services/BattleHistoryWriterTests.cs
Normal file
95
SVSim.UnitTests/Services/BattleHistoryWriterTests.cs
Normal file
@@ -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<IBattleHistoryWriter>();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
|
||||||
|
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<IBattleHistoryWriter>();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
|
||||||
|
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<IBattleHistoryWriter>();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
|
||||||
|
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<IBattleHistoryWriter>();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
|
||||||
|
// 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user