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:
@@ -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 }
|
||||
]
|
||||
@@ -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" }
|
||||
]
|
||||
@@ -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 }
|
||||
]
|
||||
@@ -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).
|
||||
|
||||
@@ -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<SlotRarityWeights> (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,
|
||||
};
|
||||
}
|
||||
|
||||
30
SVSim.EmulatedEntrypoint/Services/WeightedPick.cs
Normal file
30
SVSim.EmulatedEntrypoint/Services/WeightedPick.cs
Normal 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 <1 absorb
|
||||
/// into the last band; sums >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];
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
63
SVSim.UnitTests/Services/WeightedPickTests.cs
Normal file
63
SVSim.UnitTests/Services/WeightedPickTests.cs
Normal 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user