From 1470406e17bcbcf55ea0f8b7d42e77dc045970ef Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 00:21:42 -0400 Subject: [PATCH] fix(gacha-points): include IsLeader cards regardless of draw tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prod's /pack/get_gacha_point_rewards offers leader cards from packs where the leader sits in a non-Legendary tier — UCL pack 16015 has Kyoka (711531010, Runecraft) and Miyako (711331010, Dragoncraft) as Gold-tier rows with is_leader=1 in the drawrates. The old filter (Tier == Legendary && !IsAltArt) excluded them, so the in-game exchange UI was empty despite the banner advertising leader-card draw rates. Fix: filter on (Tier == Legendary || IsLeader) && !IsAltArt. Captures every legendary plus any leader card regardless of page tier. Verified against the captured 16015 response in traffic_prod_all_gacha_exchange.ndjson (28 entries: 26 legendaries + 2 Gold-tier leaders). Across the seeded data this surfaces 6 additional cards: 3 Bronze-tier leaders + 3 Gold-tier leaders. The 68 Legendary-tier and 81 Special- tier leaders were already included. Renames legendaryCardIds -> exchangeableCardIds for clarity. Regression test seeds a Gold-tier IsLeader=true card with a Skin row and asserts the exchange catalog returns it with the Skin reward entry. Co-Authored-By: Claude Opus 4.7 --- .../Services/GachaPointService.cs | 20 +++-- .../PackControllerGachaPointTests.cs | 78 +++++++++++++++++++ 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Services/GachaPointService.cs b/SVSim.EmulatedEntrypoint/Services/GachaPointService.cs index c837fb2..c565be8 100644 --- a/SVSim.EmulatedEntrypoint/Services/GachaPointService.cs +++ b/SVSim.EmulatedEntrypoint/Services/GachaPointService.cs @@ -38,23 +38,27 @@ public sealed class GachaPointService : IGachaPointService .Select(r => r.CardId) .ToListAsync()).ToHashSet(); - // Legendaries in the pack's draw table — exchange ignores foils (the alt-art foil - // printing is gated separately) and tiers other than Legendary. - var legendaryCardIds = drawTable.CardWeights - .Where(w => w.Tier == DrawTier.Legendary && !w.IsAltArt) + // Cards exchangeable for gacha points: the pack's draw-table pool, excluding alt-art + // (the foil/alt printing is gated separately). The exchange covers (a) every Legendary + // and (b) any IsLeader card regardless of tier — UCL pack 16015 has Kyoka and Miyako + // as Gold-tier leaders that prod still offers. Filtering on Legendary alone would miss + // them. Verified against the captured 16015 response in + // traffic_prod_all_gacha_exchange.ndjson. + var exchangeableCardIds = drawTable.CardWeights + .Where(w => !w.IsAltArt && (w.Tier == DrawTier.Legendary || w.IsLeader)) .Select(w => w.CardId) .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". + // Re-query 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)) + .Where(c => exchangeableCardIds.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) + .Where(r => exchangeableCardIds.Contains(r.CardId) && (r.Type == CosmeticType.Emblem || r.Type == CosmeticType.Skin)) .ToListAsync(); var cosmeticLookup = cosmeticsByCard diff --git a/SVSim.UnitTests/Controllers/PackControllerGachaPointTests.cs b/SVSim.UnitTests/Controllers/PackControllerGachaPointTests.cs index 6b28bc5..8b7c242 100644 --- a/SVSim.UnitTests/Controllers/PackControllerGachaPointTests.cs +++ b/SVSim.UnitTests/Controllers/PackControllerGachaPointTests.cs @@ -129,6 +129,84 @@ public class PackControllerGachaPointTests Assert.That(rewards[0].GetProperty("card_id").GetInt64(), Is.EqualTo(115041010)); } + [Test] + public async Task GetGachaPointRewards_includes_gold_tier_leader_cards() + { + // Regression for the UCL pack 16015 case captured in traffic_prod_all_gacha_exchange.ndjson: + // leader cards Kyoka (711531010, Runecraft) and Miyako (711331010, Dragoncraft) show up + // in the prod /pack/get_gacha_point_rewards response despite being Gold tier rather than + // Legendary. The exchange filter must include IsLeader=true cards regardless of their + // page tier. + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + const long GoldLeaderCardId = 711531010; + const int LeaderSkinId = 1805; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Classes.Add(new ClassEntry { Id = 0, Name = "Neutral" }); + var classRune = db.Classes.Local.First(); + var set = new ShadowverseCardSetEntry { Id = 16015, IsInRotation = true }; + db.CardSets.Add(set); + // Intrinsic Rarity here is Legendary (matches card master for these leaders), + // but in the pack-draw table the row carries Tier=Gold + IsLeader=true. + set.Cards.Add(new ShadowverseCardEntry + { + Id = GoldLeaderCardId, Name = "Kyoka, Prize Pupil", + Rarity = Rarity.Legendary, Class = classRune, IsFoil = false, + }); + db.CardCosmeticRewards.Add(new CardCosmeticReward + { + CardId = GoldLeaderCardId, Type = CosmeticType.Skin, CosmeticId = LeaderSkinId, + }); + db.Packs.Add(new PackConfigEntry + { + Id = 16015, BasePackId = 10015, PackCategory = PackCategory.None, + CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), + GachaType = 1, GachaDetail = "UCL", + GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = 400, IncreaseGachaPoint = 1 }, + }); + await db.SaveChangesAsync(); + + // Install the draw table manually so the Gold-tier IsLeader=true flags land + // exactly the way the extractor would emit them. + db.PackDrawConfigs.Add(new PackDrawConfigEntry { Id = 16015, AnimationRatePct = 0 }); + db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry + { + PackId = 16015, Slot = DrawSlot.General, Tier = DrawTier.Gold, RatePct = 100, + }); + db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry + { + PackId = 16015, Slot = DrawSlot.General, Tier = DrawTier.Gold, + CardId = GoldLeaderCardId, RatePct = 0.12, IsLeader = true, IsAltArt = false, + }); + await db.SaveChangesAsync(); + } + + using var client = factory.CreateAuthenticatedClient(viewerId); + var body = JsonBody("""{"odds_gacha_id":16015,"parent_gacha_id":10015,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""); + var response = await client.PostAsync("/pack/get_gacha_point_rewards", body); + + var text = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), text); + + using var doc = JsonDocument.Parse(text); + var rewards = doc.RootElement.GetProperty("gacha_point_rewards"); + Assert.That(rewards.GetArrayLength(), Is.EqualTo(1), + "Gold-tier leader card must appear in the exchange catalog"); + var entry = rewards[0]; + Assert.That(entry.GetProperty("card_id").GetInt64(), Is.EqualTo(GoldLeaderCardId)); + // Reward list must include the Skin row (type=10) — that's the leader-skin reward + // which is the whole reason this card is exchangeable. + var rewardList = entry.GetProperty("reward_list"); + var skinEntry = Enumerable.Range(0, rewardList.GetArrayLength()) + .Select(i => rewardList[i]) + .FirstOrDefault(e => e.GetProperty("reward_type").GetInt32() == 10); + Assert.That(skinEntry.ValueKind, Is.Not.EqualTo(JsonValueKind.Undefined), + "leader card entry should carry a Skin reward"); + Assert.That(skinEntry.GetProperty("reward_detail_id").GetInt64(), Is.EqualTo(LeaderSkinId)); + } + [Test] public async Task GetGachaPointRewards_wire_keys_match_prod_capture() {