Files
SVSimServer/SVSim.UnitTests/Services/ArenaTwoPickServiceWeightedRewardsTests.cs
gamer147 05d8169012 refactor: type reward_type columns as UserGoodsType enum
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>
2026-06-03 07:50:49 -04:00

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