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.Enums;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Repositories.Pack; using SVSim.Database.Repositories.Pack;
using SVSim.Database.Repositories.PackDrawTables;
using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Pack; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Pack;
@@ -25,7 +26,8 @@ public class PackController : SVSimController
private readonly IPackRepository _packs; private readonly IPackRepository _packs;
private readonly PackOpenService _opener; private readonly PackOpenService _opener;
private readonly ICardPoolProvider _pools; private readonly IPackDrawTableRepository _drawTables;
private readonly ICardFoilLookup _foils;
private readonly IRandom _rng; private readonly IRandom _rng;
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
private readonly ICardAcquisitionService _acquisition; private readonly ICardAcquisitionService _acquisition;
@@ -36,7 +38,8 @@ public class PackController : SVSimController
public PackController( public PackController(
IPackRepository packs, IPackRepository packs,
PackOpenService opener, PackOpenService opener,
ICardPoolProvider pools, IPackDrawTableRepository drawTables,
ICardFoilLookup foils,
IRandom rng, IRandom rng,
SVSimDbContext db, SVSimDbContext db,
ICardAcquisitionService acquisition, ICardAcquisitionService acquisition,
@@ -46,7 +49,8 @@ public class PackController : SVSimController
{ {
_packs = packs; _packs = packs;
_opener = opener; _opener = opener;
_pools = pools; _drawTables = drawTables;
_foils = foils;
_rng = rng; _rng = rng;
_db = db; _db = db;
_acquisition = acquisition; _acquisition = acquisition;
@@ -343,7 +347,28 @@ public class PackController : SVSimController
// Draw + persist. DAILY single overrides packNumber to 1 (it's a one-card open). // Draw + persist. DAILY single overrides packNumber to 1 (it's a one-card open).
int drawCount = child.IsDailySingle ? 1 : packNumber; 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)); 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). // 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.Enums;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Models.Config; using SVSim.Database.Repositories.PackDrawTables;
using SVSim.Database.Services; using SVSim.Database.Services;
namespace SVSim.EmulatedEntrypoint.Services; namespace SVSim.EmulatedEntrypoint.Services;
/// <summary> /// <summary>
/// Draws cards from a pack's pool using rates from <see cref="IGameConfigService"/>'s /// Draws cards from a pack's per-pack draw table. Slot tier and per-card weights are sampled
/// <see cref="PackRateConfig"/>. Slot rarity selection is unified through one /// directly from the seeded data. The bonus slot fires once at the end of a 10-pack open
/// <see cref="PickRarity"/> + <see cref="ResolveWeights"/> pair — what was previously a /// when <see cref="PackDrawConfigEntry.HasBonusSlot"/> is set.
/// 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).
/// </summary> /// </summary>
public class PackOpenService public class PackOpenService
{ {
private const int CardsPerPack = 8; private const int CardsPerPack = 8;
private readonly PackRateConfig _rates;
public PackOpenService(IGameConfigService config)
{
_rates = config.Get<PackRateConfig>();
}
public DrawResult Draw( public DrawResult Draw(
PackDrawTable drawTable,
PackConfigEntry pack, PackConfigEntry pack,
ICardPoolProvider pools,
int packNumber, int packNumber,
IReadOnlyCollection<long> excludeCardIds, IReadOnlyCollection<long> excludeCardIds,
IReadOnlyCollection<long> ownedCardIds,
ICardFoilLookup foilLookup,
IRandom rng) IRandom rng)
{ {
var pool = pools.GetPool(pack); var byKey = drawTable.CardWeights
if (pool.Count == 0) .GroupBy(w => (w.Slot, w.Tier))
{
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)
.ToDictionary(g => g.Key, g => g.ToList()); .ToDictionary(g => g.Key, g => g.ToList());
bool isLegendarySpecial = var slotRatesByKey = drawTable.SlotRates
pack.PackCategory == PackCategory.SpecialCardPack || .GroupBy(s => s.Slot)
pack.PackCategory == PackCategory.LimitedSpecialCardPack; .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 p = 0; p < packNumber; p++)
{ {
for (int s = 0; s < CardsPerPack; s++) for (int s = 0; s < CardsPerPack; s++)
{ {
int slotNum = s + 1; // 1-based int slotNum = s + 1;
var slot = slotNum == CardsPerPack ? DrawSlot.Eighth : DrawSlot.General;
Rarity rarity; var drawn = DrawOne(slot, drawTable, byKey, slotRatesByKey,
if (slotNum == CardsPerPack && isLegendarySpecial) excludeCardIds, ownedCardIds, foilLookup, rng);
{ slots.Add(drawn);
// 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));
} }
} }
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); return new DrawResult(slots);
} }
/// <summary> private static DrawnCard DrawOne(
/// Returns the rarity weights for the given 1-based slot. Looks for a per-slot override DrawSlot slot,
/// keyed by <c>Slot == slotNum.ToString()</c>; falls back to the global Default. PackDrawTable drawTable,
/// Dictionary<(DrawSlot, DrawTier), List<PackDrawCardWeightEntry>> byKey,
/// NOTE: PerSlot is List&lt;SlotRarityWeights&gt; (not Dictionary) due to an EF Core 8 Dictionary<DrawSlot, List<PackDrawSlotRateEntry>> slotRatesByKey,
/// jsonb-mapping limitation. Per-pack overrides would extend this resolver to check a IReadOnlyCollection<long> excludeCardIds,
/// per-pack collection first. IReadOnlyCollection<long> ownedCardIds,
/// </summary> ICardFoilLookup foilLookup,
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,
IRandom rng) IRandom rng)
{ {
// Fallback if the rolled rarity has no cards: walk down (and up) through all rarities. var slotRates = slotRatesByKey.TryGetValue(slot, out var sr) ? sr : new();
// Order: rolled rarity first, then Legendary -> Gold -> Silver -> Bronze, deduped by if (slotRates.Count == 0)
// LINQ Distinct. This handles both "no Legendaries" (fall down) and sparse pools that throw new InvalidOperationException(
// only contain a single rarity (fall up). Safety net for sparse master data. $"PackOpenService: no slot rates for pack {drawTable.Config.Id} slot {slot}");
Rarity[] fallback = new[] { rarity, Rarity.Legendary, Rarity.Gold, Rarity.Silver, Rarity.Bronze }
.Distinct().ToArray(); var tiers = slotRates.Select(r => r.Tier).ToList();
foreach (var r in fallback) 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) byKey.TryGetValue((DrawSlot.General, tier), out rows);
{
return list[rng.Next(list.Count)];
}
} }
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(); using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync(); long viewerId = await factory.SeedViewerAsync();
int parentGachaId = await SeedSingleLeaderCardPack(factory, LeaderCardId); int parentGachaId = await SeedSingleLeaderCardPack(factory, LeaderCardId);
await factory.SeedPackDrawTableAsync(parentGachaId, LeaderCardId);
await SeedCosmeticMapping(factory, LeaderCardId, SkinId); await SeedCosmeticMapping(factory, LeaderCardId, SkinId);
using (var scope = factory.Services.CreateScope()) using (var scope = factory.Services.CreateScope())
@@ -507,6 +508,10 @@ public class PackControllerOpenTests
viewer.Currency.Rupees = 10000; viewer.Currency.Rupees = 10000;
await db.SaveChangesAsync(); 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); using var client = factory.CreateAuthenticatedClient(viewerId);
var body = new StringContent( var body = new StringContent(
@@ -588,6 +593,9 @@ public class PackControllerOpenTests
viewer.MissionData.TutorialState = 41; // pre-END so the tutorial path is allowed viewer.MissionData.TutorialState = 41; // pre-END so the tutorial path is allowed
await db.SaveChangesAsync(); 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); using var client = factory.CreateAuthenticatedClient(viewerId);
var body = new StringContent( var body = new StringContent(

View File

@@ -97,6 +97,8 @@ public class PackControllerTests
}); });
await db.SaveChangesAsync(); 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); using var client = factory.CreateAuthenticatedClient(viewerId);

