From baebe8bab860074b136d47226ba33ec843014975 Mon Sep 17 00:00:00 2001 From: Jay Malhotra <5047192+SapiensAnatis@users.noreply.github.com> Date: Sat, 1 Jun 2024 14:56:20 +0100 Subject: [PATCH] Implement summoning pity rates (#849) First half of #698 - Refactor summoning odds calculation into static pure functions to make them easier to test - Add new pity counter column to DbPlayerBannerData This does not activate the pity rate calculation for actual results; everyone will have a counter of 0 which will not accumulate. The algorithm for pity rates is somewhat empirical based on a random screenshot and may not distribute the .5% increases in the exact way that the game originally did. --- .config/dotnet-tools.json | 11 +- .../Entities/DbPlayerBannerData.cs | 6 +- ...240601133017_summon_pity_rates.Designer.cs | 2633 +++++++++++++++++ .../20240601133017_summon_pity_rates.cs | 40 + .../Migrations/ApiContextModelSnapshot.cs | 9 +- ...sServiceTest.cs => SummonOddsLogicTest.cs} | 1042 ++++--- .../Summoning/RedoableSummonController.cs | 15 +- .../Features/Summoning/SummonBannerOptions.cs | 21 + .../Features/Summoning/SummonController.cs | 21 +- .../Features/Summoning/SummonOddsLogic.cs | 586 ++++ .../Features/Summoning/SummonOddsService.cs | 543 +--- .../Features/Summoning/SummonService.cs | 18 +- 12 files changed, 4010 insertions(+), 935 deletions(-) create mode 100644 DragaliaAPI/DragaliaAPI.Database/Migrations/20240601133017_summon_pity_rates.Designer.cs create mode 100644 DragaliaAPI/DragaliaAPI.Database/Migrations/20240601133017_summon_pity_rates.cs rename DragaliaAPI/DragaliaAPI.Test/Features/Summon/{SummonOddsServiceTest.cs => SummonOddsLogicTest.cs} (53%) create mode 100644 DragaliaAPI/DragaliaAPI/Features/Summoning/SummonOddsLogic.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index c7e935435..c23f8cced 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -6,19 +6,22 @@ "version": "0.6.4", "commands": [ "husky" - ] + ], + "rollForward": false }, "csharpier": { "version": "0.28.2", "commands": [ "dotnet-csharpier" - ] + ], + "rollForward": false }, "dotnet-ef": { - "version": "8.0.4", + "version": "8.0.6", "commands": [ "dotnet-ef" - ] + ], + "rollForward": false } } } \ No newline at end of file diff --git a/DragaliaAPI/DragaliaAPI.Database/Entities/DbPlayerBannerData.cs b/DragaliaAPI/DragaliaAPI.Database/Entities/DbPlayerBannerData.cs index 4d940e346..94d7e8a3a 100644 --- a/DragaliaAPI/DragaliaAPI.Database/Entities/DbPlayerBannerData.cs +++ b/DragaliaAPI/DragaliaAPI.Database/Entities/DbPlayerBannerData.cs @@ -14,14 +14,12 @@ public class DbPlayerBannerData : DbPlayerData [Required] public int SummonBannerId { get; set; } - [Column("Pity")] - [Required] - public byte PityRate { get; set; } - [Column("SummonCount")] [Required] public int SummonCount { get; set; } + public int SummonCountSinceLastFiveStar { get; set; } + [Column("DailyLimitedSummons")] [Required] public int DailyLimitedSummonCount { get; set; } diff --git a/DragaliaAPI/DragaliaAPI.Database/Migrations/20240601133017_summon_pity_rates.Designer.cs b/DragaliaAPI/DragaliaAPI.Database/Migrations/20240601133017_summon_pity_rates.Designer.cs new file mode 100644 index 000000000..b250f4f4c --- /dev/null +++ b/DragaliaAPI/DragaliaAPI.Database/Migrations/20240601133017_summon_pity_rates.Designer.cs @@ -0,0 +1,2633 @@ +// +using System; +using DragaliaAPI.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DragaliaAPI.Database.Migrations +{ + [DbContext(typeof(ApiContext))] + [Migration("20240601133017_summon_pity_rates")] + partial class summon_pity_rates + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbAbilityCrest", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("AbilityCrestId") + .HasColumnType("integer"); + + b.Property("AttackPlusCount") + .HasColumnType("integer"); + + b.Property("BuildupCount") + .HasColumnType("integer"); + + b.Property("EquipableCount") + .HasColumnType("integer"); + + b.Property("GetTime") + .HasColumnType("timestamp with time zone"); + + b.Property("HpPlusCount") + .HasColumnType("integer"); + + b.Property("IsFavorite") + .HasColumnType("boolean"); + + b.Property("IsNew") + .HasColumnType("boolean"); + + b.Property("LimitBreakCount") + .HasColumnType("integer"); + + b.HasKey("ViewerId", "AbilityCrestId"); + + b.ToTable("PlayerAbilityCrests"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbAbilityCrestSet", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("AbilityCrestSetNo") + .HasColumnType("integer"); + + b.Property("AbilityCrestSetName") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CrestSlotType1CrestId1") + .HasColumnType("integer"); + + b.Property("CrestSlotType1CrestId2") + .HasColumnType("integer"); + + b.Property("CrestSlotType1CrestId3") + .HasColumnType("integer"); + + b.Property("CrestSlotType2CrestId1") + .HasColumnType("integer"); + + b.Property("CrestSlotType2CrestId2") + .HasColumnType("integer"); + + b.Property("CrestSlotType3CrestId1") + .HasColumnType("integer"); + + b.Property("CrestSlotType3CrestId2") + .HasColumnType("integer"); + + b.Property("TalismanKeyId") + .HasColumnType("numeric(20,0)"); + + b.HasKey("ViewerId", "AbilityCrestSetNo"); + + b.ToTable("PlayerAbilityCrestSets"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbCompletedDailyMission", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("Id") + .HasColumnType("integer"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Progress") + .HasColumnType("integer"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ViewerId", "Id", "Date"); + + b.ToTable("CompletedDailyMissions"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbDeviceAccount", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("HashedPassword") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DeviceAccounts"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbEmblem", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("EmblemId") + .HasColumnType("integer") + .HasColumnName("EmblemId"); + + b.Property("GetTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("GetTime"); + + b.Property("IsNew") + .HasColumnType("boolean") + .HasColumnName("IsNew"); + + b.HasKey("ViewerId", "EmblemId"); + + b.ToTable("Emblems"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbEquippedStamp", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("Slot") + .HasColumnType("integer"); + + b.Property("StampId") + .HasColumnType("integer"); + + b.HasKey("ViewerId", "Slot"); + + b.ToTable("EquippedStamps"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbFortBuild", b => + { + b.Property("BuildId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BuildId")); + + b.Property("BuildEndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("BuildStartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsNew") + .HasColumnType("boolean"); + + b.Property("LastIncomeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("PlantId") + .HasColumnType("integer"); + + b.Property("PositionX") + .HasColumnType("integer"); + + b.Property("PositionZ") + .HasColumnType("integer"); + + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.HasKey("BuildId"); + + b.HasIndex("ViewerId"); + + b.ToTable("PlayerFortBuilds"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbFortDetail", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("CarpenterNum") + .HasColumnType("integer") + .HasColumnName("CarpenterNum"); + + b.HasKey("ViewerId"); + + b.ToTable("PlayerFortDetail"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbLoginBonus", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("Id") + .HasColumnType("integer"); + + b.Property("CurrentDay") + .HasColumnType("integer"); + + b.Property("IsComplete") + .HasColumnType("boolean"); + + b.HasKey("ViewerId", "Id"); + + b.ToTable("LoginBonuses"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbNewsItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Headline") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Time") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("NewsItems"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbParty", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("PartyNo") + .HasColumnType("integer"); + + b.Property("PartyName") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("ViewerId", "PartyNo"); + + b.ToTable("PartyData"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPartyPower", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("MaxPartyPower") + .HasColumnType("integer") + .HasColumnName("MaxPartyPower"); + + b.HasKey("ViewerId"); + + b.ToTable("PartyPowers"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPartyUnit", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CharaId") + .HasColumnType("integer"); + + b.Property("EditSkill1CharaId") + .HasColumnType("integer"); + + b.Property("EditSkill2CharaId") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType1CrestId1") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType1CrestId2") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType1CrestId3") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType2CrestId1") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType2CrestId2") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType3CrestId1") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType3CrestId2") + .HasColumnType("integer"); + + b.Property("EquipDragonKeyId") + .HasColumnType("bigint"); + + b.Property("EquipTalismanKeyId") + .HasColumnType("bigint"); + + b.Property("EquipWeaponBodyId") + .HasColumnType("integer"); + + b.Property("EquipWeaponSkinId") + .HasColumnType("integer"); + + b.Property("PartyNo") + .HasColumnType("integer"); + + b.Property("UnitNo") + .HasColumnType("integer"); + + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ViewerId", "PartyNo"); + + b.ToTable("PlayerPartyUnits"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayer", b => + { + b.Property("ViewerId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ViewerId")); + + b.Property("AccountId") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SavefileVersion") + .HasColumnType("integer"); + + b.HasKey("ViewerId"); + + b.HasIndex("AccountId"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerBannerData", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("SummonBannerId") + .HasColumnType("integer") + .HasColumnName("SummonBannerId"); + + b.Property("ConsecutionSummonPoints") + .HasColumnType("integer") + .HasColumnName("CsSummonPoints"); + + b.Property("ConsecutionSummonPointsMaxDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("CsSummonPointsMaxDate"); + + b.Property("ConsecutionSummonPointsMinDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("CsSummonPointsMinDate"); + + b.Property("DailyLimitedSummonCount") + .HasColumnType("integer") + .HasColumnName("DailyLimitedSummons"); + + b.Property("IsBeginnerFreeSummonAvailable") + .HasColumnType("integer") + .HasColumnName("BeginnerSummonAvailable"); + + b.Property("IsConsecutionFreeSummonAvailable") + .HasColumnType("integer") + .HasColumnName("CsSummonAvailable"); + + b.Property("IsFreeSummonAvailable") + .HasColumnType("integer") + .HasColumnName("FreeSummonAvailable"); + + b.Property("SummonCount") + .HasColumnType("integer") + .HasColumnName("SummonCount"); + + b.Property("SummonCountSinceLastFiveStar") + .HasColumnType("integer"); + + b.Property("SummonPoints") + .HasColumnType("integer") + .HasColumnName("SummonPoints"); + + b.HasKey("ViewerId", "SummonBannerId"); + + b.ToTable("PlayerBannerData"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerCharaData", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("CharaId") + .HasColumnType("integer") + .HasColumnName("CharaId"); + + b.Property("Ability1Level") + .HasColumnType("smallint") + .HasColumnName("Abil1Lvl"); + + b.Property("Ability2Level") + .HasColumnType("smallint") + .HasColumnName("Abil2Lvl"); + + b.Property("Ability3Level") + .HasColumnType("smallint") + .HasColumnName("Abil3Lvl"); + + b.Property("AttackBase") + .HasColumnType("integer") + .HasColumnName("AtkBase"); + + b.Property("AttackNode") + .HasColumnType("integer") + .HasColumnName("AtkNode"); + + b.Property("AttackPlusCount") + .HasColumnType("smallint") + .HasColumnName("AtkPlusCount"); + + b.Property("BurstAttackLevel") + .HasColumnType("smallint") + .HasColumnName("BurstAtkLvl"); + + b.Property("ComboBuildupCount") + .HasColumnType("integer") + .HasColumnName("ComboBuildupCount"); + + b.Property("ExAbility2Level") + .HasColumnType("smallint") + .HasColumnName("ExAbility2Lvl"); + + b.Property("ExAbilityLevel") + .HasColumnType("smallint") + .HasColumnName("ExAbility1Lvl"); + + b.Property("Exp") + .HasColumnType("integer") + .HasColumnName("Exp"); + + b.Property("GetTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("GetTime"); + + b.Property("HpBase") + .HasColumnType("integer") + .HasColumnName("HpBase"); + + b.Property("HpNode") + .HasColumnType("integer") + .HasColumnName("HpNode"); + + b.Property("HpPlusCount") + .HasColumnType("smallint") + .HasColumnName("HpPlusCount"); + + b.Property("IsNew") + .HasColumnType("boolean") + .HasColumnName("IsNew"); + + b.Property("IsTemporary") + .HasColumnType("boolean") + .HasColumnName("IsTemp"); + + b.Property("IsUnlockEditSkill") + .HasColumnType("boolean") + .HasColumnName("IsUnlockEditSkill"); + + b.Property("Level") + .HasColumnType("smallint") + .HasColumnName("Level"); + + b.Property("ListViewFlag") + .HasColumnType("boolean") + .HasColumnName("ListViewFlag"); + + b.Property("ManaNodeUnlockCount") + .HasColumnType("integer") + .HasColumnName("ManaNodeUnlockCount"); + + b.Property("Rarity") + .HasColumnType("smallint") + .HasColumnName("Rarity"); + + b.Property("Skill1Level") + .HasColumnType("smallint") + .HasColumnName("Skill1Lvl"); + + b.Property("Skill2Level") + .HasColumnType("smallint") + .HasColumnName("Skill2Lvl"); + + b.HasKey("ViewerId", "CharaId"); + + b.ToTable("PlayerCharaData"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerDmodeChara", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("CharaId") + .HasColumnType("integer") + .HasColumnName("CharaId"); + + b.Property("MaxFloor") + .HasColumnType("integer") + .HasColumnName("MaxFloor"); + + b.Property("MaxScore") + .HasColumnType("integer") + .HasColumnName("MaxScore"); + + b.Property("SelectEditSkillCharaId1") + .HasColumnType("integer") + .HasColumnName("SelectEditSkillCharaId1"); + + b.Property("SelectEditSkillCharaId2") + .HasColumnType("integer") + .HasColumnName("SelectEditSkillCharaId2"); + + b.Property("SelectEditSkillCharaId3") + .HasColumnType("integer") + .HasColumnName("SelectEditSkillCharaId3"); + + b.Property("SelectedServitorId") + .HasColumnType("integer") + .HasColumnName("SelectedServitorId"); + + b.HasKey("ViewerId", "CharaId"); + + b.ToTable("PlayerDmodeCharas"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerDmodeDungeon", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("CharaId") + .HasColumnType("integer") + .HasColumnName("CharaId"); + + b.Property("DungeonScore") + .HasColumnType("integer") + .HasColumnName("DungeonScore"); + + b.Property("Floor") + .HasColumnType("integer") + .HasColumnName("Floor"); + + b.Property("IsPlayEnd") + .HasColumnType("boolean") + .HasColumnName("IsPlayEnd"); + + b.Property("QuestTime") + .HasColumnType("integer") + .HasColumnName("QuestTime"); + + b.Property("State") + .HasColumnType("integer") + .HasColumnName("State"); + + b.HasKey("ViewerId"); + + b.ToTable("PlayerDmodeDungeons"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerDmodeExpedition", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("CharaId1") + .HasColumnType("integer") + .HasColumnName("CharaId1"); + + b.Property("CharaId2") + .HasColumnType("integer") + .HasColumnName("CharaId2"); + + b.Property("CharaId3") + .HasColumnType("integer") + .HasColumnName("CharaId3"); + + b.Property("CharaId4") + .HasColumnType("integer") + .HasColumnName("CharaId4"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("StartTime"); + + b.Property("State") + .HasColumnType("integer") + .HasColumnName("State"); + + b.Property("TargetFloor") + .HasColumnType("integer") + .HasColumnName("TargetFloor"); + + b.HasKey("ViewerId"); + + b.ToTable("PlayerDmodeExpeditions"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerDmodeInfo", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("FloorSkipCount") + .HasColumnType("integer") + .HasColumnName("FloorSkipCount"); + + b.Property("FloorSkipTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("FloorSkipTime"); + + b.Property("Point1Quantity") + .HasColumnType("integer") + .HasColumnName("Point1Quantity"); + + b.Property("Point2Quantity") + .HasColumnType("integer") + .HasColumnName("Point2Quantity"); + + b.Property("RecoveryCount") + .HasColumnType("integer") + .HasColumnName("RecoveryCount"); + + b.Property("RecoveryTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("RecoveryTime"); + + b.HasKey("ViewerId"); + + b.ToTable("PlayerDmodeInfos"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerDmodeServitorPassive", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("PassiveId") + .HasColumnType("integer") + .HasColumnName("PassiveId"); + + b.Property("Level") + .HasColumnType("integer") + .HasColumnName("Level"); + + b.HasKey("ViewerId", "PassiveId"); + + b.ToTable("PlayerDmodeServitorPassives"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerDragonData", b => + { + b.Property("DragonKeyId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("DragonKeyId"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("DragonKeyId")); + + b.Property("Ability1Level") + .HasColumnType("smallint") + .HasColumnName("Abil1Level"); + + b.Property("Ability2Level") + .HasColumnType("smallint") + .HasColumnName("Abil2Level"); + + b.Property("AttackPlusCount") + .HasColumnType("smallint") + .HasColumnName("AttackPlusCount"); + + b.Property("DragonId") + .HasColumnType("integer") + .HasColumnName("DragonId"); + + b.Property("Exp") + .HasColumnType("integer") + .HasColumnName("Exp"); + + b.Property("GetTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("GetTime"); + + b.Property("HpPlusCount") + .HasColumnType("smallint") + .HasColumnName("HpPlusCount"); + + b.Property("IsLock") + .HasColumnType("boolean") + .HasColumnName("IsLocked"); + + b.Property("IsNew") + .HasColumnType("boolean") + .HasColumnName("IsNew"); + + b.Property("Level") + .HasColumnType("smallint") + .HasColumnName("Level"); + + b.Property("LimitBreakCount") + .HasColumnType("smallint") + .HasColumnName("LimitBreakCount"); + + b.Property("Skill1Level") + .HasColumnType("smallint") + .HasColumnName("Skill1Level"); + + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.HasKey("DragonKeyId"); + + b.HasIndex("ViewerId"); + + b.ToTable("PlayerDragonData"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerDragonGift", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("DragonGiftId") + .HasColumnType("integer") + .HasColumnName("DragonGiftId"); + + b.Property("Quantity") + .HasColumnType("integer") + .HasColumnName("Quantity"); + + b.HasKey("ViewerId", "DragonGiftId"); + + b.ToTable("PlayerDragonGift"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerDragonReliability", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("DragonId") + .HasColumnType("integer") + .HasColumnName("DragonId"); + + b.Property("Exp") + .HasColumnType("integer") + .HasColumnName("TotalExp"); + + b.Property("GetTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastContactTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("LastContactTime"); + + b.Property("Level") + .HasColumnType("smallint") + .HasColumnName("Level"); + + b.HasKey("ViewerId", "DragonId"); + + b.ToTable("PlayerDragonReliability"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerEventData", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("EventId") + .HasColumnType("integer") + .HasColumnName("EventId"); + + b.Property("CustomEventFlag") + .HasColumnType("boolean") + .HasColumnName("CustomEventFlag"); + + b.HasKey("ViewerId", "EventId"); + + b.ToTable("PlayerEventData"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerEventItem", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("Id") + .HasColumnType("integer") + .HasColumnName("Id"); + + b.Property("EventId") + .HasColumnType("integer") + .HasColumnName("EventId"); + + b.Property("Quantity") + .HasColumnType("integer") + .HasColumnName("Quantity"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("Type"); + + b.HasKey("ViewerId", "Id"); + + b.HasIndex("ViewerId", "EventId"); + + b.ToTable("PlayerEventItems"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerEventPassive", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("EventId") + .HasColumnType("integer") + .HasColumnName("EventId"); + + b.Property("PassiveId") + .HasColumnType("integer") + .HasColumnName("PassiveId"); + + b.Property("Progress") + .HasColumnType("integer") + .HasColumnName("Progress"); + + b.HasKey("ViewerId", "EventId", "PassiveId"); + + b.ToTable("PlayerEventPassives"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerEventReward", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("EventId") + .HasColumnType("integer") + .HasColumnName("EventId"); + + b.Property("RewardId") + .HasColumnType("integer") + .HasColumnName("RewardId"); + + b.HasKey("ViewerId", "EventId", "RewardId"); + + b.ToTable("PlayerEventRewards"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerMaterial", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("MaterialId") + .HasColumnType("integer") + .HasColumnName("MaterialId"); + + b.Property("Quantity") + .HasColumnType("integer") + .HasColumnName("Quantity"); + + b.HasKey("ViewerId", "MaterialId"); + + b.ToTable("PlayerMaterial"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerMission", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("Id") + .HasColumnType("integer") + .HasColumnName("MissionId"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("Type"); + + b.Property("End") + .HasColumnType("timestamp with time zone") + .HasColumnName("EndDate"); + + b.Property("GroupId") + .HasColumnType("integer") + .HasColumnName("GroupId"); + + b.Property("Pickup") + .HasColumnType("boolean") + .HasColumnName("Pickup"); + + b.Property("Progress") + .HasColumnType("integer") + .HasColumnName("Progress"); + + b.Property("Start") + .HasColumnType("timestamp with time zone") + .HasColumnName("StartDate"); + + b.Property("State") + .HasColumnType("integer") + .HasColumnName("State"); + + b.HasKey("ViewerId", "Id", "Type"); + + b.ToTable("PlayerMissions"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerPresent", b => + { + b.Property("PresentId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("PresentId"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PresentId")); + + b.Property("CreateTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("CreateTime"); + + b.Property("EntityId") + .HasColumnType("integer") + .HasColumnName("EntityId"); + + b.Property("EntityLevel") + .HasColumnType("integer") + .HasColumnName("EntityLevel"); + + b.Property("EntityLimitBreakCount") + .HasColumnType("integer") + .HasColumnName("EntityLimitBreakCount"); + + b.Property("EntityQuantity") + .HasColumnType("integer") + .HasColumnName("EntityQuantity"); + + b.Property("EntityStatusPlusCount") + .HasColumnType("integer") + .HasColumnName("EntityStatusPlusCount"); + + b.Property("EntityType") + .HasColumnType("integer") + .HasColumnName("EntityType"); + + b.Property("MasterId") + .HasColumnType("bigint") + .HasColumnName("MasterId"); + + b.Property("MessageId") + .HasColumnType("integer") + .HasColumnName("MessageId"); + + b.Property("MessageParamValue1") + .HasColumnType("integer") + .HasColumnName("MessageParamValue1"); + + b.Property("MessageParamValue2") + .HasColumnType("integer") + .HasColumnName("MessageParamValue2"); + + b.Property("MessageParamValue3") + .HasColumnType("integer") + .HasColumnName("MessageParamValue3"); + + b.Property("MessageParamValue4") + .HasColumnType("integer") + .HasColumnName("MessageParamValue4"); + + b.Property("ReceiveLimitTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("ReceiveLimitTime"); + + b.Property("State") + .HasColumnType("bigint") + .HasColumnName("State"); + + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.HasKey("PresentId"); + + b.HasIndex("ViewerId"); + + b.ToTable("PlayerPresent"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerPresentHistory", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CreateTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("CreateTime"); + + b.Property("EntityId") + .HasColumnType("integer") + .HasColumnName("EntityId"); + + b.Property("EntityLevel") + .HasColumnType("integer") + .HasColumnName("EntityLevel"); + + b.Property("EntityLimitBreakCount") + .HasColumnType("integer") + .HasColumnName("EntityLimitBreakCount"); + + b.Property("EntityQuantity") + .HasColumnType("integer") + .HasColumnName("EntityQuantity"); + + b.Property("EntityStatusPlusCount") + .HasColumnType("integer") + .HasColumnName("EntityStatusPlusCount"); + + b.Property("EntityType") + .HasColumnType("integer") + .HasColumnName("EntityType"); + + b.Property("MessageId") + .HasColumnType("integer") + .HasColumnName("MessageId"); + + b.Property("MessageParamValue1") + .HasColumnType("integer") + .HasColumnName("MessageParamValue1"); + + b.Property("MessageParamValue2") + .HasColumnType("integer") + .HasColumnName("MessageParamValue2"); + + b.Property("MessageParamValue3") + .HasColumnType("integer") + .HasColumnName("MessageParamValue3"); + + b.Property("MessageParamValue4") + .HasColumnType("integer") + .HasColumnName("MessageParamValue4"); + + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ViewerId"); + + b.ToTable("PlayerPresentHistory"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerQuestWall", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("WallId") + .HasColumnType("integer"); + + b.Property("IsStartNextLevel") + .HasColumnType("boolean"); + + b.Property("WallLevel") + .HasColumnType("integer"); + + b.HasKey("ViewerId", "WallId"); + + b.ToTable("PlayerQuestWalls"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerShopInfo", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("DailySummonCount") + .HasColumnType("integer"); + + b.Property("LastSummonTime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ViewerId"); + + b.ToTable("PlayerShopInfos"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerShopPurchase", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("GoodsId") + .HasColumnType("integer"); + + b.Property("BuyCount") + .HasColumnType("integer"); + + b.Property("EffectEndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectStartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastBuyTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ShopType") + .HasColumnType("integer"); + + b.HasKey("ViewerId", "GoodsId"); + + b.ToTable("PlayerPurchases"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerStoryState", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("StoryType") + .HasColumnType("integer") + .HasColumnName("StoryType"); + + b.Property("StoryId") + .HasColumnType("integer") + .HasColumnName("StoryId"); + + b.Property("State") + .HasColumnType("integer") + .HasColumnName("State"); + + b.HasKey("ViewerId", "StoryType", "StoryId"); + + b.ToTable("PlayerStoryState"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerSummonHistory", b => + { + b.Property("KeyId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("KeyId")); + + b.Property("EntityAttackPlusCount") + .HasColumnType("integer") + .HasColumnName("AtkPlusCount"); + + b.Property("EntityHpPlusCount") + .HasColumnType("integer") + .HasColumnName("HpPlusCount"); + + b.Property("EntityId") + .HasColumnType("integer") + .HasColumnName("EntityId"); + + b.Property("EntityLevel") + .HasColumnType("smallint") + .HasColumnName("Level"); + + b.Property("EntityLimitBreakCount") + .HasColumnType("smallint") + .HasColumnName("LimitBreakCount"); + + b.Property("EntityQuantity") + .HasColumnType("integer") + .HasColumnName("Quantity"); + + b.Property("EntityRarity") + .HasColumnType("smallint") + .HasColumnName("Rarity"); + + b.Property("EntityType") + .HasColumnType("integer") + .HasColumnName("EntityType"); + + b.Property("ExecDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("SummonDate"); + + b.Property("GetDewPointQuantity") + .HasColumnType("integer") + .HasColumnName("DewPointGet"); + + b.Property("PaymentType") + .HasColumnType("integer") + .HasColumnName("PaymentType"); + + b.Property("SummonExecType") + .HasColumnType("smallint") + .HasColumnName("SummonExecType"); + + b.Property("SummonId") + .HasColumnType("integer") + .HasColumnName("BannerId"); + + b.Property("SummonPoint") + .HasColumnType("integer") + .HasColumnName("SummonPointGet"); + + b.Property("SummonPrizeRank") + .HasColumnType("integer") + .HasColumnName("SummonPrizeRank"); + + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.HasKey("KeyId"); + + b.HasIndex("ViewerId"); + + b.ToTable("PlayerSummonHistory"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerTrade", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("Id") + .HasColumnType("integer") + .HasColumnName("TradeId"); + + b.Property("Count") + .HasColumnType("integer") + .HasColumnName("TradeCount"); + + b.Property("LastTradeTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("LastTrade"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("TradeType"); + + b.HasKey("ViewerId", "Id"); + + b.HasIndex("ViewerId", "Type"); + + b.ToTable("PlayerTrades"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerUseItem", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("ItemId") + .HasColumnType("integer") + .HasColumnName("ItemId"); + + b.Property("Quantity") + .HasColumnType("integer") + .HasColumnName("Quantity"); + + b.HasKey("ViewerId", "ItemId"); + + b.ToTable("PlayerUseItems"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerUserData", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("ActiveMemoryEventId") + .HasColumnType("integer"); + + b.Property("BuildTimePoint") + .HasColumnType("integer"); + + b.Property("Coin") + .HasColumnType("bigint"); + + b.Property("CreateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Crystal") + .HasColumnType("integer"); + + b.Property("DewPoint") + .HasColumnType("integer"); + + b.Property("EmblemId") + .HasColumnType("integer"); + + b.Property("Exp") + .HasColumnType("integer"); + + b.Property("FortOpenTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastLoginTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSaveImportTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastStaminaMultiUpdateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastStaminaSingleUpdateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("MainPartyNo") + .HasColumnType("integer"); + + b.Property("ManaPoint") + .HasColumnType("integer"); + + b.Property("MaxDragonQuantity") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("QuestSkipPoint") + .HasColumnType("integer"); + + b.Property("StaminaMulti") + .HasColumnType("integer"); + + b.Property("StaminaMultiSurplusSecond") + .HasColumnType("integer"); + + b.Property("StaminaSingle") + .HasColumnType("integer"); + + b.Property("StaminaSingleSurplusSecond") + .HasColumnType("integer"); + + b.Property("TutorialFlag") + .HasColumnType("integer"); + + b.Property("TutorialStatus") + .HasColumnType("integer"); + + b.HasKey("ViewerId"); + + b.ToTable("PlayerUserData"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbQuest", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("QuestId") + .HasColumnType("integer"); + + b.Property("BestClearTime") + .HasColumnType("real"); + + b.Property("DailyPlayCount") + .HasColumnType("integer"); + + b.Property("IsAppear") + .HasColumnType("boolean"); + + b.Property("IsMissionClear1") + .HasColumnType("boolean"); + + b.Property("IsMissionClear2") + .HasColumnType("boolean"); + + b.Property("IsMissionClear3") + .HasColumnType("boolean"); + + b.Property("LastDailyResetTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastWeeklyResetTime") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayCount") + .HasColumnType("integer"); + + b.Property("State") + .HasColumnType("smallint"); + + b.Property("WeeklyPlayCount") + .HasColumnType("integer"); + + b.HasKey("ViewerId", "QuestId"); + + b.ToTable("PlayerQuests"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbQuestClearPartyUnit", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("QuestId") + .HasColumnType("integer"); + + b.Property("IsMulti") + .HasColumnType("boolean"); + + b.Property("UnitNo") + .HasColumnType("integer"); + + b.Property("CharaId") + .HasColumnType("integer"); + + b.Property("EditSkill1CharaId") + .HasColumnType("integer"); + + b.Property("EditSkill2CharaId") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType1CrestId1") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType1CrestId2") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType1CrestId3") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType2CrestId1") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType2CrestId2") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType3CrestId1") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType3CrestId2") + .HasColumnType("integer"); + + b.Property("EquipDragonKeyId") + .HasColumnType("bigint"); + + b.Property("EquipTalismanKeyId") + .HasColumnType("bigint"); + + b.Property("EquipWeaponBodyId") + .HasColumnType("integer"); + + b.Property("EquipWeaponSkinId") + .HasColumnType("integer"); + + b.Property("EquippedDragonEntityId") + .HasColumnType("integer"); + + b.Property("EquippedTalismanEntityId") + .HasColumnType("integer"); + + b.HasKey("ViewerId", "QuestId", "IsMulti", "UnitNo"); + + b.ToTable("QuestClearPartyUnits"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbQuestEvent", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("QuestEventId") + .HasColumnType("integer") + .HasColumnName("QuestEventId"); + + b.Property("DailyPlayCount") + .HasColumnType("integer") + .HasColumnName("DailyPlayCount"); + + b.Property("LastDailyResetTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("LastDailyResetTime"); + + b.Property("LastWeeklyResetTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("LastWeeklyResetTime"); + + b.Property("QuestBonusReceiveCount") + .HasColumnType("integer") + .HasColumnName("QuestBonusReceiveCount"); + + b.Property("QuestBonusReserveCount") + .HasColumnType("integer") + .HasColumnName("QuestBonusReserveCount"); + + b.Property("QuestBonusReserveTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("QuestBonusReserveTime"); + + b.Property("QuestBonusStackCount") + .HasColumnType("integer") + .HasColumnName("QuestBonusStackCount"); + + b.Property("QuestBonusStackTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("QuestBonusStackTime"); + + b.Property("WeeklyPlayCount") + .HasColumnType("integer") + .HasColumnName("WeeklyPlayCount"); + + b.HasKey("ViewerId", "QuestEventId"); + + b.ToTable("QuestEvents"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbQuestTreasureList", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("QuestTreasureId") + .HasColumnType("integer") + .HasColumnName("QuestTreasureId"); + + b.HasKey("ViewerId", "QuestTreasureId"); + + b.ToTable("QuestTreasureList"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbReceivedRankingTierReward", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("RewardId") + .HasColumnType("integer"); + + b.Property("QuestId") + .HasColumnType("integer"); + + b.HasKey("ViewerId", "RewardId"); + + b.HasIndex("QuestId"); + + b.ToTable("ReceivedRankingTierRewards"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbSetUnit", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("CharaId") + .HasColumnType("integer"); + + b.Property("UnitSetNo") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType1CrestId1") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType1CrestId2") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType1CrestId3") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType2CrestId1") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType2CrestId2") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType3CrestId1") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType3CrestId2") + .HasColumnType("integer"); + + b.Property("EquipDragonKeyId") + .HasColumnType("bigint"); + + b.Property("EquipTalismanKeyId") + .HasColumnType("bigint"); + + b.Property("EquipWeaponBodyId") + .HasColumnType("integer"); + + b.Property("UnitSetName") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("ViewerId", "CharaId", "UnitSetNo"); + + b.ToTable("PlayerSetUnit"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbSummonTicket", b => + { + b.Property("KeyId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("TicketKeyId"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("KeyId")); + + b.Property("Quantity") + .HasColumnType("integer") + .HasColumnName("Quantity"); + + b.Property("SummonTicketId") + .HasColumnType("integer") + .HasColumnName("Type"); + + b.Property("UseLimitTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("ExpirationTime"); + + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.HasKey("KeyId"); + + b.HasIndex("ViewerId"); + + b.ToTable("PlayerSummonTickets"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbTalisman", b => + { + b.Property("TalismanKeyId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TalismanKeyId")); + + b.Property("AdditionalAttack") + .HasColumnType("integer"); + + b.Property("AdditionalHp") + .HasColumnType("integer"); + + b.Property("GetTime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsLock") + .HasColumnType("boolean"); + + b.Property("IsNew") + .HasColumnType("boolean"); + + b.Property("TalismanAbilityId1") + .HasColumnType("integer"); + + b.Property("TalismanAbilityId2") + .HasColumnType("integer"); + + b.Property("TalismanAbilityId3") + .HasColumnType("integer"); + + b.Property("TalismanId") + .HasColumnType("integer"); + + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.HasKey("TalismanKeyId"); + + b.HasIndex("ViewerId"); + + b.ToTable("PlayerTalismans"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbTimeAttackClear", b => + { + b.Property("GameId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QuestId") + .HasColumnType("integer"); + + b.Property("Time") + .HasColumnType("real"); + + b.HasKey("GameId"); + + b.HasIndex("QuestId"); + + b.ToTable("TimeAttackClears"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbTimeAttackClearUnit", b => + { + b.Property("GameId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("UnitNo") + .HasColumnType("integer"); + + b.Property("CharaId") + .HasColumnType("integer"); + + b.Property("EditSkill1CharaId") + .HasColumnType("integer"); + + b.Property("EditSkill2CharaId") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType1CrestId1") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType1CrestId2") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType1CrestId3") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType2CrestId1") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType2CrestId2") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType3CrestId1") + .HasColumnType("integer"); + + b.Property("EquipCrestSlotType3CrestId2") + .HasColumnType("integer"); + + b.Property("EquipDragonKeyId") + .HasColumnType("bigint"); + + b.Property("EquipTalismanKeyId") + .HasColumnType("bigint"); + + b.Property("EquipWeaponBodyId") + .HasColumnType("integer"); + + b.Property("EquipWeaponSkinId") + .HasColumnType("integer"); + + b.Property("EquippedDragonEntityId") + .HasColumnType("integer"); + + b.Property("EquippedTalismanEntityId") + .HasColumnType("integer"); + + b.Property("TalismanAbility1") + .HasColumnType("integer"); + + b.Property("TalismanAbility2") + .HasColumnType("integer"); + + b.HasKey("GameId", "ViewerId", "UnitNo"); + + b.ToTable("TimeAttackClearUnits"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbTimeAttackPlayer", b => + { + b.Property("GameId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("PartyInfo") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("GameId", "ViewerId"); + + b.HasIndex("ViewerId"); + + b.ToTable("TimeAttackPlayers"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbWallRewardDate", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("LastClaimDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ViewerId"); + + b.ToTable("WallRewardDates"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbWeaponBody", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("WeaponBodyId") + .HasColumnType("integer"); + + b.Property("AdditionalCrestSlotType1Count") + .HasColumnType("integer"); + + b.Property("AdditionalCrestSlotType2Count") + .HasColumnType("integer"); + + b.Property("AdditionalCrestSlotType3Count") + .HasColumnType("integer"); + + b.Property("BuildupCount") + .HasColumnType("integer"); + + b.Property("EquipableCount") + .HasColumnType("integer"); + + b.Property("FortPassiveCharaWeaponBuildupCount") + .HasColumnType("integer"); + + b.Property("GetTime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsNew") + .HasColumnType("boolean"); + + b.Property("LimitBreakCount") + .HasColumnType("integer"); + + b.Property("LimitOverCount") + .HasColumnType("integer"); + + b.Property("UnlockWeaponPassiveAbilityNoList") + .IsRequired() + .HasColumnType("integer[]"); + + b.HasKey("ViewerId", "WeaponBodyId"); + + b.ToTable("PlayerWeapons"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbWeaponPassiveAbility", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("WeaponPassiveAbilityId") + .HasColumnType("integer"); + + b.HasKey("ViewerId", "WeaponPassiveAbilityId"); + + b.ToTable("PlayerPassiveAbilities"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbWeaponSkin", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("WeaponSkinId") + .HasColumnType("integer"); + + b.Property("GetTime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsNew") + .HasColumnType("boolean"); + + b.HasKey("ViewerId", "WeaponSkinId"); + + b.ToTable("PlayerWeaponSkins"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbAbilityCrest", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("AbilityCrestList") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbAbilityCrestSet", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("AbilityCrestSetList") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbCompletedDailyMission", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany() + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbEmblem", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("Emblems") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbEquippedStamp", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("EquippedStampList") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbFortBuild", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("BuildList") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbFortDetail", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithOne("FortDetail") + .HasForeignKey("DragaliaAPI.Database.Entities.DbFortDetail", "ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbLoginBonus", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany() + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbParty", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("PartyList") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPartyPower", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithOne("PartyPower") + .HasForeignKey("DragaliaAPI.Database.Entities.DbPartyPower", "ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPartyUnit", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbParty", "Party") + .WithMany("Units") + .HasForeignKey("ViewerId", "PartyNo") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Party"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerBannerData", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("BannerData") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerCharaData", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("CharaList") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerDmodeChara", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("DmodeCharas") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerDmodeDungeon", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithOne("DmodeDungeon") + .HasForeignKey("DragaliaAPI.Database.Entities.DbPlayerDmodeDungeon", "ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerDmodeExpedition", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithOne("DmodeExpedition") + .HasForeignKey("DragaliaAPI.Database.Entities.DbPlayerDmodeExpedition", "ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerDmodeInfo", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithOne("DmodeInfo") + .HasForeignKey("DragaliaAPI.Database.Entities.DbPlayerDmodeInfo", "ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerDmodeServitorPassive", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("DmodeServitorPassives") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerDragonData", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("DragonList") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerDragonGift", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("DragonGiftList") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerDragonReliability", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("DragonReliabilityList") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerEventData", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany() + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerEventItem", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany() + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerEventPassive", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany() + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerEventReward", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany() + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerMaterial", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("MaterialList") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerMission", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany() + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerPresent", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("Presents") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerPresentHistory", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("PresentHistory") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerQuestWall", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("QuestWalls") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerShopInfo", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithOne("ShopInfo") + .HasForeignKey("DragaliaAPI.Database.Entities.DbPlayerShopInfo", "ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerShopPurchase", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany() + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerStoryState", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("StoryStates") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerSummonHistory", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("SummonHistory") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerTrade", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("Trades") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerUseItem", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany() + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayerUserData", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithOne("UserData") + .HasForeignKey("DragaliaAPI.Database.Entities.DbPlayerUserData", "ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbQuest", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("QuestList") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbQuestClearPartyUnit", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany() + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbQuestEvent", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("QuestEvents") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbQuestTreasureList", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("QuestTreasureList") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbReceivedRankingTierReward", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany() + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbSetUnit", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("UnitSets") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbSummonTicket", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("SummonTickets") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbTalisman", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("TalismanList") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbTimeAttackClearUnit", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbTimeAttackPlayer", "Player") + .WithMany("Units") + .HasForeignKey("GameId", "ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbTimeAttackPlayer", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbTimeAttackClear", "Clear") + .WithMany("Players") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Player") + .WithMany() + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Clear"); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbWallRewardDate", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithOne("WallRewardDate") + .HasForeignKey("DragaliaAPI.Database.Entities.DbWallRewardDate", "ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbWeaponBody", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("WeaponBodyList") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbWeaponPassiveAbility", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("WeaponPassiveAbilityList") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbWeaponSkin", b => + { + b.HasOne("DragaliaAPI.Database.Entities.DbPlayer", "Owner") + .WithMany("WeaponSkinList") + .HasForeignKey("ViewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbParty", b => + { + b.Navigation("Units"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbPlayer", b => + { + b.Navigation("AbilityCrestList"); + + b.Navigation("AbilityCrestSetList"); + + b.Navigation("BannerData"); + + b.Navigation("BuildList"); + + b.Navigation("CharaList"); + + b.Navigation("DmodeCharas"); + + b.Navigation("DmodeDungeon"); + + b.Navigation("DmodeExpedition"); + + b.Navigation("DmodeInfo"); + + b.Navigation("DmodeServitorPassives"); + + b.Navigation("DragonGiftList"); + + b.Navigation("DragonList"); + + b.Navigation("DragonReliabilityList"); + + b.Navigation("Emblems"); + + b.Navigation("EquippedStampList"); + + b.Navigation("FortDetail"); + + b.Navigation("MaterialList"); + + b.Navigation("PartyList"); + + b.Navigation("PartyPower"); + + b.Navigation("PresentHistory"); + + b.Navigation("Presents"); + + b.Navigation("QuestEvents"); + + b.Navigation("QuestList"); + + b.Navigation("QuestTreasureList"); + + b.Navigation("QuestWalls"); + + b.Navigation("ShopInfo"); + + b.Navigation("StoryStates"); + + b.Navigation("SummonHistory"); + + b.Navigation("SummonTickets"); + + b.Navigation("TalismanList"); + + b.Navigation("Trades"); + + b.Navigation("UnitSets"); + + b.Navigation("UserData"); + + b.Navigation("WallRewardDate"); + + b.Navigation("WeaponBodyList"); + + b.Navigation("WeaponPassiveAbilityList"); + + b.Navigation("WeaponSkinList"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbTimeAttackClear", b => + { + b.Navigation("Players"); + }); + + modelBuilder.Entity("DragaliaAPI.Database.Entities.DbTimeAttackPlayer", b => + { + b.Navigation("Units"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DragaliaAPI/DragaliaAPI.Database/Migrations/20240601133017_summon_pity_rates.cs b/DragaliaAPI/DragaliaAPI.Database/Migrations/20240601133017_summon_pity_rates.cs new file mode 100644 index 000000000..2cdbfa963 --- /dev/null +++ b/DragaliaAPI/DragaliaAPI.Database/Migrations/20240601133017_summon_pity_rates.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DragaliaAPI.Database.Migrations +{ + /// + public partial class summon_pity_rates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Pity", + table: "PlayerBannerData"); + + migrationBuilder.AddColumn( + name: "SummonCountSinceLastFiveStar", + table: "PlayerBannerData", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SummonCountSinceLastFiveStar", + table: "PlayerBannerData"); + + migrationBuilder.AddColumn( + name: "Pity", + table: "PlayerBannerData", + type: "smallint", + nullable: false, + defaultValue: (byte)0); + } + } +} diff --git a/DragaliaAPI/DragaliaAPI.Database/Migrations/ApiContextModelSnapshot.cs b/DragaliaAPI/DragaliaAPI.Database/Migrations/ApiContextModelSnapshot.cs index c243981ad..752f2645b 100644 --- a/DragaliaAPI/DragaliaAPI.Database/Migrations/ApiContextModelSnapshot.cs +++ b/DragaliaAPI/DragaliaAPI.Database/Migrations/ApiContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("ProductVersion", "8.0.5") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -435,14 +435,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("integer") .HasColumnName("FreeSummonAvailable"); - b.Property("PityRate") - .HasColumnType("smallint") - .HasColumnName("Pity"); - b.Property("SummonCount") .HasColumnType("integer") .HasColumnName("SummonCount"); + b.Property("SummonCountSinceLastFiveStar") + .HasColumnType("integer"); + b.Property("SummonPoints") .HasColumnType("integer") .HasColumnName("SummonPoints"); diff --git a/DragaliaAPI/DragaliaAPI.Test/Features/Summon/SummonOddsServiceTest.cs b/DragaliaAPI/DragaliaAPI.Test/Features/Summon/SummonOddsLogicTest.cs similarity index 53% rename from DragaliaAPI/DragaliaAPI.Test/Features/Summon/SummonOddsServiceTest.cs rename to DragaliaAPI/DragaliaAPI.Test/Features/Summon/SummonOddsLogicTest.cs index 6136a18cd..efb70b974 100644 --- a/DragaliaAPI/DragaliaAPI.Test/Features/Summon/SummonOddsServiceTest.cs +++ b/DragaliaAPI/DragaliaAPI.Test/Features/Summon/SummonOddsLogicTest.cs @@ -1,50 +1,35 @@ using DragaliaAPI.Features.Summoning; using DragaliaAPI.Shared.Definitions.Enums; -using Microsoft.Extensions.Options; -using NSubstitute; +using FluentAssertions.Execution; namespace DragaliaAPI.Test.Features.Summon; -public class SummonOddsServiceTest +public class SummonOddsLogicTest { // Even though we are using decimal, some error remains because the reference rates sourced from the game were rounded. private const decimal AssertionPrecision = 0.00005m; - private readonly IOptionsMonitor optionsMonitor; - private readonly SummonOddsService summonOddsService; - - public SummonOddsServiceTest() - { - this.optionsMonitor = Substitute.For>(); - this.summonOddsService = new(this.optionsMonitor); - } - [Fact] - public async Task GetUnitRates_FiveStarPickup_ProducesExpectedRates() + public void GetUnitRates_FiveStarPickup_ProducesExpectedRates() { - this.optionsMonitor.CurrentValue.Returns( - new SummonBannerOptions() - { - Banners = - [ + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + SummonOddsLogic.GetRates( + InitializeBanner( new Banner() { Id = 1, PickupCharas = [Charas.FaeblessedTobias], PickupDragons = [Dragons.Simurgh], } - ] - } - ); - - (IEnumerable PickupRates, IEnumerable NormalRates) rates = - await this.summonOddsService.GetUnitRates(1); + ), + numSummonsSinceLastFiveStar: 0 + ); List pickupRates = rates.PickupRates.ToList(); List normalRates = rates.NormalRates.ToList(); List combined = [.. pickupRates, .. normalRates]; - combined.Sum(x => x.Rate).Should().BeApproximately(1m, AssertionPrecision); + combined.SumOfRates().Should().BeApproximately(1m, AssertionPrecision); combined.Should().OnlyHaveUniqueItems(x => new { x.Id, x.EntityType }); pickupRates @@ -61,61 +46,51 @@ public async Task GetUnitRates_FiveStarPickup_ProducesExpectedRates() decimal expectedOffPickupRate = 0.04m - 0.005m - 0.008m; normalRates - .Where(x => x.Rarity == 5) - .Sum(x => x.Rate) + .SumOfRatesForRarity(5) .Should() .BeApproximately(expectedOffPickupRate, AssertionPrecision); normalRates - .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Chara }) - .Sum(x => x.Rate) + .SumOfRatesForRarityAndType(4, EntityTypes.Chara) .Should() .BeApproximately(0.0855m, AssertionPrecision); normalRates - .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Dragon }) - .Sum(x => x.Rate) + .SumOfRatesForRarityAndType(4, EntityTypes.Dragon) .Should() .BeApproximately(0.0745m, AssertionPrecision); normalRates - .Where(x => x is { Rarity: 3, EntityType: EntityTypes.Chara }) - .Sum(x => x.Rate) + .SumOfRatesForRarityAndType(3, EntityTypes.Chara) .Should() .BeApproximately(0.48m, AssertionPrecision); normalRates - .Where(x => x is { Rarity: 3, EntityType: EntityTypes.Dragon }) - .Sum(x => x.Rate) + .SumOfRatesForRarityAndType(3, EntityTypes.Dragon) .Should() .BeApproximately(0.32m, AssertionPrecision); } [Fact] - public async Task GetUnitRates_MultiFiveStarCharaPickup_ProducesExpectedRates() + public void GetUnitRates_MultiFiveStarCharaPickup_ProducesExpectedRates() { - this.optionsMonitor.CurrentValue.Returns( - new SummonBannerOptions() - { - Banners = - [ + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + SummonOddsLogic.GetRates( + InitializeBanner( new Banner() { Id = 1, PickupCharas = [Charas.GalaNedrick, Charas.Akasha, Charas.Eirene], } - ] - } - ); - - (IEnumerable PickupRates, IEnumerable NormalRates) rates = - await this.summonOddsService.GetUnitRates(1); + ), + numSummonsSinceLastFiveStar: 0 + ); List pickupRates = rates.PickupRates.ToList(); List normalRates = rates.NormalRates.ToList(); List combined = [.. pickupRates, .. normalRates]; - combined.Sum(x => x.Rate).Should().BeApproximately(1m, AssertionPrecision); + combined.SumOfRates().Should().BeApproximately(1m, AssertionPrecision); combined.Should().OnlyHaveUniqueItems(x => new { x.Id, x.EntityType }); pickupRates @@ -130,32 +105,21 @@ public async Task GetUnitRates_MultiFiveStarCharaPickup_ProducesExpectedRates() decimal expectedOffPickupRate = 0.04m - 0.005m - 0.005m - 0.005m; normalRates - .Where(x => x.Rarity == 5) - .Sum(x => x.Rate) + .SumOfRatesForRarity(5) .Should() .BeApproximately(expectedOffPickupRate, AssertionPrecision); - normalRates - .Where(x => x.Rarity == 4) - .Sum(x => x.Rate) - .Should() - .BeApproximately(0.16m, AssertionPrecision); + normalRates.SumOfRatesForRarity(4).Should().BeApproximately(0.16m, AssertionPrecision); - normalRates - .Where(x => x.Rarity == 3) - .Sum(x => x.Rate) - .Should() - .BeApproximately(0.8m, AssertionPrecision); + normalRates.SumOfRatesForRarity(3).Should().BeApproximately(0.8m, AssertionPrecision); } [Fact] - public async Task GetUnitRates_Gala_ProducesExpectedRates() + public void GetUnitRates_Gala_ProducesExpectedRates() { - this.optionsMonitor.CurrentValue.Returns( - new SummonBannerOptions() - { - Banners = - [ + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + SummonOddsLogic.GetRates( + InitializeBanner( new Banner() { Id = 1, @@ -163,18 +127,15 @@ public async Task GetUnitRates_Gala_ProducesExpectedRates() PickupCharas = [Charas.GalaZena, Charas.GalaRanzal], PickupDragons = [Dragons.GalaBeastCiella] } - ] - } - ); - - (IEnumerable PickupRates, IEnumerable NormalRates) rates = - await this.summonOddsService.GetUnitRates(1); + ), + numSummonsSinceLastFiveStar: 0 + ); List pickupRates = rates.PickupRates.ToList(); List normalRates = rates.NormalRates.ToList(); List combined = [.. pickupRates, .. normalRates]; - combined.Sum(x => x.Rate).Should().BeApproximately(1m, AssertionPrecision); + combined.SumOfRates().Should().BeApproximately(1m, AssertionPrecision); combined.Should().OnlyHaveUniqueItems(x => new { x.Id, x.EntityType }); pickupRates @@ -187,43 +148,33 @@ public async Task GetUnitRates_Gala_ProducesExpectedRates() ] ); - combined - .Where(x => x.Rarity == 5) - .Sum(x => x.Rate) - .Should() - .BeApproximately(0.06m, AssertionPrecision); + combined.SumOfRatesForRarity(5).Should().BeApproximately(0.06m, AssertionPrecision); combined - .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Chara }) - .Sum(x => x.Rate) + .SumOfRatesForRarityAndType(4, EntityTypes.Chara) .Should() .BeApproximately(0.0855m, AssertionPrecision); combined - .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Dragon }) - .Sum(x => x.Rate) + .SumOfRatesForRarityAndType(4, EntityTypes.Dragon) .Should() .BeApproximately(0.0745m, AssertionPrecision); combined - .Where(x => x is { Rarity: 3, EntityType: EntityTypes.Chara }) - .Sum(x => x.Rate) + .SumOfRatesForRarityAndType(3, EntityTypes.Chara) .Should() .BeApproximately(0.47m, AssertionPrecision); combined - .Where(x => x is { Rarity: 3, EntityType: EntityTypes.Dragon }) - .Sum(x => x.Rate) + .SumOfRatesForRarityAndType(3, EntityTypes.Dragon) .Should() .BeApproximately(0.31m, AssertionPrecision); } [Fact] - public async Task GetUnitRates_Gala_AddsLimitedUnits() + public void GetUnitRates_Gala_AddsLimitedUnits() { - this.optionsMonitor.CurrentValue.Returns( - new SummonBannerOptions() - { - Banners = - [ + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + SummonOddsLogic.GetRates( + InitializeBanner( new Banner() { Id = 1, @@ -231,12 +182,9 @@ public async Task GetUnitRates_Gala_AddsLimitedUnits() PickupCharas = [Charas.GalaZena, Charas.GalaRanzal], PickupDragons = [Dragons.GalaBeastCiella] } - ] - } - ); - - (IEnumerable PickupRates, IEnumerable NormalRates) rates = - await this.summonOddsService.GetUnitRates(1); + ), + numSummonsSinceLastFiveStar: 0 + ); List pickupRates = rates.PickupRates.ToList(); List normalRates = rates.NormalRates.ToList(); @@ -298,17 +246,15 @@ public async Task GetUnitRates_Gala_AddsLimitedUnits() } [Fact] - public async Task GetUnitRates_DoesNotContainRestrictedCharas() + public void GetUnitRates_DoesNotContainRestrictedCharas() { - this.optionsMonitor.CurrentValue.Returns( - new SummonBannerOptions() - { - Banners = [new Banner() { Id = 1, PickupCharas = [Charas.BeauticianZardin], }] - } - ); - (IEnumerable PickupRates, IEnumerable NormalRates) rates = - await this.summonOddsService.GetUnitRates(1); + SummonOddsLogic.GetRates( + InitializeBanner( + new Banner() { Id = 1, PickupCharas = [Charas.BeauticianZardin], } + ), + numSummonsSinceLastFiveStar: 0 + ); List combinedRates = [.. rates.PickupRates, .. rates.NormalRates]; @@ -425,17 +371,15 @@ public async Task GetUnitRates_DoesNotContainRestrictedCharas() } [Fact] - public async Task GetUnitRates_DoesNotContainRestrictedDragons() + public void GetUnitRates_DoesNotContainRestrictedDragons() { - this.optionsMonitor.CurrentValue.Returns( - new SummonBannerOptions() - { - Banners = [new Banner() { Id = 1, PickupCharas = [Charas.BeauticianZardin], }] - } - ); - (IEnumerable PickupRates, IEnumerable NormalRates) rates = - await this.summonOddsService.GetUnitRates(1); + SummonOddsLogic.GetRates( + InitializeBanner( + new Banner() { Id = 1, PickupCharas = [Charas.BeauticianZardin], } + ), + numSummonsSinceLastFiveStar: 0 + ); List combinedRates = [.. rates.PickupRates, .. rates.NormalRates]; @@ -499,17 +443,13 @@ public async Task GetUnitRates_DoesNotContainRestrictedDragons() } [Fact] - public async Task GetUnitRates_LimitedUnit_AddsToPool() + public void GetUnitRates_LimitedUnit_AddsToPool() { - this.optionsMonitor.CurrentValue.Returns( - new SummonBannerOptions() - { - Banners = [new Banner() { Id = 1, LimitedCharas = [Charas.Joker], }] - } - ); - (IEnumerable PickupRates, IEnumerable NormalRates) rates = - await this.summonOddsService.GetUnitRates(1); + SummonOddsLogic.GetRates( + InitializeBanner(new Banner() { Id = 1, LimitedCharas = [Charas.Joker], }), + numSummonsSinceLastFiveStar: 0 + ); List normalRates = rates.NormalRates.ToList(); @@ -518,513 +458,685 @@ public async Task GetUnitRates_LimitedUnit_AddsToPool() } [Fact] - public async Task GetGuaranteeUnitRates_FiveStarPickup_ProducesExpectedRates() + public void GetUnitRates_FourStarPickup_ProducesExpectedRates() { - this.optionsMonitor.CurrentValue.Returns( - new SummonBannerOptions() - { - Banners = - [ + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + SummonOddsLogic.GetRates( + InitializeBanner( new Banner() { Id = 1, - PickupCharas = [Charas.Eirene], - PickupDragons = [Dragons.Agni], + PickupCharas = [Charas.KuHai], + PickupDragons = [Dragons.Roc], } - ] - } - ); - - (IEnumerable PickupRates, IEnumerable NormalRates) rates = - await this.summonOddsService.GetGuaranteeUnitRates(1); + ), + numSummonsSinceLastFiveStar: 0 + ); List pickupRates = rates.PickupRates.ToList(); List normalRates = rates.NormalRates.ToList(); List combined = [.. pickupRates, .. normalRates]; - combined.Sum(x => x.Rate).Should().BeApproximately(1m, AssertionPrecision); + combined.SumOfRates().Should().BeApproximately(1m, AssertionPrecision); combined.Should().OnlyHaveUniqueItems(x => new { x.Id, x.EntityType }); pickupRates .Should() .BeEquivalentTo( - [new UnitRate(Charas.Eirene, 0.005m), new UnitRate(Dragons.Agni, 0.008m)] + [new UnitRate(Charas.KuHai, 0.035m), new UnitRate(Dragons.Roc, 0.035m)] ); - decimal expectedOffPickupRate = 0.04m - 0.005m - 0.008m; - normalRates - .Where(x => x.Rarity == 5) - .Sum(x => x.Rate) - .Should() - .BeApproximately(expectedOffPickupRate, AssertionPrecision); + combined.SumOfRatesForRarity(5).Should().BeApproximately(0.04m, AssertionPrecision); + combined.SumOfRatesForRarity(4).Should().BeApproximately(0.16m, AssertionPrecision); - normalRates - .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Chara }) - .Sum(x => x.Rate) + combined + .SumOfRatesForRarityAndType(3, EntityTypes.Chara) .Should() - .BeApproximately(0.513m, AssertionPrecision); - - normalRates - .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Dragon }) - .Sum(x => x.Rate) + .BeApproximately(0.48m, AssertionPrecision); + combined + .SumOfRatesForRarityAndType(3, EntityTypes.Dragon) .Should() - .BeApproximately(0.447m, AssertionPrecision); - - normalRates.Should().NotContain(x => x.Rarity == 3); + .BeApproximately(0.32m, AssertionPrecision); } [Fact] - public async Task GetGuaranteeUnitRates_FiveStarPickup_Gala_ProducesExpectedRates() + public void GetUnitRates_FourStarPickup_Gala_ProducesExpectedRates() { - this.optionsMonitor.CurrentValue.Returns( - new SummonBannerOptions() - { - Banners = - [ + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + SummonOddsLogic.GetRates( + InitializeBanner( new Banner() { Id = 1, IsGala = true, - PickupCharas = [Charas.GalaLeif], - PickupDragons = [Dragons.GalaRebornAgni], + PickupCharas = [Charas.KuHai, Charas.GalaAudric], + PickupDragons = [Dragons.Roc, Dragons.GalaBahamut], } - ] - } - ); - - (IEnumerable PickupRates, IEnumerable NormalRates) rates = - await this.summonOddsService.GetGuaranteeUnitRates(1); + ), + numSummonsSinceLastFiveStar: 0 + ); List pickupRates = rates.PickupRates.ToList(); List normalRates = rates.NormalRates.ToList(); List combined = [.. pickupRates, .. normalRates]; - // The sum is actually 0.9999000000000000000000000026M and needs lower precision to be accepted. - // An error this small is acceptable - could be down to rounding in the reference figures. - combined.Sum(x => x.Rate).Should().BeApproximately(1m, AssertionPrecision * 10); + combined.SumOfRates().Should().BeApproximately(1m, AssertionPrecision); combined.Should().OnlyHaveUniqueItems(x => new { x.Id, x.EntityType }); pickupRates .Should() .BeEquivalentTo( [ - new UnitRate(Charas.GalaLeif, 0.005m), - new UnitRate(Dragons.GalaRebornAgni, 0.008m) + new UnitRate(Charas.KuHai, 0.035m), + new UnitRate(Charas.GalaAudric, 0.005m), + new UnitRate(Dragons.Roc, 0.035m), + new UnitRate(Dragons.GalaBahamut, 0.008m) ] ); - decimal expectedOffPickupRate = 0.06m - 0.005m - 0.008m; - normalRates - .Where(x => x.Rarity == 5) - .Sum(x => x.Rate) - .Should() - .BeApproximately(expectedOffPickupRate, AssertionPrecision); + combined.SumOfRatesForRarity(5).Should().BeApproximately(0.06m, AssertionPrecision); - normalRates - .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Chara }) - .Sum(x => x.Rate) - .Should() - .BeApproximately(0.5023m, AssertionPrecision); + combined.SumOfRatesForRarity(4).Should().BeApproximately(0.16m, AssertionPrecision); - normalRates - .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Dragon }) - .Sum(x => x.Rate) + combined + .SumOfRatesForRarityAndType(3, EntityTypes.Chara) .Should() - .BeApproximately(0.4376m, AssertionPrecision); - - normalRates.Should().NotContain(x => x.Rarity == 3); + .BeApproximately(0.47m, AssertionPrecision); + combined + .SumOfRatesForRarityAndType(3, EntityTypes.Dragon) + .Should() + .BeApproximately(0.31m, AssertionPrecision); } [Fact] - public async Task GetUnitRates_FourStarPickup_ProducesExpectedRates() + public void GetUnitRates_FourStarPickup_Pity_ProducesExpectedRates() { - this.optionsMonitor.CurrentValue.Returns( - new SummonBannerOptions() - { - Banners = - [ + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + SummonOddsLogic.GetRates( + InitializeBanner( new Banner() { Id = 1, - PickupCharas = [Charas.KuHai], - PickupDragons = [Dragons.Roc], + IsGala = false, + PickupCharas = [Charas.KuHai, Charas.GalaAudric], + PickupDragons = [Dragons.Roc, Dragons.GalaBahamut], } - ] - } - ); - - (IEnumerable PickupRates, IEnumerable NormalRates) rates = - await this.summonOddsService.GetUnitRates(1); + ), + numSummonsSinceLastFiveStar: 10 + ); List pickupRates = rates.PickupRates.ToList(); List normalRates = rates.NormalRates.ToList(); List combined = [.. pickupRates, .. normalRates]; - combined.Sum(x => x.Rate).Should().BeApproximately(1m, AssertionPrecision); + combined.SumOfRates().Should().BeApproximately(1m, AssertionPrecision); combined.Should().OnlyHaveUniqueItems(x => new { x.Id, x.EntityType }); - pickupRates - .Should() - .BeEquivalentTo( - [new UnitRate(Charas.KuHai, 0.035m), new UnitRate(Dragons.Roc, 0.035m)] - ); - - combined - .Where(x => x.Rarity == 5) - .Sum(x => x.Rate) - .Should() - .BeApproximately(0.04m, AssertionPrecision); - - combined - .Where(x => x.Rarity == 4) - .Sum(x => x.Rate) - .Should() - .BeApproximately(0.16m, AssertionPrecision); - - combined - .Where(x => x is { Rarity: 3, EntityType: EntityTypes.Chara }) - .Sum(x => x.Rate) - .Should() - .BeApproximately(0.48m, AssertionPrecision); - combined - .Where(x => x is { Rarity: 3, EntityType: EntityTypes.Dragon }) - .Sum(x => x.Rate) - .Should() - .BeApproximately(0.32m, AssertionPrecision); + combined.SumOfRatesForRarity(5).Should().BeApproximately(0.045m, AssertionPrecision); + combined.SumOfRatesForRarity(4).Should().BeApproximately(0.16m, AssertionPrecision); + combined.SumOfRatesForRarity(3).Should().BeApproximately(0.795m, AssertionPrecision); } [Fact] - public async Task GetUnitRates_FourStarPickup_Gala_ProducesExpectedRates() + public void GetUnitRates_ThreeStarPickup_ProducesExpectedRates() { - this.optionsMonitor.CurrentValue.Returns( - new SummonBannerOptions() - { - Banners = - [ + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + SummonOddsLogic.GetRates( + InitializeBanner( new Banner() { Id = 1, - IsGala = true, - PickupCharas = [Charas.KuHai, Charas.GalaAudric], - PickupDragons = [Dragons.Roc, Dragons.GalaBahamut], + PickupCharas = [Charas.Joe], + PickupDragons = [Dragons.PallidImp], } - ] - } - ); - - (IEnumerable PickupRates, IEnumerable NormalRates) rates = - await this.summonOddsService.GetUnitRates(1); + ), + numSummonsSinceLastFiveStar: 0 + ); List pickupRates = rates.PickupRates.ToList(); List normalRates = rates.NormalRates.ToList(); List combined = [.. pickupRates, .. normalRates]; - combined.Sum(x => x.Rate).Should().BeApproximately(1m, AssertionPrecision); + combined.SumOfRates().Should().BeApproximately(1m, AssertionPrecision); combined.Should().OnlyHaveUniqueItems(x => new { x.Id, x.EntityType }); pickupRates .Should() .BeEquivalentTo( - [ - new UnitRate(Charas.KuHai, 0.035m), - new UnitRate(Charas.GalaAudric, 0.005m), - new UnitRate(Dragons.Roc, 0.035m), - new UnitRate(Dragons.GalaBahamut, 0.008m) - ] + [new UnitRate(Charas.Joe, 0.04m), new UnitRate(Dragons.PallidImp, 0.04m),] ); - combined - .Where(x => x.Rarity == 5) - .Sum(x => x.Rate) - .Should() - .BeApproximately(0.06m, AssertionPrecision); - - combined - .Where(x => x.Rarity == 4) - .Sum(x => x.Rate) - .Should() - .BeApproximately(0.16m, AssertionPrecision); + combined.SumOfRatesForRarity(5).Should().BeApproximately(0.04m, AssertionPrecision); combined - .Where(x => x is { Rarity: 3, EntityType: EntityTypes.Chara }) - .Sum(x => x.Rate) + .SumOfRatesForRarityAndType(4, EntityTypes.Chara) .Should() - .BeApproximately(0.47m, AssertionPrecision); + .BeApproximately(0.0855m, AssertionPrecision); combined - .Where(x => x is { Rarity: 3, EntityType: EntityTypes.Dragon }) - .Sum(x => x.Rate) + .SumOfRatesForRarityAndType(4, EntityTypes.Dragon) .Should() - .BeApproximately(0.31m, AssertionPrecision); + .BeApproximately(0.0745m, AssertionPrecision); + + combined.SumOfRatesForRarity(3).Should().BeApproximately(0.8m, AssertionPrecision); } [Fact] - public async Task GetGuaranteeUnitRates_FourStarPickup_ProducesExpectedRates() + public void GetUnitRates_ThreeStarPickup_Pity_ProducesExpectedRates() { - this.optionsMonitor.CurrentValue.Returns( - new SummonBannerOptions() - { - Banners = - [ + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + SummonOddsLogic.GetRates( + InitializeBanner( new Banner() { Id = 1, - PickupCharas = [Charas.KuHai], - PickupDragons = [Dragons.Roc], + PickupCharas = [Charas.Joe], + PickupDragons = [Dragons.PallidImp], } - ] - } - ); - - (IEnumerable PickupRates, IEnumerable NormalRates) rates = - await this.summonOddsService.GetGuaranteeUnitRates(1); + ), + numSummonsSinceLastFiveStar: 10 + ); List pickupRates = rates.PickupRates.ToList(); List normalRates = rates.NormalRates.ToList(); List combined = [.. pickupRates, .. normalRates]; - combined.Sum(x => x.Rate).Should().BeApproximately(1m, AssertionPrecision); + combined.SumOfRates().Should().BeApproximately(1m, AssertionPrecision); combined.Should().OnlyHaveUniqueItems(x => new { x.Id, x.EntityType }); - pickupRates - .Should() - .BeEquivalentTo([new UnitRate(Charas.KuHai, 0.21m), new UnitRate(Dragons.Roc, 0.21m)]); - - combined - .Where(x => x.Rarity == 5) - .Sum(x => x.Rate) - .Should() - .BeApproximately(0.04m, AssertionPrecision); - - combined - .Where(x => x.Rarity == 4) - .Sum(x => x.Rate) - .Should() - .BeApproximately(0.96m, AssertionPrecision); - - normalRates.Should().NotContain(x => x.Rarity == 3); + combined.SumOfRatesForRarity(5).Should().BeApproximately(0.045m, AssertionPrecision); + combined.SumOfRatesForRarity(4).Should().BeApproximately(0.16m, AssertionPrecision); + combined.SumOfRatesForRarity(3).Should().BeApproximately(0.795m, AssertionPrecision); } [Fact] - public async Task GetGuaranteeUnitRates_FourStarPickup_Gala_ProducesExpectedRates() + public void GetUnitRates_ThreeStarPickup_Gala_ProducesExpectedRates() { - this.optionsMonitor.CurrentValue.Returns( - new SummonBannerOptions() - { - Banners = - [ + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + SummonOddsLogic.GetRates( + InitializeBanner( new Banner() { Id = 1, IsGala = true, - PickupCharas = [Charas.KuHai, Charas.GalaCleo], - PickupDragons = [Dragons.Roc, Dragons.GalaChronosNyx], + PickupCharas = [Charas.Joe, Charas.GalaZethia], + PickupDragons = [Dragons.PallidImp, Dragons.GalaRebornJeanne], } - ] - } - ); - - (IEnumerable PickupRates, IEnumerable NormalRates) rates = - await this.summonOddsService.GetGuaranteeUnitRates(1); + ), + numSummonsSinceLastFiveStar: 0 + ); List pickupRates = rates.PickupRates.ToList(); List normalRates = rates.NormalRates.ToList(); List combined = [.. pickupRates, .. normalRates]; - combined.Sum(x => x.Rate).Should().BeApproximately(1m, AssertionPrecision); + combined.SumOfRates().Should().BeApproximately(1m, AssertionPrecision); combined.Should().OnlyHaveUniqueItems(x => new { x.Id, x.EntityType }); pickupRates .Should() .BeEquivalentTo( [ - new UnitRate(Charas.KuHai, 0.205625m), - new UnitRate(Charas.GalaCleo, 0.005m), - new UnitRate(Dragons.Roc, 0.205625m), - new UnitRate(Dragons.GalaChronosNyx, 0.008m) + new UnitRate(Charas.Joe, 0.04m), + new UnitRate(Charas.GalaZethia, 0.005m), + new UnitRate(Dragons.PallidImp, 0.04m), + new UnitRate(Dragons.GalaRebornJeanne, 0.008m) ] ); + combined.SumOfRatesForRarity(5).Should().BeApproximately(0.06m, AssertionPrecision); + combined - .Where(x => x.Rarity == 5) - .Sum(x => x.Rate) + .SumOfRatesForRarityAndType(4, EntityTypes.Chara) .Should() - .BeApproximately(0.06m, AssertionPrecision); - + .BeApproximately(0.0855m, AssertionPrecision); combined - .Where(x => x.Rarity == 4) - .Sum(x => x.Rate) + .SumOfRatesForRarityAndType(4, EntityTypes.Dragon) .Should() - .BeApproximately(0.94m, AssertionPrecision); + .BeApproximately(0.0745m, AssertionPrecision); - normalRates.Should().NotContain(x => x.Rarity == 3); + combined.SumOfRatesForRarity(3).Should().BeApproximately(0.78m, AssertionPrecision); } [Fact] - public async Task GetUnitRates_ThreeStarPickup_ProducesExpectedRates() + public void GetUnitRates_FiveStarPickup_Pity_ProductsExpectedRates() { - this.optionsMonitor.CurrentValue.Returns( - new SummonBannerOptions() - { - Banners = - [ + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + SummonOddsLogic.GetRates( + InitializeBanner( new Banner() { Id = 1, - PickupCharas = [Charas.Joe], - PickupDragons = [Dragons.PallidImp], + IsGala = false, + PickupCharas = [Charas.JiangZiya], + PickupDragons = [Dragons.Simurgh], } - ] - } - ); + ), + numSummonsSinceLastFiveStar: 10 + ); + List pickupRates = rates.PickupRates.ToList(); + List normalRates = rates.NormalRates.ToList(); + List combined = [.. pickupRates, .. normalRates]; + + combined.SumOfRates().Should().BeApproximately(1m, AssertionPrecision); + combined.Should().OnlyHaveUniqueItems(x => new { x.Id, x.EntityType }); + + combined.SumOfRatesForRarity(5).Should().BeApproximately(0.045m, AssertionPrecision); + combined.SumOfRatesForRarity(4).Should().BeApproximately(0.16m, AssertionPrecision); + combined.SumOfRatesForRarity(3).Should().BeApproximately(0.795m, AssertionPrecision); + } + + [Theory] + [InlineData(5, 0.04)] + [InlineData(10, 0.045)] + [InlineData(20, 0.05)] + [InlineData(22, 0.05)] + [InlineData(30, 0.055)] + [InlineData(40, 0.060)] + [InlineData(50, 0.065)] + [InlineData(60, 0.070)] + [InlineData(70, 0.075)] + [InlineData(80, 0.080)] + [InlineData(90, 0.085)] + [InlineData(100, 0.090)] + public void GetUnitRates_FiveStarPickup_Pity_FiveStarRateIncreasesWithSummonCount( + int numSummonsSinceLastFiveStar, + decimal expectedFiveStarRate + ) + { + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + SummonOddsLogic.GetRates( + InitializeBanner( + new Banner() + { + Id = 1, + IsGala = false, + PickupCharas = [Charas.JiangZiya], + PickupDragons = [Dragons.Simurgh], + } + ), + numSummonsSinceLastFiveStar: numSummonsSinceLastFiveStar + ); + + List pickupRates = rates.PickupRates.ToList(); + List normalRates = rates.NormalRates.ToList(); + List combined = [.. pickupRates, .. normalRates]; + + combined.SumOfRates().Should().BeApproximately(1m, AssertionPrecision); + combined.Should().OnlyHaveUniqueItems(x => new { x.Id, x.EntityType }); + + combined + .SumOfRatesForRarity(5) + .Should() + .BeApproximately(expectedFiveStarRate, AssertionPrecision); + combined.SumOfRatesForRarity(4).Should().BeApproximately(0.16m, AssertionPrecision); + combined + .SumOfRatesForRarity(3) + .Should() + .BeApproximately(1 - 0.16m - expectedFiveStarRate, AssertionPrecision); + } + + [Theory] + [InlineData(5, 0.06)] + [InlineData(10, 0.065)] + [InlineData(20, 0.07)] + [InlineData(22, 0.07)] + [InlineData(30, 0.075)] + [InlineData(40, 0.080)] + [InlineData(50, 0.085)] + [InlineData(60, 0.090)] + public void GetUnitRates_FiveStarPickup_Pity_Gala_FiveStarRateIncreasesWithSummonCount( + int numSummonsSinceLastFiveStar, + decimal expectedFiveStarRate + ) + { (IEnumerable PickupRates, IEnumerable NormalRates) rates = - await this.summonOddsService.GetUnitRates(1); + SummonOddsLogic.GetRates( + InitializeBanner( + new Banner() + { + Id = 1, + IsGala = true, + PickupCharas = [Charas.JiangZiya], + PickupDragons = [Dragons.Simurgh], + } + ), + numSummonsSinceLastFiveStar: numSummonsSinceLastFiveStar + ); List pickupRates = rates.PickupRates.ToList(); List normalRates = rates.NormalRates.ToList(); List combined = [.. pickupRates, .. normalRates]; - combined.Sum(x => x.Rate).Should().BeApproximately(1m, AssertionPrecision); + combined.SumOfRates().Should().BeApproximately(1m, AssertionPrecision); + combined.Should().OnlyHaveUniqueItems(x => new { x.Id, x.EntityType }); + + combined + .SumOfRatesForRarity(5) + .Should() + .BeApproximately(expectedFiveStarRate, AssertionPrecision); + combined.SumOfRatesForRarity(4).Should().BeApproximately(0.16m, AssertionPrecision); + combined + .SumOfRatesForRarity(3) + .Should() + .BeApproximately(1 - 0.16m - expectedFiveStarRate, AssertionPrecision); + } + + [Fact] + public void GetUnitRates_FiveStarPickup_Gala_MaxPity_ProductsExpectedRates() + { + // Compares against real screenshot from the Bondforged banner. + + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + SummonOddsLogic.GetRates( + InitializeBanner( + new Banner() + { + Id = 1, + IsGala = true, + PickupCharas = [Charas.BondforgedPrince, Charas.BondforgedZethia], + PickupDragons = [], + } + ), + numSummonsSinceLastFiveStar: 60 + ); + + List pickupRates = rates.PickupRates.ToList(); + List normalRates = rates.NormalRates.ToList(); + List combined = [.. pickupRates, .. normalRates]; + + combined.SumOfRates().Should().BeApproximately(1m, AssertionPrecision); + combined.Should().OnlyHaveUniqueItems(x => new { x.Id, x.EntityType }); + + combined.SumOfRatesForRarity(5).Should().BeApproximately(0.09m, AssertionPrecision); + + pickupRates.SumOfRates().Should().BeApproximately(0.0148m, AssertionPrecision); + + using (var ass = new AssertionScope()) + { + normalRates + .SumOfRatesForRarity(5) + .Should() + .BeApproximately(0.0752m, AssertionPrecision); + normalRates + .SumOfRatesForRarityAndType(5, EntityTypes.Dragon) + .Should() + .BeApproximately(0.0451m, AssertionPrecision * 10); + normalRates + .SumOfRatesForRarityAndType(5, EntityTypes.Chara) + .Should() + .BeApproximately(0.03m, AssertionPrecision * 10); + } + } + + [Fact] + public void GetGuaranteeUnitRates_FiveStarPickup_ProducesExpectedRates() + { + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + SummonOddsLogic.GetGuaranteeRates( + InitializeBanner( + new Banner() + { + Id = 1, + PickupCharas = [Charas.Eirene], + PickupDragons = [Dragons.Agni], + } + ), + numSummonsSinceLastFiveStar: 0 + ); + + List pickupRates = rates.PickupRates.ToList(); + List normalRates = rates.NormalRates.ToList(); + List combined = [.. pickupRates, .. normalRates]; + + combined.SumOfRates().Should().BeApproximately(1m, AssertionPrecision); combined.Should().OnlyHaveUniqueItems(x => new { x.Id, x.EntityType }); pickupRates .Should() .BeEquivalentTo( - [new UnitRate(Charas.Joe, 0.04m), new UnitRate(Dragons.PallidImp, 0.04m),] + [new UnitRate(Charas.Eirene, 0.005m), new UnitRate(Dragons.Agni, 0.008m)] ); - combined + decimal expectedOffPickupRate = 0.04m - 0.005m - 0.008m; + normalRates .Where(x => x.Rarity == 5) .Sum(x => x.Rate) .Should() - .BeApproximately(0.04m, AssertionPrecision); + .BeApproximately(expectedOffPickupRate, AssertionPrecision); - combined + normalRates .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Chara }) .Sum(x => x.Rate) .Should() - .BeApproximately(0.0855m, AssertionPrecision); - combined + .BeApproximately(0.513m, AssertionPrecision); + + normalRates .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Dragon }) .Sum(x => x.Rate) .Should() - .BeApproximately(0.0745m, AssertionPrecision); + .BeApproximately(0.447m, AssertionPrecision); - combined - .Where(x => x.Rarity == 3) - .Sum(x => x.Rate) - .Should() - .BeApproximately(0.8m, AssertionPrecision); + normalRates.Should().NotContain(x => x.Rarity == 3); } [Fact] - public async Task GetUnitRates_ThreeStarPickup_Gala_ProducesExpectedRates() + public void GetGuaranteeUnitRates_FiveStarPickup_Pity_ProducesExpectedRates() { - this.optionsMonitor.CurrentValue.Returns( - new SummonBannerOptions() - { - Banners = - [ + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + SummonOddsLogic.GetGuaranteeRates( + InitializeBanner( new Banner() { Id = 1, - IsGala = true, - PickupCharas = [Charas.Joe, Charas.GalaZethia], - PickupDragons = [Dragons.PallidImp, Dragons.GalaRebornJeanne], + PickupCharas = [Charas.Eirene], + PickupDragons = [Dragons.Agni], } - ] - } - ); + ), + numSummonsSinceLastFiveStar: 10 + ); + + List pickupRates = rates.PickupRates.ToList(); + List normalRates = rates.NormalRates.ToList(); + List combined = [.. pickupRates, .. normalRates]; + + combined.SumOfRates().Should().BeApproximately(1m, AssertionPrecision); + combined.Should().OnlyHaveUniqueItems(x => new { x.Id, x.EntityType }); + combined.SumOfRatesForRarity(5).Should().BeApproximately(0.045m, AssertionPrecision); + combined.SumOfRatesForRarity(4).Should().BeApproximately(0.955m, AssertionPrecision); + } + + [Fact] + public void GetGuaranteeUnitRates_FiveStarPickup_Gala_ProducesExpectedRates() + { (IEnumerable PickupRates, IEnumerable NormalRates) rates = - await this.summonOddsService.GetUnitRates(1); + SummonOddsLogic.GetGuaranteeRates( + InitializeBanner( + new Banner() + { + Id = 1, + IsGala = true, + PickupCharas = [Charas.GalaLeif], + PickupDragons = [Dragons.GalaRebornAgni], + } + ), + numSummonsSinceLastFiveStar: 0 + ); List pickupRates = rates.PickupRates.ToList(); List normalRates = rates.NormalRates.ToList(); List combined = [.. pickupRates, .. normalRates]; - combined.Sum(x => x.Rate).Should().BeApproximately(1m, AssertionPrecision); + // The sum is actually 0.9999000000000000000000000026M and needs lower precision to be accepted. + // An error this small is acceptable - could be down to rounding in the reference figures. + combined.SumOfRates().Should().BeApproximately(1m, AssertionPrecision * 10); combined.Should().OnlyHaveUniqueItems(x => new { x.Id, x.EntityType }); pickupRates .Should() .BeEquivalentTo( [ - new UnitRate(Charas.Joe, 0.04m), - new UnitRate(Charas.GalaZethia, 0.005m), - new UnitRate(Dragons.PallidImp, 0.04m), - new UnitRate(Dragons.GalaRebornJeanne, 0.008m) + new UnitRate(Charas.GalaLeif, 0.005m), + new UnitRate(Dragons.GalaRebornAgni, 0.008m) ] ); - combined - .Where(x => x.Rarity == 5) - .Sum(x => x.Rate) + decimal expectedOffPickupRate = 0.06m - 0.005m - 0.008m; + normalRates + .SumOfRatesForRarity(5) .Should() - .BeApproximately(0.06m, AssertionPrecision); + .BeApproximately(expectedOffPickupRate, AssertionPrecision); - combined - .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Chara }) - .Sum(x => x.Rate) + normalRates + .SumOfRatesForRarityAndType(4, EntityTypes.Chara) .Should() - .BeApproximately(0.0855m, AssertionPrecision); - combined - .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Dragon }) - .Sum(x => x.Rate) + .BeApproximately(0.5023m, AssertionPrecision); + + normalRates + .SumOfRatesForRarityAndType(4, EntityTypes.Dragon) .Should() - .BeApproximately(0.0745m, AssertionPrecision); + .BeApproximately(0.4376m, AssertionPrecision); - combined - .Where(x => x.Rarity == 3) - .Sum(x => x.Rate) + normalRates.Should().NotContain(x => x.Rarity == 3); + } + + [Fact] + public void GetGuaranteeUnitRates_FourStarPickup_ProducesExpectedRates() + { + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + SummonOddsLogic.GetGuaranteeRates( + InitializeBanner( + new Banner() + { + Id = 1, + PickupCharas = [Charas.KuHai], + PickupDragons = [Dragons.Roc], + } + ), + numSummonsSinceLastFiveStar: 0 + ); + + List pickupRates = rates.PickupRates.ToList(); + List normalRates = rates.NormalRates.ToList(); + List combined = [.. pickupRates, .. normalRates]; + + combined.SumOfRates().Should().BeApproximately(1m, AssertionPrecision); + combined.Should().OnlyHaveUniqueItems(x => new { x.Id, x.EntityType }); + + pickupRates .Should() - .BeApproximately(0.78m, AssertionPrecision); + .BeEquivalentTo([new UnitRate(Charas.KuHai, 0.21m), new UnitRate(Dragons.Roc, 0.21m)]); + + combined.SumOfRatesForRarity(5).Should().BeApproximately(0.04m, AssertionPrecision); + combined.SumOfRatesForRarity(4).Should().BeApproximately(0.96m, AssertionPrecision); + + normalRates.Should().NotContain(x => x.Rarity == 3); } [Fact] - public async Task GetGuaranteeUnitRates_ThreeStarPickup_ProducesExpectedRates() + public void GetGuaranteeUnitRates_FourStarPickup_Pity_ProducesExpectedRates() { - this.optionsMonitor.CurrentValue.Returns( - new SummonBannerOptions() - { - Banners = - [ + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + SummonOddsLogic.GetGuaranteeRates( + InitializeBanner( new Banner() { Id = 1, - PickupCharas = [Charas.Joe], - PickupDragons = [Dragons.PallidImp], + PickupCharas = [Charas.KuHai], + PickupDragons = [Dragons.Roc], } + ), + numSummonsSinceLastFiveStar: 10 + ); + + List pickupRates = rates.PickupRates.ToList(); + List normalRates = rates.NormalRates.ToList(); + List combined = [.. pickupRates, .. normalRates]; + + combined.SumOfRates().Should().BeApproximately(1m, AssertionPrecision); + combined.Should().OnlyHaveUniqueItems(x => new { x.Id, x.EntityType }); + + combined.SumOfRatesForRarity(5).Should().BeApproximately(0.045m, AssertionPrecision); + combined.SumOfRatesForRarity(4).Should().BeApproximately(0.955m, AssertionPrecision); + + normalRates.Should().NotContain(x => x.Rarity == 3); + } + + [Fact] + public void GetGuaranteeUnitRates_FourStarPickup_Gala_ProducesExpectedRates() + { + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + SummonOddsLogic.GetGuaranteeRates( + InitializeBanner( + new Banner() + { + Id = 1, + IsGala = true, + PickupCharas = [Charas.KuHai, Charas.GalaCleo], + PickupDragons = [Dragons.Roc, Dragons.GalaChronosNyx], + } + ), + numSummonsSinceLastFiveStar: 0 + ); + + List pickupRates = rates.PickupRates.ToList(); + List normalRates = rates.NormalRates.ToList(); + List combined = [.. pickupRates, .. normalRates]; + + combined.SumOfRates().Should().BeApproximately(1m, AssertionPrecision); + combined.Should().OnlyHaveUniqueItems(x => new { x.Id, x.EntityType }); + + pickupRates + .Should() + .BeEquivalentTo( + [ + new UnitRate(Charas.KuHai, 0.205625m), + new UnitRate(Charas.GalaCleo, 0.005m), + new UnitRate(Dragons.Roc, 0.205625m), + new UnitRate(Dragons.GalaChronosNyx, 0.008m) ] - } - ); + ); + + combined.SumOfRatesForRarity(5).Should().BeApproximately(0.06m, AssertionPrecision); + combined.SumOfRatesForRarity(4).Should().BeApproximately(0.94m, AssertionPrecision); + normalRates.Should().NotContain(x => x.Rarity == 3); + } + + [Fact] + public void GetGuaranteeUnitRates_ThreeStarPickup_ProducesExpectedRates() + { (IEnumerable PickupRates, IEnumerable NormalRates) rates = - await this.summonOddsService.GetGuaranteeUnitRates(1); + SummonOddsLogic.GetGuaranteeRates( + InitializeBanner( + new Banner() + { + Id = 1, + PickupCharas = [Charas.Joe], + PickupDragons = [Dragons.PallidImp], + } + ), + numSummonsSinceLastFiveStar: 0 + ); List pickupRates = rates.PickupRates.ToList(); List normalRates = rates.NormalRates.ToList(); List combined = [.. pickupRates, .. normalRates]; - combined.Sum(x => x.Rate).Should().BeApproximately(1m, AssertionPrecision); + combined.SumOfRates().Should().BeApproximately(1m, AssertionPrecision); combined.Should().OnlyHaveUniqueItems(x => new { x.Id, x.EntityType }); pickupRates.Should().BeEmpty(); - combined - .Where(x => x.Rarity == 5) - .Sum(x => x.Rate) - .Should() - .BeApproximately(0.04m, AssertionPrecision); + combined.SumOfRatesForRarity(5).Should().BeApproximately(0.04m, AssertionPrecision); normalRates - .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Chara }) - .Sum(x => x.Rate) + .SumOfRatesForRarityAndType(4, EntityTypes.Chara) .Should() .BeApproximately(0.5130m, AssertionPrecision); normalRates - .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Dragon }) - .Sum(x => x.Rate) + .SumOfRatesForRarityAndType(4, EntityTypes.Dragon) .Should() .BeApproximately(0.4470m, AssertionPrecision); @@ -1032,13 +1144,42 @@ public async Task GetGuaranteeUnitRates_ThreeStarPickup_ProducesExpectedRates() } [Fact] - public async Task GetGuaranteeUnitRates_ThreeStarPickup_Gala_ProducesExpectedRates() + public void GetGuaranteeUnitRates_ThreeStarPickup_Pity_ProducesExpectedRates() { - this.optionsMonitor.CurrentValue.Returns( - new SummonBannerOptions() - { - Banners = - [ + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + SummonOddsLogic.GetGuaranteeRates( + InitializeBanner( + new Banner() + { + Id = 1, + PickupCharas = [Charas.Joe], + PickupDragons = [Dragons.PallidImp], + } + ), + numSummonsSinceLastFiveStar: 10 + ); + + List pickupRates = rates.PickupRates.ToList(); + List normalRates = rates.NormalRates.ToList(); + List combined = [.. pickupRates, .. normalRates]; + + combined.SumOfRates().Should().BeApproximately(1m, AssertionPrecision); + combined.Should().OnlyHaveUniqueItems(x => new { x.Id, x.EntityType }); + + pickupRates.Should().BeEmpty(); + + combined.SumOfRatesForRarity(5).Should().BeApproximately(0.045m, AssertionPrecision); + combined.SumOfRatesForRarity(4).Should().BeApproximately(0.955m, AssertionPrecision); + + combined.Should().NotContain(x => x.Rarity == 3); + } + + [Fact] + public void GetGuaranteeUnitRates_ThreeStarPickup_Gala_ProducesExpectedRates() + { + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + SummonOddsLogic.GetGuaranteeRates( + InitializeBanner( new Banner() { Id = 1, @@ -1046,19 +1187,16 @@ public async Task GetGuaranteeUnitRates_ThreeStarPickup_Gala_ProducesExpectedRat PickupCharas = [Charas.Joe, Charas.GalaZethia], PickupDragons = [Dragons.PallidImp, Dragons.GalaRebornJeanne], } - ] - } - ); - - (IEnumerable PickupRates, IEnumerable NormalRates) rates = - await this.summonOddsService.GetGuaranteeUnitRates(1); + ), + numSummonsSinceLastFiveStar: 0 + ); List pickupRates = rates.PickupRates.ToList(); List normalRates = rates.NormalRates.ToList(); List combined = [.. pickupRates, .. normalRates]; - // Similar precision issues here. - combined.Sum(x => x.Rate).Should().BeApproximately(1m, AssertionPrecision * 10); + // Even worse! + combined.SumOfRates().Should().BeApproximately(1m, AssertionPrecision * 100); combined.Should().OnlyHaveUniqueItems(x => new { x.Id, x.EntityType }); pickupRates @@ -1070,23 +1208,51 @@ public async Task GetGuaranteeUnitRates_ThreeStarPickup_Gala_ProducesExpectedRat ] ); - combined - .Where(x => x.Rarity == 5) - .Sum(x => x.Rate) - .Should() - .BeApproximately(0.06m, AssertionPrecision); + combined.SumOfRatesForRarity(5).Should().BeApproximately(0.06m, AssertionPrecision); normalRates - .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Chara }) - .Sum(x => x.Rate) + .SumOfRatesForRarityAndType(4, EntityTypes.Chara) .Should() .BeApproximately(0.5023m, AssertionPrecision); normalRates - .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Dragon }) - .Sum(x => x.Rate) + .SumOfRatesForRarityAndType(4, EntityTypes.Dragon) .Should() .BeApproximately(0.4376m, AssertionPrecision); combined.Should().NotContain(x => x.Rarity == 3); } + + [Theory] + [InlineData(0, 10)] + [InlineData(9, 1)] + [InlineData(10, 10)] + [InlineData(12, 8)] + [InlineData(15, 5)] + public void GetSummonCountToPityIncrease_ReturnsExpectedResult(int count, int expected) + { + SummonOddsLogic.GetSummonCountToPityIncrease(count).Should().Be(expected); + } + + private static Banner InitializeBanner(Banner banner) + { + banner.PostConfigure(); + return banner; + } +} + +file static class Extensions +{ + private static IEnumerable OfRarity(this IEnumerable rates, int rarity) => + rates.Where(x => x.Rarity == rarity); + + public static decimal SumOfRates(this IEnumerable rates) => rates.Sum(x => x.Rate); + + public static decimal SumOfRatesForRarity(this IEnumerable rates, int rarity) => + rates.OfRarity(rarity).SumOfRates(); + + public static decimal SumOfRatesForRarityAndType( + this IEnumerable rates, + int rarity, + EntityTypes type + ) => rates.OfRarity(rarity).Where(x => x.EntityType == type).SumOfRates(); } diff --git a/DragaliaAPI/DragaliaAPI/Features/Summoning/RedoableSummonController.cs b/DragaliaAPI/DragaliaAPI/Features/Summoning/RedoableSummonController.cs index 7ab212c34..2ec2b6643 100644 --- a/DragaliaAPI/DragaliaAPI/Features/Summoning/RedoableSummonController.cs +++ b/DragaliaAPI/DragaliaAPI/Features/Summoning/RedoableSummonController.cs @@ -32,13 +32,18 @@ public static string SessionId_CachedSummonResult(string sessionId) => [HttpPost] [Route("get_data")] - public async Task> GetData() + public DragaliaResult GetData() { - OddsRate normalOddsRate = await summonOddsService.GetNormalOddsRate( - SummonConstants.RedoableSummonBannerId + // The reroll banner does not have a pity mechanic. + const int summonCountSinceLastFiveStar = 0; + + OddsRate normalOddsRate = summonOddsService.GetNormalOddsRate( + SummonConstants.RedoableSummonBannerId, + summonCountSinceLastFiveStar ); - OddsRate? guaranteeRate = await summonOddsService.GetGuaranteeOddsRate( - SummonConstants.RedoableSummonBannerId + OddsRate? guaranteeRate = summonOddsService.GetGuaranteeOddsRate( + SummonConstants.RedoableSummonBannerId, + summonCountSinceLastFiveStar ); return new RedoableSummonGetDataResponse() diff --git a/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonBannerOptions.cs b/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonBannerOptions.cs index ddc95e7ae..b26b261f3 100644 --- a/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonBannerOptions.cs +++ b/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonBannerOptions.cs @@ -3,6 +3,7 @@ using DragaliaAPI.Shared.Definitions.Enums; using DragaliaAPI.Shared.Definitions.Enums.Summon; using DragaliaAPI.Shared.MasterAsset; +using DragaliaAPI.Shared.MasterAsset.Models; using DragaliaAPI.Shared.MasterAsset.Models.Summon; namespace DragaliaAPI.Features.Summoning; @@ -42,6 +43,9 @@ public class Banner { private SummonTypes summonType; + private List? pickupCharaData = null; + private List? pickupDragonData = null; + /// /// Gets the ID of the banner, as well as its associated summon point trade. /// @@ -72,11 +76,25 @@ public class Banner /// public IReadOnlyList PickupCharas { get; init; } = []; + /// + /// Gets a list of objects for the rate up adventurers. + /// + /// has not yet been called. + public IReadOnlyList PickupCharaData => + this.pickupCharaData ?? throw new InvalidOperationException("Banner not yet initialized!"); + /// /// Gets a list of dragons on rate up. /// public IReadOnlyList PickupDragons { get; init; } = []; + /// + /// Gets a list of objects for the rate up dragons. + /// + /// has not yet been called. + public IReadOnlyList PickupDragonData => + this.pickupDragonData ?? throw new InvalidOperationException("Banner not yet initialized!"); + /// /// Gets a list of limited characters that are available, but not on rate up. /// @@ -171,5 +189,8 @@ public void PostConfigure() { this.summonType = summonData.SummonType; } + + this.pickupCharaData = this.PickupCharas.Select(MasterAsset.CharaData.Get).ToList(); + this.pickupDragonData = this.PickupDragons.Select(MasterAsset.DragonData.Get).ToList(); } } diff --git a/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonController.cs b/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonController.cs index ef85e959c..7146aff3f 100644 --- a/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonController.cs +++ b/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonController.cs @@ -47,14 +47,27 @@ public async Task> GetOddsData( SummonGetOddsDataRequest request ) { - OddsRate baseOddsRate = await summonOddsService.GetNormalOddsRate(request.SummonId); - OddsRate? guaranteeOddsRate = await summonOddsService.GetGuaranteeOddsRate( + int summonCountSinceLastFiveStar = await summonOddsService.GetSummonCountSinceLastFiveStar( request.SummonId ); + OddsRate baseOddsRate = summonOddsService.GetNormalOddsRate( + request.SummonId, + summonCountSinceLastFiveStar + ); + + OddsRate? guaranteeOddsRate = summonOddsService.GetGuaranteeOddsRate( + request.SummonId, + summonCountSinceLastFiveStar + ); + + int requiredCountToNext = SummonOddsLogic.GetSummonCountToPityIncrease( + summonCountSinceLastFiveStar + ); + return new SummonGetOddsDataResponse( - new OddsRateList(int.MaxValue, baseOddsRate, guaranteeOddsRate), - new(null, null) + new OddsRateList(requiredCountToNext, baseOddsRate, guaranteeOddsRate), + new SummonPrizeOddsRateList(null, null) ); } diff --git a/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonOddsLogic.cs b/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonOddsLogic.cs new file mode 100644 index 000000000..5fd89bd43 --- /dev/null +++ b/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonOddsLogic.cs @@ -0,0 +1,586 @@ +using System.Diagnostics; +using DragaliaAPI.Shared.Definitions.Enums; +using DragaliaAPI.Shared.Features.Summoning; +using DragaliaAPI.Shared.MasterAsset; +using DragaliaAPI.Shared.MasterAsset.Models; + +namespace DragaliaAPI.Features.Summoning; + +/* Algorithm sourced from https://dragalialost.wiki/w/Summoning#Rarity_Distribution */ + +using UnitRateCollection = (IEnumerable PickupRates, IEnumerable NormalRates); + +public static class SummonOddsLogic +{ + public static UnitRateCollection GetRates(Banner banner, int numSummonsSinceLastFiveStar) + { + if (banner.OverrideCharaPool is not null) + { + return new UnitRateCollection + { + NormalRates = banner.OverrideCharaPool.Select(x => new UnitRate( + x, + 1m / banner.OverrideCharaPool.Count + )), + PickupRates = [], + }; + } + + if (banner.OverrideDragonPool is not null) + { + return new UnitRateCollection + { + NormalRates = banner.OverrideDragonPool.Select(x => new UnitRate( + x, + 1m / banner.OverrideDragonPool.Count + )), + PickupRates = [], + }; + } + + BaseRateData normalRates = GetBaseRates(banner); + BaseRateData pickupRates = GetBasePickupRates(banner); + + (BaseRateData UpdatedNormalRates, BaseRateData UpdatedPickupRates) newRates = ApplyPityRate( + rateData: normalRates, + pickupRateData: pickupRates, + banner: banner, + numSummonsSinceLastFiveStar: numSummonsSinceLastFiveStar + ); + + List charaPool = MasterAsset + .CharaData.Enumerable.Where(x => IsCharaInBannerRegularPool(x, banner)) + .ToList(); + List dragonPool = MasterAsset + .DragonData.Enumerable.Where(x => IsDragonInBannerRegularPool(x, banner)) + .ToList(); + + return ( + PickupRates: DistributeRates( + banner.PickupCharaData, + banner.PickupDragonData, + newRates.UpdatedPickupRates + ), + NormalRates: DistributeRates(charaPool, dragonPool, newRates.UpdatedNormalRates) + ); + } + + public static UnitRateCollection GetGuaranteeRates( + Banner banner, + int numSummonsSinceLastFiveStar + ) + { + if (banner.OverrideCharaPool is not null || banner.OverrideDragonPool is not null) + { + throw new ArgumentException( + "Cannot get guarantee rates of a banner with an overridden pool", + nameof(banner) + ); + } + + BaseRateData normalRates = GetBaseGuaranteeRates(banner); + BaseRateData pickupRates = GetBasePickupGuaranteeRates(banner); + + (BaseRateData UpdatedNormalRates, BaseRateData UpdatedPickupRates) newRates = ApplyPityRate( + normalRates, + pickupRates, + banner, + numSummonsSinceLastFiveStar + ); + + List charaPool = MasterAsset + .CharaData.Enumerable.Where(x => IsCharaInBannerRegularPool(x, banner)) + .Where(x => x.Rarity >= 4) + .ToList(); + List dragonPool = MasterAsset + .DragonData.Enumerable.Where(x => IsDragonInBannerRegularPool(x, banner)) + .Where(x => x.Rarity >= 4) + .ToList(); + + List pickupCharaPool = banner.PickupCharaData.Where(x => x.Rarity >= 4).ToList(); + List pickupDragonPool = banner + .PickupDragonData.Where(x => x.Rarity >= 4) + .ToList(); + + return ( + PickupRates: DistributeRates( + pickupCharaPool, + pickupDragonPool, + newRates.UpdatedPickupRates + ), + NormalRates: DistributeRates(charaPool, dragonPool, newRates.UpdatedNormalRates) + ); + } + + public static int GetSummonCountToPityIncrease(int numSummonsSinceLastFiveStar) => + 10 - (numSummonsSinceLastFiveStar % 10); + + private static BaseRateData GetBaseRates(Banner banner) + { + decimal fiveStarRate = banner.IsGala ? 0.06m : 0.04m; + + fiveStarRate -= banner.PickupCharaData.Count(x => x.Rarity == 5) * 0.005m; + fiveStarRate -= banner.PickupDragonData.Count(x => x.Rarity == 5) * 0.008m; + + decimal fourStarAdvRate; + decimal fourStarDragonRate; + + bool anyFourStarPickup = + banner.PickupCharaData.Any(x => x.Rarity == 4) + || banner.PickupDragonData.Any(x => x.Rarity == 4); + + if (anyFourStarPickup) + { + fourStarAdvRate = 0.045m; + fourStarDragonRate = 0.045m; + } + else + { + fourStarAdvRate = 0.0855m; + fourStarDragonRate = 0.0745m; + } + + int threeStarPickupCount = + banner.PickupCharaData.Count(x => x.Rarity == 3) + + banner.PickupDragonData.Count(x => x.Rarity == 3); + + decimal threeStarAdvRate; + decimal threeStarDragonRate; + + if (threeStarPickupCount > 0) + { + decimal threeStarRate = banner.IsGala ? 0.78m : 0.8m; + threeStarRate -= threeStarPickupCount * 0.04m; + + threeStarAdvRate = threeStarRate / 2m; + threeStarDragonRate = threeStarRate / 2m; + } + else + { + threeStarAdvRate = banner.IsGala ? 0.47m : 0.48m; + threeStarDragonRate = banner.IsGala ? 0.31m : 0.32m; + } + + return new() + { + FiveStarAdvRate = fiveStarRate / 2, + FiveStarDragonRate = fiveStarRate / 2, + FourStarAdvRate = fourStarAdvRate, + FourStarDragonRate = fourStarDragonRate, + ThreeStarAdvRate = threeStarAdvRate, + ThreeStarDragonRate = threeStarDragonRate + }; + } + + private static BaseRateData GetBasePickupRates(Banner banner) + { + List pickupCharaData = banner + .PickupCharas.Select(x => MasterAsset.CharaData[x]) + .ToList(); + List pickupDragonData = banner + .PickupDragons.Select(x => MasterAsset.DragonData[x]) + .ToList(); + + decimal fiveStarAdvRate = 0, + fiveStarDragonRate = 0, + fourStarAdvRate = 0, + fourStarDragonRate = 0, + threeStarAdvRate = 0, + threeStarDragonRate = 0; + + PoolSizeData advPoolMetadata = GetPoolSizeByRarity(pickupCharaData); + PoolSizeData dragonPoolMetadata = GetPoolSizeByRarity(pickupDragonData); + + if (advPoolMetadata.FiveStarPoolSize > 0) + { + fiveStarAdvRate = 0.005m * advPoolMetadata.FiveStarPoolSize; + } + if (dragonPoolMetadata.FiveStarPoolSize > 0) + { + fiveStarDragonRate = 0.008m * dragonPoolMetadata.FiveStarPoolSize; + } + + switch (advPoolMetadata.FourStarPoolSize, dragonPoolMetadata.FourStarPoolSize) + { + case (> 0, > 0): + { + fourStarAdvRate = 0.035m; + fourStarDragonRate = 0.035m; + break; + } + case (> 0, 0): + { + fourStarAdvRate = 0.07m; + break; + } + case (0, > 0): + { + fourStarDragonRate = 0.07m; + break; + } + case (0, 0): + { + break; + } + } + + if (advPoolMetadata.ThreeStarPoolSize > 0) + { + threeStarAdvRate = 0.04m * advPoolMetadata.ThreeStarPoolSize; + } + if (dragonPoolMetadata.ThreeStarPoolSize > 0) + { + threeStarDragonRate = 0.04m * dragonPoolMetadata.ThreeStarPoolSize; + } + + return new BaseRateData( + FiveStarAdvRate: fiveStarAdvRate, + FiveStarDragonRate: fiveStarDragonRate, + FourStarAdvRate: fourStarAdvRate, + FourStarDragonRate: fourStarDragonRate, + ThreeStarAdvRate: threeStarAdvRate, + ThreeStarDragonRate: threeStarDragonRate + ); + } + + private static BaseRateData GetBaseGuaranteeRates(Banner banner) + { + decimal fiveStarRate = banner.IsGala ? 0.06m : 0.04m; + + fiveStarRate -= banner.PickupCharaData.Count(x => x.Rarity == 5) * 0.005m; + fiveStarRate -= banner.PickupDragonData.Count(x => x.Rarity == 5) * 0.008m; + + decimal fourStarAdvRate; + decimal fourStarDragonRate; + + bool anyFourStarPickup = + banner.PickupCharaData.Any(x => x.Rarity == 4) + || banner.PickupDragonData.Any(x => x.Rarity == 4); + + if (anyFourStarPickup) + { + fourStarAdvRate = banner.IsGala ? 0.264375m : 0.27m; + fourStarDragonRate = banner.IsGala ? 0.264375m : 0.27m; + } + else + { + fourStarAdvRate = banner.IsGala ? 0.5023m : 0.5130m; + fourStarDragonRate = banner.IsGala ? 0.4376m : 0.4470m; + } + + return new() + { + FiveStarAdvRate = fiveStarRate / 2, + FiveStarDragonRate = fiveStarRate / 2, + FourStarAdvRate = fourStarAdvRate, + FourStarDragonRate = fourStarDragonRate, + ThreeStarAdvRate = 0m, + ThreeStarDragonRate = 0m + }; + } + + private static BaseRateData GetBasePickupGuaranteeRates(Banner banner) + { + BaseRateData rateData = GetBasePickupRates(banner); + + int fourStarAdvCount = banner.PickupCharaData.Count(x => x.Rarity == 4); + int fourStarDragonCount = banner.PickupDragonData.Count(x => x.Rarity == 4); + + decimal fourStarAdvRate = 0, + fourStarDragonRate = 0; + + decimal possibleFourStarRate = banner.IsGala ? 0.41125m : 0.42m; + + switch (fourStarAdvCount, fourStarDragonCount) + { + case (> 0, > 0): + { + fourStarAdvRate = possibleFourStarRate / 2; + fourStarDragonRate = possibleFourStarRate / 2; + break; + } + case (> 0, 0): + { + fourStarAdvRate = possibleFourStarRate; + break; + } + case (0, > 0): + { + fourStarDragonRate = possibleFourStarRate; + break; + } + + case (0, 0): + { + break; + } + } + + return rateData with + { + FourStarAdvRate = fourStarAdvRate, + FourStarDragonRate = fourStarDragonRate, + ThreeStarAdvRate = 0, + ThreeStarDragonRate = 0, + }; + } + + private static IEnumerable DistributeRates( + IReadOnlyList charaPool, + IReadOnlyList dragonPool, + BaseRateData rateData + ) + { + PoolSizeData charaPoolData = GetPoolSizeByRarity(charaPool); + PoolSizeData dragonPoolData = GetPoolSizeByRarity(dragonPool); + + foreach (CharaData chara in charaPool) + { + decimal rate = chara.Rarity switch + { + 5 => rateData.FiveStarAdvRate / charaPoolData.FiveStarPoolSize, + 4 => rateData.FourStarAdvRate / charaPoolData.FourStarPoolSize, + 3 => rateData.ThreeStarAdvRate / charaPoolData.ThreeStarPoolSize, + _ + => throw new UnreachableException( + $"Invalid rarity {chara.Rarity} for character {chara.Id}" + ) + }; + + yield return new UnitRate(chara.Id, rate); + } + + foreach (DragonData dragon in dragonPool) + { + decimal rate = dragon.Rarity switch + { + 5 => rateData.FiveStarDragonRate / dragonPoolData.FiveStarPoolSize, + 4 => rateData.FourStarDragonRate / dragonPoolData.FourStarPoolSize, + 3 => rateData.ThreeStarDragonRate / dragonPoolData.ThreeStarPoolSize, + _ + => throw new UnreachableException( + $"Invalid rarity {dragon.Rarity} for dragon {dragon.Id}" + ) + }; + + yield return new UnitRate(dragon.Id, rate); + } + } + + private static PoolSizeData GetPoolSizeByRarity(IEnumerable pool) + { + int fiveStarPoolSize = 0; + int fourStarPoolSize = 0; + int threeStarPoolSize = 0; + + foreach (IUnitData data in pool) + { + switch (data.Rarity) + { + case 5: + fiveStarPoolSize++; + break; + case 4: + fourStarPoolSize++; + break; + case 3: + threeStarPoolSize++; + break; + } + } + + return new(fiveStarPoolSize, fourStarPoolSize, threeStarPoolSize); + } + + private static bool IsCharaInBannerRegularPool(CharaData charaData, Banner banner) + { + if (!charaData.IsPlayable) + { + return false; + } + + if (banner.PickupCharas.Contains(charaData.Id)) + { + // They are in the pickup pool instead. + return false; + } + + UnitAvailability availability = charaData.GetAvailability(); + + return availability switch + { + UnitAvailability.Permanent => true, + UnitAvailability.Gala => banner.IsGala, + UnitAvailability.Limited => banner.LimitedCharas.Contains(charaData.Id), + _ => false + }; + } + + private static bool IsDragonInBannerRegularPool(DragonData dragonData, Banner banner) + { + if (!dragonData.IsPlayable) + { + return false; + } + + if (banner.PickupDragons.Contains(dragonData.Id)) + { + return false; + } + + UnitAvailability availability = dragonData.GetAvailability(); + + return availability switch + { + UnitAvailability.Permanent => true, + UnitAvailability.Gala => banner.IsGala, + UnitAvailability.Limited => banner.LimitedDragons.Contains(dragonData.Id), + _ => false + }; + } + + private static (BaseRateData UpdatedNormalRates, BaseRateData UpdatedPickupRates) ApplyPityRate( + BaseRateData rateData, + BaseRateData pickupRateData, + Banner banner, + int numSummonsSinceLastFiveStar + ) + { + int pityAccumulations = numSummonsSinceLastFiveStar / 10; + + if (pityAccumulations == 0) + { + return (rateData, pickupRateData); + } + + decimal fiveStarIncrease = pityAccumulations * 0.005m; + decimal originalFiveStarRate = banner.IsGala ? 0.06m : 0.04m; + + BaseRateData newRateData; + BaseRateData newPickupRateData; + + switch + ( + banner.PickupCharaData.Any(x => x.Rarity == 5), + banner.PickupDragonData.Any(x => x.Rarity == 5) + ) + + { + case (true, true): + { + newPickupRateData = pickupRateData with + { + FiveStarAdvRate = pickupRateData.FiveStarAdvRate + (fiveStarIncrease / 2), + FiveStarDragonRate = pickupRateData.FiveStarDragonRate + (fiveStarIncrease / 2), + }; + newRateData = rateData; + break; + } + case (true, false): + { + decimal pickupCharaProportion = Math.Round( + pickupRateData.FiveStarAdvRate / originalFiveStarRate, + 2, + MidpointRounding.ToNegativeInfinity + ); + decimal regularProportion = 1 - pickupCharaProportion; + + newPickupRateData = pickupRateData with + { + FiveStarAdvRate = + pickupRateData.FiveStarAdvRate + (pickupCharaProportion * fiveStarIncrease) + }; + + // Can't work out how the remaining increase is split between characters and dragons. + // This 1/5ths and 4/5ths split is purely empirical. + newRateData = rateData with + { + FiveStarAdvRate = + rateData.FiveStarAdvRate + (regularProportion * fiveStarIncrease * 0.2m), + FiveStarDragonRate = + rateData.FiveStarDragonRate + (regularProportion * fiveStarIncrease * 0.8m) + }; + break; + } + case (false, true): + { + decimal pickupDragonProportion = Math.Round( + pickupRateData.FiveStarDragonRate / originalFiveStarRate, + 2, + MidpointRounding.ToNegativeInfinity + ); + decimal regularProportion = 1 - pickupDragonProportion; + + newPickupRateData = pickupRateData with + { + FiveStarDragonRate = + pickupRateData.FiveStarDragonRate + + (pickupDragonProportion * fiveStarIncrease) + }; + + // Same as above empirical distribution, but inverted + newRateData = rateData with + { + FiveStarAdvRate = + rateData.FiveStarAdvRate + (regularProportion * fiveStarIncrease * 0.8m), + FiveStarDragonRate = + rateData.FiveStarDragonRate + (regularProportion * fiveStarIncrease * 0.2m) + }; + break; + } + default: + { + newPickupRateData = pickupRateData; + newRateData = rateData with + { + FiveStarAdvRate = rateData.FiveStarAdvRate + (fiveStarIncrease / 2), + FiveStarDragonRate = rateData.FiveStarDragonRate + (fiveStarIncrease / 2) + }; + break; + } + } + + if (newRateData is { ThreeStarAdvRate: 0, ThreeStarDragonRate: 0 }) + { + // This is a guarantee rate, we should make room for the increase by subtracting from 4-star rates. + + // Subtract from the non-pickup rates here - not sure if the original game would distribute the + // decrease among the pickup rates within lower rarities, as I don't have the data, but it seems + // logical to take away from the 'least desirable' outcomes which would be non-pickup. + newRateData = newRateData with + { + FourStarAdvRate = newRateData.FourStarAdvRate - (fiveStarIncrease / 2), + FourStarDragonRate = newRateData.FourStarDragonRate - (fiveStarIncrease / 2), + }; + } + else if (newRateData is { ThreeStarAdvRate: > 0, ThreeStarDragonRate: > 0 }) + { + newRateData = newRateData with + { + ThreeStarAdvRate = newRateData.ThreeStarAdvRate - (fiveStarIncrease / 2), + ThreeStarDragonRate = newRateData.ThreeStarDragonRate - (fiveStarIncrease / 2), + }; + } + else + { + throw new UnreachableException("Could not determine how to subtract pity rate"); + } + + return (newRateData, newPickupRateData); + } + + private readonly record struct PoolSizeData( + int FiveStarPoolSize, + int FourStarPoolSize, + int ThreeStarPoolSize + ); + + private readonly record struct BaseRateData( + decimal FiveStarAdvRate, + decimal FiveStarDragonRate, + decimal FourStarAdvRate, + decimal FourStarDragonRate, + decimal ThreeStarAdvRate, + decimal ThreeStarDragonRate + ); +} diff --git a/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonOddsService.cs b/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonOddsService.cs index 115d48273..c20c740c0 100644 --- a/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonOddsService.cs +++ b/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonOddsService.cs @@ -1,4 +1,6 @@ using System.Diagnostics; +using DragaliaAPI.Database; +using DragaliaAPI.Database.Entities; using DragaliaAPI.Models.Generated; using DragaliaAPI.Services.Exceptions; using DragaliaAPI.Shared.Definitions.Enums; @@ -6,83 +8,48 @@ using DragaliaAPI.Shared.Features.Summoning; using DragaliaAPI.Shared.MasterAsset; using DragaliaAPI.Shared.MasterAsset.Models; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; namespace DragaliaAPI.Features.Summoning; -using RateData = (IEnumerable PickupRates, IEnumerable NormalRates); +using UnitRateCollection = (IEnumerable PickupRates, IEnumerable NormalRates); -/* Algorithm sourced from https://dragalialost.wiki/w/Summoning#Rarity_Distribution */ - -public class SummonOddsService(IOptionsMonitor optionsMonitor) +public class SummonOddsService( + IOptionsMonitor optionsMonitor, + ApiContext apiContext +) { - public Task GetUnitRates(int bannerId) + public OddsRate GetNormalOddsRate(int bannerId, int summonCountSinceLastFiveStar) { - Banner? banner = optionsMonitor.CurrentValue.Banners.SingleOrDefault(x => x.Id == bannerId); + UnitRateCollection rates = this.GetUnitRates(bannerId, summonCountSinceLastFiveStar); - if (banner is null) - { - throw new DragaliaException( - ResultCode.CommonInvalidArgument, - $"Banner ID {bannerId} was not found" - ); - } - - if (banner.OverrideCharaPool is not null) - { - return Task.FromResult( - new RateData - { - NormalRates = banner.OverrideCharaPool.Select(x => new UnitRate( - x, - 1m / banner.OverrideCharaPool.Count - )), - PickupRates = [], - } - ); - } + return BuildOddsRate(rates, [5, 4, 3]); + } - if (banner.OverrideDragonPool is not null) + public OddsRate? GetGuaranteeOddsRate(int bannerId, int summonCountSinceLastFiveStar) + { + if ( + optionsMonitor.CurrentValue.Banners.FirstOrDefault(x => x.Id == bannerId) is + { SummonType: not SummonTypes.Normal } + ) { - return Task.FromResult( - new RateData - { - NormalRates = banner.OverrideDragonPool.Select(x => new UnitRate( - x, - 1m / banner.OverrideDragonPool.Count - )), - PickupRates = [], - } - ); + // Certain special banners, like the 5* summon voucher ones, have no concept + // of a guarantee rate -- because you can't execute a tenfold on them. + // We must return null for these, otherwise the guarantee tab appears in + // Japanese text. + return null; } - List charaPool = Enum.GetValues() - .Where(x => IsCharaInBannerRegularPool(x, banner)) - .Select(x => MasterAsset.CharaData[x]) - .ToList(); - List dragonPool = Enum.GetValues() - .Where(x => IsDragonInBannerRegularPool(x, banner)) - .Select(x => MasterAsset.DragonData[x]) - .ToList(); - - List pickupCharaPool = banner - .PickupCharas.Select(x => MasterAsset.CharaData[x]) - .ToList(); - List pickupDragonPool = banner - .PickupDragons.Select(x => MasterAsset.DragonData[x]) - .ToList(); - - BaseRateData rateData = GetBaseRates(pickupCharaPool, pickupDragonPool, banner.IsGala); - - return Task.FromResult( - ( - GetPickupUnitRarities(pickupCharaPool, pickupDragonPool), - GetUnitRarities(charaPool, dragonPool, rateData) - ) + UnitRateCollection rates = this.GetGuaranteeUnitRates( + bannerId, + summonCountSinceLastFiveStar ); + + return BuildOddsRate(rates, [5, 4]); } - public Task GetGuaranteeUnitRates(int bannerId) + public UnitRateCollection GetUnitRates(int bannerId, int summonCountSinceLastFiveStar) { Banner? banner = optionsMonitor.CurrentValue.Banners.SingleOrDefault(x => x.Id == bannerId); @@ -94,126 +61,45 @@ public Task GetGuaranteeUnitRates(int bannerId) ); } - List charaPool = Enum.GetValues() - .Where(x => IsCharaInBannerRegularPool(x, banner)) - .Select(x => MasterAsset.CharaData[x]) - .Where(x => x.Rarity >= 4) - .ToList(); - List dragonPool = Enum.GetValues() - .Where(x => IsDragonInBannerRegularPool(x, banner)) - .Select(x => MasterAsset.DragonData[x]) - .Where(x => x.Rarity >= 4) - .ToList(); - - List pickupCharaPool = banner - .PickupCharas.Select(x => MasterAsset.CharaData[x]) - .Where(x => x.Rarity >= 4) - .ToList(); - List pickupDragonPool = banner - .PickupDragons.Select(x => MasterAsset.DragonData[x]) - .Where(x => x.Rarity >= 4) - .ToList(); - - BaseRateData rateData = GetGuaranteeBaseRates( - pickupCharaPool, - pickupDragonPool, - banner.IsGala - ); - - return Task.FromResult( - ( - GetGuaranteePickupUnitRarities(pickupCharaPool, pickupDragonPool, banner.IsGala), - GetUnitRarities(charaPool, dragonPool, rateData) - ) - ); + return SummonOddsLogic.GetRates(banner, summonCountSinceLastFiveStar); } - public async Task GetNormalOddsRate(int bannerId) + public UnitRateCollection GetGuaranteeUnitRates(int bannerId, int summonCountSinceLastFiveStar) { - RateData rates = await this.GetUnitRates(bannerId); - - // TODO: Factor in pity rate - Dictionary pickupRarityLists = - new() - { - [5] = new() { Rarity = 5, Pickup = true, }, - [4] = new() { Rarity = 4, Pickup = true, }, - [3] = new() { Rarity = 3, Pickup = true, }, - }; - - Dictionary rarityLists = - new() - { - [5] = new() { Rarity = 5, }, - [4] = new() { Rarity = 4, }, - [3] = new() { Rarity = 3, }, - }; - - foreach (UnitRate rate in rates.PickupRates) - PopulateRarityDict(rate, pickupRarityLists); - - foreach (UnitRate rate in rates.NormalRates) - PopulateRarityDict(rate, rarityLists); - - List combined = - [ - .. pickupRarityLists.Values.Where(x => !x.IsEmpty), - .. rarityLists.Values - ]; - - return new OddsRate() - { - RarityList = combined - .GroupBy(x => x.Rarity) - .Select(x => new AtgenRarityList() - { - Rarity = x.Key, - TotalRate = x.Sum(y => y.CharaRate + y.DragonRate).ToPercentageString2Dp() - }), - RarityGroupList = combined.Select(x => x.ToRarityGroupList()), - Unit = new() - { - CharaOddsList = combined.Select(x => x.ToAdvOddsUnitDetail()), - DragonOddsList = combined.Select(x => x.ToDragonOddsUnitDetail()), - } - }; - } + Banner? banner = optionsMonitor.CurrentValue.Banners.SingleOrDefault(x => x.Id == bannerId); - public async Task GetGuaranteeOddsRate(int bannerId) - { - if ( - optionsMonitor.CurrentValue.Banners.First(x => x.Id == bannerId).SummonType - != SummonTypes.Normal - ) + if (banner is null) { - // Certain special banners, like the 5* summon voucher ones, have no concept - // of a guarantee rate -- because you can't execute a tenfold on them. - // We must return null for these, otherwise the guarantee tab appears in - // Japanese text. - return null; + throw new DragaliaException( + ResultCode.CommonInvalidArgument, + $"Banner ID {bannerId} was not found" + ); } - RateData rates = await this.GetGuaranteeUnitRates(bannerId); + return SummonOddsLogic.GetGuaranteeRates(banner, summonCountSinceLastFiveStar); + } - Dictionary pickupRarityLists = - new() - { - [5] = new() { Rarity = 5, Pickup = true, }, - [4] = new() { Rarity = 4, Pickup = true, }, - }; + private static OddsRate BuildOddsRate(UnitRateCollection rates, int[] rarities) + { + Dictionary pickupRarityLists = rarities.ToDictionary( + x => x, + x => new RarityList() { Rarity = x, Pickup = true } + ); - Dictionary rarityLists = - new() - { - [5] = new() { Rarity = 5, }, - [4] = new() { Rarity = 4, }, - }; + Dictionary rarityLists = rarities.ToDictionary( + x => x, + x => new RarityList() { Rarity = x } + ); foreach (UnitRate rate in rates.PickupRates) + { PopulateRarityDict(rate, pickupRarityLists); + } foreach (UnitRate rate in rates.NormalRates) + { PopulateRarityDict(rate, rarityLists); + } List combined = [ @@ -237,321 +123,34 @@ .. rarityLists.Values DragonOddsList = combined.Select(x => x.ToDragonOddsUnitDetail()), } }; - } - private static void PopulateRarityDict(UnitRate rate, Dictionary dict) - { - RarityList list = dict[rate.Rarity]; - - if (rate.EntityType == EntityTypes.Chara) - { - list.CharaList.Add(rate); - list.CharaRate += rate.Rate; - } - else if (rate.EntityType == EntityTypes.Dragon) - { - list.DragonList.Add(rate); - list.DragonRate += rate.Rate; - } - else + static void PopulateRarityDict(UnitRate rate, Dictionary dict) { - throw new UnreachableException($"Invalid rarity entity type {rate.EntityType}"); - } - } - - private static IEnumerable GetPickupUnitRarities( - List pickupCharaPool, - List pickupDragonPool - ) - { - int totalPickupFourStar = - pickupCharaPool.Count(x => x.Rarity == 4) + pickupDragonPool.Count(x => x.Rarity == 4); - - foreach (CharaData data in pickupCharaPool) - { - decimal rate = data.Rarity switch - { - 5 => 0.005m, - 4 => 0.07m / totalPickupFourStar, - 3 => 0.04m, - _ - => throw new UnreachableException( - $"Invalid rarity {data.Rarity} for character {data.Id}" - ) - }; - - yield return new UnitRate(data.Id, rate); - } - - foreach (DragonData data in pickupDragonPool) - { - decimal rate = data.Rarity switch - { - 5 => 0.008m, - 4 => 0.07m / totalPickupFourStar, - 3 => 0.04m, - _ - => throw new UnreachableException( - $"Invalid rarity {data.Rarity} for dragon {data.Id}" - ) - }; - - yield return new UnitRate(data.Id, rate); - } - } + RarityList list = dict[rate.Rarity]; - private static IEnumerable GetGuaranteePickupUnitRarities( - List pickupCharaPool, - List pickupDragonPool, - bool isGala - ) - { - int totalPickupFourStar = - pickupCharaPool.Count(x => x.Rarity == 4) + pickupDragonPool.Count(x => x.Rarity == 4); - decimal fourStarPickupRate = isGala ? 0.41125m : 0.42m; - - foreach (CharaData data in pickupCharaPool) - { - decimal rate = data.Rarity switch - { - 5 => 0.005m, - 4 => fourStarPickupRate / totalPickupFourStar, - _ - => throw new UnreachableException( - $"Invalid guarantee rarity {data.Rarity} for character {data.Id}" - ) - }; - - yield return new UnitRate(data.Id, rate); - } - - foreach (DragonData data in pickupDragonPool) - { - decimal rate = data.Rarity switch + if (rate.EntityType == EntityTypes.Chara) { - 5 => 0.008m, - 4 => fourStarPickupRate / totalPickupFourStar, - _ - => throw new UnreachableException( - $"Invalid guarantee rarity {data.Rarity} for dragon {data.Id}" - ) - }; - - yield return new UnitRate(data.Id, rate); - } - } - - private static IEnumerable GetUnitRarities( - List charaPool, - List dragonPool, - BaseRateData rateData - ) - { - PoolSizeData charaPoolData = GetPoolSizeByRarity(charaPool); - PoolSizeData dragonPoolData = GetPoolSizeByRarity(dragonPool); - - foreach (CharaData chara in charaPool) - { - decimal rate = chara.Rarity switch - { - 5 => rateData.FiveStarAdvRate / charaPoolData.FiveStarPoolSize, - 4 => rateData.FourStarAdvRate / charaPoolData.FourStarPoolSize, - 3 => rateData.ThreeStarAdvRate / charaPoolData.ThreeStarPoolSize, - _ - => throw new UnreachableException( - $"Invalid rarity {chara.Rarity} for character {chara.Id}" - ) - }; - - yield return new UnitRate(chara.Id, rate); - } - - foreach (DragonData dragon in dragonPool) - { - decimal rate = dragon.Rarity switch + list.CharaList.Add(rate); + list.CharaRate += rate.Rate; + } + else if (rate.EntityType == EntityTypes.Dragon) { - 5 => rateData.FiveStarDragonRate / dragonPoolData.FiveStarPoolSize, - 4 => rateData.FourStarDragonRate / dragonPoolData.FourStarPoolSize, - 3 => rateData.ThreeStarDragonRate / dragonPoolData.ThreeStarPoolSize, - _ - => throw new UnreachableException( - $"Invalid rarity {dragon.Rarity} for dragon {dragon.Id}" - ) - }; - - yield return new UnitRate(dragon.Id, rate); - } - } - - private static bool IsCharaInBannerRegularPool(Charas chara, Banner banner) - { - if (banner.PickupCharas.Contains(chara)) - return false; // They are in the pickup pool instead. - - UnitAvailability availability = chara.GetAvailability(); - - return availability switch - { - UnitAvailability.Permanent => true, - UnitAvailability.Gala => banner.IsGala, - UnitAvailability.Limited => banner.LimitedCharas.Contains(chara), - _ => false - }; - } - - private static bool IsDragonInBannerRegularPool(Dragons dragon, Banner banner) - { - if (banner.PickupDragons.Contains(dragon)) - return false; // They are in the pickup pool instead. - - UnitAvailability availability = dragon.GetAvailability(); - - return availability switch - { - UnitAvailability.Permanent => true, - UnitAvailability.Gala => banner.IsGala, - UnitAvailability.Limited => banner.LimitedDragons.Contains(dragon), - _ => false - }; - } - - private static BaseRateData GetBaseRates( - List pickupCharaData, - List pickupDragonData, - bool isGala - ) - { - decimal fiveStarRate = isGala ? 0.06m : 0.04m; - - fiveStarRate -= pickupCharaData.Count(x => x.Rarity == 5) * 0.005m; - fiveStarRate -= pickupDragonData.Count(x => x.Rarity == 5) * 0.008m; - - decimal fourStarAdvRate; - decimal fourStarDragonRate; - - bool anyFourStarPickup = - pickupCharaData.Any(x => x.Rarity == 4) || pickupDragonData.Any(x => x.Rarity == 4); - - if (anyFourStarPickup) - { - fourStarAdvRate = 0.045m; - fourStarDragonRate = 0.045m; - } - else - { - fourStarAdvRate = 0.0855m; - fourStarDragonRate = 0.0745m; - } - - int threeStarPickupCount = - pickupCharaData.Count(x => x.Rarity == 3) + pickupDragonData.Count(x => x.Rarity == 3); - - decimal threeStarAdvRate; - decimal threeStarDragonRate; - - if (threeStarPickupCount > 0) - { - decimal threeStarRate = isGala ? 0.78m : 0.8m; - threeStarRate -= threeStarPickupCount * 0.04m; - - threeStarAdvRate = threeStarRate / 2m; - threeStarDragonRate = threeStarRate / 2m; - } - else - { - threeStarAdvRate = isGala ? 0.47m : 0.48m; - threeStarDragonRate = isGala ? 0.31m : 0.32m; - } - - return new() - { - FiveStarAdvRate = fiveStarRate / 2, - FiveStarDragonRate = fiveStarRate / 2, - FourStarAdvRate = fourStarAdvRate, - FourStarDragonRate = fourStarDragonRate, - ThreeStarAdvRate = threeStarAdvRate, - ThreeStarDragonRate = threeStarDragonRate - }; - } - - private static BaseRateData GetGuaranteeBaseRates( - List pickupCharaData, - List pickupDragonData, - bool isGala - ) - { - decimal fiveStarRate = isGala ? 0.06m : 0.04m; - - fiveStarRate -= pickupCharaData.Count(x => x.Rarity == 5) * 0.005m; - fiveStarRate -= pickupDragonData.Count(x => x.Rarity == 5) * 0.008m; - - decimal fourStarAdvRate; - decimal fourStarDragonRate; - - bool anyFourStarPickup = - pickupCharaData.Any(x => x.Rarity == 4) || pickupDragonData.Any(x => x.Rarity == 4); - - if (anyFourStarPickup) - { - fourStarAdvRate = isGala ? 0.264375m : 0.27m; - fourStarDragonRate = isGala ? 0.264375m : 0.27m; - } - else - { - fourStarAdvRate = isGala ? 0.5023m : 0.5130m; - fourStarDragonRate = isGala ? 0.4376m : 0.4470m; - } - - return new() - { - FiveStarAdvRate = fiveStarRate / 2, - FiveStarDragonRate = fiveStarRate / 2, - FourStarAdvRate = fourStarAdvRate, - FourStarDragonRate = fourStarDragonRate, - ThreeStarAdvRate = 0m, - ThreeStarDragonRate = 0m - }; - } - - private static PoolSizeData GetPoolSizeByRarity(IEnumerable pool) - { - int fiveStarPoolSize = 0; - int fourStarPoolSize = 0; - int threeStarPoolSize = 0; - - foreach (IUnitData data in pool) - { - switch (data.Rarity) + list.DragonList.Add(rate); + list.DragonRate += rate.Rate; + } + else { - case 5: - fiveStarPoolSize++; - break; - case 4: - fourStarPoolSize++; - break; - case 3: - threeStarPoolSize++; - break; + throw new UnreachableException($"Invalid rarity entity type {rate.EntityType}"); } } - - return new(fiveStarPoolSize, fourStarPoolSize, threeStarPoolSize); } - private record struct BaseRateData( - decimal FiveStarAdvRate, - decimal FiveStarDragonRate, - decimal FourStarAdvRate, - decimal FourStarDragonRate, - decimal ThreeStarAdvRate, - decimal ThreeStarDragonRate - ); - - private readonly record struct PoolSizeData( - int FiveStarPoolSize, - int FourStarPoolSize, - int ThreeStarPoolSize - ); + public Task GetSummonCountSinceLastFiveStar(int bannerId) => + // This will return 0 if no banner data is found, which is fine in this case. + apiContext + .PlayerBannerData.Where(x => x.SummonBannerId == bannerId) + .Select(x => x.SummonCountSinceLastFiveStar) + .FirstOrDefaultAsync(); private class RarityList { diff --git a/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonService.cs b/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonService.cs index 0b0a32555..fd2da6f52 100644 --- a/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonService.cs +++ b/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonService.cs @@ -550,8 +550,12 @@ private async Task> GenerateSummonResult int numSummons ) { + int numSummonsSinceLastFiveStar = await summonOddsService.GetSummonCountSinceLastFiveStar( + bannerId + ); + (IEnumerable PickupRates, IEnumerable NormalRates) rateData = - await summonOddsService.GetUnitRates(bannerId); + summonOddsService.GetUnitRates(bannerId, numSummonsSinceLastFiveStar); List allRates = [.. rateData.PickupRates, .. rateData.NormalRates]; @@ -563,7 +567,9 @@ int numSummons List result = new(numSummons); for (int i = 0; i < numSummons; i++) + { result.Add(picker.PickOne()); + } return result; } @@ -573,10 +579,14 @@ private async Task> GenerateTenfoldResul int numTenfolds ) { + int numSummonsSinceLastFiveStar = await summonOddsService.GetSummonCountSinceLastFiveStar( + bannerId + ); + (IEnumerable PickupRates, IEnumerable NormalRates) normalRateData = - await summonOddsService.GetUnitRates(bannerId); + summonOddsService.GetUnitRates(bannerId, numSummonsSinceLastFiveStar); (IEnumerable PickupRates, IEnumerable NormalRates) guaranteeRateData = - await summonOddsService.GetGuaranteeUnitRates(bannerId); + summonOddsService.GetGuaranteeUnitRates(bannerId, numSummonsSinceLastFiveStar); List allNormalRates = [ @@ -604,7 +614,9 @@ .. guaranteeRateData.NormalRates for (int tenfold = 0; tenfold < numTenfolds; tenfold++) { for (int i = 0; i < 9; i++) + { result.Add(normalPicker.PickOne()); + } result.Add(guaranteePicker.PickOne()); }