From 2b35ae08902c48e5ca03f32b7a0757d197ef182d Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 8 Jun 2026 20:36:44 -0400 Subject: [PATCH] feat(gift): unified GiftController over ViewerPresent + route aliases --- .../Controllers/GiftController.cs | 263 ++++++++---------- 1 file changed, 117 insertions(+), 146 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/GiftController.cs b/SVSim.EmulatedEntrypoint/Controllers/GiftController.cs index d15f9b6..dd56b0a 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/GiftController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/GiftController.cs @@ -1,31 +1,29 @@ +using System.Globalization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using SVSim.Database; using SVSim.Database.Enums; +using SVSim.Database.Models; using SVSim.Database.Services.Inventory; -using SVSim.EmulatedEntrypoint.Models.Dtos.Common; +using SVSim.EmulatedEntrypoint.Mapping; 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. +/// Persistent gift inbox. /gift/top + /tutorial/gift_top are pure URL aliases over the +/// same ViewerPresent query; /gift/receive_gift + /tutorial/gift_receive share a single +/// ReceiveImpl whose only divergence is the route-gated tutorial-state bump. +/// +/// Tutorial gifts are seeded as real ViewerPresent rows during /tool/signup +/// (see ViewerRepository.RegisterAnonymousViewer) — this controller carries no static +/// gift catalog. /// 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 const int PageSize = 30; + private const int GiftReceiveTutorialStep = 41; private readonly SVSimDbContext _db; private readonly IInventoryService _inv; @@ -36,180 +34,153 @@ public class GiftController : SVSimController _inv = inv; } + [HttpPost("/gift/top")] [HttpPost("/tutorial/gift_top")] - public async Task> TutorialGiftTop([FromBody] GiftTopRequest request) + public async Task> Top([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(); + var (unclaimed, history) = await ReadTopWindowAsync(viewerId, request.Page); return new GiftTopResponse { - PresentList = presents, - PresentHistoryList = history, - LimitOverPresentList = new(), + PresentList = unclaimed.Select(PresentMapper.ToWire).ToList(), + PresentHistoryList = history.Select(PresentMapper.ToWire).ToList(), + LimitOverPresentList = new(), // expiration sweep deferred — always [] for now }; } + [HttpPost("/gift/receive_gift")] + public Task> Receive([FromBody] GiftReceiveRequest r) + => ReceiveImpl(r, advanceTutorial: false); + [HttpPost("/tutorial/gift_receive")] - public async Task> TutorialGiftReceive([FromBody] GiftReceiveRequest request) + public Task> TutorialReceive([FromBody] GiftReceiveRequest r) + => ReceiveImpl(r, advanceTutorial: true); + + private async Task> ReceiveImpl( + GiftReceiveRequest request, bool advanceTutorial) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); - var requestedIds = request.PresentIdArray.ToHashSet(); + var requested = request.PresentIdArray.ToHashSet(); + var state = request.State; // 1 = MAIL_READ (claim), 3 = MAIL_DELETE - // 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) + // Pull only currently-Unclaimed rows matching the request — already-Claimed / + // Deleted / Expired rows are silently ignored (idempotent retry semantics). + var targets = await _db.ViewerPresents + .Where(p => p.ViewerId == viewerId + && p.Status == PresentStatus.Unclaimed + && requested.Contains(p.PresentId)) .ToListAsync(); - var alreadyClaimed = new HashSet(alreadyClaimedList); - var toClaim = TutorialGifts - .Where(p => requestedIds.Contains(p.PresentId) && !alreadyClaimed.Contains(p.PresentId)) - .ToList(); - - // 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 now = DateTime.UtcNow; + + foreach (var p in targets) { - var goodsType = WireRewardTypeToUserGoodsType(int.Parse(p.RewardType)); - 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) + if (state == 1) { - rewardListEntries.Add(new GiftRewardListEntry + var granted = await tx.GrantAsync( + (UserGoodsType)p.RewardType, + p.RewardDetailId, + (int)p.RewardCount); + + // reward_list carries POST-STATE TOTALS (client does direct assignment). + // See project_wire_reward_list_post_state. GrantAsync already returns post-state. + if (granted.Count > 0) { - RewardType = p.RewardType, - RewardId = p.RewardDetailId, - RewardNum = granted[0].RewardNum.ToString(System.Globalization.CultureInfo.InvariantCulture), - }); + rewardListEntries.Add(new GiftRewardListEntry + { + RewardType = p.RewardType.ToString(CultureInfo.InvariantCulture), + RewardId = p.RewardDetailId.ToString(CultureInfo.InvariantCulture), + RewardNum = granted[0].RewardNum.ToString(CultureInfo.InvariantCulture), + }); + } + + p.Status = PresentStatus.Claimed; + p.ClaimedAt = now; + } + else if (state == 3) + { + // MAIL_DELETE: no grant, no reward_list entry, no history. Tombstone the + // row so re-deletes are idempotent under the same WHERE-Unclaimed filter. + p.Status = PresentStatus.Deleted; + p.ClaimedAt = now; // overload as "decided-at" — tombstone never reaches wire } } - // 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 (tx.Viewer.MissionData.TutorialState < GiftReceiveTutorialStep) - { + // Tutorial step advance — route-gated, no Source/state checks. Preserve-max so + // replays don't downgrade viewers already past 41. + if (advanceTutorial && tx.Viewer.MissionData.TutorialState < GiftReceiveTutorialStep) tx.Viewer.MissionData.TutorialState = GiftReceiveTutorialStep; - } - // Persist claim receipts inside the same tx. - 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 tx.CommitAsync(); + await tx.CommitAsync(); // throws DbUpdateConcurrencyException on RowVersion conflict - 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); + // Rebuild the inbox window (page 1) — the client wipes its local lists and rebuilds + // from these. + var (unclaimed, history) = await ReadTopWindowAsync(viewerId, page: 1); - // 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(); + // is_unreceived_present drives the home-screen inbox badge — must be the DB count + // post-commit, NOT hardcoded false (hiding the badge after partial claims). + var stillUnclaimed = await _db.ViewerPresents + .AnyAsync(p => p.ViewerId == viewerId && p.Status == PresentStatus.Unclaimed); 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) + CardList = new(), // capture is []; reward_list carries the grants + + // Echo only ids actually transitioned by THIS call — NOT requested ids, which + // would re-fire the "received N gifts" popup on replay. + ReceivedIds = targets + .Select(t => t.PresentId) + .OrderBy(x => x, StringComparer.Ordinal) .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 + + // Per-gift summary for the "+N received" popup. Empty on state=3. + TotalReceiveCountList = (state == 1 ? targets : Enumerable.Empty()) + .Select(t => new TotalReceiveCountDto { - RewardType = int.Parse(p.RewardType), - RewardDetailId = long.Parse(p.RewardDetailId), - RewardCount = long.Parse(p.RewardCount), - ItemType = p.ItemType ?? 0, - IsUsable = true, + RewardType = t.RewardType, + RewardDetailId = t.RewardDetailId, + RewardCount = t.RewardCount, + ItemType = t.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 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. + + PresentList = unclaimed.Select(PresentMapper.ToWire).ToList(), + PresentHistoryList = history.Select(PresentMapper.ToWire).ToList(), + IsUnreceivedPresent = stillUnclaimed, 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. + + // Echo persisted state, not a hardcoded 41 — preserve-max above keeps it stable. TutorialStep = tx.Viewer.MissionData.TutorialState, }; } - private static UserGoodsType WireRewardTypeToUserGoodsType(int wireType) => wireType switch + private async Task<(List Unclaimed, List History)> ReadTopWindowAsync( + long viewerId, int page) { - 1 => UserGoodsType.Crystal, - 4 => UserGoodsType.Item, - 9 => UserGoodsType.Rupy, - _ => throw new InvalidOperationException($"Unmapped gift wire reward_type {wireType}"), - }; + int pageOneIndexed = Math.Max(1, page); + int skip = (pageOneIndexed - 1) * PageSize; - 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, - }; + // Unclaimed: chronological (oldest first — capture order matches this). + var unclaimed = await _db.ViewerPresents + .Where(p => p.ViewerId == viewerId && p.Status == PresentStatus.Unclaimed) + .OrderBy(p => p.CreatedAt).ThenBy(p => p.Id) + .Skip(skip).Take(PageSize) + .ToListAsync(); + + // History: most-recent-first (standard inbox UX). + var history = await _db.ViewerPresents + .Where(p => p.ViewerId == viewerId && p.Status == PresentStatus.Claimed) + .OrderByDescending(p => p.ClaimedAt).ThenByDescending(p => p.Id) + .Skip(skip).Take(PageSize) + .ToListAsync(); + + return (unclaimed, history); + } }