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,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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user