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

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

View File

@@ -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" }
]

View File

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

View File

@@ -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<int>(c, "CardId"))
.ToListAsync();
var draw = _opener.Draw(
drawTable,
pack,
drawCount,
request.ExcludeCardIds ?? Array.Empty<long>(),
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).

View File

@@ -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;
/// <summary>
/// Draws cards from a pack's pool using rates from <see cref="IGameConfigService"/>'s
/// <see cref="PackRateConfig"/>. Slot rarity selection is unified through one
/// <see cref="PickRarity"/> + <see cref="ResolveWeights"/> pair — what was previously a
/// hardcoded slot-1-7 vs slot-8 split now reads from <c>PackRateConfig.PerSlot</c>.
///
/// 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 <see cref="PackDrawConfigEntry.HasBonusSlot"/> is set.
/// </summary>
public class PackOpenService
{
private const int CardsPerPack = 8;
private readonly PackRateConfig _rates;
public PackOpenService(IGameConfigService config)
{
_rates = config.Get<PackRateConfig>();
}
public DrawResult Draw(
PackDrawTable drawTable,
PackConfigEntry pack,
ICardPoolProvider pools,
int packNumber,
IReadOnlyCollection<long> excludeCardIds,
IReadOnlyCollection<long> 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<DrawnCard>(packNumber * CardsPerPack + 1);
var slots = new List<DrawnCard>(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);
}
/// <summary>
/// Returns the rarity weights for the given 1-based slot. Looks for a per-slot override
/// keyed by <c>Slot == slotNum.ToString()</c>; falls back to the global Default.
///
/// NOTE: PerSlot is List&lt;SlotRarityWeights&gt; (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.
/// </summary>
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<Rarity, List<ShadowverseCardEntry>> poolByRarity,
private static DrawnCard DrawOne(
DrawSlot slot,
PackDrawTable drawTable,
Dictionary<(DrawSlot, DrawTier), List<PackDrawCardWeightEntry>> byKey,
Dictionary<DrawSlot, List<PackDrawSlotRateEntry>> slotRatesByKey,
IReadOnlyCollection<long> excludeCardIds,
IReadOnlyCollection<long> 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<PackDrawCardWeightEntry>> byKey,
IReadOnlyCollection<long> 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,
};
}

View File

@@ -0,0 +1,30 @@
using SVSim.Database.Services;
namespace SVSim.EmulatedEntrypoint.Services;
/// <summary>
/// Generic cumulative-band weighted picker used by PackOpenService for tier-by-slot
/// and card-within-tier sampling. Renormalizes weights internally (sums &lt;1 absorb
/// into the last band; sums &gt;1 scale down).
/// </summary>
public static class WeightedPick
{
public static T Pick<T>(IRandom rng, IReadOnlyList<T> items, IReadOnlyList<double> 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];
}
}

View File

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

View File

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

View File

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

View File

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

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>

View File

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

View File

@@ -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
{
/// <summary>Deterministic RNG that returns the supplied doubles in order, cycling.</summary>
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);
}
/// <summary>Simple in-memory pool keyed by rarity for slot-distribution tests.</summary>
private sealed class StubPool : ICardPoolProvider
private sealed class NoFoil : ICardFoilLookup
{
private readonly IReadOnlyList<ShadowverseCardEntry> _cards;
public StubPool(IReadOnlyList<ShadowverseCardEntry> cards) { _cards = cards; }
public IReadOnlyList<ShadowverseCardEntry> GetPool(PackConfigEntry _) => _cards;
public ShadowverseCardEntry? TryGetFoilTwin(long baseCardId) => null;
}
/// <summary>
/// Test stub that returns a single pre-built section. Only handles <see cref="PackRateConfig"/>
/// (the type <see cref="PackOpenService"/> reads in its ctor); other section types throw so a
/// future test that needs them must extend this stub explicitly.
/// </summary>
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<T>() 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<ShadowverseCardEntry> 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<long>(), rng: rng);
var result = svc.Draw(AllBronzeTable(), StandardPack(), 1,
excludeCardIds: Array.Empty<long>(), ownedCardIds: Array.Empty<long>(),
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<long>(), Array.Empty<long>(), 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<long>(),
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<long>(),
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<long>(), Array.Empty<long>(), 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<long>(), 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<long>(), 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, int>
{
{ 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<long>(), 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<ShadowverseCardEntry>
{
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<SlotRarityWeights> with a Slot string key (no Dictionary<int,T> 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<long>(), new SystemRandom(trial));
Assert.That(result.Cards[2].Rarity, Is.EqualTo(Rarity.Legendary),
$"slot 3 must be Legendary under PerSlot[3] override (trial {trial})");
}
}
/// <summary>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.</summary>
private sealed class StubPoolWithFoils : ICardPoolProvider
{
private readonly IReadOnlyList<ShadowverseCardEntry> _pool;
private readonly Dictionary<long, ShadowverseCardEntry> _foilsByBaseId;
public StubPoolWithFoils(IReadOnlyList<ShadowverseCardEntry> pool, Dictionary<long, ShadowverseCardEntry> foilsByBaseId)
{
_pool = pool;
_foilsByBaseId = foilsByBaseId;
}
public IReadOnlyList<ShadowverseCardEntry> 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<ShadowverseCardEntry> { bronze },
new Dictionary<long, ShadowverseCardEntry> { [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<long>(), 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<ShadowverseCardEntry> { bronze },
new Dictionary<long, ShadowverseCardEntry>()); // 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<long>(), 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<ShadowverseCardEntry> { leg },
new Dictionary<long, ShadowverseCardEntry> { [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<long>(), 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");
}
/// <summary>
/// Regression guard for the 2026-05-24 slot-8-ignores-PerSlot-override bug. Invariant: a
/// freshly-constructed <see cref="PackRateConfig"/> has an EMPTY PerSlot list. The original
/// trigger (EF Core 8's <c>OwnsMany</c>+<c>ToJson</c> jsonb materialisation appending rows
/// onto whatever the parent's ctor produced — leaving two slot-8 entries where the seeded one
/// silently won <see cref="PackOpenService.ResolveWeights"/>'s <c>FirstOrDefault</c>) is gone
/// now (config goes through <c>IGameConfigService</c> + 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 <see cref="PackRateConfig.ShippedDefaults"/>.
/// </summary>
[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.");
}
}

View File

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