From 1420c60486d374dbb2bb515d6304ee754d2faf5e Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 26 May 2026 22:40:45 -0400 Subject: [PATCH] feat(bp): repositories + identity generation for runtime-inserted tables Add ValueGeneratedOnAdd to ViewerBattlePassProgress.Id and ViewerBattlePassClaims.Id so Postgres generates IDENTITY values at runtime. Regenerate AddBattlePass migration in-place to include the IdentityByDefaultColumn annotations. Add IBattlePassRepository / BattlePassRepository (season lookup + level-curve cache) and IViewerBattlePassRepository / ViewerBattlePassRepository (get-or-create progress, claim reads/writes). Co-Authored-By: Claude Sonnet 4.6 --- ... 20260527023819_AddBattlePass.Designer.cs} | 8 ++- ...ass.cs => 20260527023819_AddBattlePass.cs} | 7 ++- .../Migrations/SVSimDbContextModelSnapshot.cs | 6 +++ .../BattlePass/BattlePassRepository.cs | 53 +++++++++++++++++++ .../BattlePass/IBattlePassRepository.cs | 27 ++++++++++ .../BattlePass/IViewerBattlePassRepository.cs | 23 ++++++++ .../BattlePass/ViewerBattlePassRepository.cs | 45 ++++++++++++++++ SVSim.Database/SVSimDbContext.cs | 2 + 8 files changed, 168 insertions(+), 3 deletions(-) rename SVSim.Database/Migrations/{20260527021011_AddBattlePass.Designer.cs => 20260527023819_AddBattlePass.Designer.cs} (99%) rename SVSim.Database/Migrations/{20260527021011_AddBattlePass.cs => 20260527023819_AddBattlePass.cs} (95%) create mode 100644 SVSim.Database/Repositories/BattlePass/BattlePassRepository.cs create mode 100644 SVSim.Database/Repositories/BattlePass/IBattlePassRepository.cs create mode 100644 SVSim.Database/Repositories/BattlePass/IViewerBattlePassRepository.cs create mode 100644 SVSim.Database/Repositories/BattlePass/ViewerBattlePassRepository.cs diff --git a/SVSim.Database/Migrations/20260527021011_AddBattlePass.Designer.cs b/SVSim.Database/Migrations/20260527023819_AddBattlePass.Designer.cs similarity index 99% rename from SVSim.Database/Migrations/20260527021011_AddBattlePass.Designer.cs rename to SVSim.Database/Migrations/20260527023819_AddBattlePass.Designer.cs index 7fe7023..4b8b139 100644 --- a/SVSim.Database/Migrations/20260527021011_AddBattlePass.Designer.cs +++ b/SVSim.Database/Migrations/20260527023819_AddBattlePass.Designer.cs @@ -12,7 +12,7 @@ using SVSim.Database; namespace SVSim.Database.Migrations { [DbContext(typeof(SVSimDbContext))] - [Migration("20260527021011_AddBattlePass")] + [Migration("20260527023819_AddBattlePass")] partial class AddBattlePass { /// @@ -1908,8 +1908,11 @@ namespace SVSim.Database.Migrations modelBuilder.Entity("SVSim.Database.Models.ViewerBattlePassClaimEntry", b => { b.Property("Id") + .ValueGeneratedOnAdd() .HasColumnType("bigint"); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("ClaimedAt") .HasColumnType("timestamp with time zone"); @@ -1944,8 +1947,11 @@ namespace SVSim.Database.Migrations modelBuilder.Entity("SVSim.Database.Models.ViewerBattlePassProgressEntry", b => { b.Property("Id") + .ValueGeneratedOnAdd() .HasColumnType("bigint"); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("CurrentPoint") .HasColumnType("integer"); diff --git a/SVSim.Database/Migrations/20260527021011_AddBattlePass.cs b/SVSim.Database/Migrations/20260527023819_AddBattlePass.cs similarity index 95% rename from SVSim.Database/Migrations/20260527021011_AddBattlePass.cs rename to SVSim.Database/Migrations/20260527023819_AddBattlePass.cs index 677649e..31fa6de 100644 --- a/SVSim.Database/Migrations/20260527021011_AddBattlePass.cs +++ b/SVSim.Database/Migrations/20260527023819_AddBattlePass.cs @@ -1,5 +1,6 @@ using System; using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable @@ -35,7 +36,8 @@ namespace SVSim.Database.Migrations name: "ViewerBattlePassClaims", columns: table => new { - Id = table.Column(type: "bigint", nullable: false), + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), ViewerId = table.Column(type: "bigint", nullable: false), SeasonId = table.Column(type: "integer", nullable: false), Track = table.Column(type: "integer", nullable: false), @@ -53,7 +55,8 @@ namespace SVSim.Database.Migrations name: "ViewerBattlePassProgress", columns: table => new { - Id = table.Column(type: "bigint", nullable: false), + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), ViewerId = table.Column(type: "bigint", nullable: false), SeasonId = table.Column(type: "integer", nullable: false), CurrentPoint = table.Column(type: "integer", nullable: false), diff --git a/SVSim.Database/Migrations/SVSimDbContextModelSnapshot.cs b/SVSim.Database/Migrations/SVSimDbContextModelSnapshot.cs index 754ad16..f5a73f7 100644 --- a/SVSim.Database/Migrations/SVSimDbContextModelSnapshot.cs +++ b/SVSim.Database/Migrations/SVSimDbContextModelSnapshot.cs @@ -1905,8 +1905,11 @@ namespace SVSim.Database.Migrations modelBuilder.Entity("SVSim.Database.Models.ViewerBattlePassClaimEntry", b => { b.Property("Id") + .ValueGeneratedOnAdd() .HasColumnType("bigint"); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("ClaimedAt") .HasColumnType("timestamp with time zone"); @@ -1941,8 +1944,11 @@ namespace SVSim.Database.Migrations modelBuilder.Entity("SVSim.Database.Models.ViewerBattlePassProgressEntry", b => { b.Property("Id") + .ValueGeneratedOnAdd() .HasColumnType("bigint"); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("CurrentPoint") .HasColumnType("integer"); diff --git a/SVSim.Database/Repositories/BattlePass/BattlePassRepository.cs b/SVSim.Database/Repositories/BattlePass/BattlePassRepository.cs new file mode 100644 index 0000000..0d60cf5 --- /dev/null +++ b/SVSim.Database/Repositories/BattlePass/BattlePassRepository.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore; +using SVSim.Database.Models; + +namespace SVSim.Database.Repositories.BattlePass; + +public sealed class BattlePassRepository : IBattlePassRepository +{ + private readonly SVSimDbContext _db; + + // Process-level cache for the immutable level curve. Bootstrap re-baseline = host restart = cache cleared. + private static IReadOnlyList? _curveCache; + private static readonly SemaphoreSlim _curveCacheLock = new(1, 1); + + public BattlePassRepository(SVSimDbContext db) + { + _db = db; + } + + public async Task GetActiveSeasonAsync(DateTimeOffset when, CancellationToken ct) + { + return await _db.BattlePassSeasons + .AsNoTracking() + .Where(s => s.StartDate <= when && s.EndDate > when) + .OrderByDescending(s => s.StartDate) + .FirstOrDefaultAsync(ct); + } + + public Task GetSeasonAsync(int seasonId, CancellationToken ct) => + _db.BattlePassSeasons.AsNoTracking().FirstOrDefaultAsync(s => s.Id == seasonId, ct); + + public async Task> GetSeasonRewardsAsync(int seasonId, CancellationToken ct) => + await _db.BattlePassRewards.AsNoTracking() + .Where(r => r.SeasonId == seasonId) + .OrderBy(r => r.Track).ThenBy(r => r.Level) + .ToListAsync(ct); + + public async Task> GetLevelCurveAsync(CancellationToken ct) + { + if (_curveCache is not null) return _curveCache; + await _curveCacheLock.WaitAsync(ct); + try + { + if (_curveCache is null) + { + _curveCache = await _db.BattlePassLevels.AsNoTracking() + .OrderBy(e => e.Level) + .ToListAsync(ct); + } + return _curveCache; + } + finally { _curveCacheLock.Release(); } + } +} diff --git a/SVSim.Database/Repositories/BattlePass/IBattlePassRepository.cs b/SVSim.Database/Repositories/BattlePass/IBattlePassRepository.cs new file mode 100644 index 0000000..bbd7409 --- /dev/null +++ b/SVSim.Database/Repositories/BattlePass/IBattlePassRepository.cs @@ -0,0 +1,27 @@ +using SVSim.Database.Models; + +namespace SVSim.Database.Repositories.BattlePass; + +public interface IBattlePassRepository +{ + /// + /// Active season for the given moment (StartDate <= when < EndDate). Returns null + /// if none. If multiple match (overlap), the most recently started wins. + /// + Task GetActiveSeasonAsync(DateTimeOffset when, CancellationToken ct); + + /// + /// Season by id (no time-window filter). Used by /battle_pass/buy to validate request.season_id. + /// + Task GetSeasonAsync(int seasonId, CancellationToken ct); + + /// + /// All rewards for a season, both tracks. Sorted by (Track, Level) for deterministic wire order. + /// + Task> GetSeasonRewardsAsync(int seasonId, CancellationToken ct); + + /// + /// Global level curve. Cached after first load. + /// + Task> GetLevelCurveAsync(CancellationToken ct); +} diff --git a/SVSim.Database/Repositories/BattlePass/IViewerBattlePassRepository.cs b/SVSim.Database/Repositories/BattlePass/IViewerBattlePassRepository.cs new file mode 100644 index 0000000..633335e --- /dev/null +++ b/SVSim.Database/Repositories/BattlePass/IViewerBattlePassRepository.cs @@ -0,0 +1,23 @@ +using SVSim.Database.Enums; +using SVSim.Database.Models; + +namespace SVSim.Database.Repositories.BattlePass; + +public interface IViewerBattlePassRepository +{ + /// + /// Get-or-create progress row for (viewer, season). New rows are added to the change-tracker + /// but NOT saved — caller batches with other mutations. + /// + Task GetOrCreateProgressAsync(long viewerId, int seasonId, CancellationToken ct); + + /// + /// All claim rows for (viewer, season). Used by /battle_pass/info to enrich is_received. + /// + Task> GetClaimsAsync(long viewerId, int seasonId, CancellationToken ct); + + /// + /// Append a claim row (in-memory; caller saves). + /// + void AddClaim(long viewerId, int seasonId, BattlePassTrack track, int level, DateTimeOffset claimedAt); +} diff --git a/SVSim.Database/Repositories/BattlePass/ViewerBattlePassRepository.cs b/SVSim.Database/Repositories/BattlePass/ViewerBattlePassRepository.cs new file mode 100644 index 0000000..ebdd6ea --- /dev/null +++ b/SVSim.Database/Repositories/BattlePass/ViewerBattlePassRepository.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; +using SVSim.Database.Enums; +using SVSim.Database.Models; + +namespace SVSim.Database.Repositories.BattlePass; + +public sealed class ViewerBattlePassRepository : IViewerBattlePassRepository +{ + private readonly SVSimDbContext _db; + + public ViewerBattlePassRepository(SVSimDbContext db) { _db = db; } + + public async Task GetOrCreateProgressAsync(long viewerId, int seasonId, CancellationToken ct) + { + var existing = await _db.ViewerBattlePassProgress + .FirstOrDefaultAsync(p => p.ViewerId == viewerId && p.SeasonId == seasonId, ct); + if (existing is not null) return existing; + + var entry = new ViewerBattlePassProgressEntry + { + ViewerId = viewerId, + SeasonId = seasonId, + CurrentPoint = 0, + IsPremium = false, + WeeklyPoints = 0, + WeeklyPeriodStart = null, + }; + _db.ViewerBattlePassProgress.Add(entry); + return entry; + } + + public Task> GetClaimsAsync(long viewerId, int seasonId, CancellationToken ct) => + _db.ViewerBattlePassClaims.AsNoTracking() + .Where(c => c.ViewerId == viewerId && c.SeasonId == seasonId) + .ToListAsync(ct); + + public void AddClaim(long viewerId, int seasonId, BattlePassTrack track, int level, DateTimeOffset claimedAt) + { + _db.ViewerBattlePassClaims.Add(new ViewerBattlePassClaimEntry + { + ViewerId = viewerId, SeasonId = seasonId, Track = track, + Level = level, ClaimedAt = claimedAt, + }); + } +} diff --git a/SVSim.Database/SVSimDbContext.cs b/SVSim.Database/SVSimDbContext.cs index 0fce943..6e093a1 100644 --- a/SVSim.Database/SVSimDbContext.cs +++ b/SVSim.Database/SVSimDbContext.cs @@ -230,12 +230,14 @@ public class SVSimDbContext : DbContext modelBuilder.Entity(b => { b.HasKey(e => e.Id); + b.Property(e => e.Id).ValueGeneratedOnAdd(); b.HasIndex(e => new { e.ViewerId, e.SeasonId }).IsUnique(); }); modelBuilder.Entity(b => { b.HasKey(e => e.Id); + b.Property(e => e.Id).ValueGeneratedOnAdd(); b.HasIndex(e => new { e.ViewerId, e.SeasonId, e.Track, e.Level }).IsUnique(); b.HasIndex(e => new { e.ViewerId, e.SeasonId }); });