289 lines
14 KiB
C#
289 lines
14 KiB
C#
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<SVSimDbContext> _logger;
|
|
|
|
public SVSimDbContext(ILogger<SVSimDbContext> logger, DbContextOptions<SVSimDbContext> options) : base(options)
|
|
{
|
|
_logger = logger;
|
|
}
|
|
|
|
#region DbSets
|
|
|
|
public DbSet<Viewer> Viewers => Set<Viewer>();
|
|
|
|
public DbSet<ShadowverseCardEntry> Cards => Set<ShadowverseCardEntry>();
|
|
public DbSet<ShadowverseCardSetEntry> CardSets => Set<ShadowverseCardSetEntry>();
|
|
public DbSet<ShadowverseDeckEntry> Decks => Set<ShadowverseDeckEntry>();
|
|
public DbSet<CardCosmeticReward> CardCosmeticRewards => Set<CardCosmeticReward>();
|
|
|
|
public DbSet<ClassEntry> Classes => Set<ClassEntry>();
|
|
public DbSet<ClassExpEntry> ClassExpCurve => Set<ClassExpEntry>();
|
|
public DbSet<LeaderSkinEntry> LeaderSkins => Set<LeaderSkinEntry>();
|
|
public DbSet<SleeveEntry> Sleeves => Set<SleeveEntry>();
|
|
public DbSet<EmblemEntry> Emblems => Set<EmblemEntry>();
|
|
public DbSet<DegreeEntry> Degrees => Set<DegreeEntry>();
|
|
public DbSet<MyPageBackgroundEntry> MyPageBackgrounds => Set<MyPageBackgroundEntry>();
|
|
public DbSet<BattlefieldEntry> Battlefields => Set<BattlefieldEntry>();
|
|
public DbSet<RankInfoEntry> RankInfo => Set<RankInfoEntry>();
|
|
public DbSet<ItemEntry> Items => Set<ItemEntry>();
|
|
|
|
public DbSet<GameConfigSection> GameConfigs => Set<GameConfigSection>();
|
|
|
|
// Prod-captured globals — populated by SVSim.Bootstrap, not HasData. See
|
|
// docs/audits/prod-data-capture-strategy-2026-05-23.md.
|
|
public DbSet<MyRotationSettingEntry> MyRotationSettings => Set<MyRotationSettingEntry>();
|
|
public DbSet<MyRotationAbilityEntry> MyRotationAbilities => Set<MyRotationAbilityEntry>();
|
|
public DbSet<AvatarAbilityEntry> AvatarAbilities => Set<AvatarAbilityEntry>();
|
|
public DbSet<DefaultDeckEntry> DefaultDecks => Set<DefaultDeckEntry>();
|
|
public DbSet<ArenaSeasonConfig> ArenaSeasons => Set<ArenaSeasonConfig>();
|
|
public DbSet<SpotCardEntry> SpotCards => Set<SpotCardEntry>();
|
|
public DbSet<ReprintedCardEntry> ReprintedCards => Set<ReprintedCardEntry>();
|
|
public DbSet<UnlimitedRestrictionEntry> UnlimitedRestrictions => Set<UnlimitedRestrictionEntry>();
|
|
public DbSet<LoadingExclusionCardEntry> LoadingExclusionCards => Set<LoadingExclusionCardEntry>();
|
|
public DbSet<BattlePassLevelEntry> BattlePassLevels => Set<BattlePassLevelEntry>();
|
|
public DbSet<DailyLoginBonusEntry> DailyLoginBonuses => Set<DailyLoginBonusEntry>();
|
|
public DbSet<BannerEntry> Banners => Set<BannerEntry>();
|
|
public DbSet<ColosseumConfig> Colosseums => Set<ColosseumConfig>();
|
|
public DbSet<SealedConfig> SealedSeasons => Set<SealedConfig>();
|
|
public DbSet<MasterPointRankingPeriodEntry> MasterPointRankingPeriods => Set<MasterPointRankingPeriodEntry>();
|
|
public DbSet<SpecialDeckFormatEntry> SpecialDeckFormats => Set<SpecialDeckFormatEntry>();
|
|
public DbSet<PaymentItemEntry> PaymentItems => Set<PaymentItemEntry>();
|
|
public DbSet<PackConfigEntry> Packs => Set<PackConfigEntry>();
|
|
public DbSet<BuildDeckSeriesEntry> BuildDeckSeries => Set<BuildDeckSeriesEntry>();
|
|
public DbSet<BuildDeckProductEntry> BuildDeckProducts => Set<BuildDeckProductEntry>();
|
|
public DbSet<MaintenanceCardEntry> MaintenanceCards => Set<MaintenanceCardEntry>();
|
|
public DbSet<FeatureMaintenanceEntry> FeatureMaintenances => Set<FeatureMaintenanceEntry>();
|
|
public DbSet<PreReleaseInfo> PreReleaseInfos => Set<PreReleaseInfo>();
|
|
public DbSet<PracticeOpponentEntry> PracticeOpponents => Set<PracticeOpponentEntry>();
|
|
public DbSet<PuzzleGroupEntry> PuzzleGroups => Set<PuzzleGroupEntry>();
|
|
public DbSet<PuzzleEntry> Puzzles => Set<PuzzleEntry>();
|
|
public DbSet<PuzzleMissionEntry> PuzzleMissions => Set<PuzzleMissionEntry>();
|
|
public DbSet<ViewerPuzzleClear> ViewerPuzzleClears => Set<ViewerPuzzleClear>();
|
|
|
|
// Story reference data + viewer progress
|
|
public DbSet<StoryWorld> StoryWorlds => Set<StoryWorld>();
|
|
public DbSet<StorySection> StorySections => Set<StorySection>();
|
|
public DbSet<StoryChapter> StoryChapters => Set<StoryChapter>();
|
|
public DbSet<SpecialBattleSetting> SpecialBattleSettings => Set<SpecialBattleSetting>();
|
|
public DbSet<ViewerStoryProgress> ViewerStoryProgress => Set<ViewerStoryProgress>();
|
|
public DbSet<ViewerStoryBranchUnlock> ViewerStoryBranchUnlocks => Set<ViewerStoryBranchUnlock>();
|
|
|
|
#endregion
|
|
|
|
public override async Task<int> 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<ShadowverseDeckEntry>()
|
|
.OwnsMany(de => de.Cards);
|
|
|
|
// BaseEntity<TKey> 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<ShadowverseDeckEntry>()
|
|
.Property(d => d.Id)
|
|
.ValueGeneratedOnAdd();
|
|
|
|
// EF can't figure this many-to-many out on its own
|
|
modelBuilder.Entity<SleeveEntry>()
|
|
.HasMany(se => se.Viewers)
|
|
.WithMany(v => v.Sleeves);
|
|
|
|
modelBuilder.HasSequence<long>("ShortUdidSequence").StartsAt(400000000);
|
|
modelBuilder.Entity<Viewer>()
|
|
.Property(v => v.ShortUdid)
|
|
.UseSequence("ShortUdidSequence");
|
|
|
|
modelBuilder.Entity<PackConfigEntry>().OwnsMany(p => p.ChildGachas);
|
|
modelBuilder.Entity<PackConfigEntry>().OwnsMany(p => p.Banners);
|
|
modelBuilder.Entity<Viewer>().OwnsMany(v => v.PackOpenCounts);
|
|
|
|
// 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<Viewer>().OwnsMany(v => v.Cards, b =>
|
|
{
|
|
b.HasIndex("ViewerId", "CardId").IsUnique();
|
|
});
|
|
modelBuilder.Entity<Viewer>().OwnsMany(v => v.Items, b =>
|
|
{
|
|
b.HasIndex("ViewerId", "ItemId").IsUnique();
|
|
});
|
|
|
|
modelBuilder.Entity<Viewer>().OwnsMany(v => v.BuildDeckPurchases, b =>
|
|
{
|
|
b.HasIndex("ViewerId", "ProductId").IsUnique();
|
|
});
|
|
|
|
modelBuilder.Entity<BuildDeckSeriesEntry>().OwnsMany(s => s.SeriesRewards);
|
|
modelBuilder.Entity<BuildDeckProductEntry>().OwnsMany(p => p.Cards);
|
|
modelBuilder.Entity<BuildDeckProductEntry>().OwnsMany(p => p.Rewards);
|
|
|
|
modelBuilder.Entity<BuildDeckProductEntry>()
|
|
.HasOne(p => p.Series)
|
|
.WithMany(s => s.Products)
|
|
.HasForeignKey(p => p.SeriesId)
|
|
.OnDelete(DeleteBehavior.Cascade);
|
|
|
|
modelBuilder.Entity<BuildDeckProductEntry>().HasIndex(p => p.SeriesId);
|
|
|
|
modelBuilder.Entity<CardCosmeticReward>(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<GameConfigSection>()
|
|
.Property(s => s.ValueJson)
|
|
.HasColumnType("jsonb");
|
|
}
|
|
|
|
// --- Story entities ---
|
|
|
|
// Composite PKs for viewer-state tables
|
|
modelBuilder.Entity<ViewerStoryProgress>().HasKey(x => new { x.ViewerId, x.StoryId });
|
|
modelBuilder.Entity<ViewerStoryBranchUnlock>().HasKey(x => new { x.ViewerId, x.StoryId });
|
|
|
|
// StoryChapter owned collections (shadow-PK per row)
|
|
modelBuilder.Entity<StoryChapter>(c =>
|
|
{
|
|
c.OwnsMany(x => x.BattleSettings, b => { b.WithOwner().HasForeignKey("StoryId"); b.Property<int>("Id"); b.HasKey("StoryId", "Id"); });
|
|
c.OwnsMany(x => x.Rewards, b => { b.WithOwner().HasForeignKey("StoryId"); b.Property<int>("Id"); b.HasKey("StoryId", "Id"); });
|
|
c.OwnsMany(x => x.SubChapters, b => { b.WithOwner().HasForeignKey("StoryId"); b.Property<int>("Id"); b.HasKey("StoryId", "Id"); });
|
|
});
|
|
|
|
// FK relationships
|
|
modelBuilder.Entity<StorySection>().HasOne(s => s.World).WithMany().HasForeignKey(s => s.WorldId);
|
|
modelBuilder.Entity<StoryChapter>().HasOne(c => c.Section).WithMany().HasForeignKey(c => c.SectionId);
|
|
modelBuilder.Entity<StoryChapter>().HasOne(c => c.SpecialBattleSetting).WithMany().HasForeignKey(c => c.SpecialBattleSettingId);
|
|
|
|
// Indexes
|
|
modelBuilder.Entity<StoryChapter>().HasIndex(c => new { c.SectionId, c.CharaId, c.ChapterId });
|
|
modelBuilder.Entity<StoryChapter>().HasIndex(c => c.NextChapterId);
|
|
|
|
base.OnModelCreating(modelBuilder);
|
|
}
|
|
|
|
public void UpdateDatabase()
|
|
{
|
|
IEnumerable<string> 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.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Idempotent runtime seed for entities that can't use HasData. For GameConfigSection: walks
|
|
/// every <see cref="ConfigSectionAttribute"/>-marked POCO in the Models.Config namespace and
|
|
/// inserts a row containing its <c>ShippedDefaults()</c> 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.
|
|
/// </summary>
|
|
public async Task EnsureSeedDataAsync()
|
|
{
|
|
var existing = await GameConfigs.Select(s => s.SectionName).ToListAsync();
|
|
var existingSet = new HashSet<string>(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<ConfigSectionAttribute>().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));
|
|
}
|
|
}
|
|
}
|