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();
var rewards = await _gachaPoint.GetRewardsAsync(request.ParentGachaId, 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);
var outcome = await _gachaPoint.TryExchangeAsync(viewer, request.ParentGachaId, 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 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.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 2: // CRYSTAL_MULTI
{
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 7: // RUPY_MULTI
{
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;
}
}
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 == 2)
{
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal) });
}
else if (child.TypeDetail == 7 || child.TypeDetail == 3)
{
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)_entitlements.EffectiveBalance(viewer, SpendCurrency.Rupee) });
}
}
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,
};
}
}