Replace bare `int RewardType` on 12 catalog/reward entities and GrantedReward
with the existing UserGoodsType enum. Verified against the decompiled client:
every wire reward_type decodes through the single Wizard.UserGoods.Type enum, so
one enum is correct across all endpoint families (item_type is a separate
Item.Type axis, left untouched). EF stores the enum as the same int column, so
there is no migration.
- Importers cast seed int -> UserGoodsType at the ingest boundary.
- New GrantedReward.ToRewardList() extension replaces 8 copy-pasted
GrantedReward -> RewardListEntry projections.
- Fix 3 .ToString() sites that would otherwise emit enum names ("Crystal")
instead of the int wire value ("2").
- Wire DTOs keep int; the enum is widened to int at the wire boundary only.
Build green; 962/962 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
204 lines
8.4 KiB
C#
204 lines
8.4 KiB
C#
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using SVSim.Bootstrap.Importers;
|
|
using SVSim.Database;
|
|
using SVSim.Database.Enums;
|
|
using SVSim.Database.Models;
|
|
using SVSim.Database.Repositories.Globals;
|
|
using SVSim.Database.Repositories.Viewer;
|
|
using SVSim.Database.Services;
|
|
using SVSim.Database.Services.Inventory;
|
|
using SVSim.EmulatedEntrypoint.Services;
|
|
using SVSim.UnitTests.Infrastructure;
|
|
|
|
namespace SVSim.UnitTests.Services;
|
|
|
|
public class ArenaTwoPickServiceWeightedRewardsTests
|
|
{
|
|
private const long TicketItemId = 80001;
|
|
|
|
private sealed class FakePool : IArenaTwoPickCardPoolService
|
|
{
|
|
public List<CandidatePair> GeneratePickSetsForTurn(int classId, int turn, long startingPairId, IRandom rng) => new();
|
|
}
|
|
|
|
/// <summary>
|
|
/// A fake IRandom that returns a fixed value from Next(). Used to control weighted picks.
|
|
/// </summary>
|
|
private sealed class FakeRandom : IRandom
|
|
{
|
|
private readonly Queue<int> _ints;
|
|
public FakeRandom(params int[] ints) { _ints = new Queue<int>(ints); }
|
|
public double NextDouble() => 0.0;
|
|
public int Next(int maxExclusive) => _ints.Count > 0 ? _ints.Dequeue() : 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets up a fresh in-memory DB with a viewer (id=7, 50 rupies, 5 tickets) and a run at
|
|
/// the given winCount/lossCount. Does NOT seed catalog rows — callers add their own.
|
|
/// </summary>
|
|
private static async Task<(SVSimDbContext db, ArenaTwoPickService svc, long viewerId)>
|
|
SetupAsync(int winCount, int lossCount, IRandom rng)
|
|
{
|
|
var factory = new SVSimTestFactory();
|
|
var scope = factory.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
await db.Database.EnsureCreatedAsync();
|
|
|
|
var ticketItem = new ItemEntry { Id = (int)TicketItemId, Name = "TK2 Ticket" };
|
|
db.Items.Add(ticketItem);
|
|
|
|
var viewer = new SVSim.Database.Models.Viewer
|
|
{
|
|
Id = 7, DisplayName = "v",
|
|
Currency = new ViewerCurrency { Rupees = 50 },
|
|
};
|
|
viewer.Items.Add(new OwnedItemEntry { Item = ticketItem, Count = 5 });
|
|
|
|
var classEntry = await db.Classes.FirstOrDefaultAsync(c => c.Id == 1);
|
|
if (classEntry is null)
|
|
{
|
|
classEntry = new ClassEntry { Id = 1, Name = "Class1" };
|
|
db.Classes.Add(classEntry);
|
|
}
|
|
viewer.Classes.Add(new ViewerClassData { Class = classEntry, Level = 1, Exp = 0 });
|
|
db.Viewers.Add(viewer);
|
|
await db.SaveChangesAsync();
|
|
|
|
var runs = new ArenaTwoPickRunRepository(db);
|
|
var pickList = Enumerable.Range(0, 30).Select(i => (long)(100000 + i)).ToList();
|
|
await runs.UpsertAsync(new ViewerArenaTwoPickRun
|
|
{
|
|
ViewerId = 7, EntryId = 4242,
|
|
CandidateClassIdsJson = "[1,7,8]",
|
|
ClassId = 1, LeaderSkinId = 1, MaxBattleCount = 5,
|
|
SelectTurn = 15, IsSelectCompleted = true,
|
|
SelectedCardIdsJson = JsonSerializer.Serialize(pickList),
|
|
PendingPickSetsJson = "[]",
|
|
WinCount = winCount, LossCount = lossCount,
|
|
ResultListJson = JsonSerializer.Serialize(
|
|
Enumerable.Repeat(true, winCount).Concat(Enumerable.Repeat(false, lossCount)).ToList()),
|
|
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
|
|
});
|
|
|
|
var svc = new ArenaTwoPickService(
|
|
runs,
|
|
new ArenaTwoPickRewardRepository(db),
|
|
new FakePool(),
|
|
scope.ServiceProvider.GetRequiredService<IGameConfigService>(),
|
|
scope.ServiceProvider.GetRequiredService<IViewerRepository>(),
|
|
scope.ServiceProvider.GetRequiredService<IInventoryService>(),
|
|
rng,
|
|
db);
|
|
|
|
return (db, svc, 7L);
|
|
}
|
|
|
|
[Test]
|
|
public async Task WeightedPicker_picks_high_weight_row_when_rng_lands_in_its_range()
|
|
{
|
|
// Two rows in the same group: weight=1 (Rupy 100) and weight=9 (Rupy 999).
|
|
// Total weight = 10. Roll = 5 → falls in the second row's bucket [1, 10).
|
|
// The service also uses rng.Next for MaxBattleCount resolution (GetMaxWinCountAsync
|
|
// returns rows count which is 2 for a single WinCount = 3, but MaxBattles = MAX(WinCount)
|
|
// which comes from the DB, not rng). The FakeRandom need only provide the weighted-pick roll.
|
|
var rng = new FakeRandom(5);
|
|
var (db, svc, vid) = await SetupAsync(winCount: 3, lossCount: 2, rng);
|
|
await using var _ = db;
|
|
|
|
// Seed catalog: two rows, same group, same WinCount=3.
|
|
db.ArenaTwoPickRewards.Add(new ArenaTwoPickReward
|
|
{
|
|
WinCount = 3, RewardGroup = 1, Weight = 1, RewardType = (UserGoodsType)9, RewardId = 0, RewardNum = 100
|
|
});
|
|
db.ArenaTwoPickRewards.Add(new ArenaTwoPickReward
|
|
{
|
|
WinCount = 3, RewardGroup = 1, Weight = 9, RewardType = (UserGoodsType)9, RewardId = 0, RewardNum = 999
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var dto = await svc.RetireAsync(vid);
|
|
|
|
Assert.That(dto.Rewards.Count, Is.EqualTo(1));
|
|
Assert.That(dto.Rewards[0].RewardCount, Is.EqualTo(999),
|
|
"roll=5 should land in the weight-9 row's bucket [1,10)");
|
|
}
|
|
|
|
[Test]
|
|
public async Task Weight_zero_rows_are_never_picked()
|
|
{
|
|
// One weight=0 row (should never be picked) and one weight=1 row.
|
|
var (db, svc, vid) = await SetupAsync(winCount: 2, lossCount: 3, new SystemRandom(seed: 42));
|
|
await using var _ = db;
|
|
|
|
db.ArenaTwoPickRewards.Add(new ArenaTwoPickReward
|
|
{
|
|
WinCount = 2, RewardGroup = 1, Weight = 0, RewardType = (UserGoodsType)9, RewardId = 0, RewardNum = 9999
|
|
});
|
|
db.ArenaTwoPickRewards.Add(new ArenaTwoPickReward
|
|
{
|
|
WinCount = 2, RewardGroup = 1, Weight = 1, RewardType = (UserGoodsType)9, RewardId = 0, RewardNum = 500
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var dto = await svc.RetireAsync(vid);
|
|
|
|
Assert.That(dto.Rewards.Count, Is.EqualTo(1));
|
|
Assert.That(dto.Rewards[0].RewardCount, Is.EqualTo(500),
|
|
"weight=0 row must never be picked");
|
|
}
|
|
|
|
[Test]
|
|
public async Task RewardNum_zero_pick_emits_no_delta_and_no_grant()
|
|
{
|
|
// A single group whose only pickable row has RewardNum=0 → "nothing" outcome.
|
|
var (db, svc, vid) = await SetupAsync(winCount: 1, lossCount: 4, new SystemRandom(seed: 1));
|
|
await using var _ = db;
|
|
|
|
db.ArenaTwoPickRewards.Add(new ArenaTwoPickReward
|
|
{
|
|
WinCount = 1, RewardGroup = 1, Weight = 1, RewardType = (UserGoodsType)9, RewardId = 0, RewardNum = 0
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var viewerBefore = await db.Viewers
|
|
.Include(v => v.Currency)
|
|
.FirstAsync(v => v.Id == vid);
|
|
var rupiesBefore = viewerBefore.Currency!.Rupees;
|
|
|
|
var dto = await svc.RetireAsync(vid);
|
|
|
|
Assert.That(dto.Rewards.Count, Is.EqualTo(0), "RewardNum=0 row must not emit a delta");
|
|
Assert.That(dto.RewardList.Count, Is.EqualTo(0), "RewardNum=0 row must not emit a post-state entry");
|
|
|
|
var viewerAfter = await db.Viewers.Include(v => v.Currency).FirstAsync(v => v.Id == vid);
|
|
Assert.That(viewerAfter.Currency!.Rupees, Is.EqualTo(rupiesBefore),
|
|
"viewer balance must be unchanged when all picks are RewardNum=0");
|
|
}
|
|
|
|
[Test]
|
|
public async Task Empty_WinCount_emits_empty_rewards_without_throwing()
|
|
{
|
|
// WinCount=99 has no rows in the catalog. Should return empty arrays cleanly.
|
|
var (db, svc, vid) = await SetupAsync(winCount: 99, lossCount: 0, new SystemRandom(seed: 1));
|
|
await using var _ = db;
|
|
|
|
// Seed at least one row for a different WinCount so GetMaxWinCountAsync returns >0
|
|
// (otherwise the service falls back to default MaxBattleCount=5, but battlesPlayed=99
|
|
// still satisfies the >=5 check so Retire works regardless).
|
|
db.ArenaTwoPickRewards.Add(new ArenaTwoPickReward
|
|
{
|
|
WinCount = 0, RewardGroup = 1, Weight = 1, RewardType = (UserGoodsType)9, RewardId = 0, RewardNum = 50
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
Assert.DoesNotThrowAsync(async () =>
|
|
{
|
|
var dto = await svc.RetireAsync(vid);
|
|
Assert.That(dto.Rewards.Count, Is.EqualTo(0));
|
|
Assert.That(dto.RewardList.Count, Is.EqualTo(0));
|
|
});
|
|
}
|
|
}
|