fix(gift): wire reward_type is UserGoodsType integer, not legacy 1/4/9 encoding

Replace WireRewardTypeToUserGoodsType switch with a validating identity cast backed
by GiftRewardTypes.IsSupported. Wire type 1 is RedEther (UserGoodsType.RedEther),
not Crystal (UserGoodsType.Crystal=2); the old switch silently granted the wrong
wallet for every tutorial-completion claim. Update all 5 GiftControllerTests assertions
and 1 TutorialFlowEndToEndTests assertion to expect RedEther instead of Crystals.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-09 20:45:49 -04:00
parent 366a71688d
commit b2a5b69423
3 changed files with 24 additions and 21 deletions

View File

@@ -8,6 +8,7 @@ using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Mapping; using SVSim.EmulatedEntrypoint.Mapping;
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;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers; namespace SVSim.EmulatedEntrypoint.Controllers;
@@ -87,10 +88,6 @@ public class GiftController : SVSimController
{ {
if (state == 1) if (state == 1)
{ {
// Wire reward_type on the gift endpoint follows a gift-specific scheme that
// diverges from UserGoodsType for currencies: wire "1" means Crystal (enum=2),
// wire "9" means Rupy (enum=9), wire "4" means Item (enum=4). A naked cast would
// resolve wire 1 -> UserGoodsType.RedEther and silently grant the wrong wallet.
var granted = await tx.GrantAsync( var granted = await tx.GrantAsync(
WireRewardTypeToUserGoodsType(p.RewardType), WireRewardTypeToUserGoodsType(p.RewardType),
p.RewardDetailId, p.RewardDetailId,
@@ -168,13 +165,17 @@ public class GiftController : SVSimController
}; };
} }
private static UserGoodsType WireRewardTypeToUserGoodsType(int wireType) => wireType switch /// <summary>
/// Gift wire's <c>reward_type</c> is a literal <see cref="UserGoodsType"/> integer — the
/// client's <c>Wizard/RewardBase.cs:245</c> casts it directly to <c>UserGoods.Type</c>.
/// Mirror that cast, validated against <see cref="GiftRewardTypes.IsSupported(int)"/>.
/// </summary>
private static UserGoodsType WireRewardTypeToUserGoodsType(int wireType)
{ {
1 => UserGoodsType.Crystal, if (!GiftRewardTypes.IsSupported(wireType))
4 => UserGoodsType.Item, throw new InvalidOperationException($"Unsupported gift reward_type {wireType}");
9 => UserGoodsType.Rupy, return (UserGoodsType)wireType;
_ => throw new InvalidOperationException($"Unmapped gift wire reward_type {wireType}"), }
};
private async Task<(List<ViewerPresent> Unclaimed, List<ViewerPresent> History)> ReadTopWindowAsync( private async Task<(List<ViewerPresent> Unclaimed, List<ViewerPresent> History)> ReadTopWindowAsync(
long viewerId, int page) long viewerId, int page)

View File

@@ -84,18 +84,19 @@ public class GiftControllerTests
Assert.That(root.GetProperty("present_list").GetArrayLength(), Is.EqualTo(0)); Assert.That(root.GetProperty("present_list").GetArrayLength(), Is.EqualTo(0));
Assert.That(root.GetProperty("present_history_list").GetArrayLength(), Is.EqualTo(5)); Assert.That(root.GetProperty("present_history_list").GetArrayLength(), Is.EqualTo(5));
// Currency credited: +400 crystals, +100 rupees. // Currency credited: +400 red ether, +100 rupees.
// Tutorial gift 71478626 has reward_type=1 — that's RedEther per UserGoods.Type, not Crystal.
var post = await factory.GetViewerCurrencyAsync(viewerId); var post = await factory.GetViewerCurrencyAsync(viewerId);
Assert.That(post.Crystals - pre.Crystals, Is.EqualTo(400UL)); Assert.That(post.RedEther - pre.RedEther, Is.EqualTo(400UL));
Assert.That(post.Rupees - pre.Rupees, Is.EqualTo(100UL)); Assert.That(post.Rupees - pre.Rupees, Is.EqualTo(100UL));
// reward_list carries post-state TOTALS, not deltas, per project_wire_reward_list_post_state. // reward_list carries post-state TOTALS, not deltas, per project_wire_reward_list_post_state.
// After claiming gifts, the crystal/rupy entries in reward_list should equal viewer's post-grant totals. // After claiming gifts, the crystal/rupy entries in reward_list should equal viewer's post-grant totals.
var rewardList = root.GetProperty("reward_list").EnumerateArray().ToList(); var rewardList = root.GetProperty("reward_list").EnumerateArray().ToList();
var crystalEntry = rewardList.First(e => e.GetProperty("reward_type").GetString() == "1"); var redEtherEntry = rewardList.First(e => e.GetProperty("reward_type").GetString() == "1");
var rupyEntry = rewardList.First(e => e.GetProperty("reward_type").GetString() == "9"); var rupyEntry = rewardList.First(e => e.GetProperty("reward_type").GetString() == "9");
Assert.That(crystalEntry.GetProperty("reward_num").GetString(), Assert.That(redEtherEntry.GetProperty("reward_num").GetString(),
Is.EqualTo(post.Crystals.ToString()), Is.EqualTo(post.RedEther.ToString()),
"reward_list currency entries must carry POST-STATE TOTALS, not gift deltas (client does direct assignment)."); "reward_list currency entries must carry POST-STATE TOTALS, not gift deltas (client does direct assignment).");
Assert.That(rupyEntry.GetProperty("reward_num").GetString(), Assert.That(rupyEntry.GetProperty("reward_num").GetString(),
Is.EqualTo(post.Rupees.ToString())); Is.EqualTo(post.Rupees.ToString()));
@@ -243,13 +244,13 @@ public class GiftControllerTests
await client.PostAsync("/tutorial/gift_receive", new StringContent(json, Encoding.UTF8, "application/json")); await client.PostAsync("/tutorial/gift_receive", new StringContent(json, Encoding.UTF8, "application/json"));
var midPost = await factory.GetViewerCurrencyAsync(viewerId); var midPost = await factory.GetViewerCurrencyAsync(viewerId);
Assert.That(midPost.Crystals - preFirst.Crystals, Is.EqualTo(400UL)); Assert.That(midPost.RedEther - preFirst.RedEther, Is.EqualTo(400UL));
Assert.That(midPost.Rupees - preFirst.Rupees, Is.EqualTo(100UL)); Assert.That(midPost.Rupees - preFirst.Rupees, Is.EqualTo(100UL));
var second = await client.PostAsync("/tutorial/gift_receive", new StringContent(json, Encoding.UTF8, "application/json")); var second = await client.PostAsync("/tutorial/gift_receive", new StringContent(json, Encoding.UTF8, "application/json"));
Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.OK)); Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.OK));
var finalPost = await factory.GetViewerCurrencyAsync(viewerId); 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.RedEther, Is.EqualTo(midPost.RedEther), "Second claim of same present_ids must not re-grant.");
Assert.That(finalPost.Rupees, Is.EqualTo(midPost.Rupees)); Assert.That(finalPost.Rupees, Is.EqualTo(midPost.Rupees));
} }
@@ -314,7 +315,7 @@ public class GiftControllerTests
var preCurrency = await factory.GetViewerCurrencyAsync(viewerId); var preCurrency = await factory.GetViewerCurrencyAsync(viewerId);
// Delete the crystal gift (71478626 grants +400 crystals on state=1). // Delete the red-ether gift (71478626 grants +400 RedEther on state=1).
var json = $$"""{"present_id_array":["71478626"],"state":3,{{BaseAuthBlock}}}"""; var json = $$"""{"present_id_array":["71478626"],"state":3,{{BaseAuthBlock}}}""";
var response = await client.PostAsync("/gift/receive_gift", var response = await client.PostAsync("/gift/receive_gift",
new StringContent(json, Encoding.UTF8, "application/json")); new StringContent(json, Encoding.UTF8, "application/json"));
@@ -325,7 +326,7 @@ public class GiftControllerTests
// No currency granted. // No currency granted.
var postCurrency = await factory.GetViewerCurrencyAsync(viewerId); var postCurrency = await factory.GetViewerCurrencyAsync(viewerId);
Assert.That(postCurrency.Crystals, Is.EqualTo(preCurrency.Crystals), Assert.That(postCurrency.RedEther, Is.EqualTo(preCurrency.RedEther),
"state=3 (MAIL_DELETE) must not grant."); "state=3 (MAIL_DELETE) must not grant.");
// No reward_list / total_receive_count_list entries. // No reward_list / total_receive_count_list entries.

View File

@@ -85,7 +85,8 @@ public class TutorialFlowEndToEndTests
Assert.That(receiveResp.StatusCode, Is.EqualTo(HttpStatusCode.OK)); Assert.That(receiveResp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
var midCurrency = await factory.GetViewerCurrencyAsync(viewerId); var midCurrency = await factory.GetViewerCurrencyAsync(viewerId);
Assert.That(midCurrency.Crystals - preCurrency.Crystals, Is.EqualTo(400UL)); // Tutorial gift 71478626 has reward_type=1 — that's RedEther per UserGoods.Type, not Crystal.
Assert.That(midCurrency.RedEther - preCurrency.RedEther, Is.EqualTo(400UL));
Assert.That(midCurrency.Rupees - preCurrency.Rupees, Is.EqualTo(100UL)); Assert.That(midCurrency.Rupees - preCurrency.Rupees, Is.EqualTo(100UL));
// gift_receive should also have advanced the tutorial step to 41 server-side. // gift_receive should also have advanced the tutorial step to 41 server-side.