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);
+ }
}