fix(gift): receive response is idempotent and echoes persisted state

Three coupled correctness fixes to /tutorial/gift_receive's response:

- received_ids / total_receive_count_list / reward_list are now built
  from `toClaim` (the gifts THIS call granted), not from `requestedIds`.
  Echoing the client's request meant idempotent re-claims re-fired the
  "+N received" popup and direct-assigned the same post-state totals
  again, breaking the documented idempotency contract.
- is_unreceived_present is now `unclaimedPresents.Count > 0`. The
  hardcoded false hid the inbox badge after partial claims even when
  present_list still carried unclaimed gifts.
- tutorial_step echoes the persisted (max-preserved) state instead of
  a hardcoded 41. A replay against a state>=41 viewer used to surface
  41 on the wire and regress the client's tutorial state machine.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-28 21:10:06 -04:00
parent 86759125a9
commit d13082a8ca
2 changed files with 100 additions and 17 deletions

View File

@@ -136,13 +136,32 @@ public class GiftController : SVSimController
.ToListAsync();
var allClaimed = new HashSet<string>(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(),
// Capture orders received_ids ascending — match.
ReceivedIds = requestedIds.OrderBy(x => x).ToList(),
TotalReceiveCountList = TutorialGifts
.Where(p => requestedIds.Contains(p.PresentId))
// 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),
@@ -151,22 +170,22 @@ public class GiftController : SVSimController
ItemType = p.ItemType ?? 0,
IsUsable = true,
}).ToList(),
PresentList = TutorialGifts
.Where(p => !allClaimed.Contains(p.PresentId))
.Select(p => Clone(p, nowString))
.ToList(),
PresentHistoryList = TutorialGifts
.Where(p => allClaimed.Contains(p.PresentId))
.Select(p => Clone(p, nowString))
.ToList(),
IsUnreceivedPresent = false,
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.
RewardList = TutorialGifts
.Where(p => requestedIds.Contains(p.PresentId))
//
// 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,
@@ -174,7 +193,11 @@ public class GiftController : SVSimController
RewardNum = ResolvePostStateRewardNum(p, viewer),
})
.ToList(),
TutorialStep = 41,
// 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,
};
}