Pack opening

This commit is contained in:
gamer147
2026-05-24 02:03:13 -04:00
parent bdff142d16
commit 79209bd70b
41 changed files with 37320 additions and 0 deletions

View File

@@ -0,0 +1,233 @@
using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Pack;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Pack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Pack;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// /pack/* — card-pack shop catalog and pack opening. Tutorial aliases (/tutorial/pack_info,
/// /tutorial/pack_open) are out of scope for v1.
/// </summary>
[Route("pack")]
public class PackController : SVSimController
{
private const string WireDateFormat = "yyyy-MM-dd HH:mm:ss";
private readonly IPackRepository _packs;
private readonly PackOpenService _opener;
private readonly ICardPoolProvider _pools;
private readonly IRandom _rng;
private readonly SVSimDbContext _db;
public PackController(
IPackRepository packs,
PackOpenService opener,
ICardPoolProvider pools,
IRandom rng,
SVSimDbContext db)
{
_packs = packs;
_opener = opener;
_pools = pools;
_rng = rng;
_db = db;
}
[HttpPost("info")]
public async Task<ActionResult<PackInfoResponse>> Info(BaseRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var packs = await _packs.GetActivePacks(DateTime.UtcNow);
var openCounts = await _packs.GetOpenCountsForViewer(viewerId);
return new PackInfoResponse
{
PackConfigList = packs.Select(p => ToDto(p, openCounts)).ToList(),
};
}
private static PackConfigDto ToDto(PackConfigEntry p, IReadOnlyDictionary<int, ViewerPackOpenCount> openCounts)
{
int openCount = openCounts.TryGetValue(p.Id, out var oc) ? oc.OpenCount : 0;
return new PackConfigDto
{
ParentGachaId = p.Id,
BasePackId = p.BasePackId,
OverrideDrawEffectPackId = p.OverrideDrawEffectPackId,
OverrideUiEffectPackId = p.OverrideUiEffectPackId,
GachaType = p.GachaType,
SleeveId = p.SleeveId,
SpecialSleeveId = p.SpecialSleeveId,
CommenceDate = p.CommenceDate.ToString(WireDateFormat, CultureInfo.InvariantCulture),
CompleteDate = p.CompleteDate.ToString(WireDateFormat, CultureInfo.InvariantCulture),
CardpackBannerList = p.Banners.Select(b => new PackBannerDto
{
BannerName = b.BannerName,
DialogTitle = b.DialogTitle,
}).ToList(),
GachaDetail = p.GachaDetail,
ChildGachaInfo = p.ChildGachas.Select(c => new PackChildGachaDto
{
GachaId = c.GachaId,
TypeDetail = c.TypeDetail,
Cost = c.Cost,
Count = c.CardCount,
ItemId = c.ItemId?.ToString(CultureInfo.InvariantCulture),
IsDailySingle = c.IsDailySingle,
OverrideIncreaseGachaPoint = c.OverrideIncreaseGachaPoint.ToString(CultureInfo.InvariantCulture),
}).ToList(),
OpenCount = openCount,
OpenCountLimit = p.OpenCountLimit,
IsHide = p.IsHide ? 1 : 0,
PackCategory = (int)p.PackCategory,
GachaPoint = p.GachaPointConfig is null ? null : new PackGachaPointDto
{
PackId = p.BasePackId.ToString(CultureInfo.InvariantCulture),
GachaPoint = 0,
IncreaseGachaPoint = p.GachaPointConfig.IncreaseGachaPoint.ToString(CultureInfo.InvariantCulture),
ExchangeableGachaPoint = p.GachaPointConfig.ExchangeablePoint,
IsExchangeableGachaPoint = false,
},
IsPreRelease = p.IsPreRelease,
ExistsPurchaseReward = false,
IsNew = p.IsNew,
PosterType = p.PosterType,
SalesPeriodInfo = new(), // emit `{}` per the DTO docstring
};
}
[HttpPost("open")]
public async Task<ActionResult<PackOpenResponse>> Open(PackOpenRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
// Reject paths up front — class_id/target_card_id overloads aren't implemented.
if (request.ClassId.HasValue)
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "starter_overload_not_implemented" });
if (request.TargetCardId.HasValue)
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "skin_overload_not_implemented" });
var pack = await _packs.GetPack(request.ParentGachaId);
if (pack is null) return NotFound(new { error = "unknown_pack" });
// Skin / starter / leader-skin packs aren't drawn in v1 regardless of child type.
if (pack.PackCategory is PackCategory.LeaderSkinPack
or PackCategory.FreePackLeaderSkin
or PackCategory.RotationStarterCardPack
or PackCategory.LegendAndLeaderSkinSinglePack)
{
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "skin_starter_category_not_implemented" });
}
var child = pack.ChildGachas.FirstOrDefault(c => c.GachaId == request.GachaId);
if (child is null)
return BadRequest(new { error = "unknown_child_gacha" });
// Note: request.GachaType is the PARENT pack's gacha_type (a routing/disambiguation field),
// NOT the child's type_detail. Prod traffic confirms the client sends gacha_type=1 even
// when buying a RUPY_MULTI (type_detail=7) child. The gacha_id alone disambiguates the
// child; gacha_type validation against child.TypeDetail would falsely reject every buy.
// Supported currency types in v1: CRYSTAL_MULTI=2, DAILY=3, RUPY_MULTI=7. Ticket flows
// (TICKET=4, TICKET_MULTI=5) and the rest are explicitly out of scope.
if (child.TypeDetail is not (2 or 3 or 7))
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "currency_path_not_implemented" });
var viewer = await _db.Viewers.Include(v => v.PackOpenCounts).FirstAsync(v => v.Id == viewerId);
int packNumber = Math.Max(1, request.PackNumber);
// Currency check + deduction
switch (child.TypeDetail)
{
case 2: // CRYSTAL_MULTI
{
ulong cost = (ulong)child.Cost * (ulong)packNumber;
if (viewer.Currency.Crystals < cost)
return BadRequest(new { error = "insufficient_crystals" });
viewer.Currency.Crystals -= cost;
break;
}
case 7: // RUPY_MULTI
{
ulong cost = (ulong)child.Cost * (ulong)packNumber;
if (viewer.Currency.Rupees < cost)
return BadRequest(new { error = "insufficient_rupees" });
viewer.Currency.Rupees -= cost;
break;
}
case 3: // DAILY single — once per UTC day
{
// TODO(daily-reset): no project-wide daily-reset convention exists yet. Using UTC
// midnight; revisit when the global reset boundary is settled.
var now = DateTime.UtcNow;
var existing = viewer.PackOpenCounts.FirstOrDefault(p => p.PackId == pack.Id);
if (existing?.LastDailyFreeAt is DateTime last && last.Date == now.Date)
return BadRequest(new { error = "daily_free_already_claimed" });
ulong cost = (ulong)child.Cost * (ulong)packNumber;
if (cost > 0 && viewer.Currency.Rupees < cost)
return BadRequest(new { error = "insufficient_rupees" });
if (cost > 0) viewer.Currency.Rupees -= cost;
break;
}
}
await _db.SaveChangesAsync();
// Increment open count + mark daily-free timestamp where relevant
await _packs.IncrementOpenCount(viewerId, pack.Id, packNumber);
if (child.TypeDetail == 3)
{
await _packs.MarkDailyFreeUsed(viewerId, pack.Id, DateTime.UtcNow);
}
// 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);
await _packs.GrantCardsToViewer(viewerId, draw.Cards.Select(c => c.CardId));
// Build reward_list with post-state totals. The client's PlayerStaticData.UpdateHaveUserGoodsNum
// does direct assignment (`UserRupyCount = reward_num`, owned-count = reward_num), so we
// emit the new totals — not deltas. Without these the on-screen rupee/crystal/collection
// counts stay stale until the next /mypage/refresh or restart.
var rewardList = new List<RewardListEntry>();
var postViewer = await _db.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.FirstAsync(v => v.Id == viewerId);
if (child.TypeDetail == 2)
{
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)postViewer.Currency.Crystals });
}
else if (child.TypeDetail == 7 || child.TypeDetail == 3)
{
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)postViewer.Currency.Rupees });
}
var drawnCardIds = draw.Cards.Select(c => c.CardId).Distinct().ToHashSet();
foreach (var owned in postViewer.Cards.Where(c => drawnCardIds.Contains(c.Card.Id)))
{
rewardList.Add(new RewardListEntry { RewardType = 5, RewardId = owned.Card.Id, RewardNum = owned.Count });
}
return new PackOpenResponse
{
PackList = draw.Cards.Select(c => new CardPackEntryDto
{
CardId = c.CardId,
Rarity = (int)c.Rarity,
Number = 1,
}).ToList(),
RewardList = rewardList,
};
}
}

