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:
gamer147
2026-05-28 12:13:12 -04:00
parent 2034034c1b
commit f6f9216162
5 changed files with 323 additions and 1 deletions

View File

@@ -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,

View File

@@ -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; }
}

View File

@@ -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";
}

View File

@@ -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));
}
}

View File

@@ -384,6 +384,18 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
return viewer.MissionData.TutorialState;
}
/// <summary>
/// Reads the viewer's current currency balances from the DB. Used by gift_receive tests
/// to assert delta grants after claiming tutorial presents.
/// </summary>
public async Task<(ulong Crystals, ulong Rupees, ulong RedEther)> GetViewerCurrencyAsync(long viewerId)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
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);