fix(gacha-points): include IsLeader cards regardless of draw tier
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 <noreply@anthropic.com>
This commit is contained in:
@@ -38,23 +38,27 @@ public sealed class GachaPointService : IGachaPointService
|
|||||||
.Select(r => r.CardId)
|
.Select(r => r.CardId)
|
||||||
.ToListAsync()).ToHashSet();
|
.ToListAsync()).ToHashSet();
|
||||||
|
|
||||||
// Legendaries in the pack's draw table — exchange ignores foils (the alt-art foil
|
// Cards exchangeable for gacha points: the pack's draw-table pool, excluding alt-art
|
||||||
// printing is gated separately) and tiers other than Legendary.
|
// (the foil/alt printing is gated separately). The exchange covers (a) every Legendary
|
||||||
var legendaryCardIds = drawTable.CardWeights
|
// and (b) any IsLeader card regardless of tier — UCL pack 16015 has Kyoka and Miyako
|
||||||
.Where(w => w.Tier == DrawTier.Legendary && !w.IsAltArt)
|
// 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)
|
.Select(w => w.CardId)
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
|
|
||||||
// Re-query legendaries with Class loaded — pool provider doesn't include navs,
|
// Re-query with Class loaded — pool provider doesn't include navs, so card.Class is
|
||||||
// so card.Class is null on every pool entry and class_id would collapse to "0".
|
// null on every pool entry and class_id would collapse to "0".
|
||||||
var legendariesWithClass = await _db.Cards
|
var legendariesWithClass = await _db.Cards
|
||||||
.Where(c => legendaryCardIds.Contains(c.Id))
|
.Where(c => exchangeableCardIds.Contains(c.Id))
|
||||||
.Include(c => c.Class)
|
.Include(c => c.Class)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
// Pull both cosmetic types in one trip. Group by card_id for O(1) lookup below.
|
// Pull both cosmetic types in one trip. Group by card_id for O(1) lookup below.
|
||||||
var cosmeticsByCard = await _db.CardCosmeticRewards
|
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))
|
&& (r.Type == CosmeticType.Emblem || r.Type == CosmeticType.Skin))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var cosmeticLookup = cosmeticsByCard
|
var cosmeticLookup = cosmeticsByCard
|
||||||
|
|||||||
@@ -129,6 +129,84 @@ public class PackControllerGachaPointTests
|
|||||||
Assert.That(rewards[0].GetProperty("card_id").GetInt64(), Is.EqualTo(115041010));
|
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<SVSimDbContext>();
|
||||||
|
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]
|
[Test]
|
||||||
public async Task GetGachaPointRewards_wire_keys_match_prod_capture()
|
public async Task GetGachaPointRewards_wire_keys_match_prod_capture()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user