Stack [HttpPost("/tutorial/pack_open")] alias on PackController.Open. Detect
isTutorialPath via HttpContext.Request.Path; gate the type_detail rejection,
currency switch, open-count tracking, and currency reward_list entries behind
!isTutorialPath so the starter legendary pack (99047/990047, type_detail=5)
bypasses the purchasable-pack code path. After grant, set MissionData.TutorialState=100
and emit tutorial_step=100 in PackOpenResponse — this is the sole END transition,
per live-traffic capture. Add pack 99047 to test-fixture packs.json.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
263 lines
12 KiB
C#
263 lines
12 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.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;
|
|
|
|
/// <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 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<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);
|
|
|
|
return new PackInfoResponse
|
|
{
|
|
PackConfigList = packs.Select(p => ToDto(p, openCounts)).ToList(),
|
|
};
|
|
}
|
|
|
|
private static PackConfigDto ToDto(PackConfigEntry p, IReadOnlyDictionary<int, ViewerPackOpenCount> openCounts)
|
|
{
|
|
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),
|
|
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<ActionResult<PackOpenResponse>> 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)
|
|
.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<RewardListEntry>();
|
|
|
|
// 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);
|
|
|
|
// Advance tutorial state to 100 (END) on the tutorial path. This is the sole mechanism
|
|
// for the END transition — there is no /tutorial/update → 100 call in captured traffic.
|
|
int? responseTutorialStep = null;
|
|
if (isTutorialPath)
|
|
{
|
|
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,
|
|
};
|
|
}
|
|
}
|