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.Database.Repositories.PackDrawTables; 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.Database.Services; 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 IPackDrawTableRepository _drawTables; private readonly ICardFoilLookup _foils; private readonly IRandom _rng; private readonly SVSimDbContext _db; private readonly ICardAcquisitionService _acquisition; private readonly IGachaPointService _gachaPoint; private readonly ICurrencySpendService _spend; private readonly IViewerEntitlements _entitlements; public PackController( IPackRepository packs, PackOpenService opener, IPackDrawTableRepository drawTables, ICardFoilLookup foils, IRandom rng, SVSimDbContext db, ICardAcquisitionService acquisition, IGachaPointService gachaPoint, ICurrencySpendService spend, IViewerEntitlements entitlements) { _packs = packs; _opener = opener; _drawTables = drawTables; _foils = foils; _rng = rng; _db = db; _acquisition = acquisition; _gachaPoint = gachaPoint; _spend = spend; _entitlements = entitlements; } [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. // // Use EF.Property(i, "ItemId") to read the shadow FK directly instead of going // through the OwnedItemEntry.Item nav. The nav route works today (EF translates // `i.Item.Id` to the FK column), but a future model change that renames the FK or // breaks the nav→column mapping would silently fall back to client eval — where // `i.Item.Id` returns 0 for every row (the default-initialised ItemEntry) and the // dictionary collapses every ticket to item_number=0. Shadow-FK access bypasses // that hazard entirely. var ownedItemsByItemId = await _db.Viewers .Where(v => v.Id == viewerId) .SelectMany(v => v.Items) .Select(i => new { ItemId = (long)EF.Property(i, "ItemId"), i.Count }) .ToDictionaryAsync(x => x.ItemId, x => x.Count); var gachaPointBalancesByPackId = await _db.Viewers .Where(v => v.Id == viewerId) .SelectMany(v => v.GachaPointBalances) .Select(b => new { b.PackId, b.Points }) .ToDictionaryAsync(x => x.PackId, x => x.Points); return new PackInfoResponse { PackConfigList = packs .Select(p => ToDto(p, openCounts, ownedItemsByItemId, gachaPointBalancesByPackId)) .ToList(), }; } private static PackConfigDto ToDto( PackConfigEntry p, IReadOnlyDictionary openCounts, IReadOnlyDictionary ownedItemsByItemId, IReadOnlyDictionary gachaPointBalancesByPackId) { int openCount = openCounts.TryGetValue(p.Id, out var oc) ? oc.OpenCount : 0; // Ticket-only pack: every child is TICKET (4) or TICKET_MULTI (5). These are // gifted-currency packs (tutorial starter, throwback) that don't participate in // gacha-point accrual or exchange, even if GachaPointConfig is set in seed. bool isTicketOnly = p.ChildGachas.All(c => c.TypeDetail == 4 || c.TypeDetail == 5); PackGachaPointDto? gachaPointDto = null; if (p.GachaPointConfig is not null && !isTicketOnly) { int balance = gachaPointBalancesByPackId.TryGetValue(p.Id, out var b) ? b : 0; int threshold = p.GachaPointConfig.ExchangeablePoint; gachaPointDto = new PackGachaPointDto { PackId = p.BasePackId.ToString(CultureInfo.InvariantCulture), GachaPoint = balance, IncreaseGachaPoint = p.GachaPointConfig.IncreaseGachaPoint.ToString(CultureInfo.InvariantCulture), ExchangeableGachaPoint = threshold, IsExchangeableGachaPoint = balance >= threshold, }; } 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 = gachaPointDto, IsPreRelease = p.IsPreRelease, ExistsPurchaseReward = false, IsNew = p.IsNew, PosterType = p.PosterType, SalesPeriodInfo = new(), // emit `{}` per the DTO docstring }; } [HttpPost("get_gacha_point_rewards")] public async Task> GetGachaPointRewards( GetGachaPointRewardsRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); // odds_gacha_id is the active seasonal pack id (the one with GachaPointConfig + // balance). parent_gacha_id is the base_pack_id of the family — not the lookup key. // See GetGachaPointRewardsRequest docstring; verified against // traffic_prod_all_gacha_exchange.ndjson. var rewards = await _gachaPoint.GetRewardsAsync(request.OddsGachaId, viewerId); return new GetGachaPointRewardsResponse { GachaPointRewards = rewards.ToList(), }; } [HttpPost("exchange_gacha_point")] public async Task> ExchangeGachaPoint( ExchangeGachaPointRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); // Load the viewer with the collections the service mutates (balances, received marker, // cards, cosmetics). AsSplitQuery per project_ef_split_query memory. var viewer = await _db.Viewers .Include(v => v.GachaPointBalances) .Include(v => v.GachaPointReceived) .Include(v => v.Cards) .Include(v => v.Sleeves) .Include(v => v.Emblems) .Include(v => v.Degrees) .Include(v => v.LeaderSkins) .Include(v => v.MyPageBackgrounds) .AsSplitQuery() .FirstAsync(v => v.Id == viewerId); // Use odds_gacha_id (the seasonal pack id) — that's where the balance / received marker // live. Mirrors the GetGachaPointRewards fix. var outcome = await _gachaPoint.TryExchangeAsync(viewer, request.OddsGachaId, request.CardId); if (!outcome.Success) return BadRequest(new { error = outcome.Error }); await _db.SaveChangesAsync(); return new ExchangeGachaPointResponse { RewardList = outcome.RewardList.ToList(), }; } [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"); // The tutorial alias bypasses the currency / type_detail / open-count guards because // the legendary starter pack (99047) is a free server-grant during the 41→100 tutorial // transition. Constrain the alias to that one pack so the bypass isn't a free draw on // ANY pack the client supplies a parent_gacha_id for. const int StarterParentGachaId = 99047; if (isTutorialPath && request.ParentGachaId != StarterParentGachaId) return BadRequest(new { error = "tutorial_path_only_for_starter_pack" }); // 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 type_details on the normal path: // 1 CRYSTAL / 2 CRYSTAL_MULTI -> spend crystals // 6 RUPY / 7 RUPY_MULTI -> spend rupees // 3 DAILY -> spend rupees, once per UTC day // 4 TICKET / 5 TICKET_MULTI -> consume child.ItemId from OwnedItemEntry // Skin-overload types (8/9/13) and free-pack overlays (10/11/12) need extra // selection / banner plumbing — kept 501 until the relevant flows land. if (!isTutorialPath && child.TypeDetail is not (1 or 2 or 3 or 4 or 5 or 6 or 7)) return StatusCode(StatusCodes.Status501NotImplemented, new { error = "currency_path_not_implemented" }); var viewer = await _db.Viewers .Include(v => v.PackOpenCounts) .Include(v => v.GachaPointBalances) .Include(v => v.MissionData) .Include(v => v.Items).ThenInclude(i => i.Item) .AsSplitQuery() .FirstAsync(v => v.Id == viewerId); // Tutorial alias is only valid pre-END. After state>=100 the viewer has already // completed the tutorial — re-running the path would re-consume the ticket they // chose to keep, and (without the max-preserve write below) could regress a higher // state value. Mirrors the 31<41 guard in GiftController.TutorialGiftReceive. const int TutorialEndStep = 100; if (isTutorialPath && viewer.MissionData.TutorialState >= TutorialEndStep) return BadRequest(new { error = "tutorial_already_complete" }); int packNumber = Math.Max(1, request.PackNumber); // Currency check + deduction (skipped for tutorial path — starter pack is free) if (!isTutorialPath) { switch (child.TypeDetail) { case 1: // CRYSTAL (single) case 2: // CRYSTAL_MULTI (10-pack) { long cost = (long)child.Cost * packNumber; var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, cost); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); break; } case 6: // RUPY (single) case 7: // RUPY_MULTI (10-pack) { long cost = (long)child.Cost * packNumber; var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, cost); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); 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" }); long cost = (long)child.Cost * packNumber; var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, cost); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); break; } case 4: // TICKET (single) case 5: // TICKET_MULTI (10-pack) { if (child.ItemId is not long ticketItemId) return StatusCode(StatusCodes.Status501NotImplemented, new { error = "ticket_pack_missing_item_id" }); int ticketsNeeded = child.Cost * packNumber; var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId); if (owned is null || owned.Count < ticketsNeeded) return BadRequest(new { error = "insufficient_tickets" }); owned.Count -= ticketsNeeded; 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 drawTable = await _drawTables.GetAsync(pack.Id); if (drawTable is null) return StatusCode(StatusCodes.Status501NotImplemented, new { error = "pack_draw_table_missing" }); // Owned card_ids for the rate-less Guaranteed-Leader-Card branch. Project to longs to // avoid pulling viewer.Cards entities into memory. Shadow-FK access (EF.Property) per // the project_ef_nav_include_pitfall memory. var ownedCardIds = await _db.Viewers .Where(v => v.Id == viewerId) .SelectMany(v => v.Cards) .Select(c => (long)EF.Property(c, "CardId")) .ToListAsync(); var draw = _opener.Draw( drawTable, pack, drawCount, request.ExcludeCardIds ?? Array.Empty(), ownedCardIds, _foils, _rng); var grant = await _acquisition.GrantManyAsync(viewerId, draw.Cards.Select(c => c.CardId)); // Accrue gacha points (skip tutorial path — the starter pack isn't a real open). if (!isTutorialPath) { _gachaPoint.Accrue(viewer, pack, child, drawCount); await _db.SaveChangesAsync(); } // 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) { if (child.TypeDetail is 1 or 2) { rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal) }); } else if (child.TypeDetail is 3 or 6 or 7) { rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)_entitlements.EffectiveBalance(viewer, SpendCurrency.Rupee) }); } else if (child.TypeDetail is 4 or 5 && child.ItemId is long ticketItemId) { // Item post-state count for the ticket we just consumed — client direct-assigns // _userItemDict, so this must be the new total (project_wire_reward_list_post_state). var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId); rewardList.Add(new RewardListEntry { RewardType = 4, // Item RewardId = ticketItemId, RewardNum = owned?.Count ?? 0, // post-state total }); } } 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 }); } } // Max-preserve: never regress the persisted state, even though Gate B already // rejected state>=100 above. Belt-and-braces against a future caller that // bypasses Gate B (refactor, new alias, etc.). Wire still emits 100 — that's // the tutorial-END signal the client expects. if (viewer.MissionData.TutorialState < TutorialEndStep) viewer.MissionData.TutorialState = TutorialEndStep; await _db.SaveChangesAsync(); responseTutorialStep = TutorialEndStep; } return new PackOpenResponse { PackList = draw.Cards.Select(c => new CardPackEntryDto { CardId = c.CardId, Rarity = (int)c.Rarity, Number = 1, }).ToList(), RewardList = rewardList, TutorialStep = responseTutorialStep, }; } }