From c37c04c1b7c388ecbbf265d7c016a28b12b26082 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 16:55:08 -0400 Subject: [PATCH] refactor(gacha-point): route TryExchangeAsync through IInventoryTransaction Change signature from (Viewer, packId, cardId) to (IInventoryTransaction, packId, cardId). Drop RewardGrantService from GachaPointService ctor. PackController.ExchangeGachaPoint opens tx with GachaPointBalances/Received extra includes, passes tx, commits on success. Update GachaPointServiceTests to use inv.BeginAsync + tx pattern. Co-Authored-By: Claude Opus 4.7 --- .../Controllers/PackController.cs | 22 +++----- .../Services/GachaPointService.cs | 32 ++++------- .../Services/IGachaPointService.cs | 12 +++-- .../Services/GachaPointServiceTests.cs | 54 ++++++++++++------- 4 files changed, 60 insertions(+), 60 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs index a6a85e5..06fbf4c 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs @@ -202,26 +202,18 @@ public class PackController : SVSimController { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); - // Load the viewer with the collections the service mutates (balances, received marker, - // cards, cosmetics). AsSplitQuery per project_ef_split_query memory. - var viewer = await _db.Viewers - .Include(v => v.GachaPointBalances) - .Include(v => v.GachaPointReceived) - .Include(v => v.Cards) - .Include(v => v.Sleeves) - .Include(v => v.Emblems) - .Include(v => v.Degrees) - .Include(v => v.LeaderSkins) - .Include(v => v.MyPageBackgrounds) - .AsSplitQuery() - .FirstAsync(v => v.Id == viewerId); + // 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(viewer, request.OddsGachaId, request.CardId); + var outcome = await _gachaPoint.TryExchangeAsync(tx, request.OddsGachaId, request.CardId); if (!outcome.Success) return BadRequest(new { error = outcome.Error }); - await _db.SaveChangesAsync(); + await tx.CommitAsync(); return new ExchangeGachaPointResponse { diff --git a/SVSim.EmulatedEntrypoint/Services/GachaPointService.cs b/SVSim.EmulatedEntrypoint/Services/GachaPointService.cs index c565be8..c909991 100644 --- a/SVSim.EmulatedEntrypoint/Services/GachaPointService.cs +++ b/SVSim.EmulatedEntrypoint/Services/GachaPointService.cs @@ -5,6 +5,7 @@ using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Repositories.PackDrawTables; using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Models.Dtos; namespace SVSim.EmulatedEntrypoint.Services; @@ -13,13 +14,11 @@ public sealed class GachaPointService : IGachaPointService { private readonly SVSimDbContext _db; private readonly IPackDrawTableRepository _drawTables; - private readonly RewardGrantService _grants; - public GachaPointService(SVSimDbContext db, IPackDrawTableRepository drawTables, RewardGrantService grants) + public GachaPointService(SVSimDbContext db, IPackDrawTableRepository drawTables) { _db = db; _drawTables = drawTables; - _grants = grants; } public async Task> GetRewardsAsync(int packId, long viewerId) @@ -176,8 +175,9 @@ public sealed class GachaPointService : IGachaPointService } } - public async Task TryExchangeAsync(Viewer viewer, int packId, long cardId) + public async Task TryExchangeAsync(IInventoryTransaction tx, int packId, long cardId) { + var viewer = tx.Viewer; var pack = await _db.Packs.FirstOrDefaultAsync(p => p.Id == packId); if (pack?.GachaPointConfig is null) return ExchangeOutcome.Fail("pack_not_exchangeable"); @@ -206,23 +206,13 @@ public sealed class GachaPointService : IGachaPointService PackId = packId, CardId = cardId, ReceivedAt = DateTime.UtcNow, }); - // Grant the card itself through RewardGrantService — its CardCosmeticReward cascade - // covers the Emblem (standard legendary) or Skin+Emblem (leader) the catalog - // advertised. The catalog's reward_list is a wire-shape *display* (what the player - // sees on /pack/get_gacha_point_rewards) — the actual grant uses the canonical - // primitive per feedback_reward_grant_service. For leader-card exchanges the catalog - // also advertises a synthetic Sleeve(=card_id) entry, but that's not in - // CardCosmeticRewards; if a capture ever shows leader exchanges granting a sleeve - // row, add that here. Today no leader exchange has been captured. - var granted = await _grants.ApplyAsync(viewer, UserGoodsType.Card, cardId, 1); - var rewardList = new List(); - foreach (var g in granted) - { - rewardList.Add(new RewardListEntry - { - RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum, - }); - } + // Grant the card via the inventory tx — its CardCosmeticReward cascade covers the + // Emblem (standard legendary) or Skin+Emblem (leader). Convert at the wire boundary + // so ExchangeOutcome still carries RewardListEntry for the controller response. + var granted = await tx.GrantAsync(UserGoodsType.Card, cardId, 1); + var rewardList = granted + .Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum }) + .ToList(); return ExchangeOutcome.Ok(rewardList); } diff --git a/SVSim.EmulatedEntrypoint/Services/IGachaPointService.cs b/SVSim.EmulatedEntrypoint/Services/IGachaPointService.cs index 7d89b5a..fea859c 100644 --- a/SVSim.EmulatedEntrypoint/Services/IGachaPointService.cs +++ b/SVSim.EmulatedEntrypoint/Services/IGachaPointService.cs @@ -1,4 +1,5 @@ using SVSim.Database.Models; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Models.Dtos; namespace SVSim.EmulatedEntrypoint.Services; @@ -23,11 +24,14 @@ public interface IGachaPointService void Accrue(Viewer viewer, PackConfigEntry pack, PackChildGachaEntry child, int packNumber); /// - /// Validate + execute an exchange. Returns the grant outcome on success (reward_list - /// entries the controller will return in ), - /// or a failure result describing why. Mutates the in-memory graph; caller saves. + /// Validate + execute an exchange using the provided inventory transaction (which must + /// have GachaPointBalances and GachaPointReceived loaded on tx.Viewer + /// via extra includes). Grants the card via + /// the tx. Returns the grant outcome on success (reward_list entries already converted to + /// ), or a failure result describing why. Caller commits + /// the tx on success. /// - Task TryExchangeAsync(Viewer viewer, int packId, long cardId); + Task TryExchangeAsync(IInventoryTransaction tx, int packId, long cardId); } public sealed record ExchangeOutcome(bool Success, string? Error, IReadOnlyList RewardList) diff --git a/SVSim.UnitTests/Services/GachaPointServiceTests.cs b/SVSim.UnitTests/Services/GachaPointServiceTests.cs index 0c153f4..51d81a1 100644 --- a/SVSim.UnitTests/Services/GachaPointServiceTests.cs +++ b/SVSim.UnitTests/Services/GachaPointServiceTests.cs @@ -4,6 +4,7 @@ using NUnit.Framework; using SVSim.Database; using SVSim.Database.Enums; using SVSim.Database.Models; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Services; using SVSim.UnitTests.Infrastructure; @@ -380,14 +381,17 @@ public class GachaPointServiceTests SeedPackWithOneLegendary(db, packId: 10008, threshold: 400); - var viewer = await db.Viewers - .Include(v => v.GachaPointBalances) - .FirstAsync(v => v.Id == viewerId); + var viewer = await db.Viewers.Include(v => v.GachaPointBalances).FirstAsync(v => v.Id == viewerId); viewer.GachaPointBalances.Add(new ViewerGachaPointBalance { PackId = 10008, Points = 399 }); await db.SaveChangesAsync(); var svc = scope.ServiceProvider.GetRequiredService(); - var outcome = await svc.TryExchangeAsync(viewer, 10008, 108041010); + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId, configure: cfg => cfg + .WithInclude(v => v.GachaPointBalances) + .WithInclude(v => v.GachaPointReceived)); + + var outcome = await svc.TryExchangeAsync(tx, 10008, 108041010); Assert.That(outcome.Success, Is.False); Assert.That(outcome.Error, Is.EqualTo("insufficient_gacha_points")); @@ -403,14 +407,17 @@ public class GachaPointServiceTests SeedPackWithOneLegendary(db, packId: 10008, threshold: 400); - var viewer = await db.Viewers - .Include(v => v.GachaPointBalances) - .FirstAsync(v => v.Id == viewerId); + var viewer = await db.Viewers.Include(v => v.GachaPointBalances).FirstAsync(v => v.Id == viewerId); viewer.GachaPointBalances.Add(new ViewerGachaPointBalance { PackId = 10008, Points = 400 }); await db.SaveChangesAsync(); var svc = scope.ServiceProvider.GetRequiredService(); - var outcome = await svc.TryExchangeAsync(viewer, 10008, cardId: 999999999); // not in pool + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId, configure: cfg => cfg + .WithInclude(v => v.GachaPointBalances) + .WithInclude(v => v.GachaPointReceived)); + + var outcome = await svc.TryExchangeAsync(tx, 10008, cardId: 999999999); // not in pool Assert.That(outcome.Success, Is.False); Assert.That(outcome.Error, Is.EqualTo("card_not_exchangeable")); @@ -438,7 +445,12 @@ public class GachaPointServiceTests await db.SaveChangesAsync(); var svc = scope.ServiceProvider.GetRequiredService(); - var outcome = await svc.TryExchangeAsync(viewer, 10008, 108041010); + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId, configure: cfg => cfg + .WithInclude(v => v.GachaPointBalances) + .WithInclude(v => v.GachaPointReceived)); + + var outcome = await svc.TryExchangeAsync(tx, 10008, 108041010); Assert.That(outcome.Success, Is.False); Assert.That(outcome.Error, Is.EqualTo("already_received")); @@ -454,29 +466,31 @@ public class GachaPointServiceTests SeedPackWithOneLegendary(db, packId: 10008, threshold: 400); - var viewer = await db.Viewers + var preViewer = await db.Viewers .Include(v => v.GachaPointBalances) - .Include(v => v.GachaPointReceived) - .Include(v => v.Cards) - .Include(v => v.Emblems) .FirstAsync(v => v.Id == viewerId); - viewer.GachaPointBalances.Add(new ViewerGachaPointBalance { PackId = 10008, Points = 500 }); + preViewer.GachaPointBalances.Add(new ViewerGachaPointBalance { PackId = 10008, Points = 500 }); await db.SaveChangesAsync(); var svc = scope.ServiceProvider.GetRequiredService(); - var outcome = await svc.TryExchangeAsync(viewer, 10008, 108041010); - await db.SaveChangesAsync(); + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId, configure: cfg => cfg + .WithInclude(v => v.GachaPointBalances) + .WithInclude(v => v.GachaPointReceived)); + var outcome = await svc.TryExchangeAsync(tx, 10008, 108041010); Assert.That(outcome.Success, Is.True); - // Balance debited. - Assert.That(viewer.GachaPointBalances.Single().Points, Is.EqualTo(100)); + await tx.CommitAsync(); + + // Balance debited (check via tx.Viewer which is tracked). + Assert.That(tx.Viewer.GachaPointBalances.Single().Points, Is.EqualTo(100)); // Marker written. - Assert.That(viewer.GachaPointReceived + Assert.That(tx.Viewer.GachaPointReceived .Any(r => r.PackId == 10008 && r.CardId == 108041010), Is.True); - // Reward list non-empty: at minimum the card grant and the gacha-point post-state entry. + // Reward list non-empty: at minimum the card grant. Assert.That(outcome.RewardList, Is.Not.Empty); Assert.That(outcome.RewardList.Any(r => r.RewardType == (int)UserGoodsType.Card && r.RewardId == 108041010), Is.True, "card grant missing");