From 4d6da2344392936ae646bef9885cddccaae4e1a3 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 16:20:23 -0400 Subject: [PATCH] refactor(pack): route Open through InventoryService Co-Authored-By: Claude Opus 4.7 --- .../Controllers/PackController.cs | 106 ++++++------------ 1 file changed, 32 insertions(+), 74 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs index 8a588af..a6a85e5 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs @@ -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(); - - // 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