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