feat(packs): wire PackDrawTableImporter; retire ICardPoolProvider

Bootstrap Program.cs now calls PackDrawTableImporter after PackImporter.
Delete DbCardPoolProvider, ICardPoolProvider, and the DbCardPoolProvider
tests — the new IPackDrawTableRepository covers what GachaPointService
needed (legendary-tier card_ids per pack) and PackOpenService takes the
draw table directly.

GachaPointService now resolves the legendary catalog from
PackDrawTable.CardWeights filtered by Tier==Legendary, instead of
ICardPoolProvider.GetPool then a rarity filter. Same end set, no DB pool
walk.

Test fallout: tests that fabricate custom card sets for gacha-point
tests now call factory.SeedPackDrawTableFromSetAsync(packId, setId)
to install a matching legendary-tier stub. Full suite: 647/647 green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-30 22:45:02 -04:00
parent 1c386b5ed0
commit 517f855112
9 changed files with 60 additions and 239 deletions

View File

@@ -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

View File

@@ -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<SVSim.Database.Services.IGameConfigService, GameConfigService>();
builder.Services.AddScoped<ICardPoolProvider, DbCardPoolProvider>();
builder.Services.AddScoped<ICardFoilLookup, DbCardFoilLookup>();
builder.Services.AddScoped<PackOpenService>();
builder.Services.AddScoped<IGachaPointService, GachaPointService>();

View File

@@ -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<ShadowverseCardEntry> 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 (AltersphereColosseum 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<ShadowverseCardEntry>();
}
}
public ShadowverseCardEntry? TryGetFoilTwin(long baseCardId) =>
_db.Cards.FirstOrDefault(c => c.Id == baseCardId + 1 && c.IsFoil);
}

View File

@@ -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<GachaPointRewardDto>();
var pool = _pools.GetPool(pack);
var drawTable = await _drawTables.GetAsync(packId);
if (drawTable is null) return Array.Empty<GachaPointRewardDto>();
// 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,

View File

@@ -1,17 +0,0 @@
using SVSim.Database.Models;
namespace SVSim.EmulatedEntrypoint.Services;
/// <summary>Resolves the card pool a pack draws from. Pure function over master data.</summary>
public interface ICardPoolProvider
{
IReadOnlyList<ShadowverseCardEntry> GetPool(PackConfigEntry pack);
/// <summary>
/// Returns the foil twin of <paramref name="baseCardId"/> 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.
/// </summary>
ShadowverseCardEntry? TryGetFoilTwin(long baseCardId);
}

View File

@@ -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":""}""");

View File

@@ -276,7 +276,32 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
/// 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.
/// </summary>
public async Task SeedPackDrawTableAsync(int packId, params long[] cardIds)
public Task SeedPackDrawTableAsync(int packId, params long[] cardIds)
=> SeedPackDrawTableAsync(packId, DrawTier.Bronze, cardIds);
/// <summary>
/// Convenience for gacha-point tests: picks Legendary cards from <paramref name="cardSetId"/>
/// (skipping foils) and seeds them as the draw table's Legendary tier for <paramref name="packId"/>.
/// </summary>
public async Task SeedPackDrawTableFromSetAsync(int packId, int cardSetId)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
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<SVSimDbContext>();
@@ -284,12 +309,12 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
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();
}

View File

@@ -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<SVSimDbContext>();
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<ICardPoolProvider>();
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<ICardPoolProvider>();
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<ICardPoolProvider>();
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<SVSimDbContext>();
// 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<ICardPoolProvider>();
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<SVSimDbContext>();
// 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<ICardPoolProvider>();
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<SVSimDbContext>();
anyCardId = await db.Cards.OrderBy(c => c.Id).Select(c => c.Id).FirstAsync();
}
using var scope2 = factory.Services.CreateScope();
var provider = scope2.ServiceProvider.GetRequiredService<ICardPoolProvider>();
Assert.That(provider.TryGetFoilTwin(anyCardId), Is.Null,
"no foil seeded at anyCardId+1, so TryGetFoilTwin must return null");
}
}

View File

@@ -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<IGachaPointService>();
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<IGachaPointService>();
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<IGachaPointService>();
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<IGachaPointService>();
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<IGachaPointService>();
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<IGachaPointService>();
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();
}
}