diff --git a/SVSim.EmulatedEntrypoint/Controllers/GiftController.cs b/SVSim.EmulatedEntrypoint/Controllers/GiftController.cs index 619de2e..8828900 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/GiftController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/GiftController.cs @@ -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> 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(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(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, diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Gift/GiftReceiveRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Gift/GiftReceiveRequest.cs new file mode 100644 index 0000000..ddc5c46 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Gift/GiftReceiveRequest.cs @@ -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 PresentIdArray { get; set; } = new(); + + [JsonPropertyName("state")] + [Key("state")] + public int State { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Gift/GiftReceiveResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Gift/GiftReceiveResponse.cs new file mode 100644 index 0000000..f7ac3f1 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Gift/GiftReceiveResponse.cs @@ -0,0 +1,96 @@ +using System.Text.Json.Serialization; +using MessagePack; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Gift; + +[MessagePackObject] +public class GiftReceiveResponse +{ + /// Cards granted (always empty for tutorial — the starter bundle has no card-type rewards). + [JsonPropertyName("card_list")] + [Key("card_list")] + public List CardList { get; set; } = new(); + + [JsonPropertyName("received_ids")] + [Key("received_ids")] + public List ReceivedIds { get; set; } = new(); + + [JsonPropertyName("total_receive_count_list")] + [Key("total_receive_count_list")] + public List TotalReceiveCountList { get; set; } = new(); + + [JsonPropertyName("present_list")] + [Key("present_list")] + public List PresentList { get; set; } = new(); + + [JsonPropertyName("present_history_list")] + [Key("present_history_list")] + public List 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 RewardList { get; set; } = new(); + + /// + /// 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. + /// + [JsonPropertyName("tutorial_step")] + [Key("tutorial_step")] + public int? TutorialStep { get; set; } +} + +/// +/// 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). +/// +[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; } + + /// 0 for currency rewards, 1 or 2 for item rewards. Prod wire is int; the client's .ToInt() handles both int and string values. + [JsonPropertyName("item_type")] + [Key("item_type")] + public int ItemType { get; set; } + + [JsonPropertyName("is_usable")] + [Key("is_usable")] + public bool IsUsable { get; set; } = true; +} + +/// +/// 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. +/// +[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"; +} diff --git a/SVSim.UnitTests/Controllers/GiftControllerTests.cs b/SVSim.UnitTests/Controllers/GiftControllerTests.cs index 336c3a7..8aaa52d 100644 --- a/SVSim.UnitTests/Controllers/GiftControllerTests.cs +++ b/SVSim.UnitTests/Controllers/GiftControllerTests.cs @@ -47,4 +47,87 @@ public class GiftControllerTests Assert.That(root.GetProperty("present_history_list").GetArrayLength(), Is.EqualTo(0)); Assert.That(root.GetProperty("limit_over_present_list").GetArrayLength(), Is.EqualTo(0)); } + + [Test] + public async Task GiftReceive_grants_currency_and_items_then_history_is_populated() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + long viewerId = await factory.SeedViewerAsync(tutorialState: 31); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var pre = await factory.GetViewerCurrencyAsync(viewerId); + + var requestJson = $$""" + {"present_id_array":["71478626","71478627","71478628","71478629","71478630"],"state":1,{{BaseAuthBlock}}} + """; + var response = await client.PostAsync("/tutorial/gift_receive", + new StringContent(requestJson, Encoding.UTF8, "application/json")); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var root = doc.RootElement; + + // Five received ids echoed. + var ids = root.GetProperty("received_ids").EnumerateArray() + .Select(e => e.GetString()).ToHashSet(); + Assert.That(ids, Is.EquivalentTo(new[] { "71478626", "71478627", "71478628", "71478629", "71478630" })); + + // present_list emptied, history populated. + Assert.That(root.GetProperty("present_list").GetArrayLength(), Is.EqualTo(0)); + Assert.That(root.GetProperty("present_history_list").GetArrayLength(), Is.EqualTo(5)); + + // Currency credited: +400 crystals, +100 rupees. + var post = await factory.GetViewerCurrencyAsync(viewerId); + Assert.That(post.Crystals - pre.Crystals, Is.EqualTo(400UL)); + Assert.That(post.Rupees - pre.Rupees, Is.EqualTo(100UL)); + } + + [Test] + public async Task GiftReceive_advances_tutorial_state_from_31_to_41() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + long viewerId = await factory.SeedViewerAsync(tutorialState: 31); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var json = $$"""{"present_id_array":["71478626"],"state":1,{{BaseAuthBlock}}}"""; + var response = await client.PostAsync("/tutorial/gift_receive", + new StringContent(json, Encoding.UTF8, "application/json")); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var root = doc.RootElement; + + // Response carries the new step inline. + Assert.That(root.GetProperty("tutorial_step").GetInt32(), Is.EqualTo(41)); + Assert.That(root.GetProperty("is_unreceived_present").GetBoolean(), Is.False); + Assert.That(root.GetProperty("reward_list").GetArrayLength(), Is.EqualTo(1)); + + // Side effect: viewer state advanced to 41. + Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(41)); + } + + [Test] + public async Task GiftReceive_second_call_with_same_ids_does_not_double_grant() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + long viewerId = await factory.SeedViewerAsync(tutorialState: 31); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var preFirst = await factory.GetViewerCurrencyAsync(viewerId); + var json = $$"""{"present_id_array":["71478626","71478627"],"state":1,{{BaseAuthBlock}}}"""; + + await client.PostAsync("/tutorial/gift_receive", new StringContent(json, Encoding.UTF8, "application/json")); + var midPost = await factory.GetViewerCurrencyAsync(viewerId); + Assert.That(midPost.Crystals - preFirst.Crystals, Is.EqualTo(400UL)); + Assert.That(midPost.Rupees - preFirst.Rupees, Is.EqualTo(100UL)); + + var second = await client.PostAsync("/tutorial/gift_receive", new StringContent(json, Encoding.UTF8, "application/json")); + Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + var finalPost = await factory.GetViewerCurrencyAsync(viewerId); + Assert.That(finalPost.Crystals, Is.EqualTo(midPost.Crystals), "Second claim of same present_ids must not re-grant."); + Assert.That(finalPost.Rupees, Is.EqualTo(midPost.Rupees)); + } } diff --git a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs index 481a130..1eef314 100644 --- a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs +++ b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs @@ -384,6 +384,18 @@ internal sealed class SVSimTestFactory : WebApplicationFactory return viewer.MissionData.TutorialState; } + /// + /// Reads the viewer's current currency balances from the DB. Used by gift_receive tests + /// to assert delta grants after claiming tutorial presents. + /// + public async Task<(ulong Crystals, ulong Rupees, ulong RedEther)> GetViewerCurrencyAsync(long viewerId) + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var viewer = await db.Viewers.FirstAsync(v => v.Id == viewerId); + return (viewer.Currency.Crystals, viewer.Currency.Rupees, viewer.Currency.RedEther); + } + protected override void Dispose(bool disposing) { base.Dispose(disposing);