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; /// /// /pack/* — card-pack shop catalog and pack opening. Tutorial aliases (/tutorial/pack_info, /// /tutorial/pack_open) are out of scope for v1. /// [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> 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 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> 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(); 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, }; } }