517 lines
25 KiB
C#
517 lines
25 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// /pack/* — card-pack shop catalog and pack opening. /tutorial/pack_info and
|
||
/// /tutorial/pack_open are aliased here.
|
||
/// </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 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<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);
|
||
|
||
// 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<int>(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<int>(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<int, ViewerPackOpenCount> openCounts,
|
||
IReadOnlyDictionary<long, int> ownedItemsByItemId,
|
||
IReadOnlyDictionary<int, int> gachaPointBalancesByPackId,
|
||
IReadOnlyDictionary<int, (DateTime LastClaimedAt, int ClaimCount)> 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<ActionResult<GetGachaPointRewardsResponse>> 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<ActionResult<ExchangeGachaPointResponse>> 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.Source = GrantSource.GachaPointExchange;
|
||
cfg.WithInclude(v => v.GachaPointBalances);
|
||
cfg.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<ActionResult<PackOpenResponse>> 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<int>(c, "CardId"))
|
||
.ToListAsync();
|
||
|
||
var draw = _opener.Draw(
|
||
drawTable,
|
||
pack,
|
||
drawCount,
|
||
request.ExcludeCardIds ?? Array.Empty<long>(),
|
||
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,
|
||
};
|
||
}
|
||
}
|