using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Models.Config; using SVSim.Database.Services; using SVSim.EmulatedEntrypoint.Services; namespace SVSim.UnitTests.Services; public class PackOpenServiceTests { /// Deterministic RNG that returns the supplied doubles in order, cycling. private sealed class ScriptedRandom : IRandom { private readonly double[] _seq; private int _i; public ScriptedRandom(params double[] seq) { _seq = seq; } public double NextDouble() { var v = _seq[_i++ % _seq.Length]; return v; } public int Next(int maxExclusive) => (int)(NextDouble() * maxExclusive); } /// Simple in-memory pool keyed by rarity for slot-distribution tests. private sealed class StubPool : ICardPoolProvider { private readonly IReadOnlyList _cards; public StubPool(IReadOnlyList cards) { _cards = cards; } public IReadOnlyList GetPool(PackConfigEntry _) => _cards; public ShadowverseCardEntry? TryGetFoilTwin(long baseCardId) => null; } /// /// Test stub that returns a single pre-built section. Only handles /// (the type reads in its ctor); other section types throw so a /// future test that needs them must extend this stub explicitly. /// private sealed class StubConfig : IGameConfigService { private readonly PackRateConfig _rates; public StubConfig(PackRateConfig rates) { _rates = rates; } public T Get() where T : class, new() { if (typeof(T) == typeof(PackRateConfig)) return (T)(object)_rates; throw new NotImplementedException($"StubConfig: unhandled section type {typeof(T)}."); } } private static PackOpenService MakeService(PackRateConfig rates) => new(new StubConfig(rates)); private static List MakeFourCards() => new() { new ShadowverseCardEntry { Id = 1, Rarity = Rarity.Bronze }, new ShadowverseCardEntry { Id = 2, Rarity = Rarity.Silver }, new ShadowverseCardEntry { Id = 3, Rarity = Rarity.Gold }, new ShadowverseCardEntry { Id = 4, Rarity = Rarity.Legendary }, }; private static PackConfigEntry StandardPack() => new() { Id = 10001, BasePackId = 10001, PackCategory = PackCategory.None, }; [Test] public void Draw_returns_eight_cards_for_one_pack() { var svc = MakeService(PackRateConfig.ShippedDefaults()); var pool = new StubPool(MakeFourCards()); var rng = new ScriptedRandom(0.5); // anything in Bronze/Silver range for non-slot-8 var result = svc.Draw(StandardPack(), pool, packNumber: 1, excludeCardIds: Array.Empty(), rng: rng); Assert.That(result.Cards.Count, Is.EqualTo(8)); } [Test] public void Draw_slot_8_never_returns_bronze_for_standard_pack() { // PackRateConfig.ShippedDefaults() includes the SV Classic slot-8 "Silver-or-better // guarantee" entry (PerSlot Bronze=0). Same shape the runtime seeder writes to GameConfigs. var svc = MakeService(PackRateConfig.ShippedDefaults()); var pool = new StubPool(MakeFourCards()); for (int trial = 0; trial < 1000; trial++) { var result = svc.Draw(StandardPack(), pool, 1, Array.Empty(), new SystemRandom(trial)); Assert.That(result.Cards[7].Rarity, Is.Not.EqualTo(Rarity.Bronze), $"slot 8 must never be Bronze (trial {trial})"); } } [Test] public void Draw_legendary_special_forces_slot_8_to_legendary() { var svc = MakeService(PackRateConfig.ShippedDefaults()); var pool = new StubPool(MakeFourCards()); var pack = new PackConfigEntry { Id = 92001, BasePackId = 90001, PackCategory = PackCategory.SpecialCardPack }; for (int trial = 0; trial < 100; trial++) { var result = svc.Draw(pack, pool, 1, Array.Empty(), new SystemRandom(trial)); Assert.That(result.Cards[7].Rarity, Is.EqualTo(Rarity.Legendary), $"legendary-special pack slot 8 must be Legendary (trial {trial})"); } } [Test] public void Draw_distribution_over_10k_slots_1_to_7_matches_declared_rates_within_2_percent() { var svc = MakeService(PackRateConfig.ShippedDefaults()); var pool = new StubPool(MakeFourCards()); var counts = new Dictionary { { Rarity.Bronze, 0 }, { Rarity.Silver, 0 }, { Rarity.Gold, 0 }, { Rarity.Legendary, 0 } }; var rng = new SystemRandom(seed: 42); const int packs = 10_000; for (int i = 0; i < packs; i++) { var r = svc.Draw(StandardPack(), pool, 1, Array.Empty(), rng); // Only look at slots 0..6 (the unrestricted rarity slots) for (int s = 0; s < 7; s++) counts[r.Cards[s].Rarity]++; } int total = packs * 7; double bronze = counts[Rarity.Bronze] / (double)total; double silver = counts[Rarity.Silver] / (double)total; double gold = counts[Rarity.Gold] / (double)total; double leg = counts[Rarity.Legendary] / (double)total; Assert.That(bronze, Is.EqualTo(0.6744).Within(0.02), $"bronze rate {bronze:P}"); Assert.That(silver, Is.EqualTo(0.2500).Within(0.02), $"silver rate {silver:P}"); Assert.That(gold, Is.EqualTo(0.0600).Within(0.01), $"gold rate {gold:P}"); Assert.That(leg, Is.EqualTo(0.0150).Within(0.01), $"legendary rate {leg:P}"); } [Test] public void Draw_excludes_listed_card_ids() { var svc = MakeService(PackRateConfig.ShippedDefaults()); // Pool with two bronze cards; exclude one — every Bronze slot must pick the other. var pool = new StubPool(new List { new() { Id = 1, Rarity = Rarity.Bronze }, new() { Id = 99, Rarity = Rarity.Bronze }, new() { Id = 2, Rarity = Rarity.Silver }, }); var rng = new SystemRandom(seed: 7); var result = svc.Draw(StandardPack(), pool, 1, excludeCardIds: new long[] { 1 }, rng: rng); foreach (var c in result.Cards.Where(x => x.Rarity == Rarity.Bronze)) { Assert.That(c.CardId, Is.EqualTo(99), "excluded card 1 must never appear in Bronze slot"); } } [Test] public void Draw_per_slot_override_is_applied_for_that_slot_and_default_for_others() { // Config: slot 3 is forced to Legendary; everything else uses Default. // PerSlot is a List with a Slot string key (no Dictionary of // complex types under jsonb-friendly serialisation — see Task 5 notes). var rates = PackRateConfig.ShippedDefaults(); rates.PerSlot.Add(new SlotRarityWeights { Slot = "3", Bronze = 0, Silver = 0, Gold = 0, Legendary = 1.0, }); var svc = MakeService(rates); var pool = new StubPool(MakeFourCards()); for (int trial = 0; trial < 50; trial++) { var result = svc.Draw(StandardPack(), pool, 1, Array.Empty(), new SystemRandom(trial)); Assert.That(result.Cards[2].Rarity, Is.EqualTo(Rarity.Legendary), $"slot 3 must be Legendary under PerSlot[3] override (trial {trial})"); } } /// StubPool variant that also implements TryGetFoilTwin via the SAME id+1 convention /// as the DB-backed provider, but keyed off an injected dictionary so tests stay hermetic. private sealed class StubPoolWithFoils : ICardPoolProvider { private readonly IReadOnlyList _pool; private readonly Dictionary _foilsByBaseId; public StubPoolWithFoils(IReadOnlyList pool, Dictionary foilsByBaseId) { _pool = pool; _foilsByBaseId = foilsByBaseId; } public IReadOnlyList GetPool(PackConfigEntry _) => _pool; public ShadowverseCardEntry? TryGetFoilTwin(long baseCardId) => _foilsByBaseId.TryGetValue(baseCardId, out var f) ? f : null; } [Test] public void Draw_animated_rate_upgrades_about_8_percent_of_slots_within_tolerance() { // One bronze card with a foil twin; rate = 0.08; ~8% of 8000 slots should be foil. var bronze = new ShadowverseCardEntry { Id = 1, Rarity = Rarity.Bronze, IsFoil = false }; var bronzeFoil = new ShadowverseCardEntry { Id = 2, Rarity = Rarity.Bronze, IsFoil = true }; var pools = new StubPoolWithFoils( new List { bronze }, new Dictionary { [bronze.Id] = bronzeFoil }); var svc = MakeService(PackRateConfig.ShippedDefaults()); // default AnimatedRate = 0.08 const int packs = 1_000; // 8000 slots int foilCount = 0; var rng = new SystemRandom(seed: 7); for (int i = 0; i < packs; i++) { var r = svc.Draw(StandardPack(), pools, 1, Array.Empty(), rng); foilCount += r.Cards.Count(c => c.CardId == bronzeFoil.Id); } double rate = foilCount / (double)(packs * 8); Assert.That(rate, Is.EqualTo(0.08).Within(0.015), $"observed animated rate {rate:P} outside the ±1.5% tolerance of 8%"); } [Test] public void Draw_animated_upgrade_silently_keeps_base_when_no_foil_twin_exists() { var bronze = new ShadowverseCardEntry { Id = 1, Rarity = Rarity.Bronze, IsFoil = false }; var pools = new StubPoolWithFoils( new List { bronze }, new Dictionary()); // no foils // Force the animated roll to always hit by setting AnimatedRate = 1.0. var rates = PackRateConfig.ShippedDefaults(); rates.AnimatedRate = 1.0; var svc = MakeService(rates); var r = svc.Draw(StandardPack(), pools, 1, Array.Empty(), new SystemRandom(seed: 1)); foreach (var c in r.Cards) { Assert.That(c.CardId, Is.EqualTo(bronze.Id), "no foil twin available; every slot must keep the base card despite 100% animated rate"); } } [Test] public void Draw_animated_upgrade_applies_to_slot_8_including_legendary_specials() { var leg = new ShadowverseCardEntry { Id = 4, Rarity = Rarity.Legendary, IsFoil = false }; var legFoil = new ShadowverseCardEntry { Id = 5, Rarity = Rarity.Legendary, IsFoil = true }; var pools = new StubPoolWithFoils( new List { leg }, new Dictionary { [leg.Id] = legFoil }); var rates = PackRateConfig.ShippedDefaults(); rates.AnimatedRate = 1.0; // every slot upgrades var svc = MakeService(rates); var specialPack = new PackConfigEntry { Id = 92001, BasePackId = 90001, PackCategory = PackCategory.SpecialCardPack }; var r = svc.Draw(specialPack, pools, 1, Array.Empty(), new SystemRandom(seed: 3)); // Slot 8 is forced Legendary by the structural rule; with AnimatedRate=1.0 it must be the foil legendary. Assert.That(r.Cards[7].CardId, Is.EqualTo(legFoil.Id), "legendary-special slot 8 must be the foil-legendary when animated rate is forced to 1.0"); } /// /// Regression guard for the 2026-05-24 slot-8-ignores-PerSlot-override bug. Invariant: a /// freshly-constructed has an EMPTY PerSlot list. The original /// trigger (EF Core 8's OwnsMany+ToJson jsonb materialisation appending rows /// onto whatever the parent's ctor produced — leaving two slot-8 entries where the seeded one /// silently won 's FirstOrDefault) is gone /// now (config goes through IGameConfigService + STJ, which replaces correctly). The /// invariant stays because any future config layer that hydrates into a pre-initialised /// collection (custom deserialiser, ORM, manual Add loop) would resurrect the same failure /// mode. Defaults for collections live in . /// [Test] public void PackRateConfig_PerSlot_defaults_to_empty_to_avoid_jsonb_append_bug() { Assert.That(new PackRateConfig().PerSlot, Is.Empty, "PackRateConfig.PerSlot must default to empty — see test docstring for why."); } }