View File

@@ -42,6 +42,8 @@ public class TutorialFlowEndToEndTests
}); });
await db.SaveChangesAsync(); 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); using var client = factory.CreateAuthenticatedClient(viewerId);

View File

@@ -20,14 +20,17 @@ public class PackDrawTableImporterTests
await new PackDrawTableImporter().ImportAsync(db, SeedDir); await new PackDrawTableImporter().ImportAsync(db, SeedDir);
Assert.That(await db.PackDrawConfigs.CountAsync(), Is.EqualTo(2)); // Production seed is the source of truth in test output (no test-fixture overlay).
Assert.That(await db.PackDrawSlotRates.CountAsync(), Is.EqualTo(8)); Assert.That(await db.PackDrawConfigs.CountAsync(), Is.GreaterThanOrEqualTo(200));
Assert.That(await db.PackDrawCardWeights.CountAsync(), Is.EqualTo(5)); 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 var bonus = await db.PackDrawCardWeights
.Where(w => w.PackId == 98001 && w.Slot == DrawSlot.Bonus) .Where(w => w.PackId == 98001 && w.Slot == DrawSlot.Bonus)
.ToListAsync(); .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); 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) // IsInRotation so both standard-pack (by setId) and special-pack (rotation scan)
// tests see real data. // tests see real data.
SeedMinimalCardSet(db); SeedMinimalCardSet(db);
SeedMinimalPackDrawTable(db);
return host; 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) private static void SeedMinimalCardSet(SVSimDbContext db)
{ {
if (db.CardSets.Any()) if (db.CardSets.Any())
@@ -242,6 +264,34 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
await new DefaultDeckImporter().ImportAsync(ctx, seedDir); await new DefaultDeckImporter().ImportAsync(ctx, seedDir);
await new PackImporter().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> /// <summary>

View File

@@ -36,8 +36,8 @@ public class PackDrawTableRepositoryTests
Assert.That(table, Is.Not.Null); Assert.That(table, Is.Not.Null);
Assert.That(table!.Config.AnimationRatePct, Is.EqualTo(8.0)); Assert.That(table!.Config.AnimationRatePct, Is.EqualTo(8.0));
Assert.That(table.SlotRates.Count, Is.EqualTo(7)); Assert.That(table.SlotRates.Count, Is.GreaterThanOrEqualTo(4)); // bronze/silver/gold/legendary at minimum
Assert.That(table.CardWeights.Count, Is.EqualTo(3)); Assert.That(table.CardWeights.Count, Is.GreaterThan(0));
Assert.That(table.CardWeights.All(w => w.PackId == 10000), Is.True); Assert.That(table.CardWeights.All(w => w.PackId == 10000), Is.True);
} }
} }

