From 3f55034e15a6ebe1d852fd3a1ab5fe34a789ee91 Mon Sep 17 00:00:00 2001 From: Jay Malhotra <5047192+SapiensAnatis@users.noreply.github.com> Date: Thu, 7 Mar 2024 12:59:28 +0000 Subject: [PATCH] Add summoning odds calculation (#699) Adds the logic to calculate the summoning odds distribution. Initially only used for `/summon/get_odds_data` - i.e. the 'Appearance Rates' menu - however the code has been designed to hopefully be easily reusable for calculating summoning results with FluentRandomPicker. --- .../Entities/DbPlayerCharaData.cs | 3 +- .../Dragalia/SummonTest.cs | 112 +- .../Unit/MasterAssetTest.cs | 14 +- .../Definitions/Enums/CharaAvailability.cs | 7 - .../Definitions/Enums/UnitAvailability.cs | 32 + .../Summoning/UnitAvailabilityExtensions.cs | 231 ++++ .../MasterAsset/Models/CharaData.cs | 24 +- .../MasterAsset/Models/DragonData.cs | 2 +- .../MasterAsset/Models/IUnitData.cs | 6 + .../Features/Summon/SummonOddsServiceTest.cs | 1096 +++++++++++++++++ .../Features/Summoning/Extensions.cs | 16 + .../Features/Summoning/FeatureExtensions.cs | 3 +- .../Features/Summoning/SummonBannerOptions.cs | 12 +- .../Features/Summoning/SummonController.cs | 23 +- .../Features/Summoning/SummonOddsService.cs | 561 +++++++++ .../Features/Summoning/SummonService.cs | 5 +- .../Features/Summoning/UnitRate.cs | 39 + .../DragaliaAPI/Resources/bannerConfig.json | 13 +- 18 files changed, 2131 insertions(+), 68 deletions(-) delete mode 100644 DragaliaAPI/DragaliaAPI.Shared/Definitions/Enums/CharaAvailability.cs create mode 100644 DragaliaAPI/DragaliaAPI.Shared/Definitions/Enums/UnitAvailability.cs create mode 100644 DragaliaAPI/DragaliaAPI.Shared/Features/Summoning/UnitAvailabilityExtensions.cs create mode 100644 DragaliaAPI/DragaliaAPI.Shared/MasterAsset/Models/IUnitData.cs create mode 100644 DragaliaAPI/DragaliaAPI.Test/Features/Summon/SummonOddsServiceTest.cs create mode 100644 DragaliaAPI/DragaliaAPI/Features/Summoning/Extensions.cs create mode 100644 DragaliaAPI/DragaliaAPI/Features/Summoning/SummonOddsService.cs create mode 100644 DragaliaAPI/DragaliaAPI/Features/Summoning/UnitRate.cs diff --git a/DragaliaAPI/DragaliaAPI.Database/Entities/DbPlayerCharaData.cs b/DragaliaAPI/DragaliaAPI.Database/Entities/DbPlayerCharaData.cs index fe327dbc9..c1a2cb2ea 100644 --- a/DragaliaAPI/DragaliaAPI.Database/Entities/DbPlayerCharaData.cs +++ b/DragaliaAPI/DragaliaAPI.Database/Entities/DbPlayerCharaData.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using DragaliaAPI.Database.Entities.Abstract; using DragaliaAPI.Shared.Definitions.Enums; +using DragaliaAPI.Shared.Features.Summoning; using DragaliaAPI.Shared.MasterAsset; using DragaliaAPI.Shared.MasterAsset.Models; using Microsoft.EntityFrameworkCore; @@ -202,6 +203,6 @@ public DbPlayerCharaData(long viewerId, Charas id) this.Ability1Level = (byte)data.DefaultAbility1Level; this.Ability2Level = (byte)data.DefaultAbility2Level; this.Ability3Level = (byte)data.DefaultAbility3Level; - this.IsUnlockEditSkill = data.Availability == CharaAvailabilities.Story; + this.IsUnlockEditSkill = data.GetAvailability() == UnitAvailability.Story; } } diff --git a/DragaliaAPI/DragaliaAPI.Integration.Test/Dragalia/SummonTest.cs b/DragaliaAPI/DragaliaAPI.Integration.Test/Dragalia/SummonTest.cs index 39b2e9a95..66c951b33 100644 --- a/DragaliaAPI/DragaliaAPI.Integration.Test/Dragalia/SummonTest.cs +++ b/DragaliaAPI/DragaliaAPI.Integration.Test/Dragalia/SummonTest.cs @@ -26,16 +26,122 @@ await this.Client.PostMsgpack( } [Fact] - public async Task SummonGetOddsData_ReturnsAnyData() + public async Task SummonGetOddsData_ReturnsExpectedData() { SummonGetOddsDataResponse response = ( await this.Client.PostMsgpack( "summon/get_odds_data", - new SummonGetOddsDataRequest(1020203) + new SummonGetOddsDataRequest(1020010) ) ).Data; - response.Should().NotBeNull(); + OddsRate normalOdds = response.OddsRateList.Normal; + OddsRate guaranteeOdds = response.OddsRateList.Guarantee; + + normalOdds + .RarityList.Should() + .BeEquivalentTo( + [ + new AtgenRarityList { Rarity = 5, TotalRate = "4.00%" }, + new AtgenRarityList { Rarity = 4, TotalRate = "16.00%" }, + new AtgenRarityList { Rarity = 3, TotalRate = "80.00%" }, + ] + ); + + guaranteeOdds + .RarityList.Should() + .BeEquivalentTo( + [ + new AtgenRarityList { Rarity = 5, TotalRate = "4.00%" }, + new AtgenRarityList { Rarity = 4, TotalRate = "96.00%" }, + ] + ); + + normalOdds + .RarityGroupList.Should() + .BeEquivalentTo( + [ + new AtgenRarityGroupList + { + Rarity = 5, + CharaRate = "1.00%", + DragonRate = "0.80%", + Pickup = true, + TotalRate = "1.80%" + }, + new AtgenRarityGroupList + { + Rarity = 5, + CharaRate = "1.10%", + DragonRate = "1.10%", + Pickup = false, + TotalRate = "2.20%" + }, + new AtgenRarityGroupList + { + Rarity = 4, + CharaRate = "8.55%", + DragonRate = "7.45%", + Pickup = false, + TotalRate = "16.00%" + }, + new AtgenRarityGroupList + { + Rarity = 3, + CharaRate = "48.00%", + DragonRate = "32.00%", + Pickup = false, + TotalRate = "80.00%" + } + ] + ); + + guaranteeOdds + .RarityGroupList.Should() + .BeEquivalentTo( + [ + new AtgenRarityGroupList + { + Rarity = 5, + CharaRate = "1.00%", + DragonRate = "0.80%", + Pickup = true, + TotalRate = "1.80%" + }, + new AtgenRarityGroupList + { + Rarity = 5, + CharaRate = "1.10%", + DragonRate = "1.10%", + Pickup = false, + TotalRate = "2.20%" + }, + new AtgenRarityGroupList + { + Rarity = 4, + CharaRate = "51.30%", + DragonRate = "44.70%", + Pickup = false, + TotalRate = "96.00%" + } + ] + ); + + normalOdds.Unit.CharaOddsList.Should().HaveCount(4); + + normalOdds + .Unit.CharaOddsList.ElementAt(0) + .UnitList.Should() + .BeEquivalentTo( + [ + new AtgenUnitList { Id = (int)Charas.Joker, Rate = "0.500%" }, + new AtgenUnitList { Id = (int)Charas.Mona, Rate = "0.500%" } + ] + ); + normalOdds + .Unit.DragonOddsList.ElementAt(0) + .UnitList.Should() + .BeEquivalentTo([new AtgenUnitList { Id = (int)Dragons.Arsene, Rate = "0.800%" }]); } [Fact] diff --git a/DragaliaAPI/DragaliaAPI.Shared.Test/Unit/MasterAssetTest.cs b/DragaliaAPI/DragaliaAPI.Shared.Test/Unit/MasterAssetTest.cs index 8f1cd8b8c..b6da0254c 100644 --- a/DragaliaAPI/DragaliaAPI.Shared.Test/Unit/MasterAssetTest.cs +++ b/DragaliaAPI/DragaliaAPI.Shared.Test/Unit/MasterAssetTest.cs @@ -1,6 +1,7 @@ using System.Collections.Frozen; using DragaliaAPI.Photon.Shared.Enums; using DragaliaAPI.Shared.Definitions.Enums; +using DragaliaAPI.Shared.Features.Summoning; using DragaliaAPI.Shared.MasterAsset; using DragaliaAPI.Shared.MasterAsset.Models; using DragaliaAPI.Shared.MasterAsset.Models.ManaCircle; @@ -157,17 +158,14 @@ int expectedAbility3Level } [Theory] - [InlineData(Charas.Elisanne, CharaAvailabilities.Story)] - [InlineData(Charas.Annelie, CharaAvailabilities.Default)] - [InlineData(Charas.Chelle, CharaAvailabilities.Story)] - public void CharaData_Availability_ReturnsExpectedResult( - Charas id, - CharaAvailabilities expected - ) + [InlineData(Charas.Elisanne, UnitAvailability.Story)] + [InlineData(Charas.Annelie, UnitAvailability.Permanent)] + [InlineData(Charas.Chelle, UnitAvailability.Story)] + public void CharaData_Availability_ReturnsExpectedResult(Charas id, UnitAvailability expected) { CharaData chara = MasterAsset.MasterAsset.CharaData.Get(id); - chara.Availability.Should().Be(expected); + chara.GetAvailability().Should().Be(expected); } [Fact] diff --git a/DragaliaAPI/DragaliaAPI.Shared/Definitions/Enums/CharaAvailability.cs b/DragaliaAPI/DragaliaAPI.Shared/Definitions/Enums/CharaAvailability.cs deleted file mode 100644 index dad1a4ead..000000000 --- a/DragaliaAPI/DragaliaAPI.Shared/Definitions/Enums/CharaAvailability.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace DragaliaAPI.Shared.Definitions.Enums; - -public enum CharaAvailabilities -{ - Default, - Story -} diff --git a/DragaliaAPI/DragaliaAPI.Shared/Definitions/Enums/UnitAvailability.cs b/DragaliaAPI/DragaliaAPI.Shared/Definitions/Enums/UnitAvailability.cs new file mode 100644 index 000000000..a1f2d2d77 --- /dev/null +++ b/DragaliaAPI/DragaliaAPI.Shared/Definitions/Enums/UnitAvailability.cs @@ -0,0 +1,32 @@ +namespace DragaliaAPI.Shared.Definitions.Enums; + +/// +/// Represents a unit's availability from a summoning perspective. +/// +public enum UnitAvailability +{ + /// + /// The unit is always able to be summoned. + /// + Permanent, + + /// + /// The unit can only be summoned on Gala banners. + /// + Gala, + + /// + /// The unit can only be summoned on particular limited banners. + /// + Limited, + + /// + /// The unit can never be summoned, but is available from completing a main story quest. + /// + Story, + + /// + /// The unit can never be summoned. + /// + Other +} diff --git a/DragaliaAPI/DragaliaAPI.Shared/Features/Summoning/UnitAvailabilityExtensions.cs b/DragaliaAPI/DragaliaAPI.Shared/Features/Summoning/UnitAvailabilityExtensions.cs new file mode 100644 index 000000000..a0916e38c --- /dev/null +++ b/DragaliaAPI/DragaliaAPI.Shared/Features/Summoning/UnitAvailabilityExtensions.cs @@ -0,0 +1,231 @@ +using System.Collections.Frozen; +using System.Diagnostics; +using DragaliaAPI.Shared.Definitions.Enums; +using DragaliaAPI.Shared.MasterAsset.Models; + +namespace DragaliaAPI.Shared.Features.Summoning; + +public static class UnitAvailabilityExtensions +{ + public static UnitAvailability GetAvailability(this Charas chara) => + CharaAvailabilityMap.GetValueOrDefault(chara, UnitAvailability.Permanent); + + public static UnitAvailability GetAvailability(this CharaData charaData) => + GetAvailability(charaData.Id); + + public static UnitAvailability GetAvailability(this Dragons dragon) => + DragonAvailabilityMap.GetValueOrDefault(dragon, UnitAvailability.Permanent); + + public static UnitAvailability GetAvailability(this DragonData dragonData) => + GetAvailability(dragonData.Id); + + private static readonly FrozenDictionary CharaAvailabilityMap = + new Dictionary + { + // Story units + [Charas.ThePrince] = UnitAvailability.Story, + [Charas.Elisanne] = UnitAvailability.Story, + [Charas.Ranzal] = UnitAvailability.Story, + [Charas.Cleo] = UnitAvailability.Story, + [Charas.Luca] = UnitAvailability.Story, + [Charas.Alex] = UnitAvailability.Story, + [Charas.Laxi] = UnitAvailability.Story, + [Charas.Zena] = UnitAvailability.Story, + [Charas.Chelle] = UnitAvailability.Story, + + // Seasonal limited units + [Charas.HalloweenEdward] = UnitAvailability.Limited, + [Charas.HalloweenElisanne] = UnitAvailability.Limited, + [Charas.HalloweenAlthemia] = UnitAvailability.Limited, + + [Charas.DragonyuleCleo] = UnitAvailability.Limited, + [Charas.DragonyuleNefaria] = UnitAvailability.Limited, + [Charas.DragonyuleXander] = UnitAvailability.Limited, + + [Charas.Addis] = UnitAvailability.Limited, + [Charas.Ieyasu] = UnitAvailability.Limited, + [Charas.Sazanka] = UnitAvailability.Limited, + + [Charas.HalloweenMym] = UnitAvailability.Limited, + [Charas.HalloweenLowen] = UnitAvailability.Limited, + [Charas.HalloweenOdetta] = UnitAvailability.Limited, + + [Charas.DragonyuleXainfried] = UnitAvailability.Limited, + [Charas.DragonyuleMalora] = UnitAvailability.Limited, + + [Charas.Nobunaga] = UnitAvailability.Limited, + [Charas.Mitsuhide] = UnitAvailability.Limited, + [Charas.Chitose] = UnitAvailability.Limited, + + [Charas.ValentinesMelody] = UnitAvailability.Limited, + [Charas.ValentinesAddis] = UnitAvailability.Limited, + + [Charas.HalloweenAkasha] = UnitAvailability.Limited, + [Charas.HalloweenMelsa] = UnitAvailability.Limited, + + [Charas.DragonyuleLily] = UnitAvailability.Limited, + [Charas.DragonyuleVictor] = UnitAvailability.Limited, + + [Charas.Seimei] = UnitAvailability.Limited, + [Charas.Yoshitsune] = UnitAvailability.Limited, + + [Charas.ValentinesChelsea] = UnitAvailability.Limited, + + [Charas.SummerMitsuhide] = UnitAvailability.Limited, + [Charas.SummerIeyasu] = UnitAvailability.Limited, + + [Charas.HalloweenLaxi] = UnitAvailability.Limited, + [Charas.HalloweenSylas] = UnitAvailability.Limited, + + [Charas.DragonyuleNevin] = UnitAvailability.Limited, + [Charas.DragonyuleIlia] = UnitAvailability.Limited, + + [Charas.Shingen] = UnitAvailability.Limited, + [Charas.Yukimura] = UnitAvailability.Limited, + + // Gala units + [Charas.GalaSarisse] = UnitAvailability.Gala, + [Charas.GalaRanzal] = UnitAvailability.Gala, + [Charas.GalaMym] = UnitAvailability.Gala, + [Charas.GalaCleo] = UnitAvailability.Gala, + [Charas.GalaPrince] = UnitAvailability.Gala, + [Charas.GalaElisanne] = UnitAvailability.Gala, + [Charas.GalaLuca] = UnitAvailability.Gala, + [Charas.GalaAlex] = UnitAvailability.Gala, + [Charas.GalaLeif] = UnitAvailability.Gala, + [Charas.GalaLaxi] = UnitAvailability.Gala, + [Charas.GalaZena] = UnitAvailability.Gala, + [Charas.GalaLeonidas] = UnitAvailability.Gala, + [Charas.GalaChelle] = UnitAvailability.Gala, + [Charas.GalaNotte] = UnitAvailability.Gala, + [Charas.GalaMascula] = UnitAvailability.Gala, + [Charas.GalaAudric] = UnitAvailability.Gala, + [Charas.GalaZethia] = UnitAvailability.Gala, + [Charas.GalaGatov] = UnitAvailability.Gala, + [Charas.GalaEmile] = UnitAvailability.Gala, + [Charas.GalaNedrick] = UnitAvailability.Gala, + + // Welfare units + [Charas.Celliera] = UnitAvailability.Other, + [Charas.Melsa] = UnitAvailability.Other, + [Charas.Elias] = UnitAvailability.Other, + [Charas.Botan] = UnitAvailability.Other, + [Charas.SuFang] = UnitAvailability.Other, + [Charas.Felicia] = UnitAvailability.Other, + [Charas.XuanZang] = UnitAvailability.Other, + [Charas.SummerEstelle] = UnitAvailability.Other, + [Charas.MegaMan] = UnitAvailability.Other, + [Charas.Hanabusa] = UnitAvailability.Other, + [Charas.Aldred] = UnitAvailability.Other, + [Charas.WuKong] = UnitAvailability.Other, + [Charas.SummerAmane] = UnitAvailability.Other, + [Charas.ForagerCleo] = UnitAvailability.Other, + [Charas.Kuzunoha] = UnitAvailability.Other, + [Charas.SophiePersona] = UnitAvailability.Other, + [Charas.HumanoidMidgardsormr] = UnitAvailability.Other, + [Charas.SummerPrince] = UnitAvailability.Other, + [Charas.Izumo] = UnitAvailability.Other, + [Charas.Alfonse] = UnitAvailability.Other, + [Charas.Audric] = UnitAvailability.Other, + [Charas.Sharena] = UnitAvailability.Other, + [Charas.Mordecai] = UnitAvailability.Other, + [Charas.Harle] = UnitAvailability.Other, + [Charas.Origa] = UnitAvailability.Other, + + // Collab units + [Charas.Marth] = UnitAvailability.Limited, + [Charas.Fjorm] = UnitAvailability.Limited, + [Charas.Veronica] = UnitAvailability.Limited, + + [Charas.HunterBerserker] = UnitAvailability.Limited, + [Charas.HunterVanessa] = UnitAvailability.Limited, + [Charas.HunterSarisse] = UnitAvailability.Limited, + + [Charas.Chrom] = UnitAvailability.Limited, + [Charas.Peony] = UnitAvailability.Limited, + [Charas.Tiki] = UnitAvailability.Limited, + + [Charas.Mona] = UnitAvailability.Limited, + [Charas.Joker] = UnitAvailability.Limited, + [Charas.Panther] = UnitAvailability.Limited, + + // Not playable + [Charas.Empty] = UnitAvailability.Other, + }.ToFrozenDictionary(); + + private static readonly FrozenDictionary DragonAvailabilityMap = + new Dictionary() + { + // Story + [Dragons.Brunhilda] = UnitAvailability.Story, + [Dragons.Mercury] = UnitAvailability.Story, + [Dragons.Midgardsormr] = UnitAvailability.Story, + [Dragons.Jupiter] = UnitAvailability.Story, + [Dragons.Zodiark] = UnitAvailability.Story, + + // Seasonal limited units + [Dragons.HalloweenSilke] = UnitAvailability.Limited, + [Dragons.DragonyuleJeanne] = UnitAvailability.Limited, + [Dragons.Marishiten] = UnitAvailability.Limited, + [Dragons.HalloweenMaritimus] = UnitAvailability.Limited, + [Dragons.Daikokuten] = UnitAvailability.Limited, + [Dragons.GozuTenno] = UnitAvailability.Limited, + [Dragons.SummerMarishiten] = UnitAvailability.Limited, + [Dragons.FudoMyoo] = UnitAvailability.Limited, + + // Gala units + [Dragons.GalaMars] = UnitAvailability.Gala, + [Dragons.GalaCatSith] = UnitAvailability.Gala, + [Dragons.GalaThor] = UnitAvailability.Gala, + [Dragons.GalaRebornPoseidon] = UnitAvailability.Gala, + [Dragons.GalaRebornZephyr] = UnitAvailability.Gala, + [Dragons.GalaRebornJeanne] = UnitAvailability.Gala, + [Dragons.GalaRebornAgni] = UnitAvailability.Gala, + [Dragons.GalaRebornNidhogg] = UnitAvailability.Gala, + [Dragons.GalaBeastVolk] = UnitAvailability.Gala, + [Dragons.GalaBahamut] = UnitAvailability.Gala, + [Dragons.GalaChronosNyx] = UnitAvailability.Gala, + [Dragons.GalaBeastCiella] = UnitAvailability.Gala, + [Dragons.GalaElysium] = UnitAvailability.Gala, + + // Welfare units + [Dragons.Pele] = UnitAvailability.Other, + [Dragons.Sylvia] = UnitAvailability.Other, + [Dragons.Maritimus] = UnitAvailability.Other, + [Dragons.Shishimai] = UnitAvailability.Other, + [Dragons.PengLai] = UnitAvailability.Other, + [Dragons.Phantom] = UnitAvailability.Other, + [Dragons.Yulong] = UnitAvailability.Other, + [Dragons.Erasmus] = UnitAvailability.Other, + [Dragons.Ebisu] = UnitAvailability.Other, + [Dragons.Rathalos] = UnitAvailability.Other, + [Dragons.Barbatos] = UnitAvailability.Other, + [Dragons.ParallelZodiark] = UnitAvailability.Other, + + // Collab units + [Dragons.Fatalis] = UnitAvailability.Limited, + [Dragons.DreadkingRathalos] = UnitAvailability.Limited, + [Dragons.Arsene] = UnitAvailability.Limited, + + // Treasure trade / other means + [Dragons.HighMidgardsormr] = UnitAvailability.Other, + [Dragons.HighMercury] = UnitAvailability.Other, + [Dragons.HighBrunhilda] = UnitAvailability.Other, + [Dragons.HighJupiter] = UnitAvailability.Other, + [Dragons.HighZodiark] = UnitAvailability.Other, + + [Dragons.GoldFafnir] = UnitAvailability.Other, + [Dragons.SilverFafnir] = UnitAvailability.Other, + [Dragons.BronzeFafnir] = UnitAvailability.Other, + + [Dragons.MiniMids] = UnitAvailability.Other, + [Dragons.MiniMercs] = UnitAvailability.Other, + [Dragons.MiniHildy] = UnitAvailability.Other, + [Dragons.MiniJupi] = UnitAvailability.Other, + [Dragons.MiniZodi] = UnitAvailability.Other, + + // Not playable + [Dragons.Puppy] = UnitAvailability.Other, + [Dragons.Empty] = UnitAvailability.Other + }.ToFrozenDictionary(); +} diff --git a/DragaliaAPI/DragaliaAPI.Shared/MasterAsset/Models/CharaData.cs b/DragaliaAPI/DragaliaAPI.Shared/MasterAsset/Models/CharaData.cs index f219ed924..8737d838a 100644 --- a/DragaliaAPI/DragaliaAPI.Shared/MasterAsset/Models/CharaData.cs +++ b/DragaliaAPI/DragaliaAPI.Shared/MasterAsset/Models/CharaData.cs @@ -1,4 +1,5 @@ -using DragaliaAPI.Shared.Definitions.Enums; +using System.Collections.Frozen; +using DragaliaAPI.Shared.Definitions.Enums; using DragaliaAPI.Shared.MasterAsset.Models.ManaCircle; namespace DragaliaAPI.Shared.MasterAsset.Models; @@ -87,7 +88,7 @@ public record CharaData( int EditReleaseEntityQuantity1, int BaseId, int VariationId -) +) : IUnitData { public bool HasManaSpiral => this.MaxLimitBreakCount > 4; @@ -149,25 +150,6 @@ public IEnumerable GetManaNodes() ); } - public CharaAvailabilities Availability => - AvailabilityMap.TryGetValue(this.Id, out CharaAvailabilities availability) - ? availability - : CharaAvailabilities.Default; - - private static readonly IReadOnlyDictionary AvailabilityMap = - new Dictionary() - { - { Charas.ThePrince, CharaAvailabilities.Story }, - { Charas.Elisanne, CharaAvailabilities.Story }, - { Charas.Ranzal, CharaAvailabilities.Story }, - { Charas.Cleo, CharaAvailabilities.Story }, - { Charas.Luca, CharaAvailabilities.Story }, - { Charas.Alex, CharaAvailabilities.Story }, - { Charas.Laxi, CharaAvailabilities.Story }, - { Charas.Chelle, CharaAvailabilities.Story }, - { Charas.Zena, CharaAvailabilities.Story } - }; - public readonly int[] ExAbility = { ExAbilityData1, diff --git a/DragaliaAPI/DragaliaAPI.Shared/MasterAsset/Models/DragonData.cs b/DragaliaAPI/DragaliaAPI.Shared/MasterAsset/Models/DragonData.cs index 894757dc7..3e3f64324 100644 --- a/DragaliaAPI/DragaliaAPI.Shared/MasterAsset/Models/DragonData.cs +++ b/DragaliaAPI/DragaliaAPI.Shared/MasterAsset/Models/DragonData.cs @@ -37,7 +37,7 @@ public record DragonData( int SellDewPoint, int BaseId, int VariationId -) +) : IUnitData { public readonly int[][] Abilities = { diff --git a/DragaliaAPI/DragaliaAPI.Shared/MasterAsset/Models/IUnitData.cs b/DragaliaAPI/DragaliaAPI.Shared/MasterAsset/Models/IUnitData.cs new file mode 100644 index 000000000..a257204d6 --- /dev/null +++ b/DragaliaAPI/DragaliaAPI.Shared/MasterAsset/Models/IUnitData.cs @@ -0,0 +1,6 @@ +namespace DragaliaAPI.Shared.MasterAsset.Models; + +public interface IUnitData +{ + public int Rarity { get; init; } +} diff --git a/DragaliaAPI/DragaliaAPI.Test/Features/Summon/SummonOddsServiceTest.cs b/DragaliaAPI/DragaliaAPI.Test/Features/Summon/SummonOddsServiceTest.cs new file mode 100644 index 000000000..09b82fd49 --- /dev/null +++ b/DragaliaAPI/DragaliaAPI.Test/Features/Summon/SummonOddsServiceTest.cs @@ -0,0 +1,1096 @@ +using DragaliaAPI.Features.Summoning; +using DragaliaAPI.Shared.Definitions.Enums; +using DragaliaAPI.Shared.Features.Summoning; +using DragaliaAPI.Shared.MasterAsset; +using DragaliaAPI.Shared.MasterAsset.Models; +using FluentAssertions.Execution; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace DragaliaAPI.Test.Features.Summon; + +public class SummonOddsServiceTest +{ + // 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() + { + this.optionsMonitor.CurrentValue.Returns( + new SummonBannerOptions() + { + Banners = + [ + new Banner() + { + Id = 1, + PickupCharas = [Charas.FaeblessedTobias], + PickupDragons = [Dragons.Simurgh], + } + ] + } + ); + + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + await this.summonOddsService.GetUnitRates(1); + + List pickupRates = rates.PickupRates.ToList(); + List normalRates = rates.NormalRates.ToList(); + List combined = [.. pickupRates, .. normalRates]; + + combined.Sum(x => x.Rate).Should().BeApproximately(1m, AssertionPrecision); + combined.Should().OnlyHaveUniqueItems(x => new { x.Id, x.EntityType }); + + pickupRates + .Should() + .BeEquivalentTo( + [ + new UnitRate(Charas.FaeblessedTobias, 0.005m), + new UnitRate(Dragons.Simurgh, 0.008m) + ] + ); + + normalRates.Should().NotContain(x => x.Id == (int)Charas.FaeblessedTobias); + normalRates.Should().NotContain(x => x.Id == (int)Dragons.Simurgh); + + decimal expectedOffPickupRate = 0.04m - 0.005m - 0.008m; + normalRates + .Where(x => x.Rarity == 5) + .Sum(x => x.Rate) + .Should() + .BeApproximately(expectedOffPickupRate, AssertionPrecision); + + normalRates + .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Chara }) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.0855m, AssertionPrecision); + + normalRates + .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Dragon }) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.0745m, AssertionPrecision); + + normalRates + .Where(x => x is { Rarity: 3, EntityType: EntityTypes.Chara }) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.48m, AssertionPrecision); + + normalRates + .Where(x => x is { Rarity: 3, EntityType: EntityTypes.Dragon }) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.32m, AssertionPrecision); + } + + [Fact] + public async Task GetUnitRates_MultiFiveStarCharaPickup_ProducesExpectedRates() + { + this.optionsMonitor.CurrentValue.Returns( + new SummonBannerOptions() + { + Banners = + [ + new Banner() + { + Id = 1, + PickupCharas = [Charas.GalaNedrick, Charas.Akasha, Charas.Eirene], + } + ] + } + ); + + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + await this.summonOddsService.GetUnitRates(1); + + List pickupRates = rates.PickupRates.ToList(); + List normalRates = rates.NormalRates.ToList(); + List combined = [.. pickupRates, .. normalRates]; + + combined.Sum(x => x.Rate).Should().BeApproximately(1m, AssertionPrecision); + combined.Should().OnlyHaveUniqueItems(x => new { x.Id, x.EntityType }); + + pickupRates + .Should() + .BeEquivalentTo( + [ + new UnitRate(Charas.GalaNedrick, 0.005m), + new UnitRate(Charas.Akasha, 0.005m), + new UnitRate(Charas.Eirene, 0.005m) + ] + ); + + decimal expectedOffPickupRate = 0.04m - 0.005m - 0.005m - 0.005m; + normalRates + .Where(x => x.Rarity == 5) + .Sum(x => x.Rate) + .Should() + .BeApproximately(expectedOffPickupRate, AssertionPrecision); + + normalRates + .Where(x => x.Rarity == 4) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.16m, AssertionPrecision); + + normalRates + .Where(x => x.Rarity == 3) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.8m, AssertionPrecision); + } + + [Fact] + public async Task GetUnitRates_Gala_ProducesExpectedRates() + { + this.optionsMonitor.CurrentValue.Returns( + new SummonBannerOptions() + { + Banners = + [ + new Banner() + { + Id = 1, + IsGala = true, + PickupCharas = [Charas.GalaZena, Charas.GalaRanzal], + PickupDragons = [Dragons.GalaBeastCiella] + } + ] + } + ); + + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + await this.summonOddsService.GetUnitRates(1); + + List pickupRates = rates.PickupRates.ToList(); + List normalRates = rates.NormalRates.ToList(); + List combined = [.. pickupRates, .. normalRates]; + + combined.Sum(x => x.Rate).Should().BeApproximately(1m, AssertionPrecision); + combined.Should().OnlyHaveUniqueItems(x => new { x.Id, x.EntityType }); + + pickupRates + .Should() + .BeEquivalentTo( + [ + new UnitRate(Charas.GalaZena, 0.005m), + new UnitRate(Charas.GalaRanzal, 0.005m), + new UnitRate(Dragons.GalaBeastCiella, 0.008m) + ] + ); + + combined + .Where(x => x.Rarity == 5) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.06m, AssertionPrecision); + + combined + .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Chara }) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.0855m, AssertionPrecision); + combined + .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Dragon }) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.0745m, AssertionPrecision); + + combined + .Where(x => x is { Rarity: 3, EntityType: EntityTypes.Chara }) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.47m, AssertionPrecision); + combined + .Where(x => x is { Rarity: 3, EntityType: EntityTypes.Dragon }) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.31m, AssertionPrecision); + } + + [Fact] + public async Task GetUnitRates_Gala_AddsLimitedUnits() + { + this.optionsMonitor.CurrentValue.Returns( + new SummonBannerOptions() + { + Banners = + [ + new Banner() + { + Id = 1, + IsGala = true, + PickupCharas = [Charas.GalaZena, Charas.GalaRanzal], + PickupDragons = [Dragons.GalaBeastCiella] + } + ] + } + ); + + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + await this.summonOddsService.GetUnitRates(1); + + List pickupRates = rates.PickupRates.ToList(); + List normalRates = rates.NormalRates.ToList(); + + pickupRates + .Should() + .BeEquivalentTo( + [ + new UnitRate(Charas.GalaZena, 0.005m), + new UnitRate(Charas.GalaRanzal, 0.005m), + new UnitRate(Dragons.GalaBeastCiella, 0.008m) + ] + ); + + List offPickupCharaIds = normalRates + .Where(x => x is { EntityType: EntityTypes.Chara, Rarity: 5 }) + .Select(x => (Charas)x.Id) + .ToList(); + + offPickupCharaIds.Should().NotContain(Charas.GalaRanzal); + offPickupCharaIds.Should().NotContain(Charas.GalaZena); + + offPickupCharaIds.Should().Contain(Charas.GalaSarisse); + offPickupCharaIds.Should().Contain(Charas.GalaMym); + offPickupCharaIds.Should().Contain(Charas.GalaCleo); + offPickupCharaIds.Should().Contain(Charas.GalaPrince); + offPickupCharaIds.Should().Contain(Charas.GalaElisanne); + offPickupCharaIds.Should().Contain(Charas.GalaLuca); + offPickupCharaIds.Should().Contain(Charas.GalaAlex); + offPickupCharaIds.Should().Contain(Charas.GalaLeif); + offPickupCharaIds.Should().Contain(Charas.GalaLaxi); + offPickupCharaIds.Should().Contain(Charas.GalaLeonidas); + offPickupCharaIds.Should().Contain(Charas.GalaChelle); + offPickupCharaIds.Should().Contain(Charas.GalaNotte); + offPickupCharaIds.Should().Contain(Charas.GalaMascula); + offPickupCharaIds.Should().Contain(Charas.GalaAudric); + offPickupCharaIds.Should().Contain(Charas.GalaZethia); + offPickupCharaIds.Should().Contain(Charas.GalaGatov); + offPickupCharaIds.Should().Contain(Charas.GalaEmile); + offPickupCharaIds.Should().Contain(Charas.GalaNedrick); + + List offPickupDragonIds = normalRates + .Where(x => x is { EntityType: EntityTypes.Dragon, Rarity: 5 }) + .Select(x => (Dragons)x.Id) + .ToList(); + + offPickupDragonIds.Should().NotContain(Dragons.GalaBeastCiella); + + offPickupDragonIds.Should().Contain(Dragons.GalaMars); + offPickupDragonIds.Should().Contain(Dragons.GalaCatSith); + offPickupDragonIds.Should().Contain(Dragons.GalaThor); + offPickupDragonIds.Should().Contain(Dragons.GalaRebornPoseidon); + offPickupDragonIds.Should().Contain(Dragons.GalaRebornZephyr); + offPickupDragonIds.Should().Contain(Dragons.GalaRebornJeanne); + offPickupDragonIds.Should().Contain(Dragons.GalaRebornNidhogg); + offPickupDragonIds.Should().Contain(Dragons.GalaBeastVolk); + offPickupDragonIds.Should().Contain(Dragons.GalaBahamut); + offPickupDragonIds.Should().Contain(Dragons.GalaChronosNyx); + } + + [Fact] + public async Task 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); + + List combinedRates = [.. rates.PickupRates, .. rates.NormalRates]; + + Charas[] unexpectedCharas = + [ + Charas.ThePrince, + Charas.Elisanne, + Charas.Ranzal, + Charas.Luca, + Charas.Cleo, + Charas.Alex, + Charas.Laxi, + Charas.Zena, + Charas.Chelle, + Charas.GalaSarisse, + Charas.GalaMym, + Charas.GalaCleo, + Charas.GalaPrince, + Charas.GalaElisanne, + Charas.GalaLuca, + Charas.GalaAlex, + Charas.GalaLeif, + Charas.GalaLaxi, + Charas.GalaLeonidas, + Charas.GalaChelle, + Charas.GalaNotte, + Charas.GalaMascula, + Charas.GalaAudric, + Charas.GalaZethia, + Charas.GalaGatov, + Charas.GalaEmile, + Charas.GalaNedrick, + Charas.HalloweenEdward, + Charas.HalloweenElisanne, + Charas.HalloweenAlthemia, + Charas.DragonyuleCleo, + Charas.DragonyuleNefaria, + Charas.DragonyuleXander, + Charas.Addis, + Charas.Ieyasu, + Charas.Sazanka, + Charas.HalloweenMym, + Charas.HalloweenLowen, + Charas.HalloweenOdetta, + Charas.DragonyuleXainfried, + Charas.DragonyuleMalora, + Charas.Nobunaga, + Charas.Mitsuhide, + Charas.Chitose, + Charas.ValentinesMelody, + Charas.ValentinesAddis, + Charas.HalloweenAkasha, + Charas.HalloweenMelsa, + Charas.DragonyuleLily, + Charas.DragonyuleVictor, + Charas.Seimei, + Charas.Yoshitsune, + Charas.ValentinesChelsea, + Charas.SummerMitsuhide, + Charas.SummerIeyasu, + Charas.HalloweenLaxi, + Charas.HalloweenSylas, + Charas.DragonyuleNevin, + Charas.DragonyuleIlia, + Charas.Shingen, + Charas.Yukimura, + Charas.Celliera, + Charas.Melsa, + Charas.Elias, + Charas.Botan, + Charas.SuFang, + Charas.Felicia, + Charas.XuanZang, + Charas.SummerEstelle, + Charas.MegaMan, + Charas.Hanabusa, + Charas.Aldred, + Charas.WuKong, + Charas.SummerAmane, + Charas.ForagerCleo, + Charas.Kuzunoha, + Charas.SophiePersona, + Charas.HumanoidMidgardsormr, + Charas.SummerPrince, + Charas.Izumo, + Charas.Alfonse, + Charas.Audric, + Charas.Sharena, + Charas.Mordecai, + Charas.Harle, + Charas.Origa, + Charas.Marth, + Charas.Fjorm, + Charas.Alfonse, + Charas.Veronica, + Charas.MegaMan, + Charas.HunterBerserker, + Charas.HunterVanessa, + Charas.HunterSarisse, + Charas.Chrom, + Charas.Sharena, + Charas.Peony, + Charas.Tiki, + Charas.Mona, + Charas.SophiePersona, + Charas.Joker, + Charas.Panther + ]; + + foreach (Charas c in unexpectedCharas) + { + combinedRates.Should().NotContain(x => x.Id == (int)c); + } + } + + [Fact] + public async Task 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); + + List combinedRates = [.. rates.PickupRates, .. rates.NormalRates]; + + Dragons[] unexpectedDragons = + [ + Dragons.Midgardsormr, + Dragons.Mercury, + Dragons.Brunhilda, + Dragons.Jupiter, + Dragons.Zodiark, + Dragons.GalaMars, + Dragons.GalaCatSith, + Dragons.GalaThor, + Dragons.GalaRebornPoseidon, + Dragons.GalaRebornZephyr, + Dragons.GalaRebornJeanne, + Dragons.GalaRebornAgni, + Dragons.GalaRebornNidhogg, + Dragons.GalaBeastVolk, + Dragons.GalaBahamut, + Dragons.GalaChronosNyx, + Dragons.HalloweenSilke, + Dragons.DragonyuleJeanne, + Dragons.Marishiten, + Dragons.HalloweenMaritimus, + Dragons.Daikokuten, + Dragons.GozuTenno, + Dragons.SummerMarishiten, + Dragons.FudoMyoo, + Dragons.Pele, + Dragons.Sylvia, + Dragons.Maritimus, + Dragons.Shishimai, + Dragons.PengLai, + Dragons.Phantom, + Dragons.Yulong, + Dragons.Erasmus, + Dragons.Ebisu, + Dragons.Rathalos, + Dragons.Barbatos, + Dragons.ParallelZodiark, + Dragons.HighMidgardsormr, + Dragons.HighMercury, + Dragons.HighBrunhilda, + Dragons.HighJupiter, + Dragons.HighZodiark, + Dragons.BronzeFafnir, + Dragons.SilverFafnir, + Dragons.GoldFafnir, + Dragons.MiniMids, + Dragons.MiniZodi, + Dragons.MiniHildy, + Dragons.MiniMercs, + Dragons.MiniJupi + ]; + + foreach (Dragons d in unexpectedDragons) + { + combinedRates.Should().NotContain(x => x.Id == (int)d); + } + } + + [Fact] + public async Task 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); + + List normalRates = rates.NormalRates.ToList(); + + normalRates.Should().Contain(x => x.Id == (int)Charas.Joker); + normalRates.Should().NotContain(x => x.Id == (int)Charas.Mona); + } + + [Fact] + public async Task GetGuaranteeUnitRates_FiveStarPickup_ProducesExpectedRates() + { + this.optionsMonitor.CurrentValue.Returns( + new SummonBannerOptions() + { + Banners = + [ + new Banner() + { + Id = 1, + PickupCharas = [Charas.Eirene], + PickupDragons = [Dragons.Agni], + } + ] + } + ); + + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + await this.summonOddsService.GetGuaranteeUnitRates(1); + + List pickupRates = rates.PickupRates.ToList(); + List normalRates = rates.NormalRates.ToList(); + List combined = [.. pickupRates, .. normalRates]; + + combined.Sum(x => x.Rate).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)] + ); + + decimal expectedOffPickupRate = 0.04m - 0.005m - 0.008m; + normalRates + .Where(x => x.Rarity == 5) + .Sum(x => x.Rate) + .Should() + .BeApproximately(expectedOffPickupRate, AssertionPrecision); + + normalRates + .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Chara }) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.513m, AssertionPrecision); + + normalRates + .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Dragon }) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.447m, AssertionPrecision); + + normalRates.Should().NotContain(x => x.Rarity == 3); + } + + [Fact] + public async Task GetGuaranteeUnitRates_FiveStarPickup_Gala_ProducesExpectedRates() + { + this.optionsMonitor.CurrentValue.Returns( + new SummonBannerOptions() + { + Banners = + [ + new Banner() + { + Id = 1, + IsGala = true, + PickupCharas = [Charas.GalaLeif], + PickupDragons = [Dragons.GalaRebornAgni], + } + ] + } + ); + + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + await this.summonOddsService.GetGuaranteeUnitRates(1); + + 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.Should().OnlyHaveUniqueItems(x => new { x.Id, x.EntityType }); + + pickupRates + .Should() + .BeEquivalentTo( + [ + new UnitRate(Charas.GalaLeif, 0.005m), + new UnitRate(Dragons.GalaRebornAgni, 0.008m) + ] + ); + + decimal expectedOffPickupRate = 0.06m - 0.005m - 0.008m; + normalRates + .Where(x => x.Rarity == 5) + .Sum(x => x.Rate) + .Should() + .BeApproximately(expectedOffPickupRate, AssertionPrecision); + + normalRates + .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Chara }) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.5023m, AssertionPrecision); + + normalRates + .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Dragon }) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.4376m, AssertionPrecision); + + normalRates.Should().NotContain(x => x.Rarity == 3); + } + + [Fact] + public async Task GetUnitRates_FourStarPickup_ProducesExpectedRates() + { + this.optionsMonitor.CurrentValue.Returns( + new SummonBannerOptions() + { + Banners = + [ + new Banner() + { + Id = 1, + PickupCharas = [Charas.KuHai], + PickupDragons = [Dragons.Roc], + } + ] + } + ); + + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + await this.summonOddsService.GetUnitRates(1); + + List pickupRates = rates.PickupRates.ToList(); + List normalRates = rates.NormalRates.ToList(); + List combined = [.. pickupRates, .. normalRates]; + + combined.Sum(x => x.Rate).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); + } + + [Fact] + public async Task GetUnitRates_FourStarPickup_Gala_ProducesExpectedRates() + { + this.optionsMonitor.CurrentValue.Returns( + new SummonBannerOptions() + { + Banners = + [ + new Banner() + { + Id = 1, + IsGala = true, + PickupCharas = [Charas.KuHai, Charas.GalaAudric], + PickupDragons = [Dragons.Roc, Dragons.GalaBahamut], + } + ] + } + ); + + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + await this.summonOddsService.GetUnitRates(1); + + List pickupRates = rates.PickupRates.ToList(); + List normalRates = rates.NormalRates.ToList(); + List combined = [.. pickupRates, .. normalRates]; + + combined.Sum(x => x.Rate).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) + ] + ); + + 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 + .Where(x => x is { Rarity: 3, EntityType: EntityTypes.Chara }) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.47m, AssertionPrecision); + combined + .Where(x => x is { Rarity: 3, EntityType: EntityTypes.Dragon }) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.31m, AssertionPrecision); + } + + [Fact] + public async Task GetGuaranteeUnitRates_FourStarPickup_ProducesExpectedRates() + { + this.optionsMonitor.CurrentValue.Returns( + new SummonBannerOptions() + { + Banners = + [ + new Banner() + { + Id = 1, + PickupCharas = [Charas.KuHai], + PickupDragons = [Dragons.Roc], + } + ] + } + ); + + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + await this.summonOddsService.GetGuaranteeUnitRates(1); + + List pickupRates = rates.PickupRates.ToList(); + List normalRates = rates.NormalRates.ToList(); + List combined = [.. pickupRates, .. normalRates]; + + combined.Sum(x => x.Rate).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); + } + + [Fact] + public async Task GetGuaranteeUnitRates_FourStarPickup_Gala_ProducesExpectedRates() + { + this.optionsMonitor.CurrentValue.Returns( + new SummonBannerOptions() + { + Banners = + [ + new Banner() + { + Id = 1, + IsGala = true, + PickupCharas = [Charas.KuHai, Charas.GalaCleo], + PickupDragons = [Dragons.Roc, Dragons.GalaChronosNyx], + } + ] + } + ); + + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + await this.summonOddsService.GetGuaranteeUnitRates(1); + + List pickupRates = rates.PickupRates.ToList(); + List normalRates = rates.NormalRates.ToList(); + List combined = [.. pickupRates, .. normalRates]; + + combined.Sum(x => x.Rate).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 + .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.94m, AssertionPrecision); + + normalRates.Should().NotContain(x => x.Rarity == 3); + } + + [Fact] + public async Task GetUnitRates_ThreeStarPickup_ProducesExpectedRates() + { + this.optionsMonitor.CurrentValue.Returns( + new SummonBannerOptions() + { + Banners = + [ + new Banner() + { + Id = 1, + PickupCharas = [Charas.Joe], + PickupDragons = [Dragons.PallidImp], + } + ] + } + ); + + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + await this.summonOddsService.GetUnitRates(1); + + List pickupRates = rates.PickupRates.ToList(); + List normalRates = rates.NormalRates.ToList(); + List combined = [.. pickupRates, .. normalRates]; + + combined.Sum(x => x.Rate).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),] + ); + + combined + .Where(x => x.Rarity == 5) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.04m, AssertionPrecision); + + combined + .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Chara }) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.0855m, AssertionPrecision); + combined + .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Dragon }) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.0745m, AssertionPrecision); + + combined + .Where(x => x.Rarity == 3) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.8m, AssertionPrecision); + } + + [Fact] + public async Task GetUnitRates_ThreeStarPickup_Gala_ProducesExpectedRates() + { + this.optionsMonitor.CurrentValue.Returns( + new SummonBannerOptions() + { + Banners = + [ + new Banner() + { + Id = 1, + IsGala = true, + PickupCharas = [Charas.Joe, Charas.GalaZethia], + PickupDragons = [Dragons.PallidImp, Dragons.GalaRebornJeanne], + } + ] + } + ); + + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + await this.summonOddsService.GetUnitRates(1); + + List pickupRates = rates.PickupRates.ToList(); + List normalRates = rates.NormalRates.ToList(); + List combined = [.. pickupRates, .. normalRates]; + + combined.Sum(x => x.Rate).Should().BeApproximately(1m, AssertionPrecision); + 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) + ] + ); + + combined + .Where(x => x.Rarity == 5) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.06m, AssertionPrecision); + + combined + .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Chara }) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.0855m, AssertionPrecision); + combined + .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Dragon }) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.0745m, AssertionPrecision); + + combined + .Where(x => x.Rarity == 3) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.78m, AssertionPrecision); + } + + [Fact] + public async Task GetGuaranteeUnitRates_ThreeStarPickup_ProducesExpectedRates() + { + this.optionsMonitor.CurrentValue.Returns( + new SummonBannerOptions() + { + Banners = + [ + new Banner() + { + Id = 1, + PickupCharas = [Charas.Joe], + PickupDragons = [Dragons.PallidImp], + } + ] + } + ); + + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + await this.summonOddsService.GetGuaranteeUnitRates(1); + + List pickupRates = rates.PickupRates.ToList(); + List normalRates = rates.NormalRates.ToList(); + List combined = [.. pickupRates, .. normalRates]; + + combined.Sum(x => x.Rate).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); + + normalRates + .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Chara }) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.5130m, AssertionPrecision); + normalRates + .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Dragon }) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.4470m, AssertionPrecision); + + combined.Should().NotContain(x => x.Rarity == 3); + } + + [Fact] + public async Task GetGuaranteeUnitRates_ThreeStarPickup_Gala_ProducesExpectedRates() + { + this.optionsMonitor.CurrentValue.Returns( + new SummonBannerOptions() + { + Banners = + [ + new Banner() + { + Id = 1, + IsGala = true, + PickupCharas = [Charas.Joe, Charas.GalaZethia], + PickupDragons = [Dragons.PallidImp, Dragons.GalaRebornJeanne], + } + ] + } + ); + + (IEnumerable PickupRates, IEnumerable NormalRates) rates = + await this.summonOddsService.GetGuaranteeUnitRates(1); + + 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); + combined.Should().OnlyHaveUniqueItems(x => new { x.Id, x.EntityType }); + + pickupRates + .Should() + .BeEquivalentTo( + [ + new UnitRate(Charas.GalaZethia, 0.005m), + new UnitRate(Dragons.GalaRebornJeanne, 0.008m) + ] + ); + + combined + .Where(x => x.Rarity == 5) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.06m, AssertionPrecision); + + normalRates + .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Chara }) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.5023m, AssertionPrecision); + normalRates + .Where(x => x is { Rarity: 4, EntityType: EntityTypes.Dragon }) + .Sum(x => x.Rate) + .Should() + .BeApproximately(0.4376m, AssertionPrecision); + + combined.Should().NotContain(x => x.Rarity == 3); + } +} diff --git a/DragaliaAPI/DragaliaAPI/Features/Summoning/Extensions.cs b/DragaliaAPI/DragaliaAPI/Features/Summoning/Extensions.cs new file mode 100644 index 000000000..7afe9f194 --- /dev/null +++ b/DragaliaAPI/DragaliaAPI/Features/Summoning/Extensions.cs @@ -0,0 +1,16 @@ +using System.Globalization; + +namespace DragaliaAPI.Features.Summoning; + +public static class Extensions +{ + public static string ToPercentageString2Dp(this decimal d) => d.ToString("P", TwoDpFormat); + + public static string ToPercentageString3Dp(this decimal d) => d.ToString("P", ThreeDpFormat); + + private static readonly NumberFormatInfo TwoDpFormat = + new() { PercentDecimalDigits = 2, PercentPositivePattern = 1 }; + + private static readonly NumberFormatInfo ThreeDpFormat = + new() { PercentDecimalDigits = 3, PercentPositivePattern = 1 }; +} diff --git a/DragaliaAPI/DragaliaAPI/Features/Summoning/FeatureExtensions.cs b/DragaliaAPI/DragaliaAPI/Features/Summoning/FeatureExtensions.cs index bfbc52bda..0c5ea98a1 100644 --- a/DragaliaAPI/DragaliaAPI/Features/Summoning/FeatureExtensions.cs +++ b/DragaliaAPI/DragaliaAPI/Features/Summoning/FeatureExtensions.cs @@ -11,5 +11,6 @@ this IServiceCollection serviceCollection serviceCollection .AddScoped() .AddScoped() - .AddScoped(); + .AddScoped() + .AddScoped(); } diff --git a/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonBannerOptions.cs b/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonBannerOptions.cs index 6ed6b0ff8..33403bd50 100644 --- a/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonBannerOptions.cs +++ b/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonBannerOptions.cs @@ -19,15 +19,15 @@ public class Banner public bool IsPrizeShowcase { get; init; } - public required IReadOnlyList FeaturedAdventurers { get; init; } + public IReadOnlyList PickupCharas { get; init; } = []; - public required IReadOnlyList FeaturedDragons { get; init; } + public IReadOnlyList PickupDragons { get; init; } = []; - public required IReadOnlyList LimitedAdventurers { get; init; } + public IReadOnlyList LimitedCharas { get; init; } = []; - public required IReadOnlyList LimitedDragons { get; init; } + public IReadOnlyList LimitedDragons { get; init; } = []; - public required IReadOnlyList TradeAdventurers { get; init; } + public IReadOnlyList TradeCharas { get; init; } = []; - public required IReadOnlyList TradeDragons { get; init; } + public IReadOnlyList TradeDragons { get; init; } = []; } diff --git a/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonController.cs b/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonController.cs index a3fbdfa00..9cf164d19 100644 --- a/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonController.cs +++ b/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonController.cs @@ -9,6 +9,7 @@ using DragaliaAPI.Services; using DragaliaAPI.Services.Exceptions; using DragaliaAPI.Shared.Definitions.Enums; +using DragaliaAPI.Shared.Features.Summoning; using DragaliaAPI.Shared.MasterAsset; using DragaliaAPI.Shared.MasterAsset.Models; using Microsoft.AspNetCore.Mvc; @@ -29,7 +30,8 @@ public class SummonController( ISummonService summonService, IPaymentService paymentService, SummonListService summonListService, - SummonTicketService summonTicketService + SummonTicketService summonTicketService, + SummonOddsService summonOddsService ) : DragaliaControllerBase { // Repeated from RedoableSummonController, but no point putting this in a shared location @@ -111,17 +113,16 @@ public async Task SummonExcludeGetList(SummonExcludeGetListReque [HttpPost] [Route("get_odds_data")] - public async Task GetOddsData(SummonGetOddsDataRequest request) + public async Task> GetOddsData( + SummonGetOddsDataRequest request + ) { - int bannerId = request.SummonId; - DbPlayerUserData userData = await userDataRepository.UserData.FirstAsync(); - //TODO Replace Dummy data with oddscalculation + OddsRate baseOddsRate = await summonOddsService.GetNormalOddsRate(request.SummonId); + OddsRate guaranteeOddsRate = await summonOddsService.GetGuaranteeOddsRate(request.SummonId); - return this.Ok( - new SummonGetOddsDataResponse( - new OddsRateList(0, Data.OddsRate, Data.OddsRate), - new(Data.PrizeOddsRate, Data.PrizeOddsRate) - ) + return new SummonGetOddsDataResponse( + new OddsRateList(int.MaxValue, baseOddsRate, guaranteeOddsRate), + new(null, null) ); } @@ -483,7 +484,7 @@ await summonRepository.AddSummonHistory( private static int CalculateDewValue(Charas id) { CharaData data = MasterAsset.CharaData[id]; - return data.Availability == CharaAvailabilities.Story + return data.GetAvailability() == UnitAvailability.Story ? DewValueData.DupeStorySummon[data.Rarity] : DewValueData.DupeSummon[data.Rarity]; } diff --git a/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonOddsService.cs b/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonOddsService.cs new file mode 100644 index 000000000..455565ab8 --- /dev/null +++ b/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonOddsService.cs @@ -0,0 +1,561 @@ +using System.Diagnostics; +using DragaliaAPI.Models.Generated; +using DragaliaAPI.Services.Exceptions; +using DragaliaAPI.Shared.Definitions.Enums; +using DragaliaAPI.Shared.Features.Summoning; +using DragaliaAPI.Shared.MasterAsset; +using DragaliaAPI.Shared.MasterAsset.Models; +using Microsoft.Extensions.Options; + +namespace DragaliaAPI.Features.Summoning; + +using RateData = (IEnumerable PickupRates, IEnumerable NormalRates); + +/* Algorithm sourced from https://dragalialost.wiki/w/Summoning#Rarity_Distribution */ + +public class SummonOddsService(IOptionsMonitor optionsMonitor) +{ + // Public for actual summon roll later + // ReSharper disable once MemberCanBePrivate.Global + public Task GetUnitRates(int bannerId) + { + Banner? banner = optionsMonitor.CurrentValue.Banners.FirstOrDefault(x => x.Id == bannerId); + + if (banner is null) + { + throw new DragaliaException( + ResultCode.CommonInvalidArgument, + $"Banner ID {bannerId} was not found" + ); + } + + 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) + ) + ); + } + + // Public for actual summon roll later + // ReSharper disable once MemberCanBePrivate.Global + public Task GetGuaranteeUnitRates(int bannerId) + { + Banner? banner = optionsMonitor.CurrentValue.Banners.FirstOrDefault(x => x.Id == bannerId); + + if (banner is null) + { + throw new DragaliaException( + ResultCode.CommonInvalidArgument, + $"Banner ID {bannerId} was not found" + ); + } + + 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) + ) + ); + } + + public async Task GetNormalOddsRate(int bannerId) + { + 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()), + } + }; + } + + public async Task GetGuaranteeOddsRate(int bannerId) + { + RateData rates = await this.GetGuaranteeUnitRates(bannerId); + + Dictionary pickupRarityLists = + new() + { + [5] = new() { Rarity = 5, Pickup = true, }, + [4] = new() { Rarity = 4, Pickup = true, }, + }; + + Dictionary rarityLists = + new() + { + [5] = new() { Rarity = 5, }, + [4] = new() { Rarity = 4, }, + }; + + 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()), + } + }; + } + + 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 + { + 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); + } + } + + 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 + { + 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 + { + 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) + { + case 5: + fiveStarPoolSize++; + break; + case 4: + fourStarPoolSize++; + break; + case 3: + threeStarPoolSize++; + break; + } + } + + 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 + ); + + private class RarityList + { + public required int Rarity { get; init; } + + public bool Pickup { get; init; } + + public bool IsEmpty => DragonList.Count == 0 && CharaList.Count == 0; + + public List DragonList { get; } = []; + + public List CharaList { get; } = []; + + public decimal CharaRate { get; set; } + + public decimal DragonRate { get; set; } + + public AtgenRarityGroupList ToRarityGroupList() => + new() + { + Rarity = this.Rarity, + Pickup = this.Pickup, + TotalRate = (this.CharaRate + this.DragonRate).ToPercentageString2Dp(), + DragonRate = this.DragonRate.ToPercentageString2Dp(), + CharaRate = this.CharaRate.ToPercentageString2Dp() + }; + + public OddsUnitDetail ToAdvOddsUnitDetail() => + new() + { + Pickup = this.Pickup, + Rarity = this.Rarity, + UnitList = this.CharaList.Select(x => x.ToAtgenUnitList()) + }; + + public OddsUnitDetail ToDragonOddsUnitDetail() => + new() + { + Pickup = this.Pickup, + Rarity = this.Rarity, + UnitList = this.DragonList.Select(x => x.ToAtgenUnitList()) + }; + } +} diff --git a/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonService.cs b/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonService.cs index 921712d7e..1fb255696 100644 --- a/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonService.cs +++ b/DragaliaAPI/DragaliaAPI/Features/Summoning/SummonService.cs @@ -5,6 +5,7 @@ using DragaliaAPI.Extensions; using DragaliaAPI.Models.Generated; using DragaliaAPI.Shared.Definitions.Enums; +using DragaliaAPI.Shared.Features.Summoning; using DragaliaAPI.Shared.MasterAsset; namespace DragaliaAPI.Features.Summoning; @@ -127,9 +128,7 @@ BannerSummonInfo bannerInfo */ else { Charas id = this.random.NextEnum(); - while ( - id == 0 || MasterAsset.CharaData[id].Availability == CharaAvailabilities.Story - ) + while (id == 0 || id.GetAvailability() == UnitAvailability.Story) { id = this.random.NextEnum(); } diff --git a/DragaliaAPI/DragaliaAPI/Features/Summoning/UnitRate.cs b/DragaliaAPI/DragaliaAPI/Features/Summoning/UnitRate.cs new file mode 100644 index 000000000..d08b771a9 --- /dev/null +++ b/DragaliaAPI/DragaliaAPI/Features/Summoning/UnitRate.cs @@ -0,0 +1,39 @@ +using System.Globalization; +using DragaliaAPI.Features.Reward; +using DragaliaAPI.Models.Generated; +using DragaliaAPI.Shared.Definitions.Enums; +using DragaliaAPI.Shared.MasterAsset; + +namespace DragaliaAPI.Features.Summoning; + +public class UnitRate +{ + public int Id { get; } + + public EntityTypes EntityType { get; } + + public int Rarity { get; } + + public decimal Rate { get; } + + public UnitRate(Dragons dragon, decimal rate) + { + this.Id = (int)dragon; + this.EntityType = EntityTypes.Dragon; + this.Rarity = MasterAsset.DragonData[dragon].Rarity; + this.Rate = rate; + } + + public UnitRate(Charas chara, decimal rate) + { + this.Id = (int)chara; + this.EntityType = EntityTypes.Chara; + this.Rarity = MasterAsset.CharaData[chara].Rarity; + this.Rate = rate; + } + + public AtgenUnitList ToAtgenUnitList() + { + return new AtgenUnitList() { Id = this.Id, Rate = this.Rate.ToPercentageString3Dp() }; + } +} diff --git a/DragaliaAPI/DragaliaAPI/Resources/bannerConfig.json b/DragaliaAPI/DragaliaAPI/Resources/bannerConfig.json index 0c220ba2c..75b2a8ba5 100644 --- a/DragaliaAPI/DragaliaAPI/Resources/bannerConfig.json +++ b/DragaliaAPI/DragaliaAPI/Resources/bannerConfig.json @@ -7,19 +7,20 @@ "End": "2025-02-24T15:22:06Z", "IsGala": false, "IsPrizeShowcase": false, - "FeaturedAdventurers": [ - "Joker" + "PickupCharas": [ + "Joker", + "Mona" ], - "FeaturedDragons": [ + "PickupDragons": [ "Arsene" ], - "LimitedAdventurers": [ + "LimitedCharas": [ "Mona" ], "LimitedDragons": [ - "Thor" + "GalaThor" ], - "TradeAdventurers": [ + "TradeCharas": [ "Mona" ], "TradeDragons": [