using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Models.Config; using SVSim.Database.Services; namespace SVSim.EmulatedEntrypoint.Services; /// /// Draws cards from a pack's pool using rates from 's /// . Slot rarity selection is unified through one /// + pair — what was previously a /// hardcoded slot-1-7 vs slot-8 split now reads from PackRateConfig.PerSlot. /// /// The "legendary-special slot-8 forced Legendary" rule stays in code (structural category /// promise, not a tunable rate). /// public class PackOpenService { private const int CardsPerPack = 8; private readonly PackRateConfig _rates; public PackOpenService(IGameConfigService config) { _rates = config.Get(); } public DrawResult Draw( PackConfigEntry pack, ICardPoolProvider pools, int packNumber, IReadOnlyCollection excludeCardIds, IRandom rng) { var pool = pools.GetPool(pack); if (pool.Count == 0) { throw new InvalidOperationException( $"PackOpenService: pool for pack {pack.Id} (category {pack.PackCategory}) is empty."); } var poolByRarity = pool .Where(c => !excludeCardIds.Contains(c.Id)) .GroupBy(c => c.Rarity) .ToDictionary(g => g.Key, g => g.ToList()); bool isLegendarySpecial = pack.PackCategory == PackCategory.SpecialCardPack || pack.PackCategory == PackCategory.LimitedSpecialCardPack; var slots = new List(packNumber * CardsPerPack); for (int p = 0; p < packNumber; p++) { for (int s = 0; s < CardsPerPack; s++) { int slotNum = s + 1; // 1-based Rarity rarity; if (slotNum == CardsPerPack && isLegendarySpecial) { // Structural category rule (not a tunable rate). rarity = Rarity.Legendary; } else { rarity = PickRarity(rng, ResolveWeights(slotNum)); } var card = PickCardOfRarity(rarity, poolByRarity, rng); // Per-card, per-slot animated upgrade. Applies independently of rarity, slot // position, and pack category — including forced-Legendary slot-8 of specials. if (rng.NextDouble() < _rates.AnimatedRate) { var foil = pools.TryGetFoilTwin(card.Id); if (foil is not null) card = foil; // silently keep base if no twin exists } slots.Add(new DrawnCard(card.Id, card.Rarity)); } } return new DrawResult(slots); } /// /// Returns the rarity weights for the given 1-based slot. Looks for a per-slot override /// keyed by Slot == slotNum.ToString(); falls back to the global Default. /// /// NOTE: PerSlot is List<SlotRarityWeights> (not Dictionary) due to an EF Core 8 /// jsonb-mapping limitation. Per-pack overrides would extend this resolver to check a /// per-pack collection first. /// private SlotRarityWeights ResolveWeights(int slotNum) { var slotKey = slotNum.ToString(); var perSlot = _rates.PerSlot.FirstOrDefault(s => s.Slot == slotKey); return perSlot ?? _rates.Default; } private static Rarity PickRarity(IRandom rng, SlotRarityWeights w) { // Cumulative-band order: Legendary -> Gold -> Silver -> Bronze (catch-all). // - When weights sum to <1.0 (SV Classic Default = 0.9994), the slack absorbs into // Bronze via the catch-all — preserves historic behavior. // - When weights sum to exactly 1.0 (SV Classic PerSlot[8] with Bronze=0), the catch-all // never fires and Bronze=0 holds naturally. double r = rng.NextDouble(); double cum = w.Legendary; if (r < cum) return Rarity.Legendary; cum += w.Gold; if (r < cum) return Rarity.Gold; cum += w.Silver; if (r < cum) return Rarity.Silver; return Rarity.Bronze; } private static ShadowverseCardEntry PickCardOfRarity( Rarity rarity, Dictionary> poolByRarity, IRandom rng) { // Fallback if the rolled rarity has no cards: walk down (and up) through all rarities. // Order: rolled rarity first, then Legendary -> Gold -> Silver -> Bronze, deduped by // LINQ Distinct. This handles both "no Legendaries" (fall down) and sparse pools that // only contain a single rarity (fall up). Safety net for sparse master data. Rarity[] fallback = new[] { rarity, Rarity.Legendary, Rarity.Gold, Rarity.Silver, Rarity.Bronze } .Distinct().ToArray(); foreach (var r in fallback) { if (poolByRarity.TryGetValue(r, out var list) && list.Count > 0) { return list[rng.Next(list.Count)]; } } throw new InvalidOperationException("PackOpenService: pool empty after exclude filter."); } }