diff --git a/SVSim.Bootstrap/Program.cs b/SVSim.Bootstrap/Program.cs index b10ecb2..ab2ecde 100644 --- a/SVSim.Bootstrap/Program.cs +++ b/SVSim.Bootstrap/Program.cs @@ -116,6 +116,7 @@ public static class Program await new DefaultDeckImporter().ImportAsync(context, opts.SeedDir); await new PackImporter().ImportAsync(context, opts.SeedDir); + await new PackDrawTableImporter().ImportAsync(context, opts.SeedDir); // BuildDeck pipeline: series CSV → catalog JSON → package CSV. Catalog must run after // series CSV (FK on products → series) and before package CSV (so the catalog-side diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index b6fb762..e4112cb 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -80,7 +80,6 @@ public class Program // pitfall. Cost: one indexed single-row query per section per request — trivial. No // in-process cache today; the IGameConfigService interface is shaped to allow one later. builder.Services.AddScoped(); - builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/SVSim.EmulatedEntrypoint/Services/DbCardPoolProvider.cs b/SVSim.EmulatedEntrypoint/Services/DbCardPoolProvider.cs deleted file mode 100644 index 614b0e3..0000000 --- a/SVSim.EmulatedEntrypoint/Services/DbCardPoolProvider.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using SVSim.Database; -using SVSim.Database.Enums; -using SVSim.Database.Models; - -namespace SVSim.EmulatedEntrypoint.Services; - -public class DbCardPoolProvider : ICardPoolProvider -{ - private readonly SVSimDbContext _db; - public DbCardPoolProvider(SVSimDbContext db) { _db = db; } - - public IReadOnlyList GetPool(PackConfigEntry pack) - { - switch (pack.PackCategory) - { - case PackCategory.None: - case PackCategory.LegendCardPack: - { - var pool = _db.CardSets - .Where(s => s.Id == pack.BasePackId) - .SelectMany(s => s.Cards) - .Where(c => !c.IsFoil) - .ToList(); - if (pool.Count > 0) return pool; - - // BasePackId 90001 (and the 9xxxx range generally) is a synthetic "Throwback - // Rotation" category that doesn't have a corresponding real card_set in the - // prod card master — its real pool is a curated subset of rotation-eligible - // older sets (Altersphere–Colosseum for 99047; see the gacha_detail string). - // We don't have that membership map, so fall back to all in-rotation cards. - // Broader pool than prod but produces a valid 8-card draw, which is what the - // tutorial flow needs to advance to step 100. - // TODO: import the real Throwback Rotation card-set membership and key the - // pool off that. Source data is in the client's pack-pool master, not yet - // captured. - return _db.CardSets - .Where(s => s.IsInRotation) - .SelectMany(s => s.Cards) - .Where(c => !c.IsFoil) - .Distinct() - .ToList(); - } - - case PackCategory.SpecialCardPack: - case PackCategory.LimitedSpecialCardPack: - return _db.CardSets - .Where(s => s.IsInRotation) - .SelectMany(s => s.Cards) - .Where(c => !c.IsFoil) - .Distinct() - .ToList(); - - default: - return Array.Empty(); - } - } - - public ShadowverseCardEntry? TryGetFoilTwin(long baseCardId) => - _db.Cards.FirstOrDefault(c => c.Id == baseCardId + 1 && c.IsFoil); -} diff --git a/SVSim.EmulatedEntrypoint/Services/GachaPointService.cs b/SVSim.EmulatedEntrypoint/Services/GachaPointService.cs index 0c7dfbb..c837fb2 100644 --- a/SVSim.EmulatedEntrypoint/Services/GachaPointService.cs +++ b/SVSim.EmulatedEntrypoint/Services/GachaPointService.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using SVSim.Database; using SVSim.Database.Enums; using SVSim.Database.Models; +using SVSim.Database.Repositories.PackDrawTables; using SVSim.Database.Services; using SVSim.EmulatedEntrypoint.Models.Dtos; @@ -11,13 +12,13 @@ namespace SVSim.EmulatedEntrypoint.Services; public sealed class GachaPointService : IGachaPointService { private readonly SVSimDbContext _db; - private readonly ICardPoolProvider _pools; + private readonly IPackDrawTableRepository _drawTables; private readonly RewardGrantService _grants; - public GachaPointService(SVSimDbContext db, ICardPoolProvider pools, RewardGrantService grants) + public GachaPointService(SVSimDbContext db, IPackDrawTableRepository drawTables, RewardGrantService grants) { _db = db; - _pools = pools; + _drawTables = drawTables; _grants = grants; } @@ -26,7 +27,8 @@ public sealed class GachaPointService : IGachaPointService var pack = await _db.Packs.FirstOrDefaultAsync(p => p.Id == packId); if (pack?.GachaPointConfig is null) return Array.Empty(); - var pool = _pools.GetPool(pack); + var drawTable = await _drawTables.GetAsync(packId); + if (drawTable is null) return Array.Empty(); // EF Core 8 has no ToHashSetAsync on IQueryable — materialize via ToListAsync then hash. var receivedCardIds = (await _db.Viewers @@ -36,9 +38,11 @@ public sealed class GachaPointService : IGachaPointService .Select(r => r.CardId) .ToListAsync()).ToHashSet(); - var legendaryCardIds = pool - .Where(c => c.Rarity == Rarity.Legendary && !c.IsFoil) - .Select(c => c.Id) + // Legendaries in the pack's draw table — exchange ignores foils (the alt-art foil + // printing is gated separately) and tiers other than Legendary. + var legendaryCardIds = drawTable.CardWeights + .Where(w => w.Tier == DrawTier.Legendary && !w.IsAltArt) + .Select(w => w.CardId) .ToHashSet(); // Re-query legendaries with Class loaded — pool provider doesn't include navs, diff --git a/SVSim.EmulatedEntrypoint/Services/ICardPoolProvider.cs b/SVSim.EmulatedEntrypoint/Services/ICardPoolProvider.cs deleted file mode 100644 index 0d6466e..0000000 --- a/SVSim.EmulatedEntrypoint/Services/ICardPoolProvider.cs +++ /dev/null @@ -1,17 +0,0 @@ -using SVSim.Database.Models; - -namespace SVSim.EmulatedEntrypoint.Services; - -/// Resolves the card pool a pack draws from. Pure function over master data. -public interface ICardPoolProvider -{ - IReadOnlyList GetPool(PackConfigEntry pack); - - /// - /// Returns the foil twin of if it exists in master data - /// (foil card_id = base card_id + 1 by the cards.json convention), else null. One DB - /// hit per call; expected ~0.64 calls per 8-card pack at the default 8% rate. - /// TODO(caching): folds into the broader caching wave once one exists. - /// - ShadowverseCardEntry? TryGetFoilTwin(long baseCardId); -} diff --git a/SVSim.UnitTests/Controllers/PackControllerGachaPointTests.cs b/SVSim.UnitTests/Controllers/PackControllerGachaPointTests.cs index c8a4dc5..f1cb1d1 100644 --- a/SVSim.UnitTests/Controllers/PackControllerGachaPointTests.cs +++ b/SVSim.UnitTests/Controllers/PackControllerGachaPointTests.cs @@ -44,6 +44,7 @@ public class PackControllerGachaPointTests }); await db.SaveChangesAsync(); } + await factory.SeedPackDrawTableFromSetAsync(10008, 10008); using var client = factory.CreateAuthenticatedClient(viewerId); var body = JsonBody("""{"odds_gacha_id":10008,"parent_gacha_id":10008,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""); @@ -95,6 +96,7 @@ public class PackControllerGachaPointTests }); await db.SaveChangesAsync(); } + await factory.SeedPackDrawTableFromSetAsync(10008, 10008); using var client = factory.CreateAuthenticatedClient(viewerId); var body = JsonBody("""{"odds_gacha_id":10008,"parent_gacha_id":10008,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""); @@ -145,6 +147,7 @@ public class PackControllerGachaPointTests viewer.GachaPointBalances.Add(new ViewerGachaPointBalance { PackId = 10008, Points = 500 }); await db.SaveChangesAsync(); } + await factory.SeedPackDrawTableFromSetAsync(10008, 10008); using var client = factory.CreateAuthenticatedClient(viewerId); var body = JsonBody("""{"card_id":108041010,"parent_gacha_id":10008,"odds_gacha_id":10008,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""); @@ -213,6 +216,7 @@ public class PackControllerGachaPointTests }); await db.SaveChangesAsync(); } + await factory.SeedPackDrawTableFromSetAsync(10008, 10008); using var client = factory.CreateAuthenticatedClient(viewerId); var body = JsonBody("""{"card_id":108041010,"parent_gacha_id":10008,"odds_gacha_id":10008,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""); diff --git a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs index cb816e6..f22b797 100644 --- a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs +++ b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs @@ -276,7 +276,32 @@ internal sealed class SVSimTestFactory : WebApplicationFactory /// 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) + public Task SeedPackDrawTableAsync(int packId, params long[] cardIds) + => SeedPackDrawTableAsync(packId, DrawTier.Bronze, cardIds); + + /// + /// Convenience for gacha-point tests: picks Legendary cards from + /// (skipping foils) and seeds them as the draw table's Legendary tier for . + /// + public async Task SeedPackDrawTableFromSetAsync(int packId, int cardSetId) + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var legendaryIds = await db.CardSets + .Where(s => s.Id == cardSetId) + .SelectMany(s => s.Cards) + .Where(c => c.Rarity == SVSim.Database.Enums.Rarity.Legendary && !c.IsFoil) + .Select(c => c.Id) + .ToListAsync(); + + if (legendaryIds.Count > 0) + { + await SeedPackDrawTableAsync(packId, DrawTier.Legendary, legendaryIds.ToArray()); + } + } + + public async Task SeedPackDrawTableAsync(int packId, DrawTier tier, params long[] cardIds) { using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); @@ -284,12 +309,12 @@ internal sealed class SVSimTestFactory : WebApplicationFactory 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 }); + db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry { PackId = packId, Slot = DrawSlot.General, Tier = tier, RatePct = 100 }); + db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry { PackId = packId, Slot = DrawSlot.Eighth, Tier = tier, 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 }); + db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry { PackId = packId, Slot = DrawSlot.General, Tier = tier, CardId = cid, RatePct = 100.0 / cardIds.Length }); + db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry { PackId = packId, Slot = DrawSlot.Eighth, Tier = tier, CardId = cid, RatePct = 100.0 / cardIds.Length }); } await db.SaveChangesAsync(); } diff --git a/SVSim.UnitTests/Services/DbCardPoolProviderTests.cs b/SVSim.UnitTests/Services/DbCardPoolProviderTests.cs deleted file mode 100644 index f426164..0000000 --- a/SVSim.UnitTests/Services/DbCardPoolProviderTests.cs +++ /dev/null @@ -1,148 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using SVSim.Database; -using SVSim.Database.Enums; -using SVSim.Database.Models; -using SVSim.EmulatedEntrypoint.Services; -using SVSim.UnitTests.Infrastructure; - -namespace SVSim.UnitTests.Services; - -public class DbCardPoolProviderTests -{ - [Test] - public async Task GetPool_for_standard_pack_returns_cards_of_matching_set() - { - using var factory = new SVSimTestFactory(); - long anyCardId; - int setId; - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - var setWithCards = await db.CardSets.Include(s => s.Cards) - .FirstAsync(s => s.Cards.Count > 0); - setId = setWithCards.Id; - anyCardId = setWithCards.Cards.First().Id; - } - - using var scope2 = factory.Services.CreateScope(); - var provider = scope2.ServiceProvider.GetRequiredService(); - var pool = provider.GetPool(new PackConfigEntry - { - Id = setId, BasePackId = setId, PackCategory = PackCategory.None - }); - - Assert.That(pool.Any(c => c.Id == anyCardId), Is.True); - } - - [Test] - public void GetPool_for_legendary_special_returns_cards_from_rotation_sets() - { - using var factory = new SVSimTestFactory(); - using var scope = factory.Services.CreateScope(); - var provider = scope.ServiceProvider.GetRequiredService(); - - var pool = provider.GetPool(new PackConfigEntry - { - Id = 92001, BasePackId = 90001, PackCategory = PackCategory.SpecialCardPack - }); - - Assert.That(pool.Count, Is.GreaterThan(0)); - } - - [Test] - public void GetPool_for_skin_pack_returns_empty() - { - using var factory = new SVSimTestFactory(); - using var scope = factory.Services.CreateScope(); - var provider = scope.ServiceProvider.GetRequiredService(); - - var pool = provider.GetPool(new PackConfigEntry - { - Id = 70001, BasePackId = 70001, PackCategory = PackCategory.LeaderSkinPack - }); - - Assert.That(pool, Is.Empty); - } - - [Test] - public async Task GetPool_excludes_foil_cards() - { - using var factory = new SVSimTestFactory(); - long nonFoilId, foilId; - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - // Pick the highest-Id card so that id+1 is guaranteed unoccupied. - nonFoilId = await db.Cards.OrderByDescending(c => c.Id).Select(c => c.Id).FirstAsync(); - foilId = nonFoilId + 1; - var foilCard = new ShadowverseCardEntry - { - Id = foilId, Name = $"Card {foilId}", Rarity = Rarity.Bronze, IsFoil = true, - }; - // Add directly to the Cards DbSet and set the FK via shadow property, - // avoiding nav-collection tracker conflicts. - db.Cards.Add(foilCard); - db.Entry(foilCard).Property("ShadowverseCardSetEntryId").CurrentValue = 10001; - await db.SaveChangesAsync(); - } - - using var scope2 = factory.Services.CreateScope(); - var provider = scope2.ServiceProvider.GetRequiredService(); - var pool = provider.GetPool(new PackConfigEntry - { - Id = 10001, BasePackId = 10001, - PackCategory = PackCategory.None, - }); - - Assert.That(pool.Any(c => c.Id == nonFoilId), Is.True, "non-foil must be in the pool"); - Assert.That(pool.Any(c => c.Id == foilId), Is.False, "foil must be excluded from the pool"); - } - - [Test] - public async Task TryGetFoilTwin_returns_the_id_plus_one_foil_when_present() - { - using var factory = new SVSimTestFactory(); - long nonFoilId, foilId; - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - // Pick the highest-Id card so that id+1 is guaranteed unoccupied. - nonFoilId = await db.Cards.OrderByDescending(c => c.Id).Select(c => c.Id).FirstAsync(); - foilId = nonFoilId + 1; - var foilCard = new ShadowverseCardEntry - { - Id = foilId, Name = $"Card {foilId}", Rarity = Rarity.Bronze, IsFoil = true, - }; - db.Cards.Add(foilCard); - db.Entry(foilCard).Property("ShadowverseCardSetEntryId").CurrentValue = 10001; - await db.SaveChangesAsync(); - } - - using var scope2 = factory.Services.CreateScope(); - var provider = scope2.ServiceProvider.GetRequiredService(); - - var twin = provider.TryGetFoilTwin(nonFoilId); - Assert.That(twin, Is.Not.Null); - Assert.That(twin!.Id, Is.EqualTo(foilId)); - Assert.That(twin.IsFoil, Is.True); - } - - [Test] - public async Task TryGetFoilTwin_returns_null_when_no_foil_at_id_plus_one() - { - using var factory = new SVSimTestFactory(); - long anyCardId; - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - anyCardId = await db.Cards.OrderBy(c => c.Id).Select(c => c.Id).FirstAsync(); - } - - using var scope2 = factory.Services.CreateScope(); - var provider = scope2.ServiceProvider.GetRequiredService(); - - Assert.That(provider.TryGetFoilTwin(anyCardId), Is.Null, - "no foil seeded at anyCardId+1, so TryGetFoilTwin must return null"); - } -} diff --git a/SVSim.UnitTests/Services/GachaPointServiceTests.cs b/SVSim.UnitTests/Services/GachaPointServiceTests.cs index f076d55..0c153f4 100644 --- a/SVSim.UnitTests/Services/GachaPointServiceTests.cs +++ b/SVSim.UnitTests/Services/GachaPointServiceTests.cs @@ -78,6 +78,7 @@ public class GachaPointServiceTests GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = 400, IncreaseGachaPoint = 1 }, }); await db.SaveChangesAsync(); + await factory.SeedPackDrawTableFromSetAsync(10008, 10008); var svc = scope.ServiceProvider.GetRequiredService(); var result = await svc.GetRewardsAsync(10008, viewerId); @@ -125,6 +126,7 @@ public class GachaPointServiceTests GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = 400, IncreaseGachaPoint = 1 }, }); await db.SaveChangesAsync(); + await factory.SeedPackDrawTableFromSetAsync(10008, 10008); var svc = scope.ServiceProvider.GetRequiredService(); var result = await svc.GetRewardsAsync(10008, viewerId); @@ -173,6 +175,7 @@ public class GachaPointServiceTests GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = 400, IncreaseGachaPoint = 1 }, }); await db.SaveChangesAsync(); + await factory.SeedPackDrawTableFromSetAsync(10008, 10008); var svc = scope.ServiceProvider.GetRequiredService(); var result = await svc.GetRewardsAsync(10008, viewerId); @@ -219,6 +222,7 @@ public class GachaPointServiceTests PackId = 10008, CardId = 108041010, ReceivedAt = DateTime.UtcNow, }); await db.SaveChangesAsync(); + await factory.SeedPackDrawTableFromSetAsync(10008, 10008); var svc = scope.ServiceProvider.GetRequiredService(); var result = await svc.GetRewardsAsync(10008, viewerId); @@ -515,6 +519,7 @@ public class GachaPointServiceTests GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = 400, IncreaseGachaPoint = 1 }, }); await db.SaveChangesAsync(); + await factory.SeedPackDrawTableFromSetAsync(10008, 10008); var svc = scope.ServiceProvider.GetRequiredService(); var result = await svc.GetRewardsAsync(10008, viewerId); @@ -555,6 +560,7 @@ public class GachaPointServiceTests GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = 400, IncreaseGachaPoint = 1 }, }); await db.SaveChangesAsync(); + await factory.SeedPackDrawTableFromSetAsync(10099, 10099); var svc = scope.ServiceProvider.GetRequiredService(); var result = await svc.GetRewardsAsync(10099, viewerId); @@ -586,6 +592,14 @@ public class GachaPointServiceTests CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = threshold, IncreaseGachaPoint = 1 }, }); + // Draw table pointing at the seeded legendary so IPackDrawTableRepository.GetAsync + // surfaces it for GachaPointService.GetRewardsAsync / TryExchangeAsync. + db.PackDrawConfigs.Add(new PackDrawConfigEntry { Id = packId, AnimationRatePct = 0 }); + db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry { PackId = packId, Slot = DrawSlot.General, Tier = DrawTier.Legendary, RatePct = 100 }); + db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry + { + PackId = packId, Slot = DrawSlot.General, Tier = DrawTier.Legendary, CardId = 108041010, RatePct = 100, + }); db.SaveChanges(); } }