diff --git a/SVSim.EmulatedEntrypoint/Controllers/GiftController.cs b/SVSim.EmulatedEntrypoint/Controllers/GiftController.cs index 4ed85ae..b92be10 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/GiftController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/GiftController.cs @@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using SVSim.Database; using SVSim.Database.Enums; -using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Gift; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Gift; @@ -27,12 +27,12 @@ public class GiftController : SVSimController }; private readonly SVSimDbContext _db; - private readonly RewardGrantService _rewards; + private readonly IInventoryService _inv; - public GiftController(SVSimDbContext db, RewardGrantService rewards) + public GiftController(SVSimDbContext db, IInventoryService inv) { _db = db; - _rewards = rewards; + _inv = inv; } [HttpPost("/tutorial/gift_top")] @@ -71,25 +71,7 @@ public class GiftController : SVSimController var requestedIds = request.PresentIdArray.ToHashSet(); - // Load viewer with the collections RewardGrantService mutates. Crystals/Rupees live on - // viewer.Currency (owned, auto-loads); Items live on viewer.Items (owned collection). - // MissionData is an owned type and auto-loads, but Include is listed explicitly to match - // the pattern in TutorialController.Update and to make the intent clear. - // AsSplitQuery is the default-safe pattern when including viewer collections - // (project memory: project_ef_split_query). - // - // ThenInclude(i => i.Item) is load-bearing: OwnedItemEntry.Item is a separate non-owned - // entity whose default initialiser is `new ItemEntry()` (Id=0). Without the explicit - // ThenInclude, RewardGrantService.ApplyAsync's `FirstOrDefault(i => i.Item.Id == ...)` - // never matches a pre-existing row → falls through to add a duplicate → (ViewerId, ItemId) - // unique index throws on SaveChanges (project_ef_nav_include_pitfall). - var viewer = await _db.Viewers - .Include(v => v.Items).ThenInclude(i => i.Item) - .Include(v => v.MissionData) - .AsSplitQuery() - .FirstAsync(v => v.Id == viewerId); - - // Resolve which of the requested ids are still claimable for this viewer. + // Resolve which of the requested ids are still claimable for this viewer before opening tx. var alreadyClaimedList = await _db.ViewerClaimedTutorialGifts .Where(g => g.ViewerId == viewerId && requestedIds.Contains(g.PresentId)) .Select(g => g.PresentId) @@ -100,23 +82,43 @@ public class GiftController : SVSimController .Where(p => requestedIds.Contains(p.PresentId) && !alreadyClaimed.Contains(p.PresentId)) .ToList(); - // Apply grants via the canonical primitive. Pass the loaded viewer, NOT viewerId. + // Open inventory tx with MissionData loaded for tutorial-step advance. + await using var tx = await _inv.BeginAsync(viewerId, configure: + cfg => cfg.WithInclude(v => v.MissionData)); + + // Apply grants via tx. Collect post-state per (type, detailId) for reward_list. + // Each GrantAsync returns a list of GrantedReward with post-state totals; for currencies + // only one entry is returned; for cards the cascade may return more entries (card + cosmetics). + // reward_list must carry post-state totals (client does direct assignment). + var rewardListEntries = new List(); foreach (var p in toClaim) { var goodsType = WireRewardTypeToUserGoodsType(int.Parse(p.RewardType)); - await _rewards.ApplyAsync(viewer, goodsType, long.Parse(p.RewardDetailId), int.Parse(p.RewardCount)); + var granted = await tx.GrantAsync(goodsType, long.Parse(p.RewardDetailId), int.Parse(p.RewardCount)); + // Use the first granted entry's post-state for the top-level gift reward_list entry. + // Gift rewards are currencies and items only (no cards in TutorialGifts), so granted + // always has exactly one element. The post-state total is already correct from tx. + if (granted.Count > 0) + { + rewardListEntries.Add(new GiftRewardListEntry + { + RewardType = p.RewardType, + RewardId = p.RewardDetailId, + RewardNum = granted[0].RewardNum.ToString(System.Globalization.CultureInfo.InvariantCulture), + }); + } } // Advance tutorial state from 31 → 41 as a side-effect of this claim (no separate // /tutorial/update → 41 call appears in the prod capture). Preserve max — don't downgrade // viewers who are already past step 41. const int GiftReceiveTutorialStep = 41; - if (viewer.MissionData.TutorialState < GiftReceiveTutorialStep) + if (tx.Viewer.MissionData.TutorialState < GiftReceiveTutorialStep) { - viewer.MissionData.TutorialState = GiftReceiveTutorialStep; + tx.Viewer.MissionData.TutorialState = GiftReceiveTutorialStep; } - // Persist claim receipts in the same transaction. + // Persist claim receipts inside the same tx. var now = DateTime.UtcNow; foreach (var p in toClaim) { @@ -127,7 +129,7 @@ public class GiftController : SVSimController ClaimedAt = now, }); } - await _db.SaveChangesAsync(); + await tx.CommitAsync(); var nowString = now.ToString("yyyy-MM-dd HH:mm:ss"); var allClaimedList = await _db.ViewerClaimedTutorialGifts @@ -176,54 +178,18 @@ public class GiftController : SVSimController // Hardcoding false hid the badge after partial claims even though present_list still // carried unclaimed entries. IsUnreceivedPresent = unclaimedPresents.Count > 0, - // reward_list entries must carry POST-STATE TOTALS, not gift deltas. - // The client's PlayerStaticData.UpdateHaveUserGoodsNumByJsonData does direct - // assignment on each entry's reward_num — emitting the delta would clobber - // the client-side cached balance down to the gift amount until the next /load/index. + // reward_list entries carry POST-STATE TOTALS (from tx.GrantAsync). // See project memory: project_wire_reward_list_post_state. - // - // Iterate `toClaim` so idempotent re-receive doesn't re-emit post-state entries - // the client would direct-assign again (no-op on currency, but redundant traffic - // and risk of misinterpretation on item counts). - RewardList = toClaim - .Select(p => new GiftRewardListEntry - { - RewardType = p.RewardType, - RewardId = p.RewardDetailId, - RewardNum = ResolvePostStateRewardNum(p, viewer), - }) - .ToList(), + // Iterate toClaim so idempotent re-receive doesn't re-emit post-state entries. + RewardList = rewardListEntries, // Echo the persisted state, not a hardcoded 41. The state may already be past 41 // for replay/edge-case calls (the Math.Max-preserve block above keeps it stable); // emitting 41 anyway would surface a regressed step to the client and desync the // tutorial-state machine. - TutorialStep = viewer.MissionData.TutorialState, + TutorialStep = tx.Viewer.MissionData.TutorialState, }; } - /// - /// Returns the post-grant viewer balance for the given gift entry, not the gift delta. - /// reward_list on wire carries post-state totals (client does direct assignment). - /// - private static string ResolvePostStateRewardNum(PresentDto gift, SVSim.Database.Models.Viewer viewer) - { - switch (gift.RewardType) - { - case "1": // Crystal - return ((long)viewer.Currency.Crystals).ToString(System.Globalization.CultureInfo.InvariantCulture); - case "9": // Rupy - return ((long)viewer.Currency.Rupees).ToString(System.Globalization.CultureInfo.InvariantCulture); - case "4": // Item - { - int itemId = int.Parse(gift.RewardDetailId, System.Globalization.CultureInfo.InvariantCulture); - var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == itemId); - return ((long)(owned?.Count ?? 0)).ToString(System.Globalization.CultureInfo.InvariantCulture); - } - default: - return gift.RewardCount; // unknown type — fall back to gift count (better than 0) - } - } - private static UserGoodsType WireRewardTypeToUserGoodsType(int wireType) => wireType switch { 1 => UserGoodsType.Crystal,