View File

@@ -1,6 +1,6 @@
using SVSim.Database.Enums; using SVSim.Database.Enums;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Models.Config; using SVSim.Database.Repositories.PackDrawTables;
using SVSim.Database.Services; using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Services; using SVSim.EmulatedEntrypoint.Services;
@@ -8,272 +8,163 @@ namespace SVSim.UnitTests.Services;
public class PackOpenServiceTests public class PackOpenServiceTests
{ {
/// <summary>Deterministic RNG that returns the supplied doubles in order, cycling.</summary>
private sealed class ScriptedRandom : IRandom private sealed class ScriptedRandom : IRandom
{ {
private readonly double[] _seq; private int _i; private readonly double[] _seq; private int _i;
public ScriptedRandom(params double[] seq) { _seq = seq; } 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); public int Next(int maxExclusive) => (int)(NextDouble() * maxExclusive);
} }
/// <summary>Simple in-memory pool keyed by rarity for slot-distribution tests.</summary> private sealed class NoFoil : ICardFoilLookup
private sealed class StubPool : ICardPoolProvider
{ {
private readonly IReadOnlyList<ShadowverseCardEntry> _cards;
public StubPool(IReadOnlyList<ShadowverseCardEntry> cards) { _cards = cards; }
public IReadOnlyList<ShadowverseCardEntry> GetPool(PackConfigEntry _) => _cards;
public ShadowverseCardEntry? TryGetFoilTwin(long baseCardId) => null; public ShadowverseCardEntry? TryGetFoilTwin(long baseCardId) => null;
} }
/// <summary> private static PackConfigEntry StandardPack(int id = 10000) => new()
/// 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 readonly PackRateConfig _rates; Id = id, BasePackId = id, PackCategory = PackCategory.None,
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 },
}; };
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] [Test]
public void Draw_returns_eight_cards_for_one_pack() public void Draw_returns_eight_cards_for_one_pack()
{ {
var svc = MakeService(PackRateConfig.ShippedDefaults()); var svc = new PackOpenService();
var pool = new StubPool(MakeFourCards()); var rng = new ScriptedRandom(0.1);
var rng = new ScriptedRandom(0.5); // anything in Bronze/Silver range for non-slot-8
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)); 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"));
}
}