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>
249 lines
12 KiB
C#
249 lines
12 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public class GiftController : SVSimController
|
|
{
|
|
/// <summary>The hardcoded tutorial gift bundle every fresh viewer sees at step 31.</summary>
|
|
public static readonly IReadOnlyList<PresentDto> 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<ActionResult<GiftTopResponse>> 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<string>(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<ActionResult<GiftReceiveResponse>> 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<string>(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<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(),
|
|
// 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,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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,
|
|
};
|
|
}
|