feat(replay): add ViewerBattleHistory entity + migration
New table backs /replay/info; composite PK (ViewerId, BattleId), index on (ViewerId, CreateTime) for the newest-first list query. 50-row per-viewer retention enforced by BattleHistoryWriter (next commit). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
4641
SVSim.Database/Migrations/20260610113113_AddViewerBattleHistory.Designer.cs
generated
Normal file
4641
SVSim.Database/Migrations/20260610113113_AddViewerBattleHistory.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SVSim.Database.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddViewerBattleHistory : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ViewerBattleHistories",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ViewerId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
BattleId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
BattleType = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
DeckFormat = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
TwoPickType = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
IsLimitTurn = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
SelfClassId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
SelfSubClassId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
SelfCharaId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
SelfRotationId = table.Column<string>(type: "text", nullable: false),
|
||||||
|
OpponentClassId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
OpponentSubClassId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
OpponentCharaId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
OpponentName = table.Column<string>(type: "text", nullable: false),
|
||||||
|
OpponentCountryCode = table.Column<string>(type: "text", nullable: false),
|
||||||
|
OpponentEmblemId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
OpponentDegreeId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
OpponentRotationId = table.Column<string>(type: "text", nullable: false),
|
||||||
|
IsWin = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
BattleStartTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CreateTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ViewerBattleHistories", x => new { x.ViewerId, x.BattleId });
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ViewerBattleHistories_ViewerId_CreateTime",
|
||||||
|
table: "ViewerBattleHistories",
|
||||||
|
columns: new[] { "ViewerId", "CreateTime" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ViewerBattleHistories");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2843,6 +2843,83 @@ namespace SVSim.Database.Migrations
|
|||||||
b.ToTable("ViewerArenaTwoPickRuns");
|
b.ToTable("ViewerArenaTwoPickRuns");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SVSim.Database.Models.ViewerBattleHistory", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("ViewerId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<long>("BattleId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<DateTime>("BattleStartTime")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("BattleType")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreateTime")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("DeckFormat")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("IsLimitTurn")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("IsWin")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("OpponentCharaId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("OpponentClassId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("OpponentCountryCode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<long>("OpponentDegreeId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<long>("OpponentEmblemId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<string>("OpponentName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("OpponentRotationId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("OpponentSubClassId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("SelfCharaId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("SelfClassId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("SelfRotationId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("SelfSubClassId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("TwoPickType")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("ViewerId", "BattleId");
|
||||||
|
|
||||||
|
b.HasIndex("ViewerId", "CreateTime")
|
||||||
|
.HasDatabaseName("IX_ViewerBattleHistories_ViewerId_CreateTime");
|
||||||
|
|
||||||
|
b.ToTable("ViewerBattleHistories");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SVSim.Database.Models.ViewerBattlePassClaimEntry", b =>
|
modelBuilder.Entity("SVSim.Database.Models.ViewerBattlePassClaimEntry", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
|
|||||||
39
SVSim.Database/Models/ViewerBattleHistory.cs
Normal file
39
SVSim.Database/Models/ViewerBattleHistory.cs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
namespace SVSim.Database.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row per recent battle the viewer participated in, surfaced by /replay/info.
|
||||||
|
/// Composite PK on (ViewerId, BattleId). Retention: 50 rows per viewer, oldest
|
||||||
|
/// evicted on insert (see <see cref="Services.Replay.BattleHistoryWriter"/>).
|
||||||
|
///
|
||||||
|
/// The battle payload itself is NOT stored here — the client uses its local
|
||||||
|
/// <c>NewReplay/<battle_id>/</c> cache for playback. See
|
||||||
|
/// <c>docs/superpowers/specs/2026-06-10-replay-info-design.md</c>.
|
||||||
|
/// </summary>
|
||||||
|
public class ViewerBattleHistory
|
||||||
|
{
|
||||||
|
public long ViewerId { get; set; }
|
||||||
|
public long BattleId { get; set; }
|
||||||
|
|
||||||
|
public int BattleType { get; set; }
|
||||||
|
public int DeckFormat { get; set; }
|
||||||
|
public int TwoPickType { get; set; }
|
||||||
|
public int IsLimitTurn { get; set; }
|
||||||
|
|
||||||
|
public int SelfClassId { get; set; }
|
||||||
|
public int SelfSubClassId { get; set; }
|
||||||
|
public int SelfCharaId { get; set; }
|
||||||
|
public string SelfRotationId { get; set; } = "0";
|
||||||
|
|
||||||
|
public int OpponentClassId { get; set; }
|
||||||
|
public int OpponentSubClassId { get; set; }
|
||||||
|
public int OpponentCharaId { get; set; }
|
||||||
|
public string OpponentName { get; set; } = "";
|
||||||
|
public string OpponentCountryCode { get; set; } = "";
|
||||||
|
public long OpponentEmblemId { get; set; }
|
||||||
|
public long OpponentDegreeId { get; set; }
|
||||||
|
public string OpponentRotationId { get; set; } = "0";
|
||||||
|
|
||||||
|
public bool IsWin { get; set; }
|
||||||
|
public DateTime BattleStartTime { get; set; }
|
||||||
|
public DateTime CreateTime { get; set; }
|
||||||
|
}
|
||||||
@@ -115,6 +115,7 @@ public class SVSimDbContext : DbContext
|
|||||||
public DbSet<ViewerFriend> ViewerFriends => Set<ViewerFriend>();
|
public DbSet<ViewerFriend> ViewerFriends => Set<ViewerFriend>();
|
||||||
public DbSet<ViewerFriendApply> ViewerFriendApplies => Set<ViewerFriendApply>();
|
public DbSet<ViewerFriendApply> ViewerFriendApplies => Set<ViewerFriendApply>();
|
||||||
public DbSet<ViewerPlayedTogether> ViewerPlayedTogethers => Set<ViewerPlayedTogether>();
|
public DbSet<ViewerPlayedTogether> ViewerPlayedTogethers => Set<ViewerPlayedTogether>();
|
||||||
|
public DbSet<ViewerBattleHistory> ViewerBattleHistories => Set<ViewerBattleHistory>();
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -484,6 +485,17 @@ public class SVSimDbContext : DbContext
|
|||||||
// OpponentViewerId is NOT an FK — we want survivors' history to outlive a deleted opponent.
|
// OpponentViewerId is NOT an FK — we want survivors' history to outlive a deleted opponent.
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<ViewerBattleHistory>(b =>
|
||||||
|
{
|
||||||
|
b.HasKey(e => new { e.ViewerId, e.BattleId });
|
||||||
|
b.HasIndex(e => new { e.ViewerId, e.CreateTime })
|
||||||
|
.HasDatabaseName("IX_ViewerBattleHistories_ViewerId_CreateTime");
|
||||||
|
b.Property(e => e.SelfRotationId).IsRequired();
|
||||||
|
b.Property(e => e.OpponentName).IsRequired();
|
||||||
|
b.Property(e => e.OpponentCountryCode).IsRequired();
|
||||||
|
b.Property(e => e.OpponentRotationId).IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SVSim.Database;
|
||||||
|
using SVSim.Database.Models;
|
||||||
|
using SVSim.UnitTests.Infrastructure;
|
||||||
|
|
||||||
|
namespace SVSim.UnitTests.Persistence;
|
||||||
|
|
||||||
|
public class ViewerBattleHistoryPersistenceTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task ViewerBattleHistory_round_trips_composite_PK_row()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_001UL);
|
||||||
|
|
||||||
|
using (var seedScope = factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = seedScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
db.ViewerBattleHistories.Add(new ViewerBattleHistory
|
||||||
|
{
|
||||||
|
ViewerId = viewerId,
|
||||||
|
BattleId = 234_471_983_876L,
|
||||||
|
BattleType = 4,
|
||||||
|
DeckFormat = 2,
|
||||||
|
TwoPickType = 0,
|
||||||
|
IsLimitTurn = 0,
|
||||||
|
SelfClassId = 8,
|
||||||
|
SelfSubClassId = 0,
|
||||||
|
SelfCharaId = 8,
|
||||||
|
SelfRotationId = "0",
|
||||||
|
OpponentClassId = 5,
|
||||||
|
OpponentSubClassId = 0,
|
||||||
|
OpponentCharaId = 805,
|
||||||
|
OpponentName = "Foo",
|
||||||
|
OpponentCountryCode = "",
|
||||||
|
OpponentEmblemId = 721_341_010L,
|
||||||
|
OpponentDegreeId = 120_023L,
|
||||||
|
OpponentRotationId = "0",
|
||||||
|
IsWin = false,
|
||||||
|
BattleStartTime = new DateTime(2026, 6, 4, 17, 13, 13, DateTimeKind.Utc),
|
||||||
|
CreateTime = new DateTime(2026, 6, 4, 17, 16, 6, DateTimeKind.Utc),
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var readScope = factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = readScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
var row = await db.ViewerBattleHistories.SingleAsync(h =>
|
||||||
|
h.ViewerId == viewerId && h.BattleId == 234_471_983_876L);
|
||||||
|
Assert.That(row.OpponentName, Is.EqualTo("Foo"));
|
||||||
|
Assert.That(row.OpponentEmblemId, Is.EqualTo(721_341_010L));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ViewerBattleHistory_composite_PK_rejects_duplicate_battle_id_for_same_viewer()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_001UL);
|
||||||
|
|
||||||
|
using (var seedScope = factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = seedScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
db.ViewerBattleHistories.Add(NewRow(viewerId, battleId: 1L));
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var dupScope = factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = dupScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
db.ViewerBattleHistories.Add(NewRow(viewerId, battleId: 1L));
|
||||||
|
Assert.ThrowsAsync<DbUpdateException>(async () => await db.SaveChangesAsync());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ViewerBattleHistory NewRow(long viewerId, long battleId) => new()
|
||||||
|
{
|
||||||
|
ViewerId = viewerId,
|
||||||
|
BattleId = battleId,
|
||||||
|
SelfRotationId = "0",
|
||||||
|
OpponentName = "",
|
||||||
|
OpponentCountryCode = "",
|
||||||
|
OpponentRotationId = "0",
|
||||||
|
BattleStartTime = DateTime.UtcNow,
|
||||||
|
CreateTime = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user