Pack opening
This commit is contained in:
233
SVSim.EmulatedEntrypoint/Controllers/PackController.cs
Normal file
233
SVSim.EmulatedEntrypoint/Controllers/PackController.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
16
SVSim.EmulatedEntrypoint/Models/Dtos/PackBannerDto.cs
Normal file
16
SVSim.EmulatedEntrypoint/Models/Dtos/PackBannerDto.cs
Normal 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;
|
||||
}
|
||||
43
SVSim.EmulatedEntrypoint/Models/Dtos/PackChildGachaDto.cs
Normal file
43
SVSim.EmulatedEntrypoint/Models/Dtos/PackChildGachaDto.cs
Normal 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";
|
||||
}
|
||||
110
SVSim.EmulatedEntrypoint/Models/Dtos/PackConfigDto.cs
Normal file
110
SVSim.EmulatedEntrypoint/Models/Dtos/PackConfigDto.cs
Normal 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; }
|
||||
}
|
||||
32
SVSim.EmulatedEntrypoint/Models/Dtos/PackGachaPointDto.cs
Normal file
32
SVSim.EmulatedEntrypoint/Models/Dtos/PackGachaPointDto.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
31
SVSim.EmulatedEntrypoint/Models/Dtos/RewardListEntry.cs
Normal file
31
SVSim.EmulatedEntrypoint/Models/Dtos/RewardListEntry.cs
Normal 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; }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
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