From 1c386b5ed04af639ef80c42df29e1ab85f6a6d81 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 30 May 2026 22:26:45 -0400 Subject: [PATCH] feat(packs): rewrite PackOpenService against per-pack draw table Sampler is now driven by PackDrawTable: roll DrawTier per slot by cumulative slot-rate weights, then pick a card within tier by per-card weights renormalized within the tier. Rate-less Guaranteed-Leader-Card rows draw uniform over (pool minus owned), falling back to the full pool when all are owned. Bonus slot fires once at the end of a 10-pack open when HasBonusSlot is set. Slot 8 falls back to the general slot's per-card weights for the rolled tier when slot-8 has only a rarity-level rate quoted (the common shape on normal packs). PackController.Open loads the draw table + viewer owned card ids and passes them to the sampler; the category-based forced-Legendary slot-8 override is gone. ICardFoilLookup replaces ICardPoolProvider for the foil-twin heuristic. Drops the test-fixture pack-draw seed overlay so the production seed flows through the importer tests; controller tests that fabricate their own card sets now call factory.SeedPackDrawTableAsync(...) to install a matching stub draw table. WeightedPick helper handles the cumulative-band roll for both stages. Five sampler tests + four WeightedPick tests + five importer/repo tests; full suite is 653/653 green. Co-Authored-By: Claude Opus 4.7 --- .../seeds/pack-draw-card-weights.json | 7 - .../test-fixtures/seeds/pack-draw-config.json | 4 - .../seeds/pack-draw-slot-rates.json | 10 - .../Controllers/PackController.cs | 33 +- .../Services/PackOpenService.cs | 214 +++++----- .../Services/WeightedPick.cs | 30 ++ .../Controllers/PackControllerOpenTests.cs | 8 + .../Controllers/PackControllerTests.cs | 2 + .../Controllers/TutorialFlowEndToEndTests.cs | 2 + .../Importers/PackDrawTableImporterTests.cs | 11 +- .../Infrastructure/SVSimTestFactory.cs | 50 +++ .../PackDrawTableRepositoryTests.cs | 4 +- .../Services/PackOpenServiceTests.cs | 381 +++++++----------- SVSim.UnitTests/Services/WeightedPickTests.cs | 63 +++ 14 files changed, 445 insertions(+), 374 deletions(-) delete mode 100644 SVSim.Bootstrap/Data/test-fixtures/seeds/pack-draw-card-weights.json delete mode 100644 SVSim.Bootstrap/Data/test-fixtures/seeds/pack-draw-config.json delete mode 100644 SVSim.Bootstrap/Data/test-fixtures/seeds/pack-draw-slot-rates.json create mode 100644 SVSim.EmulatedEntrypoint/Services/WeightedPick.cs create mode 100644 SVSim.UnitTests/Services/WeightedPickTests.cs diff --git a/SVSim.Bootstrap/Data/test-fixtures/seeds/pack-draw-card-weights.json b/SVSim.Bootstrap/Data/test-fixtures/seeds/pack-draw-card-weights.json deleted file mode 100644 index 5ce386a..0000000 --- a/SVSim.Bootstrap/Data/test-fixtures/seeds/pack-draw-card-weights.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - { "pack_id": 10000, "slot": "general", "tier": "bronze", "card_id": 100, "rate_pct": 50.0, "is_leader": false, "is_alt_art": false }, - { "pack_id": 10000, "slot": "general", "tier": "bronze", "card_id": 101, "rate_pct": 26.5, "is_leader": false, "is_alt_art": false }, - { "pack_id": 10000, "slot": "general", "tier": "legendary", "card_id": 200, "rate_pct": 1.5, "is_leader": false, "is_alt_art": false }, - { "pack_id": 98001, "slot": "bonus", "tier": "special", "card_id": 300, "rate_pct": null, "is_leader": true, "is_alt_art": false }, - { "pack_id": 98001, "slot": "bonus", "tier": "special", "card_id": 301, "rate_pct": null, "is_leader": true, "is_alt_art": false } -] diff --git a/SVSim.Bootstrap/Data/test-fixtures/seeds/pack-draw-config.json b/SVSim.Bootstrap/Data/test-fixtures/seeds/pack-draw-config.json deleted file mode 100644 index a256566..0000000 --- a/SVSim.Bootstrap/Data/test-fixtures/seeds/pack-draw-config.json +++ /dev/null @@ -1,4 +0,0 @@ -[ - { "pack_id": 10000, "short_code": "Basic", "animation_rate_pct": 8.0, "has_bonus_slot": false, "special_kind": null }, - { "pack_id": 98001, "short_code": "98ANV", "animation_rate_pct": 8.0, "has_bonus_slot": true, "special_kind": "leader_card" } -] diff --git a/SVSim.Bootstrap/Data/test-fixtures/seeds/pack-draw-slot-rates.json b/SVSim.Bootstrap/Data/test-fixtures/seeds/pack-draw-slot-rates.json deleted file mode 100644 index 42b2a4a..0000000 --- a/SVSim.Bootstrap/Data/test-fixtures/seeds/pack-draw-slot-rates.json +++ /dev/null @@ -1,10 +0,0 @@ -[ - { "pack_id": 10000, "slot": "general", "tier": "bronze", "rate_pct": 76.5 }, - { "pack_id": 10000, "slot": "general", "tier": "silver", "rate_pct": 16.0 }, - { "pack_id": 10000, "slot": "general", "tier": "gold", "rate_pct": 6.0 }, - { "pack_id": 10000, "slot": "general", "tier": "legendary", "rate_pct": 1.5 }, - { "pack_id": 10000, "slot": "eighth", "tier": "silver", "rate_pct": 92.5 }, - { "pack_id": 10000, "slot": "eighth", "tier": "gold", "rate_pct": 6.0 }, - { "pack_id": 10000, "slot": "eighth", "tier": "legendary", "rate_pct": 1.5 }, - { "pack_id": 98001, "slot": "bonus", "tier": "special", "rate_pct": 100.0 } -] diff --git a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs index 1f97f0c..702dc3b 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs @@ -5,6 +5,7 @@ using SVSim.Database; using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Repositories.Pack; +using SVSim.Database.Repositories.PackDrawTables; using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Pack; @@ -25,7 +26,8 @@ public class PackController : SVSimController private readonly IPackRepository _packs; private readonly PackOpenService _opener; - private readonly ICardPoolProvider _pools; + private readonly IPackDrawTableRepository _drawTables; + private readonly ICardFoilLookup _foils; private readonly IRandom _rng; private readonly SVSimDbContext _db; private readonly ICardAcquisitionService _acquisition; @@ -36,7 +38,8 @@ public class PackController : SVSimController public PackController( IPackRepository packs, PackOpenService opener, - ICardPoolProvider pools, + IPackDrawTableRepository drawTables, + ICardFoilLookup foils, IRandom rng, SVSimDbContext db, ICardAcquisitionService acquisition, @@ -46,7 +49,8 @@ public class PackController : SVSimController { _packs = packs; _opener = opener; - _pools = pools; + _drawTables = drawTables; + _foils = foils; _rng = rng; _db = db; _acquisition = acquisition; @@ -343,7 +347,28 @@ public class PackController : SVSimController // Draw + persist. DAILY single overrides packNumber to 1 (it's a one-card open). int drawCount = child.IsDailySingle ? 1 : packNumber; - var draw = _opener.Draw(pack, _pools, drawCount, request.ExcludeCardIds, _rng); + + var drawTable = await _drawTables.GetAsync(pack.Id); + if (drawTable is null) + return StatusCode(StatusCodes.Status501NotImplemented, new { error = "pack_draw_table_missing" }); + + // Owned card_ids for the rate-less Guaranteed-Leader-Card branch. Project to longs to + // avoid pulling viewer.Cards entities into memory. Shadow-FK access (EF.Property) per + // the project_ef_nav_include_pitfall memory. + var ownedCardIds = await _db.Viewers + .Where(v => v.Id == viewerId) + .SelectMany(v => v.Cards) + .Select(c => (long)EF.Property(c, "CardId")) + .ToListAsync(); + + var draw = _opener.Draw( + drawTable, + pack, + drawCount, + request.ExcludeCardIds ?? Array.Empty(), + ownedCardIds, + _foils, + _rng); var grant = await _acquisition.GrantManyAsync(viewerId, draw.Cards.Select(c => c.CardId)); // Accrue gacha points (skip tutorial path — the starter pack isn't a real open). diff --git a/SVSim.EmulatedEntrypoint/Services/PackOpenService.cs b/SVSim.EmulatedEntrypoint/Services/PackOpenService.cs index 5e5d7bc..3beb62c 100644 --- a/SVSim.EmulatedEntrypoint/Services/PackOpenService.cs +++ b/SVSim.EmulatedEntrypoint/Services/PackOpenService.cs @@ -1,134 +1,152 @@ using SVSim.Database.Enums; using SVSim.Database.Models; -using SVSim.Database.Models.Config; +using SVSim.Database.Repositories.PackDrawTables; 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). +/// 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; - private readonly PackRateConfig _rates; - - public PackOpenService(IGameConfigService config) - { - _rates = config.Get(); - } - public DrawResult Draw( + PackDrawTable drawTable, PackConfigEntry pack, - ICardPoolProvider pools, int packNumber, IReadOnlyCollection excludeCardIds, + IReadOnlyCollection ownedCardIds, + ICardFoilLookup foilLookup, 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) + var byKey = drawTable.CardWeights + .GroupBy(w => (w.Slot, w.Tier)) .ToDictionary(g => g.Key, g => g.ToList()); - bool isLegendarySpecial = - pack.PackCategory == PackCategory.SpecialCardPack || - pack.PackCategory == PackCategory.LimitedSpecialCardPack; + var slotRatesByKey = drawTable.SlotRates + .GroupBy(s => s.Slot) + .ToDictionary(g => g.Key, g => g.ToList()); + + var slots = new List(packNumber * CardsPerPack + 1); - 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)); + 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); } - /// - /// 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, + private static DrawnCard DrawOne( + DrawSlot slot, + PackDrawTable drawTable, + Dictionary<(DrawSlot, DrawTier), List> byKey, + Dictionary> slotRatesByKey, + IReadOnlyCollection excludeCardIds, + IReadOnlyCollection ownedCardIds, + ICardFoilLookup foilLookup, 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) + 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) { - if (poolByRarity.TryGetValue(r, out var list) && list.Count > 0) - { - return list[rng.Next(list.Count)]; - } + byKey.TryGetValue((DrawSlot.General, tier), out rows); } - throw new InvalidOperationException("PackOpenService: pool empty after exclude filter."); + 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, + }; } diff --git a/SVSim.EmulatedEntrypoint/Services/WeightedPick.cs b/SVSim.EmulatedEntrypoint/Services/WeightedPick.cs new file mode 100644 index 0000000..19a83c7 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Services/WeightedPick.cs @@ -0,0 +1,30 @@ +using SVSim.Database.Services; + +namespace SVSim.EmulatedEntrypoint.Services; + +/// +/// Generic cumulative-band weighted picker used by PackOpenService for tier-by-slot +/// and card-within-tier sampling. Renormalizes weights internally (sums <1 absorb +/// into the last band; sums >1 scale down). +/// +public static class WeightedPick +{ + public static T Pick(IRandom rng, IReadOnlyList items, IReadOnlyList weights) + { + if (items.Count == 0) throw new ArgumentException("WeightedPick: items is empty."); + if (items.Count != weights.Count) throw new ArgumentException("WeightedPick: items / weights length mismatch."); + + double sum = 0; + for (int i = 0; i < weights.Count; i++) sum += weights[i]; + if (sum <= 0) return items[rng.Next(items.Count)]; + + double r = rng.NextDouble() * sum; + double cum = 0; + for (int i = 0; i < items.Count - 1; i++) + { + cum += weights[i]; + if (r < cum) return items[i]; + } + return items[^1]; + } +} diff --git a/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs b/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs index f0ca05d..fd0f9cc 100644 --- a/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs +++ b/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs @@ -369,6 +369,7 @@ public class PackControllerOpenTests using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); int parentGachaId = await SeedSingleLeaderCardPack(factory, LeaderCardId); + await factory.SeedPackDrawTableAsync(parentGachaId, LeaderCardId); await SeedCosmeticMapping(factory, LeaderCardId, SkinId); using (var scope = factory.Services.CreateScope()) @@ -507,6 +508,10 @@ public class PackControllerOpenTests viewer.Currency.Rupees = 10000; await db.SaveChangesAsync(); } + // 30 card stubs were seeded above (Ids 108041010..108041039); install a draw table + // pointing the pack at those so the sampler picks from real test cards. + var seededCardIds = Enumerable.Range(0, 30).Select(i => (long)(10804_1010 + i)).ToArray(); + await factory.SeedPackDrawTableAsync(10008, seededCardIds); using var client = factory.CreateAuthenticatedClient(viewerId); var body = new StringContent( @@ -588,6 +593,9 @@ public class PackControllerOpenTests viewer.MissionData.TutorialState = 41; // pre-END so the tutorial path is allowed await db.SaveChangesAsync(); } + // Install a draw table for 99047 pointing at the 30 seeded card stubs. + var seededCardIds = Enumerable.Range(0, 30).Select(i => (long)(99047_1010 + i)).ToArray(); + await factory.SeedPackDrawTableAsync(99047, seededCardIds); using var client = factory.CreateAuthenticatedClient(viewerId); var body = new StringContent( diff --git a/SVSim.UnitTests/Controllers/PackControllerTests.cs b/SVSim.UnitTests/Controllers/PackControllerTests.cs index 7e0ee2f..af64f73 100644 --- a/SVSim.UnitTests/Controllers/PackControllerTests.cs +++ b/SVSim.UnitTests/Controllers/PackControllerTests.cs @@ -97,6 +97,8 @@ public class PackControllerTests }); await db.SaveChangesAsync(); } + // Install a draw table for 99047 pointing at the seeded starter cards. + await factory.SeedPackDrawTableAsync(99047, 90001001L, 90001002L, 90001003L); using var client = factory.CreateAuthenticatedClient(viewerId); diff --git a/SVSim.UnitTests/Controllers/TutorialFlowEndToEndTests.cs b/SVSim.UnitTests/Controllers/TutorialFlowEndToEndTests.cs index 6254f39..c50bdb2 100644 --- a/SVSim.UnitTests/Controllers/TutorialFlowEndToEndTests.cs +++ b/SVSim.UnitTests/Controllers/TutorialFlowEndToEndTests.cs @@ -42,6 +42,8 @@ public class TutorialFlowEndToEndTests }); await db.SaveChangesAsync(); } + // Install a draw table for 99047 pointing at the seeded starter cards. + await factory.SeedPackDrawTableAsync(99047, 90001001L, 90001002L, 90001003L); using var client = factory.CreateAuthenticatedClient(viewerId); diff --git a/SVSim.UnitTests/Importers/PackDrawTableImporterTests.cs b/SVSim.UnitTests/Importers/PackDrawTableImporterTests.cs index 01709e6..6af8f4d 100644 --- a/SVSim.UnitTests/Importers/PackDrawTableImporterTests.cs +++ b/SVSim.UnitTests/Importers/PackDrawTableImporterTests.cs @@ -20,14 +20,17 @@ public class PackDrawTableImporterTests await new PackDrawTableImporter().ImportAsync(db, SeedDir); - Assert.That(await db.PackDrawConfigs.CountAsync(), Is.EqualTo(2)); - Assert.That(await db.PackDrawSlotRates.CountAsync(), Is.EqualTo(8)); - Assert.That(await db.PackDrawCardWeights.CountAsync(), Is.EqualTo(5)); + // Production seed is the source of truth in test output (no test-fixture overlay). + Assert.That(await db.PackDrawConfigs.CountAsync(), Is.GreaterThanOrEqualTo(200)); + Assert.That(await db.PackDrawSlotRates.CountAsync(), Is.GreaterThanOrEqualTo(1000)); + Assert.That(await db.PackDrawCardWeights.CountAsync(), Is.GreaterThanOrEqualTo(50_000)); + // 98001 is a Guaranteed-Leader-Card bundle — bonus slot must contain rate-less + // Special-tier leader rows. var bonus = await db.PackDrawCardWeights .Where(w => w.PackId == 98001 && w.Slot == DrawSlot.Bonus) .ToListAsync(); - Assert.That(bonus.Count, Is.EqualTo(2)); + Assert.That(bonus.Count, Is.GreaterThan(0)); Assert.That(bonus.All(w => w.RatePct == null && w.IsLeader && w.Tier == DrawTier.Special), Is.True); } diff --git a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs index dfc75a1..cb816e6 100644 --- a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs +++ b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs @@ -70,10 +70,32 @@ internal sealed class SVSimTestFactory : WebApplicationFactory // IsInRotation so both standard-pack (by setId) and special-pack (rotation scan) // tests see real data. SeedMinimalCardSet(db); + SeedMinimalPackDrawTable(db); return host; } + /// + /// Seeds a minimal PackDrawConfig + slot rates + card weights for the test card-set's + /// cards (10001001/10001002/10001003) under pack id 10001. Lets PackController.Open + /// resolve a draw table without requiring tests to run the full PackDrawTableImporter. + /// + private static void SeedMinimalPackDrawTable(SVSimDbContext db) + { + if (db.PackDrawConfigs.Any()) + return; + + const int packId = 10001; + db.PackDrawConfigs.Add(new PackDrawConfigEntry { Id = packId, AnimationRatePct = 0 }); + // Slot rates: uniform single-tier so any rng lands somewhere valid. + db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry { PackId = packId, Slot = DrawSlot.General, Tier = DrawTier.Bronze, RatePct = 100 }); + db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry { PackId = packId, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, RatePct = 100 }); + // Card weights for both slots. + db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry { PackId = packId, Slot = DrawSlot.General, Tier = DrawTier.Bronze, CardId = 10001001, RatePct = 100 }); + db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry { PackId = packId, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, CardId = 10001001, RatePct = 100 }); + db.SaveChanges(); + } + private static void SeedMinimalCardSet(SVSimDbContext db) { if (db.CardSets.Any()) @@ -242,6 +264,34 @@ internal sealed class SVSimTestFactory : WebApplicationFactory await new DefaultDeckImporter().ImportAsync(ctx, seedDir); await new PackImporter().ImportAsync(ctx, seedDir); + // PackDrawTableImporter is NOT called here — production draw tables reference real + // Cygames card_ids not present in the test's minimal card master. Tests that + // exercise /pack/open use SeedPackDrawTableAsync to install a stub draw table + // pointing to their seeded test cards. + } + + /// + /// Installs a minimal PackDrawConfig + slot rates + per-card weights for , + /// pointing the per-card weights at . All cards land in the Bronze tier + /// at 100% rate; slot 1-7 and slot 8 both draw from the same pool. Use for tests that need + /// /pack/open to succeed against a custom seeded card pool. + /// + public async Task SeedPackDrawTableAsync(int packId, params long[] cardIds) + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + if (await db.PackDrawConfigs.AnyAsync(c => c.Id == packId)) return; + + db.PackDrawConfigs.Add(new PackDrawConfigEntry { Id = packId, AnimationRatePct = 0 }); + db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry { PackId = packId, Slot = DrawSlot.General, Tier = DrawTier.Bronze, RatePct = 100 }); + db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry { PackId = packId, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, RatePct = 100 }); + foreach (var cid in cardIds) + { + db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry { PackId = packId, Slot = DrawSlot.General, Tier = DrawTier.Bronze, CardId = cid, RatePct = 100.0 / cardIds.Length }); + db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry { PackId = packId, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, CardId = cid, RatePct = 100.0 / cardIds.Length }); + } + await db.SaveChangesAsync(); } /// diff --git a/SVSim.UnitTests/Repositories/PackDrawTableRepositoryTests.cs b/SVSim.UnitTests/Repositories/PackDrawTableRepositoryTests.cs index 7129004..335b359 100644 --- a/SVSim.UnitTests/Repositories/PackDrawTableRepositoryTests.cs +++ b/SVSim.UnitTests/Repositories/PackDrawTableRepositoryTests.cs @@ -36,8 +36,8 @@ public class PackDrawTableRepositoryTests Assert.That(table, Is.Not.Null); Assert.That(table!.Config.AnimationRatePct, Is.EqualTo(8.0)); - Assert.That(table.SlotRates.Count, Is.EqualTo(7)); - Assert.That(table.CardWeights.Count, Is.EqualTo(3)); + Assert.That(table.SlotRates.Count, Is.GreaterThanOrEqualTo(4)); // bronze/silver/gold/legendary at minimum + Assert.That(table.CardWeights.Count, Is.GreaterThan(0)); Assert.That(table.CardWeights.All(w => w.PackId == 10000), Is.True); } } diff --git a/SVSim.UnitTests/Services/PackOpenServiceTests.cs b/SVSim.UnitTests/Services/PackOpenServiceTests.cs index a858981..f54d3da 100644 --- a/SVSim.UnitTests/Services/PackOpenServiceTests.cs +++ b/SVSim.UnitTests/Services/PackOpenServiceTests.cs @@ -1,6 +1,6 @@ using SVSim.Database.Enums; using SVSim.Database.Models; -using SVSim.Database.Models.Config; +using SVSim.Database.Repositories.PackDrawTables; using SVSim.Database.Services; using SVSim.EmulatedEntrypoint.Services; @@ -8,272 +8,163 @@ 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 double NextDouble() => _seq[_i++ % _seq.Length]; 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 sealed class NoFoil : ICardFoilLookup { - 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 static PackConfigEntry StandardPack(int id = 10000) => new() { - 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 }, + Id = id, BasePackId = id, PackCategory = PackCategory.None, }; - private static PackConfigEntry StandardPack() => new() + private static PackDrawTable AllBronzeTable() => new() { - Id = 10001, BasePackId = 10001, PackCategory = PackCategory.None, + Config = new PackDrawConfigEntry { Id = 10000, AnimationRatePct = 0 }, + SlotRates = new[] + { + new PackDrawSlotRateEntry { PackId = 10000, Slot = DrawSlot.General, Tier = DrawTier.Bronze, RatePct = 100.0 }, + new PackDrawSlotRateEntry { PackId = 10000, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, RatePct = 100.0 }, + }, + CardWeights = new[] + { + new PackDrawCardWeightEntry { PackId = 10000, Slot = DrawSlot.General, Tier = DrawTier.Bronze, CardId = 1, RatePct = 70 }, + new PackDrawCardWeightEntry { PackId = 10000, Slot = DrawSlot.General, Tier = DrawTier.Bronze, CardId = 2, RatePct = 30 }, + new PackDrawCardWeightEntry { PackId = 10000, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, CardId = 1, RatePct = 100 }, + }, }; [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 svc = new PackOpenService(); + var rng = new ScriptedRandom(0.1); - var result = svc.Draw(StandardPack(), pool, packNumber: 1, excludeCardIds: Array.Empty(), rng: rng); + var result = svc.Draw(AllBronzeTable(), StandardPack(), 1, + excludeCardIds: Array.Empty(), ownedCardIds: Array.Empty(), + new NoFoil(), rng); + + Assert.That(result.Cards.Count, Is.EqualTo(8)); + Assert.That(result.Cards.All(c => c.CardId == 1), Is.True); + } + + [Test] + public void Draw_picks_card_by_per_card_weight_within_tier() + { + var svc = new PackOpenService(); + // Tier roll always lands in Bronze (only tier). Card pick rng=0.8 -> within Bronze + // band > 0.7 -> card 2. Slot 8 has only card 1 in its pool so it always picks card 1. + var rng = new ScriptedRandom(0.0, 0.8); + + var result = svc.Draw(AllBronzeTable(), StandardPack(), 1, + Array.Empty(), Array.Empty(), new NoFoil(), rng); + + Assert.That(result.Cards.Take(7).All(c => c.CardId == 2), Is.True, "slots 1-7 should pick card 2"); + Assert.That(result.Cards[7].CardId, Is.EqualTo(1), "slot 8 pool only contains card 1"); + } + + [Test] + public void Draw_rate_less_branch_picks_only_unowned() + { + var pack = new PackConfigEntry { Id = 98001, BasePackId = 98001, PackCategory = PackCategory.SpecialCardPack }; + var table = new PackDrawTable + { + Config = new PackDrawConfigEntry { Id = 98001, AnimationRatePct = 0, HasBonusSlot = true, SpecialKind = "leader_card" }, + SlotRates = new[] + { + new PackDrawSlotRateEntry { PackId = 98001, Slot = DrawSlot.General, Tier = DrawTier.Bronze, RatePct = 100.0 }, + new PackDrawSlotRateEntry { PackId = 98001, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, RatePct = 100.0 }, + new PackDrawSlotRateEntry { PackId = 98001, Slot = DrawSlot.Bonus, Tier = DrawTier.Special, RatePct = 100.0 }, + }, + CardWeights = new[] + { + new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.General, Tier = DrawTier.Bronze, CardId = 10, RatePct = 100 }, + new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, CardId = 10, RatePct = 100 }, + new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.Bonus, Tier = DrawTier.Special, CardId = 300, RatePct = null, IsLeader = true }, + new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.Bonus, Tier = DrawTier.Special, CardId = 301, RatePct = null, IsLeader = true }, + new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.Bonus, Tier = DrawTier.Special, CardId = 302, RatePct = null, IsLeader = true }, + }, + }; + var svc = new PackOpenService(); + var rng = new ScriptedRandom(0.1); + + var result = svc.Draw(table, pack, packNumber: 10, + excludeCardIds: Array.Empty(), + ownedCardIds: new long[] { 300, 301 }, + new NoFoil(), rng); + + Assert.That(result.Cards.Count, Is.EqualTo(81)); // 10 packs * 8 + 1 bonus + var bonus = result.Cards[^1]; + Assert.That(bonus.CardId, Is.EqualTo(302)); + } + + [Test] + public void Draw_rate_less_falls_back_to_full_pool_when_all_owned() + { + var pack = new PackConfigEntry { Id = 98001, BasePackId = 98001 }; + var table = new PackDrawTable + { + Config = new PackDrawConfigEntry { Id = 98001, AnimationRatePct = 0, HasBonusSlot = true }, + SlotRates = new[] + { + new PackDrawSlotRateEntry { PackId = 98001, Slot = DrawSlot.General, Tier = DrawTier.Bronze, RatePct = 100.0 }, + new PackDrawSlotRateEntry { PackId = 98001, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, RatePct = 100.0 }, + new PackDrawSlotRateEntry { PackId = 98001, Slot = DrawSlot.Bonus, Tier = DrawTier.Special, RatePct = 100.0 }, + }, + CardWeights = new[] + { + new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.General, Tier = DrawTier.Bronze, CardId = 10, RatePct = 100 }, + new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, CardId = 10, RatePct = 100 }, + new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.Bonus, Tier = DrawTier.Special, CardId = 300, RatePct = null, IsLeader = true }, + new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.Bonus, Tier = DrawTier.Special, CardId = 301, RatePct = null, IsLeader = true }, + }, + }; + var svc = new PackOpenService(); + var rng = new ScriptedRandom(0.1); + + var result = svc.Draw(table, pack, packNumber: 10, + excludeCardIds: Array.Empty(), + ownedCardIds: new long[] { 300, 301 }, + new NoFoil(), rng); + + var bonus = result.Cards[^1]; + Assert.That(bonus.CardId, Is.AnyOf(300L, 301L)); + } + + [Test] + public void Draw_does_not_emit_bonus_for_packNumber_less_than_10() + { + var pack = new PackConfigEntry { Id = 98001 }; + var table = new PackDrawTable + { + Config = new PackDrawConfigEntry { Id = 98001, HasBonusSlot = true, AnimationRatePct = 0 }, + SlotRates = new[] + { + new PackDrawSlotRateEntry { PackId = 98001, Slot = DrawSlot.General, Tier = DrawTier.Bronze, RatePct = 100 }, + new PackDrawSlotRateEntry { PackId = 98001, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, RatePct = 100 }, + new PackDrawSlotRateEntry { PackId = 98001, Slot = DrawSlot.Bonus, Tier = DrawTier.Special, RatePct = 100 }, + }, + CardWeights = new[] + { + new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.General, Tier = DrawTier.Bronze, CardId = 1, RatePct = 100 }, + new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, CardId = 1, RatePct = 100 }, + new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.Bonus, Tier = DrawTier.Special, CardId = 999, RatePct = null, IsLeader = true }, + }, + }; + var svc = new PackOpenService(); + var rng = new ScriptedRandom(0.1); + + var result = svc.Draw(table, pack, packNumber: 1, + Array.Empty(), Array.Empty(), new NoFoil(), 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."); - } } diff --git a/SVSim.UnitTests/Services/WeightedPickTests.cs b/SVSim.UnitTests/Services/WeightedPickTests.cs new file mode 100644 index 0000000..dc816b5 --- /dev/null +++ b/SVSim.UnitTests/Services/WeightedPickTests.cs @@ -0,0 +1,63 @@ +using SVSim.Database.Services; +using SVSim.EmulatedEntrypoint.Services; + +namespace SVSim.UnitTests.Services; + +public class WeightedPickTests +{ + private sealed class ScriptedRandom : IRandom + { + private readonly double[] _seq; private int _i; + public ScriptedRandom(params double[] seq) { _seq = seq; } + public double NextDouble() => _seq[_i++ % _seq.Length]; + public int Next(int maxExclusive) => (int)(NextDouble() * maxExclusive); + } + + [Test] + public void Picks_first_band_when_rng_low() + { + var items = new[] { "a", "b", "c" }; + var weights = new[] { 0.5, 0.3, 0.2 }; + var rng = new ScriptedRandom(0.1); + + var picked = WeightedPick.Pick(rng, items, weights); + + Assert.That(picked, Is.EqualTo("a")); + } + + [Test] + public void Picks_middle_band() + { + var items = new[] { "a", "b", "c" }; + var weights = new[] { 0.5, 0.3, 0.2 }; + var rng = new ScriptedRandom(0.7); + + var picked = WeightedPick.Pick(rng, items, weights); + + Assert.That(picked, Is.EqualTo("b")); + } + + [Test] + public void Renormalizes_when_weights_dont_sum_to_one() + { + var items = new[] { "a", "b" }; + var weights = new[] { 50.0, 50.0 }; + var rng = new ScriptedRandom(0.4); + + var picked = WeightedPick.Pick(rng, items, weights); + + Assert.That(picked, Is.EqualTo("a")); + } + + [Test] + public void Falls_through_to_last_item_when_rng_exceeds_sum_minus_epsilon() + { + var items = new[] { "a", "b" }; + var weights = new[] { 0.5, 0.5 }; + var rng = new ScriptedRandom(0.999); + + var picked = WeightedPick.Pick(rng, items, weights); + + Assert.That(picked, Is.EqualTo("b")); + } +}