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)); + } +}