Files
SVSimServer/SVSim.EmulatedEntrypoint/Controllers/PackController.cs
gamer147 ac077dfc13 fix(pack): tutorial pack_open ThenIncludes OwnedItemEntry.Item
Without .ThenInclude(i => i.Item), the OwnedItemEntry.Item nav defaults to a
new ItemEntry() with Id=0 (project_ef_nav_include_pitfall), so the
FirstOrDefault(i => i.Item.Id == ticketItemId) lookup never matched. The
ticket was never decremented and reward_list omitted the post-state entry —
on the next /tutorial/pack_info the pack stayed visible and the client
re-clicked into plain /pack/open, which 501s on type_detail=5.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:15:31 -04:00

314 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
// 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.
var ownedItemsByItemId = await _db.Viewers
.Where(v => v.Id == viewerId)
.SelectMany(v => v.Items)
.Select(i => new { ItemId = (long)i.Item.Id, i.Count })
.ToDictionaryAsync(x => x.ItemId, x => x.Count);
return new PackInfoResponse
{
PackConfigList = packs.Select(p => ToDto(p, openCounts, ownedItemsByItemId)).ToList(),
};
}
private static PackConfigDto ToDto(
PackConfigEntry p,
IReadOnlyDictionary<int, ViewerPackOpenCount> openCounts,
IReadOnlyDictionary<long, int> ownedItemsByItemId)
{
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),
// 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 = 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)
.Include(v => v.Items).ThenInclude(i => i.Item)
.AsSplitQuery()
.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);
// 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
});
}
}
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,
};
}
}