View File

@@ -0,0 +1,16 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
[MessagePackObject]
public class PackBannerDto
{
[JsonPropertyName("banner_name")]
[Key("banner_name")]
public string BannerName { get; set; } = string.Empty;
[JsonPropertyName("dialog_title")]
[Key("dialog_title")]
public string DialogTitle { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,43 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
[MessagePackObject]
public class PackChildGachaDto
{
[JsonPropertyName("gacha_id")]
[Key("gacha_id")]
public int GachaId { get; set; }
[JsonPropertyName("type_detail")]
[Key("type_detail")]
public int TypeDetail { get; set; }
[JsonPropertyName("cost")]
[Key("cost")]
public int Cost { get; set; }
[JsonPropertyName("count")]
[Key("count")]
public int Count { get; set; } = 8;
/// <summary>Stringified on the wire when present (prod sends "10001" not 10001).</summary>
[JsonPropertyName("item_id")]
[Key("item_id")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ItemId { get; set; }
[JsonPropertyName("item_number")]
[Key("item_number")]
public int ItemNumber { get; set; }
[JsonPropertyName("is_daily_single")]
[Key("is_daily_single")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool IsDailySingle { get; set; }
[JsonPropertyName("override_increase_gacha_point")]
[Key("override_increase_gacha_point")]
public string OverrideIncreaseGachaPoint { get; set; } = "0";
}

View File

@@ -0,0 +1,110 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
[MessagePackObject]
public class PackConfigDto
{
[JsonPropertyName("parent_gacha_id")]
[Key("parent_gacha_id")]
public int ParentGachaId { get; set; }
[JsonPropertyName("base_pack_id")]
[Key("base_pack_id")]
public int BasePackId { get; set; }
[JsonPropertyName("override_draw_effect_pack_id")]
[Key("override_draw_effect_pack_id")]
public int OverrideDrawEffectPackId { get; set; }
[JsonPropertyName("override_ui_effect_pack_id")]
[Key("override_ui_effect_pack_id")]
public int OverrideUiEffectPackId { get; set; }
[JsonPropertyName("gacha_type")]
[Key("gacha_type")]
public int GachaType { get; set; }
[JsonPropertyName("sleeve_id")]
[Key("sleeve_id")]
public int SleeveId { get; set; } = 3000011;
[JsonPropertyName("special_sleeve_id")]
[Key("special_sleeve_id")]
public int SpecialSleeveId { get; set; }
[JsonPropertyName("commence_date")]
[Key("commence_date")]
public string CommenceDate { get; set; } = string.Empty;
[JsonPropertyName("complete_date")]
[Key("complete_date")]
public string CompleteDate { get; set; } = string.Empty;
[JsonPropertyName("cardpack_banner_list")]
[Key("cardpack_banner_list")]
public List<PackBannerDto> CardpackBannerList { get; set; } = new();
[JsonPropertyName("gacha_detail")]
[Key("gacha_detail")]
public string GachaDetail { get; set; } = string.Empty;
[JsonPropertyName("child_gacha_info")]
[Key("child_gacha_info")]
public List<PackChildGachaDto> ChildGachaInfo { get; set; } = new();
[JsonPropertyName("open_count")]
[Key("open_count")]
public int OpenCount { get; set; }
[JsonPropertyName("open_count_limit")]
[Key("open_count_limit")]
public int OpenCountLimit { get; set; }
[JsonPropertyName("is_hide")]
[Key("is_hide")]
public int IsHide { get; set; }
[JsonPropertyName("pack_category")]
[Key("pack_category")]
public int PackCategory { get; set; }
/// <summary>
/// Null when the pack has no gacha-point participation. The key MUST be present on the wire
/// (explicit null) — client at PackInfoTask.cs:126 does <c>if (jsonData2["gacha_point"] != null)</c>,
/// a direct LitJson key access that throws KeyNotFoundException when the key is absent
/// (only protects against null *value*, not missing *key*). Override the global
/// WhenWritingNull per [[project_wire_null_policy]] memory.
/// </summary>
[JsonPropertyName("gacha_point")]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
[Key("gacha_point")]
public PackGachaPointDto? GachaPoint { get; set; }
[JsonPropertyName("is_pre_release")]
[Key("is_pre_release")]
public bool IsPreRelease { get; set; }
[JsonPropertyName("exists_purchase_reward")]
[Key("exists_purchase_reward")]
public bool ExistsPurchaseReward { get; set; }
[JsonPropertyName("is_new")]
[Key("is_new")]
public bool IsNew { get; set; }
/// <summary>
/// Prod sends an object <c>{"sales_period_time":"..."}</c> when set and an array <c>[]</c>
/// when unset. v1 always emits an empty object when the field is null on the entity —
/// matches the active-window case and the client tolerates both shapes via
/// <c>ShopExpirtyInfo</c>'s LitJson parser. Revisit if a capture proves otherwise.
/// </summary>
[JsonPropertyName("sales_period_info")]
[Key("sales_period_info")]
public Dictionary<string, string?> SalesPeriodInfo { get; set; } = new();
[JsonPropertyName("poster_type")]
[Key("poster_type")]
public int PosterType { get; set; }
}

View File

@@ -0,0 +1,32 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
/// <summary>
/// gacha_point block inside /pack/info entries. Prod ships strings for pack_id/increase_gacha_point;
/// mirror exactly per project_wire_key_serialization.
/// </summary>
[MessagePackObject]
public class PackGachaPointDto
{
[JsonPropertyName("pack_id")]
[Key("pack_id")]
public string PackId { get; set; } = "0";
[JsonPropertyName("gacha_point")]
[Key("gacha_point")]
public int GachaPoint { get; set; }
[JsonPropertyName("increase_gacha_point")]
[Key("increase_gacha_point")]
public string IncreaseGachaPoint { get; set; } = "0";
[JsonPropertyName("exchangeable_gacha_point")]
[Key("exchangeable_gacha_point")]
public int ExchangeableGachaPoint { get; set; }
[JsonPropertyName("is_exchangeable_gacha_point")]
[Key("is_exchangeable_gacha_point")]
public bool IsExchangeableGachaPoint { get; set; }
}

View File

@@ -0,0 +1,43 @@
using System.Text.Json.Serialization;
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Pack;
/// <summary>
/// Inbound /pack/open body. Accepts ALL three client-side overloads in one DTO — fields
/// for Starter (<c>class_id</c>) and Skin (<c>target_card_id</c>) are nullable so we can
/// reject those overloads in the controller without a custom binder.
/// See <c>Wizard/PackOpenTask.cs</c> for the three SetParameter variants.
/// </summary>
[MessagePackObject]
public class PackOpenRequest : BaseRequest
{
[JsonPropertyName("parent_gacha_id")]
[Key("parent_gacha_id")]
public int ParentGachaId { get; set; }
[JsonPropertyName("gacha_id")]
[Key("gacha_id")]
public int GachaId { get; set; }
[JsonPropertyName("gacha_type")]
[Key("gacha_type")]
public int GachaType { get; set; }
[JsonPropertyName("pack_number")]
[Key("pack_number")]
public int PackNumber { get; set; }
[JsonPropertyName("exclude_card_ids")]
[Key("exclude_card_ids")]
public long[] ExcludeCardIds { get; set; } = Array.Empty<long>();
[JsonPropertyName("class_id")]
[Key("class_id")]
public int? ClassId { get; set; }
[JsonPropertyName("target_card_id")]
[Key("target_card_id")]
public long? TargetCardId { get; set; }
}

View File

@@ -0,0 +1,12 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Pack;
[MessagePackObject]
public class PackInfoResponse
{
[JsonPropertyName("pack_config_list")]
[Key("pack_config_list")]
public List<PackConfigDto> PackConfigList { get; set; } = new();
}

View File

@@ -0,0 +1,46 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Pack;
[MessagePackObject]
public class PackOpenResponse
{
[JsonPropertyName("pack_list")]
[Key("pack_list")]
public List<CardPackEntryDto> PackList { get; set; } = new();
[JsonPropertyName("reward_list")]
[Key("reward_list")]
public List<RewardListEntry> RewardList { get; set; } = new();
[JsonPropertyName("rewards")]
[Key("rewards")]
public List<object> Rewards { get; set; } = new();
[JsonPropertyName("is_special_effect")]
[Key("is_special_effect")]
public bool IsSpecialEffect { get; set; }
/// <summary>Empty array literal — matches prod when no missions completed.</summary>
[JsonPropertyName("mission_result")]
[Key("mission_result")]
public List<object> MissionResult { get; set; } = new();
}
[MessagePackObject]
public class CardPackEntryDto
{
[JsonPropertyName("card_id")]
[Key("card_id")]
public long CardId { get; set; }
[JsonPropertyName("rarity")]
[Key("rarity")]
public int Rarity { get; set; }
/// <summary>Always 1 per drawn slot — matches prod sample shape.</summary>
[JsonPropertyName("number")]
[Key("number")]
public int Number { get; set; } = 1;
}

View File

@@ -0,0 +1,31 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
/// <summary>
/// One entry of <c>reward_list</c> on /pack/open (and other grant-emitting endpoints).
/// Client at <c>PlayerStaticData.UpdateHaveUserGoodsNumByJsonData</c> reads these and writes
/// <c>UserRupyCount = reward_num</c>, <c>UserCrystalCount = reward_num</c>, etc. —
/// <b>reward_num is the NEW POST-STATE TOTAL, not a delta</b>. Without these entries the
/// client's cached currency/collection counts stay stale until a full refresh (mypage, restart).
///
/// reward_type values are <c>Wizard.UserGoods.Type</c>: 1=RedEther, 2=Crystal, 4=Item, 5=Card,
/// 6=Sleeve, 7=Emblem, 8=Degree, 9=Rupy, 10=Skin, 11=SpotCard, 12=SpotCardPoint, etc.
/// reward_id is 0 for non-instanced goods (Rupy, Crystal, RedEther) and the entity id for cards.
/// </summary>
[MessagePackObject]
public class RewardListEntry
{
[JsonPropertyName("reward_type")]
[Key("reward_type")]
public int RewardType { get; set; }
[JsonPropertyName("reward_id")]
[Key("reward_id")]
public long RewardId { get; set; }
[JsonPropertyName("reward_num")]
[Key("reward_num")]
public int RewardNum { get; set; }
}

View File

@@ -6,6 +6,7 @@ using SVSim.Database.Repositories.Card;
using SVSim.Database.Repositories.Collectibles;
using SVSim.Database.Repositories.Deck;
using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Pack;
using SVSim.Database.Repositories.Viewer;
using SVSim.EmulatedEntrypoint.Configuration;
using SVSim.EmulatedEntrypoint.Extensions;
@@ -61,6 +62,10 @@ public class Program
builder.Services.AddTransient<ICollectionRepository, CollectionRepository>();
builder.Services.AddTransient<IGlobalsRepository, GlobalsRepository>();
builder.Services.AddTransient<IDeckRepository, DeckRepository>();
builder.Services.AddTransient<IPackRepository, PackRepository>();
builder.Services.AddScoped<ICardPoolProvider, DbCardPoolProvider>();
builder.Services.AddSingleton<PackOpenService>();
builder.Services.AddSingleton<IRandom, SystemRandom>();
#endregion

View 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>();
}
}
}

View 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);

View 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);
}

View 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);
}

View 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.");
}
}

View 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);
}