Files
SVSimServer/SVSim.EmulatedEntrypoint/Controllers/PackController.cs
gamer147 05d8169012 refactor: type reward_type columns as UserGoodsType enum
Replace bare `int RewardType` on 12 catalog/reward entities and GrantedReward
with the existing UserGoodsType enum. Verified against the decompiled client:
every wire reward_type decodes through the single Wizard.UserGoods.Type enum, so
one enum is correct across all endpoint families (item_type is a separate
Item.Type axis, left untouched). EF stores the enum as the same int column, so
there is no migration.

- Importers cast seed int -> UserGoodsType at the ingest boundary.
- New GrantedReward.ToRewardList() extension replaces 8 copy-pasted
  GrantedReward -> RewardListEntry projections.
- Fix 3 .ToString() sites that would otherwise emit enum names ("Crystal")
  instead of the int wire value ("2").
- Wire DTOs keep int; the enum is widened to int at the wire boundary only.

Build green; 962/962 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:50:49 -04:00

436 lines
21 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.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);
return new PackInfoResponse
{
PackConfigList = packs
.Select(p => ToDto(p, openCounts, ownedItemsByItemId, gachaPointBalancesByPackId))
.ToList(),
};
}
private static PackConfigDto ToDto(
PackConfigEntry p,
IReadOnlyDictionary<int, ViewerPackOpenCount> openCounts,
IReadOnlyDictionary<long, int> ownedItemsByItemId,
IReadOnlyDictionary<int, int> 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<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
.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<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 type_details on the normal path:
// 1 CRYSTAL / 2 CRYSTAL_MULTI -> spend crystals
// 6 RUPY / 7 RUPY_MULTI -> spend rupees
// 3 DAILY -> spend rupees, once per UTC day
// 4 TICKET / 5 TICKET_MULTI -> consume child.ItemId from OwnedItemEntry
// Skin-overload types (8/9/13) and free-pack overlays (10/11/12) need extra
// selection / banner plumbing — kept 501 until the relevant flows land.
if (!isTutorialPath && child.TypeDetail is not (1 or 2 or 3 or 4 or 5 or 6 or 7))
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
.WithInclude(v => v.PackOpenCounts)
.WithInclude(v => v.GachaPointBalances)
.WithInclude(v => v.MissionData));
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 1: // CRYSTAL (single)
case 2: // CRYSTAL_MULTI (10-pack)
{
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 6: // RUPY (single)
case 7: // RUPY_MULTI (10-pack)
{
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 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 tx.TrySpendAsync(SpendCurrency.Rupee, cost);
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
break;
}
case 4: // TICKET (single)
case 5: // TICKET_MULTI (10-pack)
{
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;
}
}
}
// 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<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,
};
}
}