Pack opening
This commit is contained in:
42
SVSim.EmulatedEntrypoint/Services/DbCardPoolProvider.cs
Normal file
42
SVSim.EmulatedEntrypoint/Services/DbCardPoolProvider.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
public class DbCardPoolProvider : ICardPoolProvider
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
public DbCardPoolProvider(SVSimDbContext db) { _db = db; }
|
||||
|
||||
public IReadOnlyList<ShadowverseCardEntry> GetPool(PackConfigEntry pack)
|
||||
{
|
||||
switch (pack.PackCategory)
|
||||
{
|
||||
case PackCategory.None:
|
||||
case PackCategory.LegendCardPack:
|
||||
// Standard pack — pool comes from the card set whose id equals base_pack_id.
|
||||
return _db.CardSets
|
||||
.Include(s => s.Cards)
|
||||
.Where(s => s.Id == pack.BasePackId)
|
||||
.SelectMany(s => s.Cards)
|
||||
.ToList();
|
||||
|
||||
case PackCategory.SpecialCardPack:
|
||||
case PackCategory.LimitedSpecialCardPack:
|
||||
// Legendary-special packs pull from all rotation sets. The slot-8 forced-Legendary
|
||||
// rule in PackOpenService delivers the "at least one legendary" promise.
|
||||
return _db.CardSets
|
||||
.Where(s => s.IsInRotation)
|
||||
.Include(s => s.Cards)
|
||||
.SelectMany(s => s.Cards)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
default:
|
||||
// Skin / starter / leader-skin packs aren't drawn in v1 — controller rejects earlier.
|
||||
return Array.Empty<ShadowverseCardEntry>();
|
||||
}
|
||||
}
|
||||
}
|
||||
7
SVSim.EmulatedEntrypoint/Services/DrawResult.cs
Normal file
7
SVSim.EmulatedEntrypoint/Services/DrawResult.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
using SVSim.Database.Enums;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
public record DrawnCard(long CardId, Rarity Rarity);
|
||||
|
||||
public record DrawResult(IReadOnlyList<DrawnCard> Cards);
|
||||
9
SVSim.EmulatedEntrypoint/Services/ICardPoolProvider.cs
Normal file
9
SVSim.EmulatedEntrypoint/Services/ICardPoolProvider.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
/// <summary>Resolves the card pool a pack draws from. Pure function over master data.</summary>
|
||||
public interface ICardPoolProvider
|
||||
{
|
||||
IReadOnlyList<ShadowverseCardEntry> GetPool(PackConfigEntry pack);
|
||||
}
|
||||
10
SVSim.EmulatedEntrypoint/Services/IRandom.cs
Normal file
10
SVSim.EmulatedEntrypoint/Services/IRandom.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
/// <summary>RNG seam for testable draw logic. Same contract as <see cref="System.Random"/>.</summary>
|
||||
public interface IRandom
|
||||
{
|
||||
/// <summary>Returns a value in [0.0, 1.0).</summary>
|
||||
double NextDouble();
|
||||
/// <summary>Returns a value in [0, maxExclusive).</summary>
|
||||
int Next(int maxExclusive);
|
||||
}
|
||||
110
SVSim.EmulatedEntrypoint/Services/PackOpenService.cs
Normal file
110
SVSim.EmulatedEntrypoint/Services/PackOpenService.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
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.
|
||||
///
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class PackOpenService
|
||||
{
|
||||
private const int CardsPerPack = 8;
|
||||
|
||||
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++)
|
||||
{
|
||||
Rarity rarity;
|
||||
if (s == CardsPerPack - 1)
|
||||
{
|
||||
// Slot 8
|
||||
if (isLegendarySpecial)
|
||||
{
|
||||
rarity = Rarity.Legendary;
|
||||
}
|
||||
else
|
||||
{
|
||||
rarity = PickSlot8Rarity(rng);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
rarity = PickSlot1To7Rarity(rng);
|
||||
}
|
||||
|
||||
var card = PickCardOfRarity(rarity, poolByRarity, rng);
|
||||
slots.Add(new DrawnCard(card.Id, card.Rarity));
|
||||
}
|
||||
}
|
||||
return new DrawResult(slots);
|
||||
}
|
||||
|
||||
private static Rarity PickSlot1To7Rarity(IRandom rng)
|
||||
{
|
||||
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)
|
||||
}
|
||||
|
||||
private static Rarity PickSlot8Rarity(IRandom rng)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
private static ShadowverseCardEntry PickCardOfRarity(
|
||||
Rarity rarity,
|
||||
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 };
|
||||
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.");
|
||||
}
|
||||
}
|
||||
10
SVSim.EmulatedEntrypoint/Services/SystemRandom.cs
Normal file
10
SVSim.EmulatedEntrypoint/Services/SystemRandom.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
public class SystemRandom : IRandom
|
||||
{
|
||||
private readonly Random _rng;
|
||||
public SystemRandom() { _rng = new Random(); }
|
||||
public SystemRandom(int seed) { _rng = new Random(seed); }
|
||||
public double NextDouble() => _rng.NextDouble();
|
||||
public int Next(int maxExclusive) => _rng.Next(maxExclusive);
|
||||
}
|
||||
Reference in New Issue
Block a user