refactor(pack): route Open through InventoryService

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-31 16:20:23 -04:00
parent 57dd524d9f
commit 4d6da23443

View File

@@ -11,6 +11,7 @@ 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;
@@ -30,10 +31,8 @@ public class PackController : SVSimController
private readonly ICardFoilLookup _foils;
private readonly IRandom _rng;
private readonly SVSimDbContext _db;
private readonly ICardAcquisitionService _acquisition;
private readonly IInventoryService _inv;
private readonly IGachaPointService _gachaPoint;
private readonly ICurrencySpendService _spend;
private readonly IViewerEntitlements _entitlements;
public PackController(
IPackRepository packs,
@@ -42,10 +41,8 @@ public class PackController : SVSimController
ICardFoilLookup foils,
IRandom rng,
SVSimDbContext db,
ICardAcquisitionService acquisition,
IGachaPointService gachaPoint,
ICurrencySpendService spend,
IViewerEntitlements entitlements)
IInventoryService inv,
IGachaPointService gachaPoint)
{
_packs = packs;
_opener = opener;
@@ -53,10 +50,8 @@ public class PackController : SVSimController
_foils = foils;
_rng = rng;
_db = db;
_acquisition = acquisition;
_inv = inv;
_gachaPoint = gachaPoint;
_spend = spend;
_entitlements = entitlements;
}
[HttpPost("info")]
@@ -287,13 +282,12 @@ public class PackController : SVSimController
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" });
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);
// 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
@@ -314,7 +308,7 @@ public class PackController : SVSimController
case 2: // CRYSTAL_MULTI (10-pack)
{
long cost = (long)child.Cost * packNumber;
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, cost);
var r = await tx.TrySpendAsync(SpendCurrency.Crystal, cost);
if (!r.Success) return BadRequest(new { error = "insufficient_crystals" });
break;
}
@@ -322,7 +316,7 @@ public class PackController : SVSimController
case 7: // RUPY_MULTI (10-pack)
{
long cost = (long)child.Cost * packNumber;
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, cost);
var r = await tx.TrySpendAsync(SpendCurrency.Rupee, cost);
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
break;
}
@@ -336,7 +330,7 @@ public class PackController : SVSimController
return BadRequest(new { error = "daily_free_already_claimed" });
long cost = (long)child.Cost * packNumber;
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, cost);
var r = await tx.TrySpendAsync(SpendCurrency.Rupee, cost);
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
break;
}
@@ -347,15 +341,11 @@ public class PackController : SVSimController
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "ticket_pack_missing_item_id" });
int ticketsNeeded = child.Cost * packNumber;
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId);
if (owned is null || owned.Count < ticketsNeeded)
return BadRequest(new { error = "insufficient_tickets" });
owned.Count -= ticketsNeeded;
var debit = await tx.TryDebitAsync(UserGoodsType.Item, ticketItemId, ticketsNeeded);
if (!debit.Success) return BadRequest(new { error = "insufficient_tickets" });
break;
}
}
await _db.SaveChangesAsync();
}
// Increment open count + mark daily-free timestamp where relevant.
@@ -394,48 +384,17 @@ public class PackController : SVSimController
ownedCardIds,
_foils,
_rng);
var grant = await _acquisition.GrantManyAsync(viewerId, draw.Cards.Select(c => c.CardId));
// 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);
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<RewardListEntry>();
// Currency reward entries only apply to purchasable packs; tutorial path omits them.
if (!isTutorialPath)
{
if (child.TypeDetail is 1 or 2)
{
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal) });
}
else if (child.TypeDetail is 3 or 6 or 7)
{
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)_entitlements.EffectiveBalance(viewer, SpendCurrency.Rupee) });
}
else if (child.TypeDetail is 4 or 5 && child.ItemId is long ticketItemId)
{
// Item post-state count for the ticket we just consumed — client direct-assigns
// _userItemDict, so this must be the new total (project_wire_reward_list_post_state).
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId);
rewardList.Add(new RewardListEntry
{
RewardType = 4, // Item
RewardId = ticketItemId,
RewardNum = owned?.Count ?? 0, // post-state total
});
}
}
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
@@ -447,19 +406,12 @@ public class PackController : SVSimController
int? responseTutorialStep = null;
if (isTutorialPath)
{
if (child.ItemId is long ticketItemId)
if (child.ItemId is long tutorialTicketItemId)
{
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
});
}
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
@@ -468,10 +420,16 @@ public class PackController : SVSimController
// the tutorial-END signal the client expects.
if (viewer.MissionData.TutorialState < TutorialEndStep)
viewer.MissionData.TutorialState = TutorialEndStep;
await _db.SaveChangesAsync();
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
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
.ToList();
return new PackOpenResponse
{
PackList = draw.Cards.Select(c => new CardPackEntryDto