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);
|
||||
}
|
||||
Reference in New Issue
Block a user