Pack logic cleanup

This commit is contained in:
gamer147
2026-05-24 09:27:10 -04:00
parent 79209bd70b
commit d9ef9fe1fc
33 changed files with 71175 additions and 245 deletions

View File

@@ -1,22 +1,29 @@
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Models.Config;
namespace SVSim.EmulatedEntrypoint.Services;
/// <summary>
/// Draws cards from a pack's pool using the original Shadowverse Classic rates:
/// Slots 1-7: Bronze 67.44% / Silver 25% / Gold 6% / Legendary 1.5%
/// Slot 8: Silver 76.92% / Gold 18.46% / Legendary 4.62% (no Bronze)
/// Legendary-special packs (category 2/3, base >= 90001): slot 8 forced to Legendary.
/// Draws cards from a pack's pool using rates from the injected <see cref="GameConfigRoot"/>'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 0.06% slack in slots 1-7 (rates sum to 99.94%) is folded into Bronze so cumulative
/// weights add to exactly 1.0 — any RNG roll past the Gold band lands in either Legendary or
/// Bronze, and we put it in Bronze to err on the player-unfriendly side of the spec.
/// 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(GameConfigRoot config)
{
_rates = config.PackRates;
}
public DrawResult Draw(
PackConfigEntry pack,
ICardPoolProvider pools,
@@ -45,48 +52,62 @@ public class PackOpenService
{
for (int s = 0; s < CardsPerPack; s++)
{
int slotNum = s + 1; // 1-based
Rarity rarity;
if (s == CardsPerPack - 1)
if (slotNum == CardsPerPack && isLegendarySpecial)
{
// Slot 8
if (isLegendarySpecial)
{
rarity = Rarity.Legendary;
}
else
{
rarity = PickSlot8Rarity(rng);
}
// Structural category rule (not a tunable rate).
rarity = Rarity.Legendary;
}
else
{
rarity = PickSlot1To7Rarity(rng);
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);
}
private static Rarity PickSlot1To7Rarity(IRandom rng)
/// <summary>
/// Returns the rarity weights for the given 1-based slot. Looks for a per-slot override
/// keyed by <c>Slot == slotNum.ToString()</c>; falls back to the global Default.
///
/// NOTE: PerSlot is List&lt;SlotRarityWeights&gt; (not Dictionary) due to an EF Core 8
/// jsonb-mapping limitation. Per-pack overrides would extend this resolver to check a
/// per-pack collection first.
/// </summary>
private SlotRarityWeights ResolveWeights(int slotNum)
{
double r = rng.NextDouble();
// Build cumulative bands in this order: Legendary -> Gold -> Silver -> Bronze (catch-all).
if (r < 0.0150) return Rarity.Legendary; // 1.5%
if (r < 0.0750) return Rarity.Gold; // +6% = 7.5%
if (r < 0.3250) return Rarity.Silver; // +25% = 32.5%
return Rarity.Bronze; // remaining (~67.5%; absorbs 0.06% slack)
var slotKey = slotNum.ToString();
var perSlot = _rates.PerSlot.FirstOrDefault(s => s.Slot == slotKey);
return perSlot ?? _rates.Default;
}
private static Rarity PickSlot8Rarity(IRandom rng)
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();
// Renormalized over 32.5: Legendary 4.62%, Gold 18.46%, Silver 76.92%.
if (r < 0.0462) return Rarity.Legendary;
if (r < 0.2308) return Rarity.Gold; // 0.0462 + 0.1846
return Rarity.Silver;
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(
@@ -94,10 +115,12 @@ public class PackOpenService
Dictionary<Rarity, List<ShadowverseCardEntry>> poolByRarity,
IRandom rng)
{
// Fallback if the rolled rarity has no cards (e.g. pool has no Legendaries):
// walk down to Gold -> Silver -> Bronze. This is a safety net for sparse master data;
// healthy production pools have all four rarities.
Rarity[] fallback = { rarity, Rarity.Gold, Rarity.Silver, Rarity.Bronze };
// 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)