diff --git a/SVSim.Bootstrap/Data/seeds/item-purchase.json b/SVSim.Bootstrap/Data/seeds/item-purchase.json new file mode 100644 index 0000000..6430311 --- /dev/null +++ b/SVSim.Bootstrap/Data/seeds/item-purchase.json @@ -0,0 +1,38 @@ +[ + { + "purchase_id": 1, + "require_item_type": 1, + "require_item_id": 0, + "require_item_num": 5000, + "purchase_item_type": 4, + "purchase_item_id": 1000, + "purchase_item_num": 1, + "purchase_name": "[b]One Time Only![/b] Seer's Globe x1", + "is_monthly_reset": false, + "purchase_limit": 1 + }, + { + "purchase_id": 100002, + "require_item_type": 4, + "require_item_id": 1001, + "require_item_num": 5, + "purchase_item_type": 4, + "purchase_item_id": 1000, + "purchase_item_num": 1, + "purchase_name": "", + "is_monthly_reset": true, + "purchase_limit": 3 + }, + { + "purchase_id": 100003, + "require_item_type": 1, + "require_item_id": 0, + "require_item_num": 30000, + "purchase_item_type": 4, + "purchase_item_id": 1000, + "purchase_item_num": 1, + "purchase_name": "", + "is_monthly_reset": true, + "purchase_limit": 10 + } +] diff --git a/SVSim.Bootstrap/Importers/ItemPurchaseImporter.cs b/SVSim.Bootstrap/Importers/ItemPurchaseImporter.cs new file mode 100644 index 0000000..d4b2ee3 --- /dev/null +++ b/SVSim.Bootstrap/Importers/ItemPurchaseImporter.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore; +using SVSim.Bootstrap.Models.Seed; +using SVSim.Database; +using SVSim.Database.Models; + +namespace SVSim.Bootstrap.Importers; + +/// +/// Idempotent upsert of the item-purchase catalog from seeds/item-purchase.json. +/// Source is the wire /item_purchase/info response, extracted via +/// data_dumps/extract/extract-item-purchase.py. Rows missing from the seed are LEFT INTACT. +/// +public class ItemPurchaseImporter +{ + public async Task ImportAsync(SVSimDbContext context, string seedDir) + { + string path = Path.Combine(seedDir, "item-purchase.json"); + var seed = SeedLoader.LoadList(path); + if (seed.Count == 0) + { + Console.WriteLine("[ItemPurchaseImporter] No seed rows; skipping."); + return 0; + } + + var existing = await context.ItemPurchaseCatalog.ToDictionaryAsync(e => e.Id); + int created = 0, updated = 0; + + foreach (var s in seed) + { + if (s.PurchaseId == 0) continue; + + var entry = existing.TryGetValue(s.PurchaseId, out var ex) + ? ex : new ItemPurchaseCatalogEntry { Id = s.PurchaseId }; + + entry.RequireItemType = s.RequireItemType; + entry.RequireItemId = s.RequireItemId; + entry.RequireItemNum = s.RequireItemNum; + entry.PurchaseItemType = s.PurchaseItemType; + entry.PurchaseItemId = s.PurchaseItemId; + entry.PurchaseItemNum = s.PurchaseItemNum; + entry.PurchaseName = s.PurchaseName; + entry.IsMonthlyReset = s.IsMonthlyReset; + entry.PurchaseLimit = s.PurchaseLimit; + entry.IsEnabled = true; + + if (ex is null) + { + context.ItemPurchaseCatalog.Add(entry); + existing[s.PurchaseId] = entry; + created++; + } + else updated++; + } + + await context.SaveChangesAsync(); + Console.WriteLine($"[ItemPurchaseImporter] +{created}/~{updated}"); + return created + updated; + } +} diff --git a/SVSim.Bootstrap/Models/Seed/ItemPurchaseSeed.cs b/SVSim.Bootstrap/Models/Seed/ItemPurchaseSeed.cs new file mode 100644 index 0000000..72c00ea --- /dev/null +++ b/SVSim.Bootstrap/Models/Seed/ItemPurchaseSeed.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace SVSim.Bootstrap.Models.Seed; + +public sealed class ItemPurchaseSeed +{ + [JsonPropertyName("purchase_id")] public int PurchaseId { get; set; } + [JsonPropertyName("require_item_type")] public int RequireItemType { get; set; } + [JsonPropertyName("require_item_id")] public long RequireItemId { get; set; } + [JsonPropertyName("require_item_num")] public int RequireItemNum { get; set; } + [JsonPropertyName("purchase_item_type")] public int PurchaseItemType { get; set; } + [JsonPropertyName("purchase_item_id")] public long PurchaseItemId { get; set; } + [JsonPropertyName("purchase_item_num")] public int PurchaseItemNum { get; set; } + [JsonPropertyName("purchase_name")] public string PurchaseName { get; set; } = ""; + [JsonPropertyName("is_monthly_reset")] public bool IsMonthlyReset { get; set; } + [JsonPropertyName("purchase_limit")] public int PurchaseLimit { get; set; } +} diff --git a/SVSim.Bootstrap/Program.cs b/SVSim.Bootstrap/Program.cs index 73eed05..a55450e 100644 --- a/SVSim.Bootstrap/Program.cs +++ b/SVSim.Bootstrap/Program.cs @@ -99,6 +99,7 @@ public static class Program await new PaymentItemImporter().ImportAsync(context, opts.SeedDir); await new ItemImporter().ImportAsync(context, opts.SeedDir); await new SleeveShopImporter().ImportAsync(context, opts.SeedDir); + await new ItemPurchaseImporter().ImportAsync(context, opts.SeedDir); var puzzleImporter = new PuzzleImporter(); await puzzleImporter.ImportGroupsAsync(context, opts.SeedDir); await puzzleImporter.ImportPuzzlesAsync(context, opts.SeedDir); diff --git a/SVSim.Database/Migrations/20260528021818_AddItemPurchaseCatalog.Designer.cs b/SVSim.Database/Migrations/20260528021818_AddItemPurchaseCatalog.Designer.cs new file mode 100644 index 0000000..c5365ed --- /dev/null +++ b/SVSim.Database/Migrations/20260528021818_AddItemPurchaseCatalog.Designer.cs @@ -0,0 +1,3430 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SVSim.Database; + +#nullable disable + +namespace SVSim.Database.Migrations +{ + [DbContext(typeof(SVSimDbContext))] + [Migration("20260528021818_AddItemPurchaseCatalog")] + partial class AddItemPurchaseCatalog + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.HasSequence("ShortUdidSequence") + .StartsAt(400000000L); + + modelBuilder.Entity("DegreeEntryViewer", b => + { + b.Property("DegreesId") + .HasColumnType("integer"); + + b.Property("ViewersId") + .HasColumnType("bigint"); + + b.HasKey("DegreesId", "ViewersId"); + + b.HasIndex("ViewersId"); + + b.ToTable("DegreeEntryViewer"); + }); + + modelBuilder.Entity("EmblemEntryViewer", b => + { + b.Property("EmblemsId") + .HasColumnType("integer"); + + b.Property("ViewersId") + .HasColumnType("bigint"); + + b.HasKey("EmblemsId", "ViewersId"); + + b.HasIndex("ViewersId"); + + b.ToTable("EmblemEntryViewer"); + }); + + modelBuilder.Entity("LeaderSkinEntryViewer", b => + { + b.Property("LeaderSkinsId") + .HasColumnType("integer"); + + b.Property("ViewersId") + .HasColumnType("bigint"); + + b.HasKey("LeaderSkinsId", "ViewersId"); + + b.HasIndex("ViewersId"); + + b.ToTable("LeaderSkinEntryViewer"); + }); + + modelBuilder.Entity("MyPageBackgroundEntryViewer", b => + { + b.Property("MyPageBackgroundsId") + .HasColumnType("integer"); + + b.Property("ViewersId") + .HasColumnType("bigint"); + + b.HasKey("MyPageBackgroundsId", "ViewersId"); + + b.HasIndex("ViewersId"); + + b.ToTable("MyPageBackgroundEntryViewer"); + }); + + modelBuilder.Entity("SVSim.Database.Entities.Story.SpecialBattleSetting", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("BanishEffectOverride") + .IsRequired() + .HasColumnType("text"); + + b.Property("ClassDestroyEffectOverride") + .HasColumnType("integer"); + + b.Property("EnemyAttachSkill") + .IsRequired() + .HasColumnType("text"); + + b.Property("EnemyStartLife") + .HasColumnType("integer"); + + b.Property("EnemyStartPp") + .HasColumnType("integer"); + + b.Property("IdOverrideInBattleLog") + .IsRequired() + .HasColumnType("text"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("PlayerAttachSkill") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlayerFirstTurn") + .HasColumnType("integer"); + + b.Property("PlayerStartLife") + .HasColumnType("integer"); + + b.Property("PlayerStartPp") + .HasColumnType("integer"); + + b.Property("ResultSkip") + .HasColumnType("integer"); + + b.Property("SpecialTokenDrawEffectOverride") + .IsRequired() + .HasColumnType("text"); + + b.Property("TokenDrawEffectOverride") + .IsRequired() + .HasColumnType("text"); + + b.Property("VsEffectOverride") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("SpecialBattleSettings"); + }); + + modelBuilder.Entity("SVSim.Database.Entities.Story.StoryChapter", b => + { + b.Property("StoryId") + .HasColumnType("integer"); + + b.Property("Battle3dFieldId") + .HasColumnType("integer"); + + b.Property("BattleExists") + .HasColumnType("boolean"); + + b.Property("BgFileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("BgmId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChapterClearTextId") + .HasColumnType("text"); + + b.Property("ChapterEffectPath") + .HasColumnType("text"); + + b.Property("ChapterId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CharaId") + .HasColumnType("integer"); + + b.Property("EnemyAiId") + .HasColumnType("integer"); + + b.Property("EnemyCharaId") + .HasColumnType("integer"); + + b.Property("EnemyClass") + .HasColumnType("integer"); + + b.Property("IsCameraMovable") + .HasColumnType("integer"); + + b.Property("IsMaintenanceChapter") + .HasColumnType("boolean"); + + b.Property("IsPlayAnotherEndAppearanceAnimation") + .HasColumnType("boolean"); + + b.Property("IsReleasedAnotherEnd") + .HasColumnType("boolean"); + + b.Property("IsSkipEnabled") + .HasColumnType("boolean"); + + b.Property("NextChapterId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReleasePoint") + .HasColumnType("integer"); + + b.Property("RequiredChapterId") + .HasColumnType("text"); + + b.Property("SectionId") + .HasColumnType("integer"); + + b.Property("SelectionDisplayPosition") + .HasColumnType("text"); + + b.Property("SelectionTextId") + .HasColumnType("text"); + + b.Property("ShowCoordinate") + .HasColumnType("integer"); + + b.Property("ShowSubtitles") + .HasColumnType("integer"); + + b.Property("SpecialBattleSettingId") + .HasColumnType("integer"); + + b.Property("UnlockText") + .HasColumnType("text"); + + b.Property("XCoordinate") + .HasColumnType("numeric"); + + b.Property("YCoordinate") + .HasColumnType("numeric"); + + b.HasKey("StoryId"); + + b.HasIndex("NextChapterId"); + + b.HasIndex("SpecialBattleSettingId"); + + b.HasIndex("SectionId", "CharaId", "ChapterId"); + + b.ToTable("StoryChapters"); + }); + + modelBuilder.Entity("SVSim.Database.Entities.Story.StorySection", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("AllStoryOrderId") + .HasColumnType("integer"); + + b.Property("BackGroundId") + .HasColumnType("integer"); + + b.Property("ChapterSelectType") + .HasColumnType("integer"); + + b.Property("ImageName") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsLeaderSelect") + .HasColumnType("boolean"); + + b.Property("IsPlayAnotherEndAppearanceAnimation") + .HasColumnType("boolean"); + + b.Property("IsSpoiler") + .HasColumnType("integer"); + + b.Property("IsUnderMaintenance") + .HasColumnType("boolean"); + + b.Property("NameTextKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrderId") + .HasColumnType("integer"); + + b.Property("SpoilerMessage") + .IsRequired() + .HasColumnType("text"); + + b.Property("StoryApiType") + .HasColumnType("integer"); + + b.Property("StoryTypeOverwrite") + .HasColumnType("integer"); + + b.Property("WorldId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("WorldId"); + + b.ToTable("StorySections"); + }); + + modelBuilder.Entity("SVSim.Database.Entities.Story.StoryWorld", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("PanelImageName") + .IsRequired() + .HasColumnType("text"); + + b.Property("RibbonText") + .IsRequired() + .HasColumnType("text"); + + b.Property("TitleTextKey") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("StoryWorlds"); + }); + + modelBuilder.Entity("SVSim.Database.Entities.Story.ViewerStoryBranchUnlock", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("StoryId") + .HasColumnType("integer"); + + b.Property("UnlockedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ViewerId", "StoryId"); + + b.ToTable("ViewerStoryBranchUnlocks"); + }); + + modelBuilder.Entity("SVSim.Database.Entities.Story.ViewerStoryProgress", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("StoryId") + .HasColumnType("integer"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsFinish") + .HasColumnType("boolean"); + + b.Property("IsSkipped") + .HasColumnType("boolean"); + + b.Property("SkippedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ViewerId", "StoryId"); + + b.ToTable("ViewerStoryProgress"); + }); + + modelBuilder.Entity("SVSim.Database.Models.AchievementCatalogEntry", b => + { + b.Property("AchievementType") + .HasColumnType("integer"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("EventArg") + .HasColumnType("integer"); + + b.Property("EventType") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrderNum") + .HasColumnType("integer"); + + b.Property("RequireNumber") + .HasColumnType("integer"); + + b.Property("RewardDetailId") + .HasColumnType("bigint"); + + b.Property("RewardNumber") + .HasColumnType("integer"); + + b.Property("RewardType") + .HasColumnType("integer"); + + b.HasKey("AchievementType", "Level"); + + b.HasIndex("AchievementType"); + + b.HasIndex("EventType", "EventArg"); + + b.ToTable("AchievementCatalog"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ArenaSeasonConfig", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("Cost") + .HasColumnType("numeric(20,0)"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("Enable") + .HasColumnType("integer"); + + b.Property("FormatInfo") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("IsJoin") + .HasColumnType("boolean"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("RupyCost") + .HasColumnType("numeric(20,0)"); + + b.Property("TicketCost") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ArenaSeasons"); + }); + + modelBuilder.Entity("SVSim.Database.Models.AvatarAbilityEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("Ability") + .IsRequired() + .HasColumnType("text"); + + b.Property("AbilityCost") + .IsRequired() + .HasColumnType("text"); + + b.Property("AbilityDesc") + .IsRequired() + .HasColumnType("text"); + + b.Property("BattleStartFirstPlayerTurnBp") + .HasColumnType("integer"); + + b.Property("BattleStartMaxLife") + .HasColumnType("integer"); + + b.Property("BattleStartSecondPlayerTurnBp") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("LeaderSkinId") + .HasColumnType("integer"); + + b.Property("PassiveAbility") + .IsRequired() + .HasColumnType("text"); + + b.Property("PassiveAbilityDesc") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AvatarAbilities"); + }); + + modelBuilder.Entity("SVSim.Database.Models.BannerEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("ChangeTime") + .HasColumnType("integer"); + + b.Property("Click") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("ImageName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ImagePaths") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RemainingTime") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Banners"); + }); + + modelBuilder.Entity("SVSim.Database.Models.BattlePassLevelEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("RequiredPoint") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("BattlePassLevels"); + }); + + modelBuilder.Entity("SVSim.Database.Models.BattlePassMonthlyMissionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BattlePassPoint") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("EventArg") + .HasColumnType("integer"); + + b.Property("EventType") + .HasColumnType("text"); + + b.Property("Month") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrderNum") + .HasColumnType("integer"); + + b.Property("RequireNumber") + .HasColumnType("integer"); + + b.Property("RewardDetailId") + .HasColumnType("bigint"); + + b.Property("RewardNumber") + .HasColumnType("integer"); + + b.Property("RewardType") + .HasColumnType("integer"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Year", "Month"); + + b.HasIndex("Year", "Month", "OrderNum") + .IsUnique(); + + b.ToTable("BattlePassMonthlyMissions"); + }); + + modelBuilder.Entity("SVSim.Database.Models.BattlePassRewardEntry", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("IsAppealExclusion") + .HasColumnType("boolean"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("RewardDetailId") + .HasColumnType("bigint"); + + b.Property("RewardNumber") + .HasColumnType("integer"); + + b.Property("RewardType") + .HasColumnType("integer"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Track") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "Track", "Level") + .IsUnique(); + + b.ToTable("BattlePassRewards"); + }); + + modelBuilder.Entity("SVSim.Database.Models.BattlePassSeasonEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("CanPurchase") + .HasColumnType("boolean"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxLevel") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PriceCrystal") + .HasColumnType("integer"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("StartDate", "EndDate"); + + b.ToTable("BattlePassSeasons"); + }); + + modelBuilder.Entity("SVSim.Database.Models.BattlefieldEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("IsOpen") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Battlefields"); + }); + + modelBuilder.Entity("SVSim.Database.Models.BuildDeckProductEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("DeckCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("FeaturedCardId") + .HasColumnType("bigint"); + + b.Property("IntroPriceCrystal") + .HasColumnType("integer"); + + b.Property("IntroPriceRupy") + .HasColumnType("integer"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LeaderId") + .HasColumnType("integer"); + + b.Property("ProductNameKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("PurchaseNumMax") + .HasColumnType("integer"); + + b.Property("RegularPriceCrystal") + .HasColumnType("integer"); + + b.Property("RegularPriceRupy") + .HasColumnType("integer"); + + b.Property("SeriesId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("BuildDeckProducts"); + }); + + modelBuilder.Entity("SVSim.Database.Models.BuildDeckSeriesEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("DrumrollPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("IntroKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("IsNew") + .HasColumnType("boolean"); + + b.Property("NameKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrderIndex") + .HasColumnType("integer"); + + b.Property("TitlePath") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("BuildDeckSeries"); + }); + + modelBuilder.Entity("SVSim.Database.Models.CardCosmeticReward", b => + { + b.Property("CardId") + .HasColumnType("bigint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("CosmeticId") + .HasColumnType("bigint"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.HasKey("CardId", "Type", "CosmeticId"); + + b.HasIndex("CardId"); + + b.ToTable("CardCosmeticRewards"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ClassEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Classes"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ClassExpEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("NecessaryExp") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClassExpCurve"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ColosseumConfig", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("CardPoolName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ColosseumId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ColosseumName") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("DeckFormat") + .IsRequired() + .HasColumnType("text"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsAllCardEnabled") + .HasColumnType("integer"); + + b.Property("IsColosseumPeriod") + .HasColumnType("boolean"); + + b.Property("IsDisplayTips") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsNormalTwoPick") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsRoundPeriod") + .HasColumnType("boolean"); + + b.Property("IsSpecialMode") + .IsRequired() + .HasColumnType("text"); + + b.Property("NowRound") + .IsRequired() + .HasColumnType("text"); + + b.Property("SalesPeriodInfo") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("TipsId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Colosseums"); + }); + + modelBuilder.Entity("SVSim.Database.Models.DailyLoginBonusEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("BonusData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("BonusId") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("DailyLoginBonuses"); + }); + + modelBuilder.Entity("SVSim.Database.Models.DefaultDeckEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("CardIdArray") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ClassId") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("DeckName") + .IsRequired() + .HasColumnType("text"); + + b.Property("DeckNo") + .HasColumnType("integer"); + + b.Property("LeaderSkinId") + .HasColumnType("integer"); + + b.Property("SleeveId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("DefaultDecks"); + }); + + modelBuilder.Entity("SVSim.Database.Models.DegreeEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Degrees"); + }); + + modelBuilder.Entity("SVSim.Database.Models.EmblemEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Emblems"); + }); + + modelBuilder.Entity("SVSim.Database.Models.FeatureMaintenanceEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("Data") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("FeatureKey") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("FeatureMaintenances"); + }); + + modelBuilder.Entity("SVSim.Database.Models.GameConfigSection", b => + { + b.Property("SectionName") + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("ValueJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("SectionName"); + + b.ToTable("GameConfigs"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ItemEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ThumbnailPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Items"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ItemPurchaseCatalogEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("IsMonthlyReset") + .HasColumnType("boolean"); + + b.Property("PurchaseItemId") + .HasColumnType("bigint"); + + b.Property("PurchaseItemNum") + .HasColumnType("integer"); + + b.Property("PurchaseItemType") + .HasColumnType("integer"); + + b.Property("PurchaseLimit") + .HasColumnType("integer"); + + b.Property("PurchaseName") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequireItemId") + .HasColumnType("bigint"); + + b.Property("RequireItemNum") + .HasColumnType("integer"); + + b.Property("RequireItemType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ItemPurchaseCatalog"); + }); + + modelBuilder.Entity("SVSim.Database.Models.LeaderSkinEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("ClassId") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("EmoteId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClassId"); + + b.ToTable("LeaderSkins"); + }); + + modelBuilder.Entity("SVSim.Database.Models.LoadingExclusionCardEntry", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CardId") + .HasColumnType("bigint"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("LoadingExclusionCards"); + }); + + modelBuilder.Entity("SVSim.Database.Models.MaintenanceCardEntry", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CardId") + .HasColumnType("bigint"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("MaintenanceCards"); + }); + + modelBuilder.Entity("SVSim.Database.Models.MasterPointRankingPeriodEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("BeginTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NecessaryScore") + .HasColumnType("bigint"); + + b.Property("PeriodNum") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("MasterPointRankingPeriods"); + }); + + modelBuilder.Entity("SVSim.Database.Models.MissionCatalogEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("BattlePassPoint") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultFlag") + .HasColumnType("boolean"); + + b.Property("EndTime") + .HasColumnType("bigint"); + + b.Property("EventArg") + .HasColumnType("integer"); + + b.Property("EventType") + .HasColumnType("text"); + + b.Property("LotType") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequireNumber") + .HasColumnType("integer"); + + b.Property("RewardDetailId") + .HasColumnType("bigint"); + + b.Property("RewardNumber") + .HasColumnType("integer"); + + b.Property("RewardType") + .HasColumnType("integer"); + + b.Property("StartTime") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("LotType"); + + b.HasIndex("EventType", "EventArg"); + + b.ToTable("MissionCatalog"); + }); + + modelBuilder.Entity("SVSim.Database.Models.MyPageBackgroundEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("MyPageBackgrounds"); + }); + + modelBuilder.Entity("SVSim.Database.Models.MyRotationAbilityEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("AbilityId") + .HasColumnType("integer"); + + b.Property("Data") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("MyRotationAbilities"); + }); + + modelBuilder.Entity("SVSim.Database.Models.MyRotationSettingEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("AbilitiesCsv") + .IsRequired() + .HasColumnType("text"); + + b.Property("CardSetIdsCsv") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("ReprintedCardIds") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RestrictedCardIds") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RotationId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("MyRotationSettings"); + }); + + modelBuilder.Entity("SVSim.Database.Models.PackConfigEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("BasePackId") + .HasColumnType("integer"); + + b.Property("CommenceDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CompleteDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("GachaDetail") + .IsRequired() + .HasColumnType("text"); + + b.Property("GachaType") + .HasColumnType("integer"); + + b.Property("IsHide") + .HasColumnType("boolean"); + + b.Property("IsNew") + .HasColumnType("boolean"); + + b.Property("IsPreRelease") + .HasColumnType("boolean"); + + b.Property("OpenCountLimit") + .HasColumnType("integer"); + + b.Property("OverrideDrawEffectPackId") + .HasColumnType("integer"); + + b.Property("OverrideUiEffectPackId") + .HasColumnType("integer"); + + b.Property("PackCategory") + .HasColumnType("integer"); + + b.Property("PosterType") + .HasColumnType("integer"); + + b.Property("SalesPeriodTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SleeveId") + .HasColumnType("integer"); + + b.Property("SpecialSleeveId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Packs"); + }); + + modelBuilder.Entity("SVSim.Database.Models.PaymentItemEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("ChargeCrystalNum") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FreeCrystalNum") + .HasColumnType("integer"); + + b.Property("ImageName") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsResaleProduct") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Price") + .HasColumnType("numeric"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("PurchaseLimit") + .HasColumnType("integer"); + + b.Property("RemainingTime") + .HasColumnType("integer"); + + b.Property("ResaleStartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SpecialShopFlag") + .HasColumnType("integer"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("StoreProductId") + .HasColumnType("bigint"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("PaymentItems"); + }); + + modelBuilder.Entity("SVSim.Database.Models.PracticeOpponentEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("AiDeckLevel") + .HasColumnType("integer"); + + b.Property("AiLogicLevel") + .HasColumnType("integer"); + + b.Property("AiMaxLife") + .HasColumnType("integer"); + + b.Property("Battle3dFieldId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CharaId") + .HasColumnType("integer"); + + b.Property("ClassId") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("DegreeId") + .HasColumnType("integer"); + + b.Property("IsCampaignPractice") + .HasColumnType("boolean"); + + b.Property("IsMaintenance") + .HasColumnType("boolean"); + + b.Property("PracticeId") + .HasColumnType("integer"); + + b.Property("TextId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("PracticeOpponents"); + }); + + modelBuilder.Entity("SVSim.Database.Models.PreReleaseInfo", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("CardMasterId") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultCardMasterId") + .IsRequired() + .HasColumnType("text"); + + b.Property("DisplayEndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FreeMatchStartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPreRotationFreeMatchTerm") + .HasColumnType("boolean"); + + b.Property("LatestReprintedBaseCardIds") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("NextCardSetId") + .IsRequired() + .HasColumnType("text"); + + b.Property("PreReleaseCardMasterId") + .IsRequired() + .HasColumnType("text"); + + b.Property("PreReleaseId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReprintedBaseCardIds") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RotationCardSetIdList") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("PreReleaseInfos"); + }); + + modelBuilder.Entity("SVSim.Database.Models.PuzzleEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("GroupId") + .HasColumnType("integer"); + + b.Property("IsAdditional") + .HasColumnType("boolean"); + + b.Property("IsPlayable") + .HasColumnType("boolean"); + + b.Property("PuzzleDifficulty") + .HasColumnType("integer"); + + b.Property("PuzzleId") + .HasColumnType("integer"); + + b.Property("ReleaseConditionTextId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("Puzzles"); + }); + + modelBuilder.Entity("SVSim.Database.Models.PuzzleGroupEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("BasicTitleTextId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CharaId") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("DifficultyNameListJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("PuzzleCharaId") + .HasColumnType("integer"); + + b.Property("PuzzleMasterId") + .HasColumnType("integer"); + + b.Property("SortType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("PuzzleGroups"); + }); + + modelBuilder.Entity("SVSim.Database.Models.PuzzleMissionEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("AchievedMessage") + .IsRequired() + .HasColumnType("text"); + + b.Property("CampaignCommenceTime") + .HasColumnType("bigint"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("MissionName") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrderId") + .HasColumnType("integer"); + + b.Property("RequireNumber") + .HasColumnType("integer"); + + b.Property("RewardDetailId") + .HasColumnType("bigint"); + + b.Property("RewardNumber") + .HasColumnType("integer"); + + b.Property("RewardType") + .HasColumnType("integer"); + + b.Property("TargetPuzzleGroupId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("PuzzleMissions"); + }); + + modelBuilder.Entity("SVSim.Database.Models.RankInfoEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("AccumulateMasterPoint") + .HasColumnType("integer"); + + b.Property("AccumulatePoint") + .HasColumnType("integer"); + + b.Property("BaseAddBp") + .HasColumnType("integer"); + + b.Property("BaseDropBp") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPromotionWar") + .HasColumnType("integer"); + + b.Property("LoseBonus") + .HasColumnType("double precision"); + + b.Property("LowerLimitPoint") + .HasColumnType("integer"); + + b.Property("MatchCount") + .HasColumnType("integer"); + + b.Property("MaxLoseBonus") + .HasColumnType("integer"); + + b.Property("MaxWinBonus") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NecessaryPoint") + .HasColumnType("integer"); + + b.Property("NecessaryWin") + .HasColumnType("integer"); + + b.Property("ResetLose") + .HasColumnType("integer"); + + b.Property("StreakBonusPt") + .HasColumnType("integer"); + + b.Property("WinBonus") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.ToTable("RankInfo"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ReprintedCardEntry", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CardId") + .HasColumnType("bigint"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("ReprintedCards"); + }); + + modelBuilder.Entity("SVSim.Database.Models.SealedConfig", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("CrystalCost") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("DeckUsingNumMin") + .HasColumnType("integer"); + + b.Property("Enable") + .HasColumnType("integer"); + + b.Property("IsDeckCodeMaintenance") + .HasColumnType("boolean"); + + b.Property("IsJoin") + .HasColumnType("boolean"); + + b.Property("PackInfo") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RupyCost") + .HasColumnType("integer"); + + b.Property("SalesPeriodInfo") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ScheduleId") + .HasColumnType("integer"); + + b.Property("TicketCost") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("SealedSeasons"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ShadowverseCardEntry", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("Attack") + .HasColumnType("integer"); + + b.Property("ClassId") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("Defense") + .HasColumnType("integer"); + + b.Property("IsFoil") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PrimaryResourceCost") + .HasColumnType("integer"); + + b.Property("Rarity") + .HasColumnType("integer"); + + b.Property("ShadowverseCardSetEntryId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ClassId"); + + b.HasIndex("ShadowverseCardSetEntryId"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ShadowverseCardSetEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("IsBasic") + .HasColumnType("boolean"); + + b.Property("IsInRotation") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("CardSets"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ShadowverseDeckEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClassId") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("Format") + .HasColumnType("integer"); + + b.Property("LeaderSkinId") + .HasColumnType("integer"); + + b.Property("MyRotationId") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("RandomLeaderSkin") + .HasColumnType("boolean"); + + b.Property("SleeveId") + .HasColumnType("integer"); + + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ClassId"); + + b.HasIndex("LeaderSkinId"); + + b.HasIndex("SleeveId"); + + b.HasIndex("ViewerId"); + + b.ToTable("Decks"); + }); + + modelBuilder.Entity("SVSim.Database.Models.SleeveEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Sleeves"); + }); + + modelBuilder.Entity("SVSim.Database.Models.SleeveShopProductEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("NameKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("PriceCrystal") + .HasColumnType("integer"); + + b.Property("PriceRupy") + .HasColumnType("integer"); + + b.Property("SeriesId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SleeveShopProducts"); + }); + + modelBuilder.Entity("SVSim.Database.Models.SleeveShopSeriesEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("IsNew") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("SleeveShopSeries"); + }); + + modelBuilder.Entity("SVSim.Database.Models.SpecialDeckFormatEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("DeckFormat") + .IsRequired() + .HasColumnType("text"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("SpecialDeckFormats"); + }); + + modelBuilder.Entity("SVSim.Database.Models.SpotCardEntry", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CardId") + .HasColumnType("bigint"); + + b.Property("Cost") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("SpotCards"); + }); + + modelBuilder.Entity("SVSim.Database.Models.UnlimitedRestrictionEntry", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CardId") + .HasColumnType("bigint"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("RestrictionValue") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("UnlimitedRestrictions"); + }); + + modelBuilder.Entity("SVSim.Database.Models.Viewer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastLogin") + .HasColumnType("timestamp with time zone"); + + b.Property("ShortUdid") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValueSql("nextval('\"ShortUdidSequence\"')"); + + NpgsqlPropertyBuilderExtensions.UseSequence(b.Property("ShortUdid"), "ShortUdidSequence"); + + b.Property("Udid") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ShortUdid"); + + b.HasIndex("Udid") + .IsUnique(); + + b.ToTable("Viewers"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ViewerAchievement", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("AchievementType") + .HasColumnType("integer"); + + b.Property("AchievementStatus") + .HasColumnType("integer"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("NowAchievedLevel") + .HasColumnType("integer"); + + b.Property("ResultAnnounceSawLevel") + .HasColumnType("integer"); + + b.HasKey("ViewerId", "AchievementType"); + + b.ToTable("ViewerAchievements"); + }); + + 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"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Track") + .HasColumnType("integer"); + + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ViewerId", "SeasonId"); + + b.HasIndex("ViewerId", "SeasonId", "Track", "Level") + .IsUnique(); + + b.ToTable("ViewerBattlePassClaims"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ViewerBattlePassProgressEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CurrentPoint") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPremium") + .HasColumnType("boolean"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("WeeklyPeriodStart") + .HasColumnType("timestamp with time zone"); + + b.Property("WeeklyPoints") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ViewerId", "SeasonId") + .IsUnique(); + + b.ToTable("ViewerBattlePassProgress"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ViewerEventCounter", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("EventKey") + .HasColumnType("text"); + + b.Property("Period") + .HasColumnType("text"); + + b.Property("Count") + .HasColumnType("integer"); + + b.HasKey("ViewerId", "EventKey", "Period"); + + b.HasIndex("ViewerId", "Period"); + + b.ToTable("ViewerEventCounters"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ViewerMission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AssignedAt") + .HasColumnType("bigint"); + + b.Property("ClaimedAt") + .HasColumnType("bigint"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("MissionCatalogId") + .HasColumnType("integer"); + + b.Property("MissionStatus") + .HasColumnType("integer"); + + b.Property("Slot") + .HasColumnType("integer"); + + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ViewerId"); + + b.HasIndex("ViewerId", "Slot") + .IsUnique(); + + b.ToTable("ViewerMissions"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ViewerPuzzleClear", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("PuzzleId") + .HasColumnType("integer"); + + b.Property("BestRetryCount") + .HasColumnType("integer"); + + b.Property("ClearedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ViewerId", "PuzzleId"); + + b.ToTable("ViewerPuzzleClears"); + }); + + modelBuilder.Entity("SleeveEntryViewer", b => + { + b.Property("SleevesId") + .HasColumnType("integer"); + + b.Property("ViewersId") + .HasColumnType("bigint"); + + b.HasKey("SleevesId", "ViewersId"); + + b.HasIndex("ViewersId"); + + b.ToTable("SleeveEntryViewer"); + }); + + modelBuilder.Entity("DegreeEntryViewer", b => + { + b.HasOne("SVSim.Database.Models.DegreeEntry", null) + .WithMany() + .HasForeignKey("DegreesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SVSim.Database.Models.Viewer", null) + .WithMany() + .HasForeignKey("ViewersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EmblemEntryViewer", b => + { + b.HasOne("SVSim.Database.Models.EmblemEntry", null) + .WithMany() + .HasForeignKey("EmblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SVSim.Database.Models.Viewer", null) + .WithMany() + .HasForeignKey("ViewersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("LeaderSkinEntryViewer", b => + { + b.HasOne("SVSim.Database.Models.LeaderSkinEntry", null) + .WithMany() + .HasForeignKey("LeaderSkinsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SVSim.Database.Models.Viewer", null) + .WithMany() + .HasForeignKey("ViewersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MyPageBackgroundEntryViewer", b => + { + b.HasOne("SVSim.Database.Models.MyPageBackgroundEntry", null) + .WithMany() + .HasForeignKey("MyPageBackgroundsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SVSim.Database.Models.Viewer", null) + .WithMany() + .HasForeignKey("ViewersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SVSim.Database.Entities.Story.StoryChapter", b => + { + b.HasOne("SVSim.Database.Entities.Story.StorySection", "Section") + .WithMany() + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SVSim.Database.Entities.Story.SpecialBattleSetting", "SpecialBattleSetting") + .WithMany() + .HasForeignKey("SpecialBattleSettingId"); + + b.OwnsMany("SVSim.Database.Entities.Story.StoryChapterBattleSetting", "BattleSettings", b1 => + { + b1.Property("StoryId") + .HasColumnType("integer"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Battle3dFieldIdOverride") + .HasColumnType("integer"); + + b1.Property("BgmIdOverride") + .HasColumnType("integer"); + + b1.Property("DeckClassId") + .HasColumnType("integer"); + + b1.Property("DeckSkinIdOverride") + .HasColumnType("integer"); + + b1.Property("EnemyEmotionOverride") + .HasColumnType("integer"); + + b1.Property("PlayerEmotionOverride") + .HasColumnType("integer"); + + b1.Property("SkinIdOverride") + .HasColumnType("integer"); + + b1.HasKey("StoryId", "Id"); + + b1.ToTable("StoryChapterBattleSetting"); + + b1.WithOwner() + .HasForeignKey("StoryId"); + }); + + b.OwnsMany("SVSim.Database.Entities.Story.StoryChapterReward", "Rewards", b1 => + { + b1.Property("StoryId") + .HasColumnType("integer"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("RewardDetailId") + .HasColumnType("bigint"); + + b1.Property("RewardNumber") + .HasColumnType("integer"); + + b1.Property("RewardType") + .HasColumnType("integer"); + + b1.HasKey("StoryId", "Id"); + + b1.ToTable("StoryChapterReward"); + + b1.WithOwner() + .HasForeignKey("StoryId"); + }); + + b.OwnsMany("SVSim.Database.Entities.Story.StorySubChapter", "SubChapters", b1 => + { + b1.Property("StoryId") + .HasColumnType("integer"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("IsMaintenanceChapter") + .HasColumnType("boolean"); + + b1.Property("SubChapterId") + .HasColumnType("integer"); + + b1.Property("SubChapterStoryId") + .HasColumnType("integer"); + + b1.HasKey("StoryId", "Id"); + + b1.ToTable("StorySubChapter"); + + b1.WithOwner() + .HasForeignKey("StoryId"); + }); + + b.Navigation("BattleSettings"); + + b.Navigation("Rewards"); + + b.Navigation("Section"); + + b.Navigation("SpecialBattleSetting"); + + b.Navigation("SubChapters"); + }); + + modelBuilder.Entity("SVSim.Database.Entities.Story.StorySection", b => + { + b.HasOne("SVSim.Database.Entities.Story.StoryWorld", "World") + .WithMany() + .HasForeignKey("WorldId"); + + b.Navigation("World"); + }); + + modelBuilder.Entity("SVSim.Database.Models.BattlePassRewardEntry", b => + { + b.HasOne("SVSim.Database.Models.BattlePassSeasonEntry", "Season") + .WithMany("Rewards") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("SVSim.Database.Models.BuildDeckProductEntry", b => + { + b.HasOne("SVSim.Database.Models.BuildDeckSeriesEntry", "Series") + .WithMany("Products") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("SVSim.Database.Models.BuildDeckProductCardEntry", "Cards", b1 => + { + b1.Property("BuildDeckProductEntryId") + .HasColumnType("integer"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("CardId") + .HasColumnType("bigint"); + + b1.Property("IsSpot") + .HasColumnType("boolean"); + + b1.Property("Number") + .HasColumnType("integer"); + + b1.HasKey("BuildDeckProductEntryId", "Id"); + + b1.ToTable("BuildDeckProductCardEntry"); + + b1.WithOwner() + .HasForeignKey("BuildDeckProductEntryId"); + }); + + b.OwnsMany("SVSim.Database.Models.BuildDeckProductRewardEntry", "Rewards", b1 => + { + b1.Property("BuildDeckProductEntryId") + .HasColumnType("integer"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("MessageId") + .HasColumnType("integer"); + + b1.Property("RewardDetailId") + .HasColumnType("bigint"); + + b1.Property("RewardIndex") + .HasColumnType("integer"); + + b1.Property("RewardNumber") + .HasColumnType("integer"); + + b1.Property("RewardType") + .HasColumnType("integer"); + + b1.HasKey("BuildDeckProductEntryId", "Id"); + + b1.ToTable("BuildDeckProductRewardEntry"); + + b1.WithOwner() + .HasForeignKey("BuildDeckProductEntryId"); + }); + + b.Navigation("Cards"); + + b.Navigation("Rewards"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("SVSim.Database.Models.BuildDeckSeriesEntry", b => + { + b.OwnsMany("SVSim.Database.Models.BuildDeckSeriesRewardEntry", "SeriesRewards", b1 => + { + b1.Property("BuildDeckSeriesEntryId") + .HasColumnType("integer"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("ItemIndex") + .HasColumnType("integer"); + + b1.Property("MessageId") + .HasColumnType("integer"); + + b1.Property("RewardDetailId") + .HasColumnType("bigint"); + + b1.Property("RewardNumber") + .HasColumnType("integer"); + + b1.Property("RewardType") + .HasColumnType("integer"); + + b1.Property("TierIndex") + .HasColumnType("integer"); + + b1.HasKey("BuildDeckSeriesEntryId", "Id"); + + b1.ToTable("BuildDeckSeriesRewardEntry"); + + b1.WithOwner() + .HasForeignKey("BuildDeckSeriesEntryId"); + }); + + b.Navigation("SeriesRewards"); + }); + + modelBuilder.Entity("SVSim.Database.Models.CardCosmeticReward", b => + { + b.HasOne("SVSim.Database.Models.ShadowverseCardEntry", "Card") + .WithMany() + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Card"); + }); + + modelBuilder.Entity("SVSim.Database.Models.LeaderSkinEntry", b => + { + b.HasOne("SVSim.Database.Models.ClassEntry", "Class") + .WithMany("LeaderSkins") + .HasForeignKey("ClassId"); + + b.Navigation("Class"); + }); + + modelBuilder.Entity("SVSim.Database.Models.PackConfigEntry", b => + { + b.OwnsMany("SVSim.Database.Models.PackBannerEntry", "Banners", b1 => + { + b1.Property("PackConfigEntryId") + .HasColumnType("integer"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("BannerName") + .IsRequired() + .HasColumnType("text"); + + b1.Property("DialogTitle") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("PackConfigEntryId", "Id"); + + b1.ToTable("PackBannerEntry"); + + b1.WithOwner() + .HasForeignKey("PackConfigEntryId"); + }); + + b.OwnsMany("SVSim.Database.Models.PackChildGachaEntry", "ChildGachas", b1 => + { + b1.Property("PackConfigEntryId") + .HasColumnType("integer"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("CampaignName") + .HasColumnType("text"); + + b1.Property("CardCount") + .HasColumnType("integer"); + + b1.Property("Cost") + .HasColumnType("integer"); + + b1.Property("FreeGachaCampaignId") + .HasColumnType("integer"); + + b1.Property("GachaId") + .HasColumnType("integer"); + + b1.Property("IsDailySingle") + .HasColumnType("boolean"); + + b1.Property("ItemId") + .HasColumnType("bigint"); + + b1.Property("OverrideIncreaseGachaPoint") + .HasColumnType("integer"); + + b1.Property("PurchaseLimitCount") + .HasColumnType("integer"); + + b1.Property("TypeDetail") + .HasColumnType("integer"); + + b1.HasKey("PackConfigEntryId", "Id"); + + b1.ToTable("PackChildGachaEntry"); + + b1.WithOwner() + .HasForeignKey("PackConfigEntryId"); + }); + + b.OwnsOne("SVSim.Database.Models.PackGachaPointConfig", "GachaPointConfig", b1 => + { + b1.Property("PackConfigEntryId") + .HasColumnType("integer"); + + b1.Property("ExchangeablePoint") + .HasColumnType("integer"); + + b1.Property("IncreaseGachaPoint") + .HasColumnType("integer"); + + b1.HasKey("PackConfigEntryId"); + + b1.ToTable("Packs"); + + b1.WithOwner() + .HasForeignKey("PackConfigEntryId"); + }); + + b.Navigation("Banners"); + + b.Navigation("ChildGachas"); + + b.Navigation("GachaPointConfig"); + }); + + modelBuilder.Entity("SVSim.Database.Models.PuzzleEntry", b => + { + b.HasOne("SVSim.Database.Models.PuzzleGroupEntry", "Group") + .WithMany("Puzzles") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ShadowverseCardEntry", b => + { + b.HasOne("SVSim.Database.Models.ClassEntry", "Class") + .WithMany() + .HasForeignKey("ClassId"); + + b.HasOne("SVSim.Database.Models.ShadowverseCardSetEntry", null) + .WithMany("Cards") + .HasForeignKey("ShadowverseCardSetEntryId"); + + b.OwnsOne("SVSim.Database.Models.CardCollectionInfo", "CollectionInfo", b1 => + { + b1.Property("ShadowverseCardEntryId") + .HasColumnType("bigint"); + + b1.Property("CraftCost") + .HasColumnType("integer"); + + b1.Property("DustReward") + .HasColumnType("integer"); + + b1.HasKey("ShadowverseCardEntryId"); + + b1.ToTable("Cards"); + + b1.WithOwner() + .HasForeignKey("ShadowverseCardEntryId"); + }); + + b.Navigation("Class"); + + b.Navigation("CollectionInfo"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ShadowverseDeckEntry", b => + { + b.HasOne("SVSim.Database.Models.ClassEntry", "Class") + .WithMany() + .HasForeignKey("ClassId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SVSim.Database.Models.LeaderSkinEntry", "LeaderSkin") + .WithMany() + .HasForeignKey("LeaderSkinId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SVSim.Database.Models.SleeveEntry", "Sleeve") + .WithMany() + .HasForeignKey("SleeveId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SVSim.Database.Models.Viewer", null) + .WithMany("Decks") + .HasForeignKey("ViewerId"); + + b.OwnsMany("SVSim.Database.Models.DeckCard", "Cards", b1 => + { + b1.Property("ShadowverseDeckEntryId") + .HasColumnType("uuid"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("CardId") + .HasColumnType("bigint"); + + b1.Property("Count") + .HasColumnType("integer"); + + b1.HasKey("ShadowverseDeckEntryId", "Id"); + + b1.HasIndex("CardId"); + + b1.ToTable("DeckCard"); + + b1.HasOne("SVSim.Database.Models.ShadowverseCardEntry", "Card") + .WithMany() + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b1.WithOwner() + .HasForeignKey("ShadowverseDeckEntryId"); + + b1.Navigation("Card"); + }); + + b.Navigation("Cards"); + + b.Navigation("Class"); + + b.Navigation("LeaderSkin"); + + b.Navigation("Sleeve"); + }); + + modelBuilder.Entity("SVSim.Database.Models.SleeveShopProductEntry", b => + { + b.HasOne("SVSim.Database.Models.SleeveShopSeriesEntry", "Series") + .WithMany("Products") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("SVSim.Database.Models.SleeveShopProductRewardEntry", "Rewards", b1 => + { + b1.Property("SleeveShopProductEntryId") + .HasColumnType("integer"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("OrderIndex") + .HasColumnType("integer"); + + b1.Property("RewardDetailId") + .HasColumnType("bigint"); + + b1.Property("RewardNumber") + .HasColumnType("integer"); + + b1.Property("RewardType") + .HasColumnType("integer"); + + b1.HasKey("SleeveShopProductEntryId", "Id"); + + b1.ToTable("SleeveShopProductRewardEntry"); + + b1.WithOwner() + .HasForeignKey("SleeveShopProductEntryId"); + }); + + b.Navigation("Rewards"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("SVSim.Database.Models.Viewer", b => + { + b.OwnsMany("SVSim.Database.Models.OwnedCardEntry", "Cards", b1 => + { + b1.Property("ViewerId") + .HasColumnType("bigint"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("CardId") + .HasColumnType("bigint"); + + b1.Property("Count") + .HasColumnType("integer"); + + b1.Property("IsProtected") + .HasColumnType("boolean"); + + b1.HasKey("ViewerId", "Id"); + + b1.HasIndex("CardId"); + + b1.HasIndex("ViewerId", "CardId") + .IsUnique(); + + b1.ToTable("OwnedCardEntry"); + + b1.HasOne("SVSim.Database.Models.ShadowverseCardEntry", "Card") + .WithMany() + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b1.WithOwner() + .HasForeignKey("ViewerId"); + + b1.Navigation("Card"); + }); + + b.OwnsMany("SVSim.Database.Models.OwnedItemEntry", "Items", b1 => + { + b1.Property("ViewerId") + .HasColumnType("bigint"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Count") + .HasColumnType("integer"); + + b1.Property("ItemId") + .HasColumnType("integer"); + + b1.HasKey("ViewerId", "Id"); + + b1.HasIndex("ItemId"); + + b1.HasIndex("ViewerId", "ItemId") + .IsUnique(); + + b1.ToTable("OwnedItemEntry"); + + b1.HasOne("SVSim.Database.Models.ItemEntry", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b1.WithOwner("Viewer") + .HasForeignKey("ViewerId"); + + b1.Navigation("Item"); + + b1.Navigation("Viewer"); + }); + + b.OwnsMany("SVSim.Database.Models.SocialAccountConnection", "SocialAccountConnections", b1 => + { + b1.Property("ViewerId") + .HasColumnType("bigint"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("AccountId") + .HasColumnType("numeric(20,0)"); + + b1.Property("AccountType") + .HasColumnType("integer"); + + b1.HasKey("ViewerId", "Id"); + + b1.HasIndex("AccountType", "AccountId") + .IsUnique(); + + b1.ToTable("SocialAccountConnection"); + + b1.WithOwner("Viewer") + .HasForeignKey("ViewerId"); + + b1.Navigation("Viewer"); + }); + + b.OwnsMany("SVSim.Database.Models.ViewerBuildDeckProductPurchase", "BuildDeckPurchases", b1 => + { + b1.Property("ViewerId") + .HasColumnType("bigint"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("ProductId") + .HasColumnType("integer"); + + b1.Property("PurchaseCount") + .HasColumnType("integer"); + + b1.HasKey("ViewerId", "Id"); + + b1.HasIndex("ViewerId", "ProductId") + .IsUnique(); + + b1.ToTable("ViewerBuildDeckProductPurchase"); + + b1.WithOwner() + .HasForeignKey("ViewerId"); + }); + + b.OwnsMany("SVSim.Database.Models.ViewerClassData", "Classes", b1 => + { + b1.Property("ViewerId") + .HasColumnType("bigint"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("ClassId") + .HasColumnType("integer"); + + b1.Property("Exp") + .HasColumnType("integer"); + + b1.Property("LeaderSkinId") + .HasColumnType("integer"); + + b1.Property("Level") + .HasColumnType("integer"); + + b1.HasKey("ViewerId", "Id"); + + b1.HasIndex("ClassId"); + + b1.HasIndex("LeaderSkinId"); + + b1.ToTable("ViewerClassData"); + + b1.HasOne("SVSim.Database.Models.ClassEntry", "Class") + .WithMany() + .HasForeignKey("ClassId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b1.HasOne("SVSim.Database.Models.LeaderSkinEntry", "LeaderSkin") + .WithMany() + .HasForeignKey("LeaderSkinId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b1.WithOwner("Viewer") + .HasForeignKey("ViewerId"); + + b1.Navigation("Class"); + + b1.Navigation("LeaderSkin"); + + b1.Navigation("Viewer"); + }); + + b.OwnsOne("SVSim.Database.Models.ViewerCurrency", "Currency", b1 => + { + b1.Property("ViewerId") + .HasColumnType("bigint"); + + b1.Property("AndroidCrystals") + .HasColumnType("numeric(20,0)"); + + b1.Property("Crystals") + .HasColumnType("numeric(20,0)"); + + b1.Property("DmmCrystals") + .HasColumnType("numeric(20,0)"); + + b1.Property("FreeCrystals") + .HasColumnType("numeric(20,0)"); + + b1.Property("IosCrystals") + .HasColumnType("numeric(20,0)"); + + b1.Property("LifeTotalCrystals") + .HasColumnType("numeric(20,0)"); + + b1.Property("RedEther") + .HasColumnType("numeric(20,0)"); + + b1.Property("Rupees") + .HasColumnType("numeric(20,0)"); + + b1.Property("SteamCrystals") + .HasColumnType("numeric(20,0)"); + + b1.HasKey("ViewerId"); + + b1.ToTable("Viewers"); + + b1.WithOwner() + .HasForeignKey("ViewerId"); + }); + + b.OwnsOne("SVSim.Database.Models.ViewerInfo", "Info", b1 => + { + b1.Property("ViewerId") + .HasColumnType("bigint"); + + b1.Property("BirthDate") + .HasColumnType("timestamp with time zone"); + + b1.Property("CountryCode") + .IsRequired() + .HasColumnType("text"); + + b1.Property("IsOfficial") + .HasColumnType("boolean"); + + b1.Property("IsOfficialMarkDisplayed") + .HasColumnType("boolean"); + + b1.Property("MaxFriends") + .HasColumnType("integer"); + + b1.Property("SelectedDegreeId") + .HasColumnType("integer"); + + b1.Property("SelectedEmblemId") + .HasColumnType("integer"); + + b1.HasKey("ViewerId"); + + b1.HasIndex("SelectedDegreeId"); + + b1.HasIndex("SelectedEmblemId"); + + b1.ToTable("Viewers"); + + b1.HasOne("SVSim.Database.Models.DegreeEntry", "SelectedDegree") + .WithMany() + .HasForeignKey("SelectedDegreeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b1.HasOne("SVSim.Database.Models.EmblemEntry", "SelectedEmblem") + .WithMany() + .HasForeignKey("SelectedEmblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b1.WithOwner() + .HasForeignKey("ViewerId"); + + b1.Navigation("SelectedDegree"); + + b1.Navigation("SelectedEmblem"); + }); + + b.OwnsOne("SVSim.Database.Models.ViewerMissionData", "MissionData", b1 => + { + b1.Property("ViewerId") + .HasColumnType("bigint"); + + b1.Property("HasReceivedPickTwoMission") + .HasColumnType("boolean"); + + b1.Property("MissionChangeTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("MissionReceiveType") + .HasColumnType("integer"); + + b1.Property("TutorialState") + .HasColumnType("integer"); + + b1.HasKey("ViewerId"); + + b1.ToTable("Viewers"); + + b1.WithOwner() + .HasForeignKey("ViewerId"); + }); + + b.OwnsMany("SVSim.Database.Models.ViewerPackOpenCount", "PackOpenCounts", b1 => + { + b1.Property("ViewerId") + .HasColumnType("bigint"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("LastDailyFreeAt") + .HasColumnType("timestamp with time zone"); + + b1.Property("OpenCount") + .HasColumnType("integer"); + + b1.Property("PackId") + .HasColumnType("integer"); + + b1.HasKey("ViewerId", "Id"); + + b1.ToTable("ViewerPackOpenCount"); + + b1.WithOwner() + .HasForeignKey("ViewerId"); + }); + + b.Navigation("BuildDeckPurchases"); + + b.Navigation("Cards"); + + b.Navigation("Classes"); + + b.Navigation("Currency") + .IsRequired(); + + b.Navigation("Info") + .IsRequired(); + + b.Navigation("Items"); + + b.Navigation("MissionData") + .IsRequired(); + + b.Navigation("PackOpenCounts"); + + b.Navigation("SocialAccountConnections"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ViewerAchievement", b => + { + b.HasOne("SVSim.Database.Models.Viewer", null) + .WithMany("Achievements") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SVSim.Database.Models.ViewerEventCounter", b => + { + b.HasOne("SVSim.Database.Models.Viewer", null) + .WithMany("EventCounters") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SVSim.Database.Models.ViewerMission", b => + { + b.HasOne("SVSim.Database.Models.Viewer", null) + .WithMany("Missions") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SleeveEntryViewer", b => + { + b.HasOne("SVSim.Database.Models.SleeveEntry", null) + .WithMany() + .HasForeignKey("SleevesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SVSim.Database.Models.Viewer", null) + .WithMany() + .HasForeignKey("ViewersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SVSim.Database.Models.BattlePassSeasonEntry", b => + { + b.Navigation("Rewards"); + }); + + modelBuilder.Entity("SVSim.Database.Models.BuildDeckSeriesEntry", b => + { + b.Navigation("Products"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ClassEntry", b => + { + b.Navigation("LeaderSkins"); + }); + + modelBuilder.Entity("SVSim.Database.Models.PuzzleGroupEntry", b => + { + b.Navigation("Puzzles"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ShadowverseCardSetEntry", b => + { + b.Navigation("Cards"); + }); + + modelBuilder.Entity("SVSim.Database.Models.SleeveShopSeriesEntry", b => + { + b.Navigation("Products"); + }); + + modelBuilder.Entity("SVSim.Database.Models.Viewer", b => + { + b.Navigation("Achievements"); + + b.Navigation("Decks"); + + b.Navigation("EventCounters"); + + b.Navigation("Missions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SVSim.Database/Migrations/20260528021818_AddItemPurchaseCatalog.cs b/SVSim.Database/Migrations/20260528021818_AddItemPurchaseCatalog.cs new file mode 100644 index 0000000..c61e469 --- /dev/null +++ b/SVSim.Database/Migrations/20260528021818_AddItemPurchaseCatalog.cs @@ -0,0 +1,45 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SVSim.Database.Migrations +{ + /// + public partial class AddItemPurchaseCatalog : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ItemPurchaseCatalog", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false), + RequireItemType = table.Column(type: "integer", nullable: false), + RequireItemId = table.Column(type: "bigint", nullable: false), + RequireItemNum = table.Column(type: "integer", nullable: false), + PurchaseItemType = table.Column(type: "integer", nullable: false), + PurchaseItemId = table.Column(type: "bigint", nullable: false), + PurchaseItemNum = table.Column(type: "integer", nullable: false), + PurchaseName = table.Column(type: "text", nullable: false), + IsMonthlyReset = table.Column(type: "boolean", nullable: false), + PurchaseLimit = table.Column(type: "integer", nullable: false), + IsEnabled = table.Column(type: "boolean", nullable: false), + DateCreated = table.Column(type: "timestamp with time zone", nullable: false), + DateUpdated = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ItemPurchaseCatalog", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ItemPurchaseCatalog"); + } + } +} diff --git a/SVSim.Database/Migrations/SVSimDbContextModelSnapshot.cs b/SVSim.Database/Migrations/SVSimDbContextModelSnapshot.cs index ba91dd9..172db9f 100644 --- a/SVSim.Database/Migrations/SVSimDbContextModelSnapshot.cs +++ b/SVSim.Database/Migrations/SVSimDbContextModelSnapshot.cs @@ -1107,6 +1107,53 @@ namespace SVSim.Database.Migrations b.ToTable("Items"); }); + modelBuilder.Entity("SVSim.Database.Models.ItemPurchaseCatalogEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("IsMonthlyReset") + .HasColumnType("boolean"); + + b.Property("PurchaseItemId") + .HasColumnType("bigint"); + + b.Property("PurchaseItemNum") + .HasColumnType("integer"); + + b.Property("PurchaseItemType") + .HasColumnType("integer"); + + b.Property("PurchaseLimit") + .HasColumnType("integer"); + + b.Property("PurchaseName") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequireItemId") + .HasColumnType("bigint"); + + b.Property("RequireItemNum") + .HasColumnType("integer"); + + b.Property("RequireItemType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ItemPurchaseCatalog"); + }); + modelBuilder.Entity("SVSim.Database.Models.LeaderSkinEntry", b => { b.Property("Id") diff --git a/SVSim.Database/Models/ItemPurchaseCatalogEntry.cs b/SVSim.Database/Models/ItemPurchaseCatalogEntry.cs new file mode 100644 index 0000000..7ae2f86 --- /dev/null +++ b/SVSim.Database/Models/ItemPurchaseCatalogEntry.cs @@ -0,0 +1,39 @@ +using SVSim.Database.Common; + +namespace SVSim.Database.Models; + +/// +/// One row of the /item_purchase/info catalog — an exchange the user can perform N times per +/// period (monthly or lifetime) by spending RequireItem* to acquire PurchaseItem*. +/// PK = wire purchase_id. +/// +/// Both sides reference . Captures show the common shape is +/// currency-for-item (RedEther 5000 → Seer's Globe ×1) or item-for-item (Orb Shard ×5 → +/// Seer's Globe ×1). Per-viewer remaining quota lives in +/// keyed by "item_purchase:{Id}". +/// +/// +public class ItemPurchaseCatalogEntry : BaseEntity +{ + public int RequireItemType { get; set; } + public long RequireItemId { get; set; } + public int RequireItemNum { get; set; } + + public int PurchaseItemType { get; set; } + public long PurchaseItemId { get; set; } + public int PurchaseItemNum { get; set; } + + /// + /// SystemText-ready display name. May be empty — the client falls back to a templated name + /// built from UserGoods.getUserGoodsName + count via SystemText key "Shop_0132". + /// + public string PurchaseName { get; set; } = string.Empty; + + /// True → quota resets at the start of each JST month. False → lifetime quota. + public bool IsMonthlyReset { get; set; } + + /// Per-period purchase cap. Wire rest = max(0, PurchaseLimit - counter). + public int PurchaseLimit { get; set; } + + public bool IsEnabled { get; set; } +} diff --git a/SVSim.Database/SVSimDbContext.cs b/SVSim.Database/SVSimDbContext.cs index 2a5ae17..dcc98d3 100644 --- a/SVSim.Database/SVSimDbContext.cs +++ b/SVSim.Database/SVSimDbContext.cs @@ -72,6 +72,7 @@ public class SVSimDbContext : DbContext public DbSet BuildDeckProducts => Set(); public DbSet SleeveShopSeries => Set(); public DbSet SleeveShopProducts => Set(); + public DbSet ItemPurchaseCatalog => Set(); public DbSet MaintenanceCards => Set(); public DbSet FeatureMaintenances => Set(); public DbSet PreReleaseInfos => Set(); diff --git a/SVSim.EmulatedEntrypoint/Controllers/ItemPurchaseController.cs b/SVSim.EmulatedEntrypoint/Controllers/ItemPurchaseController.cs new file mode 100644 index 0000000..7bd08a8 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Controllers/ItemPurchaseController.cs @@ -0,0 +1,215 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.Database.Services; +using SVSim.EmulatedEntrypoint.Models.Dtos; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ItemPurchase; +using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ItemPurchase; +using SVSim.EmulatedEntrypoint.Services; + +namespace SVSim.EmulatedEntrypoint.Controllers; + +/// +/// /item_purchase/* — the generic item shop where viewers spend item-or-currency to acquire +/// other items (e.g. RedEther → Seer's Globe, Orb Shards → Seer's Globe). Per-viewer monthly +/// or lifetime quota tracked via . +/// +[Route("item_purchase")] +public class ItemPurchaseController : SVSimController +{ + private readonly SVSimDbContext _db; + private readonly RewardGrantService _rewards; + private readonly TimeProvider _time; + + public ItemPurchaseController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time) + { + _db = db; + _rewards = rewards; + _time = time; + } + + [HttpPost("info")] + public async Task> Info() + { + if (!TryGetViewerId(out long viewerId)) return Unauthorized(); + + var catalog = await _db.ItemPurchaseCatalog + .Where(c => c.IsEnabled) + .OrderBy(c => c.Id) + .ToListAsync(); + + var now = _time.GetUtcNow(); + var monthKey = JstPeriod.MonthKey(now); + var keys = catalog.Select(c => CounterKey(c.Id)).ToList(); + var counters = await _db.ViewerEventCounters + .Where(c => c.ViewerId == viewerId && keys.Contains(c.EventKey)) + .ToListAsync(); + + var info = new List(catalog.Count); + foreach (var c in catalog) + { + int count = CounterCount(counters, c, monthKey); + info.Add(new ItemPurchaseEntryDto + { + PurchaseId = c.Id, + RequireItemType = c.RequireItemType, + RequireItemId = c.RequireItemId, + RequireItemNum = c.RequireItemNum, + PurchaseItemType = c.PurchaseItemType, + PurchaseItemId = c.PurchaseItemId, + PurchaseItemNum = c.PurchaseItemNum, + PurchaseName = c.PurchaseName, + IsMonthlyReset = c.IsMonthlyReset ? 1 : 0, + Rest = Math.Max(0, c.PurchaseLimit - count), + }); + } + + // user_card_pack_ticket_list: every item with Type == 2 paired with the viewer's count + // (zero counts included — the client unconditionally calls UpdateItemNum per entry). + var ticketItems = await _db.Items + .Where(i => i.Type == 2) + .OrderByDescending(i => i.Id) + .ToListAsync(); + var ownedByItemId = (await _db.Viewers + .Where(v => v.Id == viewerId) + .SelectMany(v => v.Items) + .Select(oi => new { oi.Item.Id, oi.Count }) + .ToListAsync()) + .ToDictionary(x => x.Id, x => x.Count); + + var ticketList = ticketItems.Select(i => new UserCardPackTicketDto + { + ItemId = i.Id, + Number = ownedByItemId.TryGetValue(i.Id, out var cnt) ? cnt : 0, + }).ToList(); + + return new ItemPurchaseInfoResponse + { + ItemPurchaseInfo = info, + UserCardPackTicketList = ticketList, + }; + } + + [HttpPost("purchase")] + public async Task> Purchase(ItemPurchasePurchaseRequest request) + { + if (!TryGetViewerId(out long viewerId)) return Unauthorized(); + + var entry = await _db.ItemPurchaseCatalog.FindAsync(request.PurchaseId); + if (entry is null || !entry.IsEnabled) + return BadRequest(new { error = "unknown_purchase" }); + + var now = _time.GetUtcNow(); + var period = entry.IsMonthlyReset ? JstPeriod.MonthKey(now) : JstPeriod.AllTime; + var key = CounterKey(entry.Id); + + var counter = await _db.ViewerEventCounters + .FirstOrDefaultAsync(c => c.ViewerId == viewerId && c.EventKey == key && c.Period == period); + int currentCount = counter?.Count ?? 0; + int rest = entry.PurchaseLimit - currentCount; + if (rest <= 0) + return BadRequest(new { error = "sold_out" }); + + var viewer = await LoadViewerGraphAsync(viewerId); + var rewardList = new List(); + + // Debit the require side. RewardGrantService is grant-only, so handle this inline. + var debit = TryDebit(viewer, (UserGoodsType)entry.RequireItemType, entry.RequireItemId, entry.RequireItemNum); + if (debit.Error is not null) return BadRequest(new { error = debit.Error }); + if (debit.PostState is not null) rewardList.Add(debit.PostState); + + // Grant the purchase side through the central dispatcher. + var granted = await _rewards.ApplyAsync(viewer, + (UserGoodsType)entry.PurchaseItemType, entry.PurchaseItemId, entry.PurchaseItemNum); + foreach (var g in granted) + { + rewardList.Add(new RewardListEntry + { + RewardType = g.RewardType, + RewardId = g.RewardId, + RewardNum = g.RewardNum, + }); + } + + // Increment the per-period counter. + if (counter is null) + { + _db.ViewerEventCounters.Add(new ViewerEventCounter + { + ViewerId = viewerId, + EventKey = key, + Period = period, + Count = 1, + }); + } + else + { + counter.Count++; + } + + await _db.SaveChangesAsync(); + + return new ItemPurchasePurchaseResponse { RewardList = rewardList }; + } + + /// + /// Debit of (, ) + /// from the viewer, returning a post-state-aware the client + /// uses to refresh its cached count. Returns an error string on insufficient balance. + /// + private static (RewardListEntry? PostState, string? Error) TryDebit( + Viewer viewer, UserGoodsType type, long detailId, int num) + { + switch (type) + { + case UserGoodsType.RedEther: + if (viewer.Currency.RedEther < (ulong)num) + return (null, "insufficient_red_ether"); + viewer.Currency.RedEther -= (ulong)num; + return (new RewardListEntry { RewardType = 1, RewardId = 0, RewardNum = (int)viewer.Currency.RedEther }, null); + + case UserGoodsType.Crystal: + if (viewer.Currency.Crystals < (ulong)num) + return (null, "insufficient_crystals"); + viewer.Currency.Crystals -= (ulong)num; + return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals }, null); + + case UserGoodsType.Rupy: + if (viewer.Currency.Rupees < (ulong)num) + return (null, "insufficient_rupees"); + viewer.Currency.Rupees -= (ulong)num; + return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees }, null); + + case UserGoodsType.Item: + var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId); + if (owned is null || owned.Count < num) + return (null, "insufficient_item"); + owned.Count -= num; + return (new RewardListEntry { RewardType = 4, RewardId = detailId, RewardNum = owned.Count }, null); + + default: + return (null, $"debit_type_not_supported:{type}"); + } + } + + private static string CounterKey(int purchaseId) => $"item_purchase:{purchaseId}"; + + private static int CounterCount(List counters, ItemPurchaseCatalogEntry entry, string monthKey) + { + var period = entry.IsMonthlyReset ? monthKey : JstPeriod.AllTime; + return counters.FirstOrDefault(c => c.EventKey == CounterKey(entry.Id) && c.Period == period)?.Count ?? 0; + } + + private Task LoadViewerGraphAsync(long viewerId) => _db.Viewers + .Include(v => v.Items).ThenInclude(i => i.Item) + .Include(v => v.Cards).ThenInclude(c => c.Card) + .Include(v => v.Sleeves) + .Include(v => v.Emblems) + .Include(v => v.LeaderSkins) + .Include(v => v.Degrees) + .Include(v => v.MyPageBackgrounds) + .AsSplitQuery() + .FirstAsync(v => v.Id == viewerId); +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/ItemPurchase/ItemPurchasePurchaseRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/ItemPurchase/ItemPurchasePurchaseRequest.cs new file mode 100644 index 0000000..da5cb17 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/ItemPurchase/ItemPurchasePurchaseRequest.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; +using MessagePack; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ItemPurchase; + +/// +/// /item_purchase/purchase request body. rest is the client's locally-cached remaining +/// quota — used as an optional optimistic-concurrency check on the server. Not authoritative; +/// the server's own counter is canonical. +/// +[MessagePackObject] +public class ItemPurchasePurchaseRequest : BaseRequest +{ + [JsonPropertyName("purchase_id")] + [Key("purchase_id")] + public int PurchaseId { get; set; } + + [JsonPropertyName("rest")] + [Key("rest")] + public int Rest { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ItemPurchase/ItemPurchaseInfoResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ItemPurchase/ItemPurchaseInfoResponse.cs new file mode 100644 index 0000000..fab060e --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ItemPurchase/ItemPurchaseInfoResponse.cs @@ -0,0 +1,86 @@ +using System.Text.Json.Serialization; +using MessagePack; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ItemPurchase; + +/// +/// /item_purchase/info response. +/// +/// item_purchase_info is an array of catalog entries with per-viewer rest +/// (PurchaseLimit minus the viewer's counter for the relevant period). +/// +/// +/// user_card_pack_ticket_list is the FULL set of card-pack-ticket items (catalog +/// Items.Type == 2) joined with the viewer's owned counts — even zero counts are emitted, as +/// the client's parser unconditionally calls PlayerStaticData.UpdateItemNum(item_id, number) +/// for every entry to refresh its in-memory mapping. +/// +/// +[MessagePackObject] +public class ItemPurchaseInfoResponse +{ + [JsonPropertyName("item_purchase_info")] + [Key("item_purchase_info")] + public List ItemPurchaseInfo { get; set; } = new(); + + [JsonPropertyName("user_card_pack_ticket_list")] + [Key("user_card_pack_ticket_list")] + public List UserCardPackTicketList { get; set; } = new(); +} + +[MessagePackObject] +public class ItemPurchaseEntryDto +{ + [JsonPropertyName("purchase_id")] + [Key("purchase_id")] + public int PurchaseId { get; set; } + + [JsonPropertyName("require_item_type")] + [Key("require_item_type")] + public int RequireItemType { get; set; } + + [JsonPropertyName("require_item_id")] + [Key("require_item_id")] + public long RequireItemId { get; set; } + + [JsonPropertyName("require_item_num")] + [Key("require_item_num")] + public int RequireItemNum { get; set; } + + [JsonPropertyName("purchase_name")] + [Key("purchase_name")] + public string PurchaseName { get; set; } = string.Empty; + + [JsonPropertyName("purchase_item_type")] + [Key("purchase_item_type")] + public int PurchaseItemType { get; set; } + + [JsonPropertyName("purchase_item_id")] + [Key("purchase_item_id")] + public long PurchaseItemId { get; set; } + + [JsonPropertyName("purchase_item_num")] + [Key("purchase_item_num")] + public int PurchaseItemNum { get; set; } + + /// 0 or 1 — client compares to int 0. + [JsonPropertyName("is_monthly_reset")] + [Key("is_monthly_reset")] + public int IsMonthlyReset { get; set; } + + [JsonPropertyName("rest")] + [Key("rest")] + public int Rest { get; set; } +} + +[MessagePackObject] +public class UserCardPackTicketDto +{ + [JsonPropertyName("item_id")] + [Key("item_id")] + public int ItemId { get; set; } + + [JsonPropertyName("number")] + [Key("number")] + public int Number { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ItemPurchase/ItemPurchasePurchaseResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ItemPurchase/ItemPurchasePurchaseResponse.cs new file mode 100644 index 0000000..5e5ca85 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ItemPurchase/ItemPurchasePurchaseResponse.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; +using MessagePack; +using SVSim.EmulatedEntrypoint.Models.Dtos; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ItemPurchase; + +/// +/// /item_purchase/purchase response. reward_list uses the standard +/// shape: post-state totals for currencies, grant counts for +/// items/cards. First entry is the debit-side post-state for the require_item; subsequent +/// entries are the grant(s) from RewardGrantService. +/// +[MessagePackObject] +public class ItemPurchasePurchaseResponse +{ + [JsonPropertyName("reward_list")] + [Key("reward_list")] + public List RewardList { get; set; } = new(); +} diff --git a/SVSim.UnitTests/Controllers/ItemPurchaseControllerTests.cs b/SVSim.UnitTests/Controllers/ItemPurchaseControllerTests.cs new file mode 100644 index 0000000..ad24217 --- /dev/null +++ b/SVSim.UnitTests/Controllers/ItemPurchaseControllerTests.cs @@ -0,0 +1,264 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Models; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Controllers; + +public class ItemPurchaseControllerTests +{ + private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json"); + + /// + /// Seeds two catalog entries: + /// #501: lifetime quota 1, costs 100 RedEther → 1 Item(1000) + /// #502: monthly quota 3, costs 5 Item(1001) → 1 Item(1000) + /// Plus the Item rows (1000, 1001) needed by RewardGrantService. + /// Caller seeds the viewer with starting currency/items. + /// + private static async Task SeedCatalog(SVSimTestFactory f) + { + using var scope = f.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + if (!await db.Items.AnyAsync(i => i.Id == 1000)) + db.Items.Add(new ItemEntry { Id = 1000, Name = "Seer's Globe", Type = 3, ThumbnailPath = "thumbnail_orb" }); + if (!await db.Items.AnyAsync(i => i.Id == 1001)) + db.Items.Add(new ItemEntry { Id = 1001, Name = "Seer's Globe Shards", Type = 5, ThumbnailPath = "thumbnail_orb_piece" }); + + db.ItemPurchaseCatalog.AddRange( + new ItemPurchaseCatalogEntry + { + Id = 501, IsEnabled = true, + RequireItemType = 1, RequireItemId = 0, RequireItemNum = 100, // 100 RedEther + PurchaseItemType = 4, PurchaseItemId = 1000, PurchaseItemNum = 1, // → 1 Globe + PurchaseName = "Lifetime Globe", IsMonthlyReset = false, PurchaseLimit = 1, + }, + new ItemPurchaseCatalogEntry + { + Id = 502, IsEnabled = true, + RequireItemType = 4, RequireItemId = 1001, RequireItemNum = 5, // 5 Shards + PurchaseItemType = 4, PurchaseItemId = 1000, PurchaseItemNum = 1, // → 1 Globe + PurchaseName = "Monthly Globe", IsMonthlyReset = true, PurchaseLimit = 3, + }); + await db.SaveChangesAsync(); + } + + private static async Task SetViewerCurrency(SVSimTestFactory f, long viewerId, ulong redEther = 0) + { + using var scope = f.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var v = await db.Viewers.FirstAsync(x => x.Id == viewerId); + v.Currency.RedEther = redEther; + await db.SaveChangesAsync(); + } + + private static async Task SetViewerItem(SVSimTestFactory f, long viewerId, int itemId, int count) + { + using var scope = f.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var item = await db.Items.FindAsync(itemId); + var v = await db.Viewers.Include(x => x.Items).ThenInclude(i => i.Item).FirstAsync(x => x.Id == viewerId); + var owned = v.Items.FirstOrDefault(i => i.Item.Id == itemId); + if (owned is null) + v.Items.Add(new OwnedItemEntry { Item = item!, Count = count, Viewer = v }); + else + owned.Count = count; + await db.SaveChangesAsync(); + } + + [Test] + public async Task Info_returns_catalog_and_full_ticket_list() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await factory.SeedGlobalsAsync(); // loads item catalog including Type==2 tickets + await SeedCatalog(factory); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var response = await client.PostAsync("/item_purchase/info", + JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""")); + + var body = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + + using var doc = JsonDocument.Parse(body); + var info = doc.RootElement.GetProperty("item_purchase_info"); + Assert.That(info.GetArrayLength(), Is.GreaterThanOrEqualTo(2), "should include seeded entries 501 and 502"); + + var entry501 = FindEntry(info, 501); + Assert.That(entry501.GetProperty("require_item_num").GetInt32(), Is.EqualTo(100)); + Assert.That(entry501.GetProperty("is_monthly_reset").GetInt32(), Is.EqualTo(0)); + Assert.That(entry501.GetProperty("rest").GetInt32(), Is.EqualTo(1)); + + var entry502 = FindEntry(info, 502); + Assert.That(entry502.GetProperty("is_monthly_reset").GetInt32(), Is.EqualTo(1)); + Assert.That(entry502.GetProperty("rest").GetInt32(), Is.EqualTo(3)); + + // Ticket list should include every Type==2 item — seeded items.json has ~33 such rows. + var tickets = doc.RootElement.GetProperty("user_card_pack_ticket_list"); + Assert.That(tickets.GetArrayLength(), Is.GreaterThan(10), "all Type==2 items should be listed"); + // First-element shape check + Assert.That(tickets[0].GetProperty("item_id").GetInt32(), Is.GreaterThan(0)); + } + + [Test] + public async Task Purchase_with_red_ether_debits_and_grants_item() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedCatalog(factory); + await SetViewerCurrency(factory, viewerId, redEther: 5000); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var response = await client.PostAsync("/item_purchase/purchase", + JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","purchase_id":501,"rest":1}""")); + + var body = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + + using var doc = JsonDocument.Parse(body); + var rewardList = doc.RootElement.GetProperty("reward_list"); + Assert.That(rewardList.GetArrayLength(), Is.EqualTo(2)); // debit post-state + grant + + // Debit: RedEther type=1, id=0, post-state total 4900 + var debit = rewardList[0]; + Assert.That(debit.GetProperty("reward_type").GetInt32(), Is.EqualTo(1)); + Assert.That(debit.GetProperty("reward_id").GetInt64(), Is.EqualTo(0)); + Assert.That(debit.GetProperty("reward_num").GetInt32(), Is.EqualTo(4900)); + + // Grant: Item type=4, id=1000, count=1 (viewer didn't have any before) + var grant = rewardList[1]; + Assert.That(grant.GetProperty("reward_type").GetInt32(), Is.EqualTo(4)); + Assert.That(grant.GetProperty("reward_id").GetInt64(), Is.EqualTo(1000)); + Assert.That(grant.GetProperty("reward_num").GetInt32(), Is.EqualTo(1)); + + // Counter row should exist for lifetime quota + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var counter = await db.ViewerEventCounters + .FirstOrDefaultAsync(c => c.ViewerId == viewerId && c.EventKey == "item_purchase:501"); + Assert.That(counter, Is.Not.Null); + Assert.That(counter!.Period, Is.EqualTo("all-time")); + Assert.That(counter.Count, Is.EqualTo(1)); + } + + [Test] + public async Task Purchase_with_item_currency_debits_and_grants_item() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedCatalog(factory); + await SetViewerItem(factory, viewerId, itemId: 1001, count: 12); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var response = await client.PostAsync("/item_purchase/purchase", + JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","purchase_id":502,"rest":3}""")); + + var body = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + + using var doc = JsonDocument.Parse(body); + var rewardList = doc.RootElement.GetProperty("reward_list"); + // Debit Item(1001) 12 → 7, grant Item(1000) 0 → 1 + var debit = rewardList[0]; + Assert.That(debit.GetProperty("reward_type").GetInt32(), Is.EqualTo(4)); + Assert.That(debit.GetProperty("reward_id").GetInt64(), Is.EqualTo(1001)); + Assert.That(debit.GetProperty("reward_num").GetInt32(), Is.EqualTo(7)); + } + + [Test] + public async Task Purchase_sold_out_returns_400() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedCatalog(factory); + await SetViewerCurrency(factory, viewerId, redEther: 500); + + using var client = factory.CreateAuthenticatedClient(viewerId); + // First buy succeeds (entry 501 is lifetime quota 1) + var first = await client.PostAsync("/item_purchase/purchase", + JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","purchase_id":501,"rest":1}""")); + Assert.That(first.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + // Second buy rejected as sold_out — currency check is never reached + var second = await client.PostAsync("/item_purchase/purchase", + JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","purchase_id":501,"rest":0}""")); + Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + } + + [Test] + public async Task Purchase_with_insufficient_red_ether_returns_400_and_does_not_increment_counter() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedCatalog(factory); + await SetViewerCurrency(factory, viewerId, redEther: 50); // < 100 required + + using var client = factory.CreateAuthenticatedClient(viewerId); + var response = await client.PostAsync("/item_purchase/purchase", + JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","purchase_id":501,"rest":1}""")); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + + // Counter must NOT have been incremented — quota stays at 1. + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var counter = await db.ViewerEventCounters + .FirstOrDefaultAsync(c => c.ViewerId == viewerId && c.EventKey == "item_purchase:501"); + Assert.That(counter, Is.Null); + } + + [Test] + public async Task Purchase_unknown_purchase_id_returns_400() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + // No SeedCatalog — purchase_id 501 doesn't exist + using var client = factory.CreateAuthenticatedClient(viewerId); + var response = await client.PostAsync("/item_purchase/purchase", + JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","purchase_id":501,"rest":1}""")); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + } + + [Test] + public async Task Monthly_quota_decrements_rest_on_repeat_buys_within_same_period() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedCatalog(factory); + await SetViewerItem(factory, viewerId, itemId: 1001, count: 20); + + using var client = factory.CreateAuthenticatedClient(viewerId); + // entry 502 is monthly quota 3; buy twice + for (int i = 0; i < 2; i++) + { + var resp = await client.PostAsync("/item_purchase/purchase", + JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","purchase_id":502,"rest":3}""")); + Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + + // /info should now report rest=1 + var info = await client.PostAsync("/item_purchase/info", + JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""")); + var body = await info.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + var entry502 = FindEntry(doc.RootElement.GetProperty("item_purchase_info"), 502); + Assert.That(entry502.GetProperty("rest").GetInt32(), Is.EqualTo(1)); + } + + private static JsonElement FindEntry(JsonElement array, int purchaseId) + { + foreach (var entry in array.EnumerateArray()) + { + if (entry.GetProperty("purchase_id").GetInt32() == purchaseId) + return entry; + } + throw new InvalidOperationException($"entry with purchase_id={purchaseId} not found"); + } +} diff --git a/SVSim.UnitTests/Importers/ItemPurchaseImporterTests.cs b/SVSim.UnitTests/Importers/ItemPurchaseImporterTests.cs new file mode 100644 index 0000000..ef33df7 --- /dev/null +++ b/SVSim.UnitTests/Importers/ItemPurchaseImporterTests.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Bootstrap.Importers; +using SVSim.Database; +using SVSim.Database.Models; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Importers; + +public class ItemPurchaseImporterTests +{ + private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds"); + + [Test] + public async Task Imports_catalog_from_seed_file() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + await new ItemPurchaseImporter().ImportAsync(db, SeedDir); + + var entries = await db.ItemPurchaseCatalog.OrderBy(e => e.Id).ToListAsync(); + Assert.That(entries.Count, Is.GreaterThan(0)); + + // Spot-check purchase_id 1: One Time Only Seer's Globe — 5000 RedEther → 1 Item(1000), + // lifetime quota of 1. + var one = entries.First(e => e.Id == 1); + Assert.That(one.RequireItemType, Is.EqualTo(1)); // RedEther + Assert.That(one.RequireItemNum, Is.EqualTo(5000)); + Assert.That(one.PurchaseItemType, Is.EqualTo(4)); // Item + Assert.That(one.PurchaseItemId, Is.EqualTo(1000)); // Seer's Globe + Assert.That(one.IsMonthlyReset, Is.False); + Assert.That(one.PurchaseLimit, Is.EqualTo(1)); + Assert.That(one.PurchaseName, Does.Contain("Seer's Globe")); + } + + [Test] + public async Task Is_idempotent_on_rerun() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + await new ItemPurchaseImporter().ImportAsync(db, SeedDir); + int before = await db.ItemPurchaseCatalog.CountAsync(); + await new ItemPurchaseImporter().ImportAsync(db, SeedDir); + int after = await db.ItemPurchaseCatalog.CountAsync(); + + Assert.That(after, Is.EqualTo(before)); + } + + [Test] + public async Task Leaves_existing_rows_untouched_when_missing_from_seed() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + const int legacyId = 999999; + db.ItemPurchaseCatalog.Add(new ItemPurchaseCatalogEntry { Id = legacyId, PurchaseName = "legacy", IsEnabled = true }); + await db.SaveChangesAsync(); + + await new ItemPurchaseImporter().ImportAsync(db, SeedDir); + + var legacy = await db.ItemPurchaseCatalog.FindAsync(legacyId); + Assert.That(legacy, Is.Not.Null); + Assert.That(legacy!.PurchaseName, Is.EqualTo("legacy")); + } +} diff --git a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs index b9884fd..90d6c7a 100644 --- a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs +++ b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs @@ -210,6 +210,7 @@ internal sealed class SVSimTestFactory : WebApplicationFactory await new PaymentItemImporter().ImportAsync(ctx, seedDir); await new ItemImporter().ImportAsync(ctx, seedDir); await new SleeveShopImporter().ImportAsync(ctx, seedDir); + await new ItemPurchaseImporter().ImportAsync(ctx, seedDir); var puzzleImporter = new PuzzleImporter(); await puzzleImporter.ImportGroupsAsync(ctx, seedDir); await puzzleImporter.ImportPuzzlesAsync(ctx, seedDir);