From a98b60dd367034cd3668d1b50a2d116511b7fcc8 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 10:46:59 -0400 Subject: [PATCH] feat(svc): ArenaTwoPickCardPoolService (rarity-weighted, class+neutral) Co-Authored-By: Claude Sonnet 4.6 --- SVSim.EmulatedEntrypoint/Program.cs | 1 + .../Services/ArenaTwoPickCardPoolService.cs | 117 +++++++++++++++ .../Services/IArenaTwoPickCardPoolService.cs | 12 ++ .../ArenaTwoPickCardPoolServiceTests.cs | 142 ++++++++++++++++++ 4 files changed, 272 insertions(+) create mode 100644 SVSim.EmulatedEntrypoint/Services/ArenaTwoPickCardPoolService.cs create mode 100644 SVSim.EmulatedEntrypoint/Services/IArenaTwoPickCardPoolService.cs create mode 100644 SVSim.UnitTests/Services/ArenaTwoPickCardPoolServiceTests.cs diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index 8019e38..9a200b9 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -104,6 +104,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickCardPoolService.cs b/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickCardPoolService.cs new file mode 100644 index 0000000..77d0546 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickCardPoolService.cs @@ -0,0 +1,117 @@ +using Microsoft.EntityFrameworkCore; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.Database.Models.Config; +using SVSim.Database.Services; + +namespace SVSim.EmulatedEntrypoint.Services; + +public class ArenaTwoPickCardPoolService : IArenaTwoPickCardPoolService +{ + private readonly SVSimDbContext _db; + private readonly IGameConfigService _config; + + public ArenaTwoPickCardPoolService(SVSimDbContext db, IGameConfigService config) + { + _db = db; _config = config; + } + + public List GeneratePickSetsForTurn(int classId, int turn, long startingPairId, IRandom rng) + { + var aCfg = _config.Get(); + var cCfg = _config.Get(); + + var setIds = cCfg.PoolCardSetIds is { Count: > 0 } ids + ? ids + : _config.Get().RotationCardSetIds ?? new List(); + + var setIdsArr = setIds.ToArray(); + + // Cards belong to sets via the ShadowverseCardSetEntry.Cards collection. + // Class membership uses the Class navigation property: null = neutral (classId 0). + // Collectible = CollectionInfo != null. + var pool = _db.CardSets + .Where(s => setIdsArr.Contains(s.Id)) + .SelectMany(s => s.Cards) + .Include(c => c.Class) + .Include(c => c.CollectionInfo) + .Where(c => c.CollectionInfo != null) + .Where(c => c.Class == null || c.Class.Id == classId) + .ToList(); + + // Group by (isNeutral, Rarity) for O(1) bucket lookup. + var byBucket = pool + .GroupBy(c => (c.Class == null, c.Rarity)) + .ToDictionary(g => g.Key, g => g.ToList()); + + var pairs = new List(2); + for (int setNum = 1; setNum <= 2; setNum++) + { + pairs.Add(new CandidatePair + { + Id = startingPairId + (setNum - 1), + Turn = turn, + SetNum = setNum, + CardId1 = DrawOne(byBucket, aCfg, rng), + CardId2 = DrawOne(byBucket, aCfg, rng), + IsSelected = false, + }); + } + return pairs; + } + + private static long DrawOne( + Dictionary<(bool isNeutral, Rarity rarity), List> byBucket, + ArenaTwoPickConfig cfg, + IRandom rng) + { + var rarity = PickRarity(cfg, rng); + var isNeutral = rng.NextDouble() < cfg.NeutralMixRate; + + var candidates = + TryBucket(byBucket, isNeutral, rarity) + ?? TryBucket(byBucket, !isNeutral, rarity) + ?? FallbackByRarity(byBucket, isNeutral, rarity) + ?? byBucket.Values.SelectMany(v => v).ToList(); + + if (candidates.Count == 0) + throw new InvalidOperationException( + "ArenaTwoPickCardPoolService: card pool is empty for the configured class/set scope"); + + var pick = candidates[rng.Next(candidates.Count)]; + return pick.Id; + } + + private static List? TryBucket( + Dictionary<(bool, Rarity), List> b, + bool neutral, + Rarity r) => + b.TryGetValue((neutral, r), out var v) && v.Count > 0 ? v : null; + + private static List? FallbackByRarity( + Dictionary<(bool, Rarity), List> b, + bool neutral, + Rarity r) + { + Rarity[] order = { Rarity.Legendary, Rarity.Gold, Rarity.Silver, Rarity.Bronze }; + int idx = Array.IndexOf(order, r); + for (int i = idx + 1; i < order.Length; i++) + { + if (TryBucket(b, neutral, order[i]) is { } v) return v; + if (TryBucket(b, !neutral, order[i]) is { } w) return w; + } + return null; + } + + private static Rarity PickRarity(ArenaTwoPickConfig cfg, IRandom rng) + { + var roll = rng.NextDouble(); + if (roll < cfg.LegendaryRate) return Rarity.Legendary; + roll -= cfg.LegendaryRate; + if (roll < cfg.GoldRate) return Rarity.Gold; + roll -= cfg.GoldRate; + if (roll < cfg.SilverRate) return Rarity.Silver; + return Rarity.Bronze; + } +} diff --git a/SVSim.EmulatedEntrypoint/Services/IArenaTwoPickCardPoolService.cs b/SVSim.EmulatedEntrypoint/Services/IArenaTwoPickCardPoolService.cs new file mode 100644 index 0000000..e6f0695 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Services/IArenaTwoPickCardPoolService.cs @@ -0,0 +1,12 @@ +using SVSim.Database.Models; + +namespace SVSim.EmulatedEntrypoint.Services; + +public interface IArenaTwoPickCardPoolService +{ + /// + /// Returns exactly 2 candidate pairs for the requested turn. Ids assigned monotonically + /// (startingPairId, startingPairId+1); set_num = 1, 2; isSelected = false. + /// + List GeneratePickSetsForTurn(int classId, int turn, long startingPairId, IRandom rng); +} diff --git a/SVSim.UnitTests/Services/ArenaTwoPickCardPoolServiceTests.cs b/SVSim.UnitTests/Services/ArenaTwoPickCardPoolServiceTests.cs new file mode 100644 index 0000000..af07b64 --- /dev/null +++ b/SVSim.UnitTests/Services/ArenaTwoPickCardPoolServiceTests.cs @@ -0,0 +1,142 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.Database.Models.Config; +using SVSim.Database.Services; +using SVSim.EmulatedEntrypoint.Services; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Services; + +public class ArenaTwoPickCardPoolServiceTests +{ + /// + /// Seeds a fresh in-memory DB with cards. Each card tuple is + /// (id, classId, setId, rarity, collectible). + /// classId == 0 means neutral (Class navigation = null). + /// + private static async Task SeedCardsAsync( + params (long id, int classId, int setId, Rarity rarity, bool collectible)[] cards) + { + var factory = new SVSimTestFactory(); + var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + + // Collect required class ids and ensure ClassEntry rows exist. + var requiredClassIds = cards.Select(c => c.classId).Where(id => id != 0).Distinct().ToList(); + foreach (var cid in requiredClassIds) + { + if (!db.Classes.Any(c => c.Id == cid)) + db.Classes.Add(new ClassEntry { Id = cid, Name = $"Class{cid}" }); + } + await db.SaveChangesAsync(); + + // Load all needed classes into the context's Local cache so navigation assignments below work. + await db.Classes.Where(c => requiredClassIds.Contains(c.Id)).LoadAsync(); + var classLookup = db.Classes.Local.ToDictionary(c => c.Id); + + // Group cards by set and create CardSet entries with navigation. + var bySet = cards.GroupBy(c => c.setId); + foreach (var group in bySet) + { + var set = new ShadowverseCardSetEntry + { + Id = group.Key, + Name = $"TestSet{group.Key}", + IsInRotation = true, + }; + foreach (var c in group) + { + var classEntry = c.classId == 0 + ? null + : classLookup[c.classId]; + set.Cards.Add(new ShadowverseCardEntry + { + Id = c.id, + Name = $"Card{c.id}", + Rarity = c.rarity, + Class = classEntry, + CollectionInfo = c.collectible + ? new CardCollectionInfo { CraftCost = 200, DustReward = 50 } + : null, + }); + } + db.CardSets.Add(set); + } + await db.SaveChangesAsync(); + return db; + } + + private sealed class FakeRandom : IRandom + { + private readonly Queue _doubles; + private readonly Queue _ints; + public FakeRandom(IEnumerable doubles, IEnumerable ints) + { _doubles = new(doubles); _ints = new(ints); } + public double NextDouble() => _doubles.Dequeue(); + public int Next(int maxExclusive) => _ints.Dequeue(); + } + + [Test] + public async Task GeneratePickSets_returns_two_pairs_with_monotonic_ids_and_correct_turn() + { + await using var db = await SeedCardsAsync( + (100001010L, 1, 10015, Rarity.Bronze, true), + (900001010L, 0, 10015, Rarity.Bronze, true)); + var config = new ArenaTwoPickConfig(); + var challenge = new ChallengeConfig { PoolCardSetIds = new() { 10015 } }; + var svc = new ArenaTwoPickCardPoolService(db, StubConfig(config, challenge)); + var rng = new FakeRandom( + doubles: Enumerable.Repeat(0.99, 8), + ints: Enumerable.Repeat(0, 8)); + + var pairs = svc.GeneratePickSetsForTurn(classId: 1, turn: 3, startingPairId: 42, rng); + + Assert.That(pairs.Count, Is.EqualTo(2)); + Assert.That(pairs[0].Id, Is.EqualTo(42)); + Assert.That(pairs[1].Id, Is.EqualTo(43)); + Assert.That(pairs[0].Turn, Is.EqualTo(3)); + Assert.That(pairs[1].Turn, Is.EqualTo(3)); + Assert.That(pairs[0].SetNum, Is.EqualTo(1)); + Assert.That(pairs[1].SetNum, Is.EqualTo(2)); + Assert.That(pairs[0].IsSelected, Is.False); + } + + [Test] + public async Task Empty_PoolCardSetIds_falls_back_to_RotationConfig_RotationCardSetIds() + { + await using var db = await SeedCardsAsync( + (100001010L, 1, 10015, Rarity.Bronze, true)); + var config = new ArenaTwoPickConfig(); + var challenge = new ChallengeConfig { PoolCardSetIds = new() }; + var rotation = new RotationConfig { RotationCardSetIds = new() { 10015 } }; + var svc = new ArenaTwoPickCardPoolService(db, StubConfig(config, challenge, rotation)); + var rng = new FakeRandom( + doubles: Enumerable.Repeat(0.99, 8), + ints: Enumerable.Repeat(0, 8)); + + var pairs = svc.GeneratePickSetsForTurn(classId: 1, turn: 1, startingPairId: 1, rng); + Assert.That(pairs.Count, Is.EqualTo(2)); + Assert.That(pairs[0].CardId1, Is.EqualTo(100001010L)); + } + + private static IGameConfigService StubConfig(ArenaTwoPickConfig a, ChallengeConfig c, RotationConfig? r = null) => + new StubGameConfigService(a, c, r ?? new RotationConfig()); + + private sealed class StubGameConfigService : IGameConfigService + { + private readonly ArenaTwoPickConfig _a; + private readonly ChallengeConfig _c; + private readonly RotationConfig _r; + public StubGameConfigService(ArenaTwoPickConfig a, ChallengeConfig c, RotationConfig r) { _a = a; _c = c; _r = r; } + public T Get() where T : class, new() => + typeof(T) == typeof(ArenaTwoPickConfig) ? (T)(object)_a : + typeof(T) == typeof(ChallengeConfig) ? (T)(object)_c : + typeof(T) == typeof(RotationConfig) ? (T)(object)_r : + new T(); + } +}