using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using SVSim.Database.Common; using SVSim.Database.Entities.Story; using SVSim.Database.Models; using SVSim.Database.Models.Config; namespace SVSim.Database; public class SVSimDbContext : DbContext { private readonly ILogger _logger; public SVSimDbContext(ILogger logger, DbContextOptions options) : base(options) { _logger = logger; } #region DbSets public DbSet Viewers => Set(); public DbSet Cards => Set(); public DbSet CardSets => Set(); public DbSet Decks => Set(); public DbSet CardCosmeticRewards => Set(); public DbSet Classes => Set(); public DbSet ClassExpCurve => Set(); public DbSet LeaderSkins => Set(); public DbSet Sleeves => Set(); public DbSet Emblems => Set(); public DbSet Degrees => Set(); public DbSet MyPageBackgrounds => Set(); public DbSet Battlefields => Set(); public DbSet RankInfo => Set(); public DbSet Items => Set(); public DbSet GameConfigs => Set(); // Prod-captured globals — populated by SVSim.Bootstrap, not HasData. See // docs/audits/prod-data-capture-strategy-2026-05-23.md. public DbSet MyRotationSettings => Set(); public DbSet MyRotationAbilities => Set(); public DbSet AvatarAbilities => Set(); public DbSet DefaultDecks => Set(); public DbSet ArenaSeasons => Set(); public DbSet SpotCards => Set(); public DbSet ReprintedCards => Set(); public DbSet UnlimitedRestrictions => Set(); public DbSet LoadingExclusionCards => Set(); public DbSet BattlePassLevels => Set(); public DbSet BattlePassSeasons => Set(); public DbSet BattlePassRewards => Set(); public DbSet ViewerBattlePassProgress => Set(); public DbSet ViewerBattlePassClaims => Set(); public DbSet MissionCatalog => Set(); public DbSet AchievementCatalog => Set(); public DbSet BattlePassMonthlyMissions => Set(); public DbSet ViewerMissions => Set(); public DbSet ViewerAchievements => Set(); public DbSet ViewerEventCounters => Set(); public DbSet DailyLoginBonuses => Set(); public DbSet Banners => Set(); public DbSet HomeDialogEntries => Set(); public DbSet Colosseums => Set(); public DbSet SealedSeasons => Set(); public DbSet MasterPointRankingPeriods => Set(); public DbSet SpecialDeckFormats => Set(); public DbSet PaymentItems => Set(); public DbSet Packs => Set(); public DbSet PackDrawConfigs => Set(); public DbSet PackDrawSlotRates => Set(); public DbSet PackDrawCardWeights => Set(); public DbSet BuildDeckSeries => Set(); public DbSet BuildDeckProducts => Set(); public DbSet StoryDecks => Set(); public DbSet SleeveShopSeries => Set(); public DbSet SleeveShopProducts => Set(); public DbSet ItemPurchaseCatalog => Set(); public DbSet LeaderSkinShopSeries => Set(); public DbSet LeaderSkinShopProducts => Set(); public DbSet ViewerLeaderSkinSetClaims => Set(); public DbSet SpotCardExchangeCatalog => Set(); public DbSet ViewerSpotCardExchanges => Set(); public DbSet MaintenanceCards => Set(); public DbSet FeatureMaintenances => Set(); public DbSet PreReleaseInfos => Set(); public DbSet PracticeOpponents => Set(); public DbSet BotRoster => Set(); public DbSet PuzzleGroups => Set(); public DbSet Puzzles => Set(); public DbSet PuzzleMissions => Set(); public DbSet ViewerPuzzleClears => Set(); // Story reference data + viewer progress public DbSet StoryWorlds => Set(); public DbSet StorySections => Set(); public DbSet StoryChapters => Set(); public DbSet SpecialBattleSettings => Set(); public DbSet ViewerStoryProgress => Set(); public DbSet ViewerStoryBranchUnlocks => Set(); public DbSet ViewerPresents => Set(); public DbSet TutorialPresentEntries => Set(); public DbSet ArenaTwoPickRewards { get; set; } = null!; public DbSet ViewerArenaTwoPickRuns { get; set; } = null!; #endregion public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) { foreach (var entityEntry in ChangeTracker.Entries()) { if (entityEntry.Entity is ITimeTrackedEntity timeTrackedEntity) { if (entityEntry.State is EntityState.Added && timeTrackedEntity.DateCreated == DateTime.MinValue) { timeTrackedEntity.DateCreated = DateTime.UtcNow; } if (entityEntry.State is EntityState.Modified or EntityState.Added) { timeTrackedEntity.DateUpdated = DateTime.UtcNow; } } } return await base.SaveChangesAsync(cancellationToken); } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity() .OwnsMany(de => de.Cards); // BaseEntity annotates Id with [DatabaseGenerated(None)] for the integer-PK // entities seeded via HasData. ShadowverseDeckEntry uses Guid and is created at // runtime — without client-side generation every new deck gets Guid.Empty and the // second deck insert collides on PK. (DDL has no column default; this only works // because EF generates a sequential Guid before INSERT.) modelBuilder.Entity() .Property(d => d.Id) .ValueGeneratedOnAdd(); // EF can't figure this many-to-many out on its own modelBuilder.Entity() .HasMany(se => se.Viewers) .WithMany(v => v.Sleeves); modelBuilder.HasSequence("ShortUdidSequence").StartsAt(400000000); modelBuilder.Entity() .Property(v => v.ShortUdid) .UseSequence("ShortUdidSequence"); modelBuilder.Entity().OwnsMany(p => p.ChildGachas); modelBuilder.Entity().OwnsMany(p => p.Banners); modelBuilder.Entity(e => { e.HasIndex(x => new { x.PackId, x.Slot, x.Tier }).IsUnique(); }); modelBuilder.Entity(e => { e.HasIndex(x => new { x.PackId, x.Slot, x.Tier }); }); modelBuilder.Entity().OwnsMany(v => v.PackOpenCounts); modelBuilder.Entity().OwnsMany(v => v.FreePackClaims, b => { b.WithOwner().HasForeignKey("ViewerId"); b.HasKey("ViewerId", nameof(ViewerFreePackClaim.FreeGachaCampaignId)); b.Property(x => x.FreeGachaCampaignId).ValueGeneratedNever(); }); // OwnedCardEntry and OwnedItemEntry use composite PK (ViewerId, Id) where Id is auto- // generated, which silently permits multiple rows per (Viewer, Card) or (Viewer, Item). // The intended semantic is one row per pair with Count as multiplicity — enforce that as // a unique index so any future find-or-add that forgets to .Include the collection (and // therefore re-creates a row that already exists in the DB) crashes loudly at SaveChanges // instead of silently duplicating ownership rows. modelBuilder.Entity().OwnsMany(v => v.Cards, b => { b.HasIndex("ViewerId", "CardId").IsUnique(); }); modelBuilder.Entity().OwnsMany(v => v.Items, b => { b.HasIndex("ViewerId", "ItemId").IsUnique(); }); modelBuilder.Entity().OwnsMany(v => v.BuildDeckPurchases, b => { b.HasIndex("ViewerId", "ProductId").IsUnique(); }); modelBuilder.Entity().OwnsMany(v => v.GachaPointBalances, b => { b.HasIndex("ViewerId", "PackId").IsUnique(); }); modelBuilder.Entity().OwnsMany(v => v.GachaPointReceived, b => { b.HasIndex("ViewerId", "PackId", "CardId").IsUnique(); }); // A given social account links to exactly one viewer — two viewers cannot share the same // Steam (or Facebook, etc.) account. This is the dedup backstop the auth handler's find- // or-link path (SteamSessionAuthenticationHandler) relies on: two concurrent first-Steam- // touch requests can both pass the .Any(...) check in LinkSteamToViewer, but the second // SaveChanges() throws unique-violation and surfaces a clean 500 instead of silently // appending duplicate connections. modelBuilder.Entity().OwnsMany(v => v.SocialAccountConnections, b => { b.HasIndex("AccountType", "AccountId").IsUnique(); }); modelBuilder.Entity().OwnsMany(s => s.SeriesRewards); modelBuilder.Entity().OwnsMany(p => p.Cards); modelBuilder.Entity().OwnsMany(p => p.Rewards); modelBuilder.Entity() .HasOne(p => p.Series) .WithMany(s => s.Products) .HasForeignKey(p => p.SeriesId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity().HasIndex(p => p.SeriesId); modelBuilder.Entity().OwnsMany(p => p.Rewards); modelBuilder.Entity() .HasOne(p => p.Series) .WithMany(s => s.Products) .HasForeignKey(p => p.SeriesId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity().HasIndex(p => p.SeriesId); modelBuilder.Entity().OwnsMany(s => s.SetCompletionRewards); modelBuilder.Entity().OwnsMany(p => p.Rewards); modelBuilder.Entity() .HasOne(p => p.Series) .WithMany(s => s.Products) .HasForeignKey(p => p.SeriesId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity().HasIndex(p => p.SeriesId); modelBuilder.Entity(b => { b.HasKey(c => new { c.ViewerId, c.SeriesId }); b.HasIndex(c => c.ViewerId); }); modelBuilder.Entity(b => { b.HasKey(e => new { e.ViewerId, e.CardId }); b.HasIndex(e => e.ViewerId); }); modelBuilder.Entity(b => { b.HasKey(r => new { r.CardId, r.Type, r.CosmeticId }); b.HasIndex(r => r.CardId); // No inverse nav on the Card side — avoid forcing CosmeticRewards to load on every // Card query. See project_ef_split_query memory for the cartesian-explode risk. b.HasOne(r => r.Card) .WithMany() .HasForeignKey(r => r.CardId) .OnDelete(DeleteBehavior.Cascade); }); // GameConfigSection: one row per top-level config section. Postgres stores ValueJson as // jsonb (gives jsonb-side queryability if needed later); SQLite gets a plain TEXT column. // EF never sees the section POCO shapes — IGameConfigService owns deserialisation via STJ. // Replaces the old single-row GameConfigurations table with its EF Core 8 OwnsOne+ToJson // tree; see 2026-05-24 config refactor. bool isPostgres = Database.ProviderName?.Contains("Npgsql", StringComparison.OrdinalIgnoreCase) == true; if (isPostgres) { modelBuilder.Entity() .Property(s => s.ValueJson) .HasColumnType("jsonb"); } // --- Story entities --- // Composite PKs for viewer-state tables modelBuilder.Entity().HasKey(x => new { x.ViewerId, x.StoryId }); modelBuilder.Entity().HasKey(x => new { x.ViewerId, x.StoryId }); // StoryChapter owned collections (shadow-PK per row) modelBuilder.Entity(c => { c.OwnsMany(x => x.BattleSettings, b => { b.WithOwner().HasForeignKey("StoryId"); b.Property("Id"); b.HasKey("StoryId", "Id"); }); c.OwnsMany(x => x.Rewards, b => { b.WithOwner().HasForeignKey("StoryId"); b.Property("Id"); b.HasKey("StoryId", "Id"); }); c.OwnsMany(x => x.SubChapters, b => { b.WithOwner().HasForeignKey("StoryId"); b.Property("Id"); b.HasKey("StoryId", "Id"); }); }); // FK relationships modelBuilder.Entity().HasOne(s => s.World).WithMany().HasForeignKey(s => s.WorldId); modelBuilder.Entity().HasOne(c => c.Section).WithMany().HasForeignKey(c => c.SectionId); modelBuilder.Entity().HasOne(c => c.SpecialBattleSetting).WithMany().HasForeignKey(c => c.SpecialBattleSettingId); // Indexes modelBuilder.Entity().HasIndex(c => new { c.SectionId, c.CharaId, c.ChapterId }); modelBuilder.Entity().HasIndex(c => c.NextChapterId); // --- Battle pass entities --- modelBuilder.Entity(b => { b.HasKey(e => e.Id); b.Property(e => e.Id).ValueGeneratedNever(); b.HasIndex(e => new { e.StartDate, e.EndDate }); b.HasMany(e => e.Rewards).WithOne(r => r.Season).HasForeignKey(r => r.SeasonId).OnDelete(DeleteBehavior.Cascade); }); modelBuilder.Entity(b => { b.HasKey(e => e.Id); b.HasIndex(e => new { e.SeasonId, e.Track, e.Level }).IsUnique(); }); 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 }); }); modelBuilder.Entity(b => { b.HasKey(e => e.Id); b.Property(e => e.Id).ValueGeneratedNever(); b.HasIndex(e => e.LotType); b.HasIndex(e => new { e.EventType, e.EventArg }); }); modelBuilder.Entity(b => { b.HasKey(e => new { e.AchievementType, e.Level }); b.HasIndex(e => e.AchievementType); b.HasIndex(e => new { e.EventType, e.EventArg }); }); modelBuilder.Entity(b => { b.HasKey(e => e.Id); b.HasIndex(e => new { e.Year, e.Month, e.OrderNum }).IsUnique(); b.HasIndex(e => new { e.Year, e.Month }); }); modelBuilder.Entity(b => { b.HasKey(e => e.Id); b.HasIndex(e => new { e.ViewerId, e.Slot }).IsUnique(); b.HasIndex(e => e.ViewerId); }); modelBuilder.Entity(b => { b.HasKey(e => new { e.ViewerId, e.AchievementType }); }); modelBuilder.Entity(b => { b.HasKey(e => new { e.ViewerId, e.EventKey, e.Period }); b.HasIndex(e => new { e.ViewerId, e.Period }); }); modelBuilder.Entity(b => { b.HasKey(p => p.Id); b.Property(p => p.PresentId).HasMaxLength(64); b.Property(p => p.Source).HasMaxLength(64); b.HasOne(p => p.Viewer) .WithMany() .HasForeignKey(p => p.ViewerId) .OnDelete(DeleteBehavior.Cascade); // Drives /gift/top — partition by status, then chronological. b.HasIndex(p => new { p.ViewerId, p.Status, p.CreatedAt }); // One row per (viewer, present_id) — backstop against double-seeding and // double-enqueue from future producers. b.HasIndex(p => new { p.ViewerId, p.PresentId }).IsUnique(); }); modelBuilder.Entity(b => { b.HasKey(p => p.PresentId); b.Property(p => p.PresentId).HasMaxLength(64); }); base.OnModelCreating(modelBuilder); } public void UpdateDatabase() { IEnumerable pendingMigrations = Database.GetPendingMigrations(); if (!pendingMigrations.Any()) { _logger.LogDebug("No pending migrations found, continuing."); return; } foreach (string migration in pendingMigrations) { _logger.LogInformation("Found pending migration with name {migrationName}.", migration); } _logger.LogInformation("Attempting to apply pending migrations..."); Database.Migrate(); _logger.LogInformation("Migrations applied."); } /// /// Idempotent runtime seed for entities that can't use HasData. For GameConfigSection: walks /// every -marked POCO in the Models.Config namespace and /// inserts a row containing its ShippedDefaults() payload if no row for that section /// name exists. Safe to run on every startup — only missing rows are added; operator-edited /// rows are left alone. /// public async Task EnsureSeedDataAsync() { var existing = await GameConfigs.Select(s => s.SectionName).ToListAsync(); var existingSet = new HashSet(existing, StringComparer.Ordinal); int added = 0; foreach (var (name, json) in EnumerateShippedDefaults()) { if (existingSet.Contains(name)) continue; GameConfigs.Add(new GameConfigSection { SectionName = name, ValueJson = json }); added++; } if (added > 0) { await SaveChangesAsync(); _logger.LogInformation("Seeded {Count} default GameConfigSection row(s).", added); } } private static IEnumerable<(string Name, string Json)> EnumerateShippedDefaults() { // Reflect over every [ConfigSection]-marked type in the same assembly as PackRateConfig. // Each type must expose a parameterless `public static T ShippedDefaults()` — see the // POCOs in Models/Config for the convention. var asm = typeof(PackRateConfig).Assembly; var stjOptions = new System.Text.Json.JsonSerializerOptions { WriteIndented = false, }; foreach (var t in asm.GetTypes()) { var attr = t.GetCustomAttributes(typeof(ConfigSectionAttribute), inherit: false) .Cast().FirstOrDefault(); if (attr is null) continue; var factory = t.GetMethod("ShippedDefaults", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static, binder: null, types: Type.EmptyTypes, modifiers: null); if (factory is null) { throw new InvalidOperationException( $"[ConfigSection] type {t.FullName} is missing `public static {t.Name} ShippedDefaults()`."); } var instance = factory.Invoke(null, null) ?? throw new InvalidOperationException($"{t.FullName}.ShippedDefaults() returned null."); yield return (attr.Name, System.Text.Json.JsonSerializer.Serialize(instance, t, stjOptions)); } } }