feat(tk2): weighted-group reward picking
Replaces the all-rows-granted reward model with per-group weighted pick. Each ArenaTwoPickReward row now belongs to a RewardGroup with a Weight; finish/retire groups the WinCount's rows by RewardGroup and picks exactly one row per group, weighted by Weight (excluding Weight==0). A RewardNum==0 outcome skips both the grant and the rewards[] emission. Empty WinCount catalogs emit empty arrays. Existing seed entries preserve deterministic behavior by living in single-option groups (each with weight 1). Future seasons can expand groups to multi-option for true randomized rewards (e.g. 200-280 rupies). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
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.EmulatedEntrypoint.Services;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Services;
|
||||
|
||||
public class ArenaTwoPickServiceWeightedRewardsTests
|
||||
{
|
||||
private const long TicketItemId = 80001;
|
||||
|
||||
private sealed class FakeEntitlements : IViewerEntitlements
|
||||
{
|
||||
public bool IsFreeplay { get; init; }
|
||||
|
||||
public long EffectiveBalance(SVSim.Database.Models.Viewer viewer, SpendCurrency currency) => 0;
|
||||
public bool OwnsCard(SVSim.Database.Models.Viewer viewer, long cardId) => IsFreeplay;
|
||||
public bool OwnsCosmetic(SVSim.Database.Models.Viewer viewer, CosmeticType type, int id) => IsFreeplay;
|
||||
public Task<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(SVSim.Database.Models.Viewer viewer, CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<OwnedCardEntry>>(new List<OwnedCardEntry>());
|
||||
public Task<EffectiveCosmetics> EffectiveCosmeticsAsync(SVSim.Database.Models.Viewer viewer, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
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<RewardGrantService>(),
|
||||
new FakeEntitlements(),
|
||||
rng,
|
||||
db,
|
||||
scope.ServiceProvider.GetRequiredService<ICurrencySpendService>());
|
||||
|
||||
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 = 9, RewardId = 0, RewardNum = 100
|
||||
});
|
||||
db.ArenaTwoPickRewards.Add(new ArenaTwoPickReward
|
||||
{
|
||||
WinCount = 3, RewardGroup = 1, Weight = 9, RewardType = 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 = 9, RewardId = 0, RewardNum = 9999
|
||||
});
|
||||
db.ArenaTwoPickRewards.Add(new ArenaTwoPickReward
|
||||
{
|
||||
WinCount = 2, RewardGroup = 1, Weight = 1, RewardType = 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 = 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 = 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));
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user