diff --git a/SVSim.Database/Models/ViewerFriend.cs b/SVSim.Database/Models/ViewerFriend.cs
new file mode 100644
index 0000000..ce58fb2
--- /dev/null
+++ b/SVSim.Database/Models/ViewerFriend.cs
@@ -0,0 +1,13 @@
+namespace SVSim.Database.Models;
+
+///
+/// One row per direction of a friendship. Approving an apply creates two rows
+/// (A → B and B → A). from a played-together row
+/// can be self-joined against this table to detect an existing friendship.
+///
+public class ViewerFriend
+{
+ public long OwnerViewerId { get; set; }
+ public long FriendViewerId { get; set; }
+ public DateTime CreatedAt { get; set; }
+}
diff --git a/SVSim.Database/Models/ViewerFriendApply.cs b/SVSim.Database/Models/ViewerFriendApply.cs
new file mode 100644
index 0000000..e0191be
--- /dev/null
+++ b/SVSim.Database/Models/ViewerFriendApply.cs
@@ -0,0 +1,17 @@
+namespace SVSim.Database.Models;
+
+///
+/// One pending friend application. is the wire apply_id
+/// (auto-generated). Unique on (FromViewerId, ToViewerId) — a viewer can only
+/// have one outstanding apply to any given target.
+///
+public class ViewerFriendApply
+{
+ public int Id { get; set; }
+ public long FromViewerId { get; set; }
+ public long ToViewerId { get; set; }
+ public DateTime CreatedAt { get; set; }
+
+ /// Beginner-friend campaign tag. Defaults to 0 (no campaign). Surfaces as optional mission_type on the wire.
+ public int MissionType { get; set; }
+}
diff --git a/SVSim.Database/Models/ViewerPlayedTogether.cs b/SVSim.Database/Models/ViewerPlayedTogether.cs
new file mode 100644
index 0000000..3509e69
--- /dev/null
+++ b/SVSim.Database/Models/ViewerPlayedTogether.cs
@@ -0,0 +1,17 @@
+namespace SVSim.Database.Models;
+
+///
+/// One row per (owner, opponent) pair. Upserted on each new battle so the table
+/// holds at most one row per opponent. Per-viewer 50-row retention cap pruned
+/// by IPlayedTogetherWriter.RecordAsync.
+///
+public class ViewerPlayedTogether
+{
+ public long OwnerViewerId { get; set; }
+ public long OpponentViewerId { get; set; }
+ public DateTime PlayedAt { get; set; }
+ public int PlayedMode { get; set; }
+ public int BattleType { get; set; }
+ public int DeckFormat { get; set; }
+ public int TwoPickType { get; set; }
+}
diff --git a/SVSim.Database/SVSimDbContext.cs b/SVSim.Database/SVSimDbContext.cs
index 4e18e29..450ddde 100644
--- a/SVSim.Database/SVSimDbContext.cs
+++ b/SVSim.Database/SVSimDbContext.cs
@@ -112,6 +112,10 @@ public class SVSimDbContext : DbContext
public DbSet SerialCodeRewards => Set();
public DbSet ViewerSerialCodeRedemptions => Set();
+ public DbSet ViewerFriends => Set();
+ public DbSet ViewerFriendApplies => Set();
+ public DbSet ViewerPlayedTogethers => Set();
+
#endregion
public override async Task SaveChangesAsync(CancellationToken cancellationToken = default)
@@ -455,6 +459,31 @@ public class SVSimDbContext : DbContext
.OnDelete(DeleteBehavior.Cascade);
});
+ modelBuilder.Entity(b =>
+ {
+ b.HasKey(e => new { e.OwnerViewerId, e.FriendViewerId });
+ b.HasIndex(e => new { e.OwnerViewerId, e.CreatedAt });
+ b.HasOne().WithMany().HasForeignKey(e => e.OwnerViewerId).OnDelete(DeleteBehavior.Cascade);
+ b.HasOne().WithMany().HasForeignKey(e => e.FriendViewerId).OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity(b =>
+ {
+ b.HasKey(e => e.Id);
+ b.HasIndex(e => new { e.FromViewerId, e.ToViewerId }).IsUnique();
+ b.HasIndex(e => e.ToViewerId);
+ b.HasOne().WithMany().HasForeignKey(e => e.FromViewerId).OnDelete(DeleteBehavior.Cascade);
+ b.HasOne().WithMany().HasForeignKey(e => e.ToViewerId).OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity(b =>
+ {
+ b.HasKey(e => new { e.OwnerViewerId, e.OpponentViewerId });
+ b.HasIndex(e => new { e.OwnerViewerId, e.PlayedAt });
+ b.HasOne().WithMany().HasForeignKey(e => e.OwnerViewerId).OnDelete(DeleteBehavior.Cascade);
+ // OpponentViewerId is NOT an FK — we want survivors' history to outlive a deleted opponent.
+ });
+
base.OnModelCreating(modelBuilder);
}
diff --git a/SVSim.UnitTests/Persistence/FriendPersistenceTests.cs b/SVSim.UnitTests/Persistence/FriendPersistenceTests.cs
new file mode 100644
index 0000000..ba7cd5e
--- /dev/null
+++ b/SVSim.UnitTests/Persistence/FriendPersistenceTests.cs
@@ -0,0 +1,111 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using SVSim.Database;
+using SVSim.Database.Models;
+using SVSim.UnitTests.Infrastructure;
+
+namespace SVSim.UnitTests.Persistence;
+
+public class FriendPersistenceTests
+{
+ [Test]
+ public async Task ViewerFriend_round_trips_composite_PK_row()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_001UL);
+ long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_002UL);
+
+ using (var seedScope = factory.Services.CreateScope())
+ {
+ var ctx = seedScope.ServiceProvider.GetRequiredService();
+ ctx.ViewerFriends.Add(new ViewerFriend
+ {
+ OwnerViewerId = viewerA,
+ FriendViewerId = viewerB,
+ CreatedAt = new DateTime(2026, 6, 9, 12, 0, 0, DateTimeKind.Utc),
+ });
+ await ctx.SaveChangesAsync();
+ }
+
+ using var verifyScope = factory.Services.CreateScope();
+ var ctx2 = verifyScope.ServiceProvider.GetRequiredService();
+ var row = await ctx2.ViewerFriends.AsNoTracking()
+ .FirstAsync(f => f.OwnerViewerId == viewerA && f.FriendViewerId == viewerB);
+ Assert.That(row.CreatedAt.Year, Is.EqualTo(2026));
+ }
+
+ [Test]
+ public async Task ViewerFriend_composite_PK_rejects_duplicate_pair()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_003UL);
+ long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_004UL);
+
+ using var scope = factory.Services.CreateScope();
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ ctx.ViewerFriends.Add(new ViewerFriend { OwnerViewerId = viewerA, FriendViewerId = viewerB, CreatedAt = DateTime.UtcNow });
+ await ctx.SaveChangesAsync();
+
+ using var dupScope = factory.Services.CreateScope();
+ var dupCtx = dupScope.ServiceProvider.GetRequiredService();
+ dupCtx.ViewerFriends.Add(new ViewerFriend { OwnerViewerId = viewerA, FriendViewerId = viewerB, CreatedAt = DateTime.UtcNow });
+ Assert.That(async () => await dupCtx.SaveChangesAsync(), Throws.Exception);
+ }
+
+ [Test]
+ public async Task ViewerFriendApply_unique_constraint_rejects_duplicate_from_to_pair()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_005UL);
+ long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_006UL);
+
+ using var scope = factory.Services.CreateScope();
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ ctx.ViewerFriendApplies.Add(new ViewerFriendApply
+ {
+ FromViewerId = viewerA, ToViewerId = viewerB, CreatedAt = DateTime.UtcNow,
+ });
+ await ctx.SaveChangesAsync();
+
+ using var dupScope = factory.Services.CreateScope();
+ var dupCtx = dupScope.ServiceProvider.GetRequiredService();
+ dupCtx.ViewerFriendApplies.Add(new ViewerFriendApply
+ {
+ FromViewerId = viewerA, ToViewerId = viewerB, CreatedAt = DateTime.UtcNow,
+ });
+ Assert.That(async () => await dupCtx.SaveChangesAsync(), Throws.Exception);
+ }
+
+ [Test]
+ public async Task ViewerPlayedTogether_round_trips_all_columns()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_007UL);
+ long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_008UL);
+
+ using (var seedScope = factory.Services.CreateScope())
+ {
+ var ctx = seedScope.ServiceProvider.GetRequiredService();
+ ctx.ViewerPlayedTogethers.Add(new ViewerPlayedTogether
+ {
+ OwnerViewerId = viewerA,
+ OpponentViewerId = viewerB,
+ PlayedAt = new DateTime(2026, 6, 9, 12, 0, 0, DateTimeKind.Utc),
+ PlayedMode = 1,
+ BattleType = 2,
+ DeckFormat = 3,
+ TwoPickType = 4,
+ });
+ await ctx.SaveChangesAsync();
+ }
+
+ using var verifyScope = factory.Services.CreateScope();
+ var ctx2 = verifyScope.ServiceProvider.GetRequiredService();
+ var row = await ctx2.ViewerPlayedTogethers.AsNoTracking()
+ .FirstAsync(p => p.OwnerViewerId == viewerA && p.OpponentViewerId == viewerB);
+ Assert.That(row.PlayedMode, Is.EqualTo(1));
+ Assert.That(row.BattleType, Is.EqualTo(2));
+ Assert.That(row.DeckFormat, Is.EqualTo(3));
+ Assert.That(row.TwoPickType, Is.EqualTo(4));
+ }
+}