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 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-30 22:26:45 -04:00
parent 0169ec57b4
commit 1c386b5ed0
14 changed files with 445 additions and 374 deletions

View File

@@ -70,10 +70,32 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
// IsInRotation so both standard-pack (by setId) and special-pack (rotation scan)
// tests see real data.
SeedMinimalCardSet(db);
SeedMinimalPackDrawTable(db);
return host;
}
/// <summary>
/// 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.
/// </summary>
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<Program>
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.
}
/// <summary>
/// Installs a minimal PackDrawConfig + slot rates + per-card weights for <paramref name="packId"/>,
/// pointing the per-card weights at <paramref name="cardIds"/>. 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.
/// </summary>
public async Task SeedPackDrawTableAsync(int packId, params long[] cardIds)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
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();
}
/// <summary>