From 7292c440824ce90ceed9ff9eae267356f9ce7b45 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 29 May 2026 08:36:37 -0400 Subject: [PATCH] fix(pack): include all pack legendaries in gacha-point catalog + correct class_id --- .../Services/GachaPointService.cs | 75 ++++++++-------- .../Services/GachaPointServiceTests.cs | 87 +++++++++++++++++++ 2 files changed, 128 insertions(+), 34 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Services/GachaPointService.cs b/SVSim.EmulatedEntrypoint/Services/GachaPointService.cs index 89975c9..0c7dfbb 100644 --- a/SVSim.EmulatedEntrypoint/Services/GachaPointService.cs +++ b/SVSim.EmulatedEntrypoint/Services/GachaPointService.cs @@ -41,6 +41,13 @@ public sealed class GachaPointService : IGachaPointService .Select(c => c.Id) .ToHashSet(); + // Re-query legendaries with Class loaded — pool provider doesn't include navs, + // so card.Class is null on every pool entry and class_id would collapse to "0". + var legendariesWithClass = await _db.Cards + .Where(c => legendaryCardIds.Contains(c.Id)) + .Include(c => c.Class) + .ToListAsync(); + // Pull both cosmetic types in one trip. Group by card_id for O(1) lookup below. var cosmeticsByCard = await _db.CardCosmeticRewards .Where(r => legendaryCardIds.Contains(r.CardId) @@ -53,56 +60,56 @@ public sealed class GachaPointService : IGachaPointService var standard = new List(); var leader = new List(); - foreach (var card in pool - .Where(c => c.Rarity == Rarity.Legendary && !c.IsFoil) + foreach (var card in legendariesWithClass // Neutral cards have Class=null; client wire-encodes them as class_id="0". .OrderBy(c => c.Class?.Id ?? 0).ThenBy(c => c.Id)) { - if (!cosmeticLookup.TryGetValue(card.Id, out var cosmetics)) continue; - var emblems = cosmetics.Where(c => c.Type == CosmeticType.Emblem).ToList(); - var skin = cosmetics.FirstOrDefault(c => c.Type == CosmeticType.Skin); - if (emblems.Count == 0) continue; // every gacha-point entry has at least one emblem + cosmeticLookup.TryGetValue(card.Id, out var cosmetics); + var emblems = cosmetics?.Where(c => c.Type == CosmeticType.Emblem).ToList() + ?? new List(); + var skin = cosmetics?.FirstOrDefault(c => c.Type == CosmeticType.Skin); var classId = (card.Class?.Id ?? 0).ToString(CultureInfo.InvariantCulture); var isReceived = receivedCardIds.Contains(card.Id); if (IsLeaderCard(skin)) { - // Leader card — 3 entries in capture order: Sleeve/Card-cosmetic (type 6), - // Skin (type 10), Emblem (type 7). The reward_type=6 entry's detail id is the - // card_id itself, mirroring the prod capture exactly (no Sleeve cosmetic row - // is required — synthesizing from card.Id is robust to missing rows). For the - // emblem we take the first row only; no capture has shown leader cards with - // multiple emblems, and the verified shape is exactly 1 Sleeve + 1 Skin + 1 - // Emblem. Revisit if such a capture lands. - var emblem = emblems[0]; + // Leader card — 2 or 3 entries: Sleeve/Card-cosmetic (type 6) with detail=card_id, + // Skin (type 10) with detail=leader_skin_id, and an Emblem (type 7) per emblem row. + // Most leader cards in captured packs have exactly 1 emblem, but we emit per-emblem + // for consistency with the standard-legendary branch. + var rewardList = new List + { + new GachaPointRewardDetailEntry + { + RewardType = (int)UserGoodsType.Sleeve, RewardDetailId = card.Id, RewardNumber = 1, + }, + new GachaPointRewardDetailEntry + { + RewardType = (int)UserGoodsType.Skin, + RewardDetailId = skin!.CosmeticId, RewardNumber = 1, + }, + }; + foreach (var emblem in emblems) + { + rewardList.Add(new GachaPointRewardDetailEntry + { + RewardType = (int)UserGoodsType.Emblem, + RewardDetailId = emblem.CosmeticId, RewardNumber = 1, + }); + } leader.Add(new GachaPointRewardDto { ClassId = classId, CardId = card.Id, IsReceived = isReceived, - RewardList = - { - new GachaPointRewardDetailEntry - { - RewardType = (int)UserGoodsType.Sleeve, RewardDetailId = card.Id, RewardNumber = 1, - }, - new GachaPointRewardDetailEntry - { - RewardType = (int)UserGoodsType.Skin, - RewardDetailId = skin!.CosmeticId, RewardNumber = 1, - }, - new GachaPointRewardDetailEntry - { - RewardType = (int)UserGoodsType.Emblem, - RewardDetailId = emblem.CosmeticId, RewardNumber = 1, - }, - }, + RewardList = rewardList, }); } else { - // Standard legendaries can have multiple emblem cosmetics (e.g. prod capture - // pack 10008 card 108044010 has emblem ids 900041040 and 900041050). Emit one - // reward_list entry per emblem. + // Standard legendary — one reward_list entry per emblem cosmetic (possibly zero + // entries for packs whose emblem mappings weren't in the capture sweep, e.g. pack + // 10001 Classic). The card is still grantable; the exchange's cosmetic cascade + // delivers whatever rows actually exist in CardCosmeticRewards. var dto = new GachaPointRewardDto { ClassId = classId, CardId = card.Id, IsReceived = isReceived, diff --git a/SVSim.UnitTests/Services/GachaPointServiceTests.cs b/SVSim.UnitTests/Services/GachaPointServiceTests.cs index 0434cb8..f076d55 100644 --- a/SVSim.UnitTests/Services/GachaPointServiceTests.cs +++ b/SVSim.UnitTests/Services/GachaPointServiceTests.cs @@ -478,6 +478,93 @@ public class GachaPointServiceTests Is.True, "card grant missing"); } + [Test] + public async Task GetRewards_emits_correct_class_id_for_non_neutral_cards() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Use the pre-seeded Forestcraft (Id=1) and Havencraft (Id=7) classes from ReferenceDataImporter. + var forest = await db.Classes.FirstAsync(c => c.Id == 1); + var haven = await db.Classes.FirstAsync(c => c.Id == 7); + + var set = new ShadowverseCardSetEntry { Id = 10008, IsInRotation = true }; + db.CardSets.Add(set); + set.Cards.AddRange(new[] + { + new ShadowverseCardEntry + { + Id = 108141010, Name = "leg-forest", Rarity = Rarity.Legendary, + Class = forest, IsFoil = false, + }, + new ShadowverseCardEntry + { + Id = 108741010, Name = "leg-haven", Rarity = Rarity.Legendary, + Class = haven, IsFoil = false, + }, + }); + db.CardCosmeticRewards.AddRange( + new CardCosmeticReward { CardId = 108141010, Type = CosmeticType.Emblem, CosmeticId = 1081410100 }, + new CardCosmeticReward { CardId = 108741010, Type = CosmeticType.Emblem, CosmeticId = 1087410100 }); + db.Packs.Add(new PackConfigEntry + { + Id = 10008, BasePackId = 10008, PackCategory = PackCategory.LegendCardPack, + CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), + GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = 400, IncreaseGachaPoint = 1 }, + }); + await db.SaveChangesAsync(); + + var svc = scope.ServiceProvider.GetRequiredService(); + var result = await svc.GetRewardsAsync(10008, viewerId); + + // Cards must surface with their REAL class id, not the "0" neutral fallback caused + // by the pool provider not Including Class. + Assert.That(result, Has.Count.EqualTo(2)); + var forestEntry = result.Single(r => r.CardId == 108141010); + var havenEntry = result.Single(r => r.CardId == 108741010); + Assert.That(forestEntry.ClassId, Is.EqualTo("1")); + Assert.That(havenEntry.ClassId, Is.EqualTo("7")); + } + + [Test] + public async Task GetRewards_includes_legendaries_without_emblem_cosmetics() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Pack 10001 simulation: a legendary with NO CardCosmeticReward rows at all. + // Must still appear in the catalog (per user spec — all pack legendaries are + // exchangeable, regardless of whether we have captured emblem mappings). + // Use a unique card-set id (10099) because SVSimTestFactory already seeds a + // minimal CardSet at Id=10001. + var set = new ShadowverseCardSetEntry { Id = 10099, IsInRotation = true }; + db.CardSets.Add(set); + set.Cards.Add(new ShadowverseCardEntry + { + Id = 101141020, Name = "old-leg", Rarity = Rarity.Legendary, + Class = null, IsFoil = false, + }); + db.Packs.Add(new PackConfigEntry + { + Id = 10099, BasePackId = 10099, PackCategory = PackCategory.LegendCardPack, + CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), + GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = 400, IncreaseGachaPoint = 1 }, + }); + await db.SaveChangesAsync(); + + var svc = scope.ServiceProvider.GetRequiredService(); + var result = await svc.GetRewardsAsync(10099, viewerId); + + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result[0].CardId, Is.EqualTo(101141020)); + Assert.That(result[0].RewardList, Is.Empty, + "legendary without emblem rows emits empty reward_list — the catalog declaration is just the card itself"); + } + private static void SeedPackWithOneLegendary(SVSimDbContext db, int packId, int threshold) { var cls = db.Classes.Find(0) ?? new ClassEntry { Id = 0, Name = "Neutral" };