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/pack_info and /// /tutorial/pack_open are aliased here. /// [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; private readonly ICardAcquisitionService _acquisition; public PackController( IPackRepository packs, PackOpenService opener, ICardPoolProvider pools, IRandom rng, SVSimDbContext db, ICardAcquisitionService acquisition) { _packs = packs; _opener = opener; _pools = pools; _rng = rng; _db = db; _acquisition = acquisition; } [HttpPost("info")] [HttpPost("/tutorial/pack_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); // Load owned-item counts so child_gacha_info.item_number reflects the viewer's actual // ticket inventory (see ToDto). The client filters tutorial packs by item_number > 0 // — without this the legendary starter pack (99047, requires 1× item 90001) and the // throwback pack (80047, requires 1× item 80001) are hidden even when the tutorial // gift just granted those tickets, blocking the END transition. // // OwnedItemEntry is [Owned] by Viewer, and EF refuses to track owned entities without // their owner in the result. Project to primitive pairs in the database query before // materialising into the dictionary — no entity tracking, single round-trip. var ownedItemsByItemId = await _db.Viewers .Where(v => v.Id == viewerId) .SelectMany(v => v.Items) .Select(i => new { ItemId = (long)i.Item.Id, i.Count }) .ToDictionaryAsync(x => x.ItemId, x => x.Count); return new PackInfoResponse { PackConfigList = packs.Select(p => ToDto(p, openCounts, ownedItemsByItemId)).ToList(), }; } private static PackConfigDto ToDto( PackConfigEntry p, IReadOnlyDictionary openCounts, IReadOnlyDictionary ownedItemsByItemId) { 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), // item_number is viewer-specific — the count of item_id this viewer currently // owns, NOT a per-pack-catalog value. Verified against the prod tutorial // capture: legendary pack 99047 reports item_number=1 right after the gift // granted 1× ticket id=90001; throwback 80047 reports 40 right after the gift // granted 40× ticket id=80001. Client filters the tutorial pack list to // packs with non-zero item_number (free packs like 92001 are special-cased // separately), so this lookup is what makes the tutorial-final pack show up. ItemNumber = c.ItemId is long iid && ownedItemsByItemId.TryGetValue(iid, out var ownedCount) ? ownedCount : 0, 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")] [HttpPost("/tutorial/pack_open")] public async Task> Open(PackOpenRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); bool isTutorialPath = HttpContext.Request.Path.StartsWithSegments("/tutorial/pack_open"); // 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 for the normal path. // The tutorial path (type_detail=5, TICKET_MULTI) bypasses this guard — the starter pack // is a free server-granted bonus, not a purchasable pack. if (!isTutorialPath && 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) .Include(v => v.MissionData) .Include(v => v.Items).ThenInclude(i => i.Item) .AsSplitQuery() .FirstAsync(v => v.Id == viewerId); int packNumber = Math.Max(1, request.PackNumber); // Currency check + deduction (skipped for tutorial path — starter pack is free) if (!isTutorialPath) { 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. // Tutorial path skips these — the starter pack is a one-time free grant, not a // purchasable/trackable open. if (!isTutorialPath) { 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); var grant = await _acquisition.GrantManyAsync(viewerId, draw.Cards.Select(c => c.CardId)); // Build reward_list. The service produces the type=5 (Card) entries with post-state counts // plus any cosmetic grants. Currency entry (type=2 Crystals or type=9 Rupy) stays in the // controller — it's a pack-purchase concern, not a card-grant concern. The client's // PlayerStaticData.UpdateHaveUserGoodsNum does direct assignment, so currency/card counts // must be the new TOTAL — emitting deltas would leave the on-screen balances stale. var rewardList = new List(); // Currency reward entries only apply to purchasable packs; tutorial path omits them. if (!isTutorialPath) { var postViewer = await _db.Viewers.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 }); } } rewardList.AddRange(grant.RewardList); // Tutorial path consumes the granted ticket (same item_id used to gate display) so the // pack drops out of /tutorial/pack_info on next refresh. Without this, the pack still // shows item_number=1 after the tutorial pack-open, the client lets the user re-click // it, and the second click hits /pack/open (not /tutorial/pack_open) — which 501s on // type_detail=5 (TICKET_MULTI is out of scope for the normal path). Emitting the // post-state count in reward_list direct-assigns the client's _userItemDict so the // UI also goes stale-safe immediately (client does direct assignment per // project_wire_reward_list_post_state memory). int? responseTutorialStep = null; if (isTutorialPath) { if (child.ItemId is long ticketItemId) { var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId); if (owned is not null) { owned.Count = Math.Max(0, owned.Count - packNumber); rewardList.Add(new RewardListEntry { RewardType = 4, // Item RewardId = ticketItemId, RewardNum = owned.Count, // POST-STATE total }); } } viewer.MissionData.TutorialState = 100; await _db.SaveChangesAsync(); responseTutorialStep = 100; } return new PackOpenResponse { PackList = draw.Cards.Select(c => new CardPackEntryDto { CardId = c.CardId, Rarity = (int)c.Rarity, Number = 1, }).ToList(), RewardList = rewardList, TutorialStep = responseTutorialStep, }; } }