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.Database.Services.Inventory; 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 IInventoryService _inv; private readonly IGachaPointService _gachaPoint; public PackController( IPackRepository packs, PackOpenService opener, IPackDrawTableRepository drawTables, ICardFoilLookup foils, IRandom rng, SVSimDbContext db, IInventoryService inv, IGachaPointService gachaPoint) { _packs = packs; _opener = opener; _drawTables = drawTables; _foils = foils; _rng = rng; _db = db; _inv = inv; _gachaPoint = gachaPoint; } [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); // Per-viewer free-pack claim records, keyed by campaign id. Drives the // "drop the type_detail=10 child once today's quota is spent" filter in ToDto. // Plain projection — no owned-entity tracking needed (mirrors the items query above). var freeClaimsByCampaignId = await _db.Viewers .Where(v => v.Id == viewerId) .SelectMany(v => v.FreePackClaims) .Select(c => new { c.FreeGachaCampaignId, c.LastClaimedAt, c.ClaimCount }) .ToDictionaryAsync(x => x.FreeGachaCampaignId, x => (x.LastClaimedAt, x.ClaimCount)); return new PackInfoResponse { PackConfigList = packs .Select(p => ToDto(p, openCounts, ownedItemsByItemId, gachaPointBalancesByPackId, freeClaimsByCampaignId)) .ToList(), }; } private static PackConfigDto ToDto( PackConfigEntry p, IReadOnlyDictionary openCounts, IReadOnlyDictionary ownedItemsByItemId, IReadOnlyDictionary gachaPointBalancesByPackId, IReadOnlyDictionary freeClaimsByCampaignId) { int openCount = openCounts.TryGetValue(p.Id, out var oc) ? oc.OpenCount : 0; // Drop type_detail=10 (FREE_PACKS) children whose daily quota for THIS viewer is spent. // Mirrors prod behavior: post-claim /pack/info simply omits the free child from // child_gacha_info (verified in traffic_event_crate_free_pack.ndjson lines 28→32). // Today's claim count >= DailyFreeGachaCount and same UTC date => hide. var today = DateTime.UtcNow.Date; bool ChildAvailable(PackChildGachaEntry c) { if (c.TypeDetail != CardPackType.FreePacks) return true; if (c.FreeGachaCampaignId is not int campaignId) return true; if (!freeClaimsByCampaignId.TryGetValue(campaignId, out var claim)) return true; if (claim.LastClaimedAt.Date != today) return true; int dailyCap = c.DailyFreeGachaCount > 0 ? c.DailyFreeGachaCount : 1; return claim.ClaimCount < dailyCap; } var visibleChildren = p.ChildGachas.Where(ChildAvailable).ToList(); // 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 = visibleChildren.All(c => c.TypeDetail == CardPackType.Ticket || c.TypeDetail == CardPackType.TicketMulti); 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 = visibleChildren.Select(c => new PackChildGachaDto { GachaId = c.GachaId, TypeDetail = (int)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), CampaignName = c.CampaignName, PurchaseLimitCount = c.PurchaseLimitCount > 0 ? c.PurchaseLimitCount.ToString(CultureInfo.InvariantCulture) : null, DailyFreeGachaCount = c.DailyFreeGachaCount > 0 ? c.DailyFreeGachaCount.ToString(CultureInfo.InvariantCulture) : null, FreeGachaCampaignId = c.FreeGachaCampaignId, }).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(); // Open inventory tx with extra includes for GachaPointBalances + GachaPointReceived // (needed by TryExchangeAsync to validate balance and already-received guard). await using var tx = await _inv.BeginAsync(viewerId, configure: cfg => cfg .WithInclude(v => v.GachaPointBalances) .WithInclude(v => v.GachaPointReceived)); // 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(tx, request.OddsGachaId, request.CardId); if (!outcome.Success) return BadRequest(new { error = outcome.Error }); await tx.CommitAsync(); 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 on the normal path: Crystal / CrystalMulti -> spend crystals; Rupy / // RupyMulti -> spend rupees; Daily -> spend rupees, once per UTC day; Ticket / // TicketMulti -> consume child.ItemId from OwnedItemEntry; FreePacks -> no debit, // gated by per-campaign daily quota. // CrystalSpecial / CrystalSelectSkin / CrystalAcquireSkinCardPack and the // FreePackWithSkin / RotationStarterPack overlays need extra selection / banner // plumbing — kept 501 until the relevant flows land. if (!isTutorialPath && child.TypeDetail is not ( CardPackType.Crystal or CardPackType.CrystalMulti or CardPackType.Daily or CardPackType.Ticket or CardPackType.TicketMulti or CardPackType.Rupy or CardPackType.RupyMulti or CardPackType.FreePacks)) return StatusCode(StatusCodes.Status501NotImplemented, new { error = "currency_path_not_implemented" }); // Load viewer via InventoryService transaction with extra includes for pack-open needs. await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg => { cfg.Source = GrantSource.PackOpen; cfg.WithInclude(v => v.PackOpenCounts); cfg.WithInclude(v => v.GachaPointBalances); cfg.WithInclude(v => v.MissionData); cfg.WithInclude(v => v.FreePackClaims); }); var viewer = tx.Viewer; // 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 CardPackType.Crystal: case CardPackType.CrystalMulti: { long cost = (long)child.Cost * packNumber; var r = await tx.TrySpendAsync(SpendCurrency.Crystal, cost); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); break; } case CardPackType.Rupy: case CardPackType.RupyMulti: { long cost = (long)child.Cost * packNumber; var r = await tx.TrySpendAsync(SpendCurrency.Rupee, cost); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); break; } case CardPackType.Daily: { // 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 tx.TrySpendAsync(SpendCurrency.Rupee, cost); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); break; } case CardPackType.Ticket: case CardPackType.TicketMulti: { if (child.ItemId is not long ticketItemId) return StatusCode(StatusCodes.Status501NotImplemented, new { error = "ticket_pack_missing_item_id" }); int ticketsNeeded = child.Cost * packNumber; var debit = await tx.TryDebitAsync(UserGoodsType.Item, ticketItemId, ticketsNeeded); if (!debit.Success) return BadRequest(new { error = "insufficient_tickets" }); break; } case CardPackType.FreePacks: { if (child.FreeGachaCampaignId is not int campaignId) return StatusCode(StatusCodes.Status501NotImplemented, new { error = "free_pack_missing_campaign_id" }); int dailyCap = child.DailyFreeGachaCount > 0 ? child.DailyFreeGachaCount : 1; var today = DateTime.UtcNow.Date; var existing = viewer.FreePackClaims.FirstOrDefault(c => c.FreeGachaCampaignId == campaignId); if (existing is not null && existing.LastClaimedAt.Date == today && existing.ClaimCount >= dailyCap) return BadRequest(new { error = "free_pack_already_claimed_today" }); // pack_number is forced to 1 — free-pack metadata never authorizes multi-opens. // The capture shows pack_number=1 even when daily_free_gacha_count=1 == daily quota. packNumber = 1; if (existing is null) { viewer.FreePackClaims.Add(new ViewerFreePackClaim { FreeGachaCampaignId = campaignId, ClaimCount = 1, LastClaimedAt = DateTime.UtcNow, }); } else if (existing.LastClaimedAt.Date != today) { existing.ClaimCount = 1; existing.LastClaimedAt = DateTime.UtcNow; } else { existing.ClaimCount++; existing.LastClaimedAt = DateTime.UtcNow; } break; } } } // 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 == CardPackType.Daily) { 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); // Grant drawn cards through the transaction — cosmetic cascade fires on first-time owners. foreach (var grp in draw.Cards.GroupBy(c => c.CardId)) await tx.GrantAsync(UserGoodsType.Card, grp.Key, grp.Count()); // Accrue gacha points (skip tutorial path — the starter pack isn't a real open). if (!isTutorialPath) { _gachaPoint.Accrue(viewer, pack, child, drawCount); } // 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 tutorialTicketItemId) { int ticketsToConsume = packNumber; var debit = await tx.TryDebitAsync(UserGoodsType.Item, tutorialTicketItemId, ticketsToConsume); // Silently accept if the viewer doesn't have the ticket (already consumed or never granted) _ = debit; } // 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; responseTutorialStep = TutorialEndStep; } // CommitAsync saves all mutations and produces reward_list with currency-collision resolved. // Tutorial path never calls TrySpendAsync so no currency op is in the log — correct. var result = await tx.CommitAsync(HttpContext.RequestAborted); var rewardList = result.RewardList.ToRewardList(); return new PackOpenResponse { PackList = draw.Cards.Select(c => new CardPackEntryDto { CardId = c.CardId, Rarity = (int)c.Rarity, Number = 1, }).ToList(), RewardList = rewardList, TutorialStep = responseTutorialStep, }; } }