fix(tk2): rewards array uses ReceivedReward shape (reward_detail_id/item_type/is_usable)

The /retire and /finish responses carry two reward arrays with DIFFERENT
key schemas:

  rewards[]      → ReceivedReward(JsonData) parser
                   {reward_type, reward_detail_id, item_type, reward_count?, is_usable}
  reward_list[]  → PlayerStaticData.UpdateHaveUserGoodsNumByJsonData
                   {reward_type, reward_id, reward_num}

We were emitting both with reward_list's schema, so the client threw
KeyNotFoundException on `data["reward_detail_id"]` while parsing each
delta entry — observed live as the retire-screen failure.

- New TwoPickRewardReceivedDto mirrors the existing Achievement/
  TotalReceiveCountDto shape.
- FinishResponseDto.Rewards switched from List<RewardEntryDto>
  to List<TwoPickRewardReceivedDto>.
- GrantRunRewardsAndDeleteAsync pre-loads ItemEntry.Type for any
  Item-typed reward so item_type ships correctly (0 for currencies).
- Existing tests renamed RewardNum→RewardCount on the deltas list.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-31 12:56:05 -04:00
parent 6381e4da51
commit 1e2e18e828
3 changed files with 58 additions and 8 deletions

View File

@@ -7,11 +7,43 @@ namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick;
[MessagePackObject]
public class FinishResponseDto
{
/// <summary>Per-grant deltas — drives "+N received" popup.</summary>
/// <summary>
/// Per-grant deltas — drives the "+N received" popup. Parsed by the client via
/// <c>ReceivedReward(JsonData)</c> (Shadowverse_Code/ReceivedReward.cs:25) which expects
/// the {reward_type, reward_detail_id, item_type, reward_count?, is_usable} shape — distinct
/// from the <c>RewardEntryDto</c> shape used by <see cref="RewardList"/>.
/// </summary>
[JsonPropertyName("rewards")] [Key("rewards")]
public List<RewardEntryDto> Rewards { get; set; } = new();
public List<TwoPickRewardReceivedDto> Rewards { get; set; } = new();
/// <summary>Post-state totals — drives PlayerStaticData.UpdateHaveUserGoodsNumByJsonData.</summary>
[JsonPropertyName("reward_list")] [Key("reward_list")]
public List<RewardEntryDto> RewardList { get; set; } = new();
}
/// <summary>
/// Wire shape parsed by Shadowverse_Code/ReceivedReward.cs ctor. Used in the
/// <c>rewards</c> arrays of /arena_two_pick/{retire,finish}.
/// </summary>
[MessagePackObject]
public class TwoPickRewardReceivedDto
{
[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 int RewardCount { get; set; }
/// <summary>
/// Item-master <c>item_type</c> enum (1=challenge ticket, 2=card-pack ticket, …) for Item-typed
/// rewards. 0 for currencies (Crystal/Rupy/RedEther) — the client only reads this for Items.
/// </summary>
[JsonPropertyName("item_type")] [Key("item_type")]
public int ItemType { get; set; }
[JsonPropertyName("is_usable")] [Key("is_usable")]
public bool IsUsable { get; set; } = true;
}

View File

@@ -297,13 +297,31 @@ public class ArenaTwoPickService : IArenaTwoPickService
var rewardRows = await _rewards.GetRewardsByWinCountAsync(run.WinCount);
var viewer = await LoadViewerForGrantsAsync(viewerId);
var deltas = new List<RewardEntryDto>();
// Pre-load item_type for any Item-typed reward so we can populate it on the
// per-grant delta entries. Currencies don't need a lookup (item_type stays 0).
var itemRewardIds = rewardRows
.Where(r => r.RewardType == (int)SVSim.Database.Enums.UserGoodsType.Item)
.Select(r => (int)r.RewardId)
.Distinct()
.ToList();
var itemTypeById = itemRewardIds.Count == 0
? new Dictionary<int, int>()
: await _db.Items.Where(i => itemRewardIds.Contains(i.Id))
.ToDictionaryAsync(i => i.Id, i => i.Type);
var deltas = new List<TwoPickRewardReceivedDto>();
foreach (var r in rewardRows)
{
var goodsType = (SVSim.Database.Enums.UserGoodsType)r.RewardType;
await _grants.ApplyAsync(viewer, goodsType, r.RewardId, r.RewardNum);
// Rewards = deltas (per-grant amounts), not post-state totals.
deltas.Add(new RewardEntryDto { RewardType = r.RewardType, RewardId = r.RewardId, RewardNum = r.RewardNum });
deltas.Add(new TwoPickRewardReceivedDto
{
RewardType = r.RewardType,
RewardDetailId = r.RewardId,
RewardCount = r.RewardNum,
ItemType = itemTypeById.TryGetValue((int)r.RewardId, out var t) ? t : 0,
IsUsable = true,
});
}
await _db.SaveChangesAsync();

View File

@@ -108,8 +108,8 @@ public class ArenaTwoPickServiceFinishTests
var dto = await svc.RetireAsync(vid);
Assert.That(dto.Rewards.Count, Is.EqualTo(2));
Assert.That(dto.Rewards.Single(r => r.RewardType == 4).RewardNum, Is.EqualTo(1));
Assert.That(dto.Rewards.Single(r => r.RewardType == 9).RewardNum, Is.EqualTo(700));
Assert.That(dto.Rewards.Single(r => r.RewardType == 4).RewardCount, Is.EqualTo(1));
Assert.That(dto.Rewards.Single(r => r.RewardType == 9).RewardCount, Is.EqualTo(700));
Assert.That(dto.RewardList.Single(r => r.RewardType == 4).RewardNum, Is.EqualTo(6), "5 + 1");
Assert.That(dto.RewardList.Single(r => r.RewardType == 9).RewardNum, Is.EqualTo(750), "50 + 700");
@@ -136,7 +136,7 @@ public class ArenaTwoPickServiceFinishTests
await using var _ = db;
var dto = await svc.FinishAsync(vid);
Assert.That(dto.Rewards.Single(r => r.RewardType == 9).RewardNum, Is.EqualTo(100));
Assert.That(dto.Rewards.Single(r => r.RewardType == 9).RewardCount, Is.EqualTo(100));
Assert.That(await db.ViewerArenaTwoPickRuns.AnyAsync(), Is.False);
}