refactor(gift): route tutorial gift_receive through InventoryService
Replace RewardGrantService with IInventoryService tx. GrantAsync returns post-state totals directly, eliminating the manual ResolvePostStateRewardNum helper. MissionData loaded via extra include on BeginAsync. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SVSim.Database;
|
using SVSim.Database;
|
||||||
using SVSim.Database.Enums;
|
using SVSim.Database.Enums;
|
||||||
using SVSim.Database.Services;
|
using SVSim.Database.Services.Inventory;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Gift;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Gift;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Gift;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Gift;
|
||||||
|
|
||||||
@@ -27,12 +27,12 @@ public class GiftController : SVSimController
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly SVSimDbContext _db;
|
private readonly SVSimDbContext _db;
|
||||||
private readonly RewardGrantService _rewards;
|
private readonly IInventoryService _inv;
|
||||||
|
|
||||||
public GiftController(SVSimDbContext db, RewardGrantService rewards)
|
public GiftController(SVSimDbContext db, IInventoryService inv)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_rewards = rewards;
|
_inv = inv;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("/tutorial/gift_top")]
|
[HttpPost("/tutorial/gift_top")]
|
||||||
@@ -71,25 +71,7 @@ public class GiftController : SVSimController
|
|||||||
|
|
||||||
var requestedIds = request.PresentIdArray.ToHashSet();
|
var requestedIds = request.PresentIdArray.ToHashSet();
|
||||||
|
|
||||||
// Load viewer with the collections RewardGrantService mutates. Crystals/Rupees live on
|
// Resolve which of the requested ids are still claimable for this viewer before opening tx.
|
||||||
// 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
|
var alreadyClaimedList = await _db.ViewerClaimedTutorialGifts
|
||||||
.Where(g => g.ViewerId == viewerId && requestedIds.Contains(g.PresentId))
|
.Where(g => g.ViewerId == viewerId && requestedIds.Contains(g.PresentId))
|
||||||
.Select(g => g.PresentId)
|
.Select(g => g.PresentId)
|
||||||
@@ -100,23 +82,43 @@ public class GiftController : SVSimController
|
|||||||
.Where(p => requestedIds.Contains(p.PresentId) && !alreadyClaimed.Contains(p.PresentId))
|
.Where(p => requestedIds.Contains(p.PresentId) && !alreadyClaimed.Contains(p.PresentId))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// Apply grants via the canonical primitive. Pass the loaded viewer, NOT viewerId.
|
// 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<GiftRewardListEntry>();
|
||||||
foreach (var p in toClaim)
|
foreach (var p in toClaim)
|
||||||
{
|
{
|
||||||
var goodsType = WireRewardTypeToUserGoodsType(int.Parse(p.RewardType));
|
var goodsType = WireRewardTypeToUserGoodsType(int.Parse(p.RewardType));
|
||||||
await _rewards.ApplyAsync(viewer, goodsType, long.Parse(p.RewardDetailId), int.Parse(p.RewardCount));
|
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)
|
||||||
|
{
|
||||||
|
rewardListEntries.Add(new GiftRewardListEntry
|
||||||
|
{
|
||||||
|
RewardType = p.RewardType,
|
||||||
|
RewardId = p.RewardDetailId,
|
||||||
|
RewardNum = granted[0].RewardNum.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Advance tutorial state from 31 → 41 as a side-effect of this claim (no separate
|
// 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
|
// /tutorial/update → 41 call appears in the prod capture). Preserve max — don't downgrade
|
||||||
// viewers who are already past step 41.
|
// viewers who are already past step 41.
|
||||||
const int GiftReceiveTutorialStep = 41;
|
const int GiftReceiveTutorialStep = 41;
|
||||||
if (viewer.MissionData.TutorialState < GiftReceiveTutorialStep)
|
if (tx.Viewer.MissionData.TutorialState < GiftReceiveTutorialStep)
|
||||||
{
|
{
|
||||||
viewer.MissionData.TutorialState = GiftReceiveTutorialStep;
|
tx.Viewer.MissionData.TutorialState = GiftReceiveTutorialStep;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist claim receipts in the same transaction.
|
// Persist claim receipts inside the same tx.
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
foreach (var p in toClaim)
|
foreach (var p in toClaim)
|
||||||
{
|
{
|
||||||
@@ -127,7 +129,7 @@ public class GiftController : SVSimController
|
|||||||
ClaimedAt = now,
|
ClaimedAt = now,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await _db.SaveChangesAsync();
|
await tx.CommitAsync();
|
||||||
|
|
||||||
var nowString = now.ToString("yyyy-MM-dd HH:mm:ss");
|
var nowString = now.ToString("yyyy-MM-dd HH:mm:ss");
|
||||||
var allClaimedList = await _db.ViewerClaimedTutorialGifts
|
var allClaimedList = await _db.ViewerClaimedTutorialGifts
|
||||||
@@ -176,54 +178,18 @@ public class GiftController : SVSimController
|
|||||||
// Hardcoding false hid the badge after partial claims even though present_list still
|
// Hardcoding false hid the badge after partial claims even though present_list still
|
||||||
// carried unclaimed entries.
|
// carried unclaimed entries.
|
||||||
IsUnreceivedPresent = unclaimedPresents.Count > 0,
|
IsUnreceivedPresent = unclaimedPresents.Count > 0,
|
||||||
// reward_list entries must carry POST-STATE TOTALS, not gift deltas.
|
// reward_list entries carry POST-STATE TOTALS (from tx.GrantAsync).
|
||||||
// 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.
|
// See project memory: project_wire_reward_list_post_state.
|
||||||
//
|
// Iterate toClaim so idempotent re-receive doesn't re-emit post-state entries.
|
||||||
// Iterate `toClaim` so idempotent re-receive doesn't re-emit post-state entries
|
RewardList = rewardListEntries,
|
||||||
// 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
|
// 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);
|
// 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
|
// emitting 41 anyway would surface a regressed step to the client and desync the
|
||||||
// tutorial-state machine.
|
// tutorial-state machine.
|
||||||
TutorialStep = viewer.MissionData.TutorialState,
|
TutorialStep = tx.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
|
private static UserGoodsType WireRewardTypeToUserGoodsType(int wireType) => wireType switch
|
||||||
{
|
{
|
||||||
1 => UserGoodsType.Crystal,
|
1 => UserGoodsType.Crystal,
|
||||||
|
|||||||
Reference in New Issue
Block a user