feat(tutorial): add /tutorial/gift_receive — grant + receipt + idempotent re-claim
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
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;
|
||||
|
||||
@@ -25,10 +27,12 @@ public class GiftController : SVSimController
|
||||
};
|
||||
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly RewardGrantService _rewards;
|
||||
|
||||
public GiftController(SVSimDbContext db)
|
||||
public GiftController(SVSimDbContext db, RewardGrantService rewards)
|
||||
{
|
||||
_db = db;
|
||||
_rewards = rewards;
|
||||
}
|
||||
|
||||
[HttpPost("/tutorial/gift_top")]
|
||||
@@ -60,6 +64,117 @@ public class GiftController : SVSimController
|
||||
};
|
||||
}
|
||||
|
||||
[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).
|
||||
var viewer = await _db.Viewers
|
||||
.Include(v => v.Items)
|
||||
.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);
|
||||
|
||||
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))
|
||||
.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 = 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,
|
||||
RewardList = TutorialGifts
|
||||
.Where(p => requestedIds.Contains(p.PresentId))
|
||||
.Select(p => new GiftRewardListEntry
|
||||
{
|
||||
RewardType = p.RewardType,
|
||||
RewardId = p.RewardDetailId,
|
||||
RewardNum = p.RewardCount,
|
||||
})
|
||||
.ToList(),
|
||||
TutorialStep = 41,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Gift;
|
||||
|
||||
[MessagePackObject]
|
||||
public class GiftReceiveRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("present_id_array")]
|
||||
[Key("present_id_array")]
|
||||
public List<string> PresentIdArray { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("state")]
|
||||
[Key("state")]
|
||||
public int State { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Gift;
|
||||
|
||||
[MessagePackObject]
|
||||
public class GiftReceiveResponse
|
||||
{
|
||||
/// <summary>Cards granted (always empty for tutorial — the starter bundle has no card-type rewards).</summary>
|
||||
[JsonPropertyName("card_list")]
|
||||
[Key("card_list")]
|
||||
public List<object> CardList { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("received_ids")]
|
||||
[Key("received_ids")]
|
||||
public List<string> ReceivedIds { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("total_receive_count_list")]
|
||||
[Key("total_receive_count_list")]
|
||||
public List<TotalReceiveCountDto> TotalReceiveCountList { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("present_list")]
|
||||
[Key("present_list")]
|
||||
public List<PresentDto> PresentList { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("present_history_list")]
|
||||
[Key("present_history_list")]
|
||||
public List<PresentDto> PresentHistoryList { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("is_unreceived_present")]
|
||||
[Key("is_unreceived_present")]
|
||||
public bool IsUnreceivedPresent { get; set; }
|
||||
|
||||
[JsonPropertyName("reward_list")]
|
||||
[Key("reward_list")]
|
||||
public List<GiftRewardListEntry> RewardList { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Tutorial step the server is advancing the viewer to as a side-effect of this claim.
|
||||
/// Nullable: omitted via global WhenWritingNull on non-tutorial uses (none yet) or when
|
||||
/// the viewer is already past the 31→41 boundary.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tutorial_step")]
|
||||
[Key("tutorial_step")]
|
||||
public int? TutorialStep { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-reward summary. Prod wire shape: reward_type/reward_detail_id/reward_count are ints
|
||||
/// (NOT strings, unlike PresentDto). item_type is int (0 for currency, 1/2 for items).
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class TotalReceiveCountDto
|
||||
{
|
||||
[JsonPropertyName("reward_type")]
|
||||
[Key("reward_type")]
|
||||
public int RewardType { get; set; }
|
||||
|
||||
[JsonPropertyName("reward_detail_id")]
|
||||
[Key("reward_detail_id")]
|
||||
public long RewardDetailId { get; set; }
|
||||
|
||||
[JsonPropertyName("reward_count")]
|
||||
[Key("reward_count")]
|
||||
public long RewardCount { get; set; }
|
||||
|
||||
/// <summary>0 for currency rewards, 1 or 2 for item rewards. Prod wire is int; the client's .ToInt() handles both int and string values.</summary>
|
||||
[JsonPropertyName("item_type")]
|
||||
[Key("item_type")]
|
||||
public int ItemType { get; set; }
|
||||
|
||||
[JsonPropertyName("is_usable")]
|
||||
[Key("is_usable")]
|
||||
public bool IsUsable { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entries in /tutorial/gift_receive's reward_list. Wire shape: reward_type and reward_id are
|
||||
/// STRINGS, reward_num is INT for currency entries (type 1, 9) and STRING for item entries
|
||||
/// (type 4). Use string for reward_num to handle both — the client tolerates string→int parse.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class GiftRewardListEntry
|
||||
{
|
||||
[JsonPropertyName("reward_type")]
|
||||
[Key("reward_type")]
|
||||
public string RewardType { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reward_id")]
|
||||
[Key("reward_id")]
|
||||
public string RewardId { get; set; } = "0";
|
||||
|
||||
[JsonPropertyName("reward_num")]
|
||||
[Key("reward_num")]
|
||||
public string RewardNum { get; set; } = "0";
|
||||
}
|
||||
Reference in New Issue
Block a user