135 lines
5.3 KiB
C#
135 lines
5.3 KiB
C#
using SVSim.Database.Enums;
|
|
using SVSim.Database.Models;
|
|
using SVSim.Database.Models.Config;
|
|
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).
|
|
/// </summary>
|
|
public class PackOpenService
|
|
{
|
|
private const int CardsPerPack = 8;
|
|
|
|
private readonly PackRateConfig _rates;
|
|
|
|
public PackOpenService(IGameConfigService config)
|
|
{
|
|
_rates = config.Get<PackRateConfig>();
|
|
}
|
|
|
|
public DrawResult Draw(
|
|
PackConfigEntry pack,
|
|
ICardPoolProvider pools,
|
|
int packNumber,
|
|
IReadOnlyCollection<long> excludeCardIds,
|
|
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)
|
|
.ToDictionary(g => g.Key, g => g.ToList());
|
|
|
|
bool isLegendarySpecial =
|
|
pack.PackCategory == PackCategory.SpecialCardPack ||
|
|
pack.PackCategory == PackCategory.LimitedSpecialCardPack;
|
|
|
|
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));
|
|
}
|
|
}
|
|
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,
|
|
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)
|
|
{
|
|
if (poolByRarity.TryGetValue(r, out var list) && list.Count > 0)
|
|
{
|
|
return list[rng.Next(list.Count)];
|
|
}
|
|
}
|
|
throw new InvalidOperationException("PackOpenService: pool empty after exclude filter.");
|
|
}
|
|
}
|