using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using SVSim.Database; using SVSim.Database.Enums; using SVSim.Database.Services; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Gift; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Gift; namespace SVSim.EmulatedEntrypoint.Controllers; /// /// Tutorial-scoped gift endpoints. We do NOT implement a generic gift system here — /// only the /tutorial/gift_top and /tutorial/gift_receive aliases needed for the /// step 31 → 41 reward flow. A full gift inbox is future work; if/when needed, /// add /gift/top and /gift/receive_gift aliases to this controller. /// public class GiftController : SVSimController { /// The hardcoded tutorial gift bundle every fresh viewer sees at step 31. public static readonly IReadOnlyList TutorialGifts = new[] { new PresentDto { PresentId = "71478626", RewardType = "1", RewardDetailId = "0", RewardCount = "400", Message = "For completing the tutorial" }, new PresentDto { PresentId = "71478627", RewardType = "9", RewardDetailId = "0", RewardCount = "100", Message = "For completing the tutorial" }, new PresentDto { PresentId = "71478628", RewardType = "4", RewardDetailId = "1", RewardCount = "3", Message = "For completing the tutorial", ItemType = 1 }, new PresentDto { PresentId = "71478629", RewardType = "4", RewardDetailId = "80001", RewardCount = "40", Message = "For completing the tutorial", ItemType = 2 }, new PresentDto { PresentId = "71478630", RewardType = "4", RewardDetailId = "90001", RewardCount = "1", Message = "For completing the tutorial", ItemType = 2 }, }; private readonly SVSimDbContext _db; private readonly RewardGrantService _rewards; public GiftController(SVSimDbContext db, RewardGrantService rewards) { _db = db; _rewards = rewards; } [HttpPost("/tutorial/gift_top")] public async Task> TutorialGiftTop([FromBody] GiftTopRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); var claimedList = await _db.ViewerClaimedTutorialGifts .Where(g => g.ViewerId == viewerId) .Select(g => g.PresentId) .ToListAsync(); var claimed = new HashSet(claimedList); var nowString = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss"); var presents = TutorialGifts .Where(p => !claimed.Contains(p.PresentId)) .Select(p => Clone(p, nowString)) .ToList(); var history = TutorialGifts .Where(p => claimed.Contains(p.PresentId)) .Select(p => Clone(p, nowString)) .ToList(); return new GiftTopResponse { PresentList = presents, PresentHistoryList = history, LimitOverPresentList = new(), }; } [HttpPost("/tutorial/gift_receive")] public async Task> TutorialGiftReceive([FromBody] GiftReceiveRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); 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. var alreadyClaimedList = await _db.ViewerClaimedTutorialGifts .Where(g => g.ViewerId == viewerId && requestedIds.Contains(g.PresentId)) .Select(g => g.PresentId) .ToListAsync(); var alreadyClaimed = new HashSet(alreadyClaimedList); var toClaim = TutorialGifts .Where(p => requestedIds.Contains(p.PresentId) && !alreadyClaimed.Contains(p.PresentId)) .ToList(); // Apply grants via the canonical primitive. Pass the loaded viewer, NOT viewerId. 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)); } // 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) { viewer.MissionData.TutorialState = GiftReceiveTutorialStep; } // Persist claim receipts in the same transaction. var now = DateTime.UtcNow; foreach (var p in toClaim) { _db.ViewerClaimedTutorialGifts.Add(new SVSim.Database.Models.ViewerClaimedTutorialGift { ViewerId = viewerId, PresentId = p.PresentId, ClaimedAt = now, }); } await _db.SaveChangesAsync(); var nowString = now.ToString("yyyy-MM-dd HH:mm:ss"); var allClaimedList = await _db.ViewerClaimedTutorialGifts .Where(g => g.ViewerId == viewerId) .Select(g => g.PresentId) .ToListAsync(); var allClaimed = new HashSet(allClaimedList); // Derive presentList/historyList up front so IsUnreceivedPresent can read the count // without re-filtering. unclaimedPresents are the gifts still on offer after this call; // claimedPresents are everything the viewer has ever received (this call + prior calls). var unclaimedPresents = TutorialGifts .Where(p => !allClaimed.Contains(p.PresentId)) .Select(p => Clone(p, nowString)) .ToList(); var claimedPresents = TutorialGifts .Where(p => allClaimed.Contains(p.PresentId)) .Select(p => Clone(p, nowString)) .ToList(); return new GiftReceiveResponse { CardList = new(), // Echo only the ids actually granted by THIS call. Building this from `requestedIds` // would falsely confirm a re-grant on idempotent retries: the client would re-show // the "received N gifts" popup and direct-assign the same post-state totals it already // applied, double-toasting the user. Sort ascending to match the prod-capture order. ReceivedIds = toClaim .Select(p => p.PresentId) .OrderBy(x => x) .ToList(), // Same idempotency contract: only the gifts granted in THIS call belong in the // per-reward summary list. The client uses this to drive the +N popups. TotalReceiveCountList = toClaim .Select(p => new TotalReceiveCountDto { RewardType = int.Parse(p.RewardType), RewardDetailId = long.Parse(p.RewardDetailId), RewardCount = long.Parse(p.RewardCount), ItemType = p.ItemType ?? 0, IsUsable = true, }).ToList(), PresentList = unclaimedPresents, PresentHistoryList = claimedPresents, // True when there are still unclaimed gifts on offer — drives the inbox badge state. // 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. // 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(), // 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, }; } /// /// 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, 4 => UserGoodsType.Item, 9 => UserGoodsType.Rupy, _ => throw new InvalidOperationException($"Unmapped gift wire reward_type {wireType}"), }; private static PresentDto Clone(PresentDto p, string createTime) => new() { PresentId = p.PresentId, RewardType = p.RewardType, RewardDetailId = p.RewardDetailId, RewardCount = p.RewardCount, ConditionNumber = p.ConditionNumber, PresentLimitType = p.PresentLimitType, RewardLimitTime = p.RewardLimitTime, CreateTime = createTime, ItemType = p.ItemType, Message = p.Message, }; }