using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Repositories.PackDrawTables; using SVSim.Database.Services; namespace SVSim.EmulatedEntrypoint.Services; /// /// Draws cards from a pack's per-pack draw table. Slot tier and per-card weights are sampled /// directly from the seeded data. The bonus slot fires once at the end of a 10-pack open /// when is set. /// public class PackOpenService { private const int CardsPerPack = 8; public DrawResult Draw( PackDrawTable drawTable, PackConfigEntry pack, int packNumber, IReadOnlyCollection excludeCardIds, IReadOnlyCollection ownedCardIds, ICardFoilLookup foilLookup, IRandom rng) { var byKey = drawTable.CardWeights .GroupBy(w => (w.Slot, w.Tier)) .ToDictionary(g => g.Key, g => g.ToList()); var slotRatesByKey = drawTable.SlotRates .GroupBy(s => s.Slot) .ToDictionary(g => g.Key, g => g.ToList()); var slots = new List(packNumber * CardsPerPack + 1); for (int p = 0; p < packNumber; p++) { for (int s = 0; s < CardsPerPack; s++) { int slotNum = s + 1; var slot = slotNum == CardsPerPack ? DrawSlot.Eighth : DrawSlot.General; var drawn = DrawOne(slot, drawTable, byKey, slotRatesByKey, excludeCardIds, ownedCardIds, foilLookup, rng); slots.Add(drawn); } } if (drawTable.Config.HasBonusSlot && packNumber == 10) { var bonus = DrawOne(DrawSlot.Bonus, drawTable, byKey, slotRatesByKey, excludeCardIds, ownedCardIds, foilLookup, rng); slots.Add(bonus); } return new DrawResult(slots); } private static DrawnCard DrawOne( DrawSlot slot, PackDrawTable drawTable, Dictionary<(DrawSlot, DrawTier), List> byKey, Dictionary> slotRatesByKey, IReadOnlyCollection excludeCardIds, IReadOnlyCollection ownedCardIds, ICardFoilLookup foilLookup, IRandom rng) { var slotRates = slotRatesByKey.TryGetValue(slot, out var sr) ? sr : new(); if (slotRates.Count == 0) throw new InvalidOperationException( $"PackOpenService: no slot rates for pack {drawTable.Config.Id} slot {slot}"); var tiers = slotRates.Select(r => r.Tier).ToList(); var tierWeights = slotRates.Select(r => r.RatePct).ToList(); var tier = WeightedPick.Pick(rng, tiers, tierWeights); // For slot 8 (and bonus), drawrates pages often quote per-rarity slot rates but no per-card // breakdown — the card pool is the same as the general slot's per-tier pool. Fall back to // (General, tier) when (slot, tier) has no card weights. if (!byKey.TryGetValue((slot, tier), out var rows) && slot != DrawSlot.General) { byKey.TryGetValue((DrawSlot.General, tier), out rows); } var pool = rows ?? new(); var filtered = pool.Where(w => !excludeCardIds.Contains(w.CardId)).ToList(); if (filtered.Count == 0) return FallbackAcrossTiers(slot, byKey, excludeCardIds, foilLookup, rng, drawTable); bool rateLess = filtered.All(w => w.RatePct == null); PackDrawCardWeightEntry picked; if (rateLess) { var unowned = filtered.Where(w => !ownedCardIds.Contains(w.CardId)).ToList(); var sourcePool = unowned.Count > 0 ? unowned : filtered; picked = sourcePool[rng.Next(sourcePool.Count)]; } else { var metered = filtered.Where(w => w.RatePct.HasValue).ToList(); if (metered.Count == 0) return FallbackAcrossTiers(slot, byKey, excludeCardIds, foilLookup, rng, drawTable); picked = WeightedPick.Pick(rng, metered, metered.Select(w => w.RatePct!.Value).ToList()); } long cardId = picked.CardId; if (drawTable.Config.AnimationRatePct > 0 && rng.NextDouble() < drawTable.Config.AnimationRatePct / 100.0) { var foil = foilLookup.TryGetFoilTwin(cardId); if (foil is not null) cardId = foil.Id; } var rarity = TierToRarity(picked); return new DrawnCard(cardId, rarity); } private static DrawnCard FallbackAcrossTiers( DrawSlot slot, Dictionary<(DrawSlot, DrawTier), List> byKey, IReadOnlyCollection excludeCardIds, ICardFoilLookup foilLookup, IRandom rng, PackDrawTable drawTable) { foreach (var tier in new[] { DrawTier.Legendary, DrawTier.Gold, DrawTier.Silver, DrawTier.Bronze, DrawTier.Special }) { if (!byKey.TryGetValue((slot, tier), out var rows)) continue; var filtered = rows.Where(w => !excludeCardIds.Contains(w.CardId)).ToList(); if (filtered.Count == 0) continue; var picked = filtered[rng.Next(filtered.Count)]; return new DrawnCard(picked.CardId, TierToRarity(picked)); } throw new InvalidOperationException( $"PackOpenService: pool empty after exclude filter for pack {drawTable.Config.Id} slot {slot}."); } private static Rarity TierToRarity(PackDrawCardWeightEntry w) => w.Tier switch { DrawTier.Bronze => Rarity.Bronze, DrawTier.Silver => Rarity.Silver, DrawTier.Gold => Rarity.Gold, DrawTier.Legendary => Rarity.Legendary, // Special tier cards typically have intrinsic Rarity.Legendary; the wire response // surfaces Rarity as an int for client coloring and the card_id is the source of // truth for what's granted. DrawTier.Special => Rarity.Legendary, _ => Rarity.Bronze, }; }