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:
@@ -1,14 +1,14 @@
|
||||
[
|
||||
{ "win_count": 0, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
|
||||
{ "win_count": 0, "reward_type": 9, "reward_id": 0, "reward_num": 100 },
|
||||
{ "win_count": 1, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
|
||||
{ "win_count": 1, "reward_type": 9, "reward_id": 0, "reward_num": 300 },
|
||||
{ "win_count": 2, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
|
||||
{ "win_count": 2, "reward_type": 9, "reward_id": 0, "reward_num": 500 },
|
||||
{ "win_count": 3, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
|
||||
{ "win_count": 3, "reward_type": 9, "reward_id": 0, "reward_num": 700 },
|
||||
{ "win_count": 4, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
|
||||
{ "win_count": 4, "reward_type": 9, "reward_id": 0, "reward_num": 850 },
|
||||
{ "win_count": 5, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
|
||||
{ "win_count": 5, "reward_type": 9, "reward_id": 0, "reward_num": 1000 }
|
||||
{ "win_count": 0, "reward_group": 1, "weight": 1, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
|
||||
{ "win_count": 0, "reward_group": 2, "weight": 1, "reward_type": 9, "reward_id": 0, "reward_num": 100 },
|
||||
{ "win_count": 1, "reward_group": 1, "weight": 1, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
|
||||
{ "win_count": 1, "reward_group": 2, "weight": 1, "reward_type": 9, "reward_id": 0, "reward_num": 300 },
|
||||
{ "win_count": 2, "reward_group": 1, "weight": 1, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
|
||||
{ "win_count": 2, "reward_group": 2, "weight": 1, "reward_type": 9, "reward_id": 0, "reward_num": 500 },
|
||||
{ "win_count": 3, "reward_group": 1, "weight": 1, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
|
||||
{ "win_count": 3, "reward_group": 2, "weight": 1, "reward_type": 9, "reward_id": 0, "reward_num": 700 },
|
||||
{ "win_count": 4, "reward_group": 1, "weight": 1, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
|
||||
{ "win_count": 4, "reward_group": 2, "weight": 1, "reward_type": 9, "reward_id": 0, "reward_num": 850 },
|
||||
{ "win_count": 5, "reward_group": 1, "weight": 1, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
|
||||
{ "win_count": 5, "reward_group": 2, "weight": 1, "reward_type": 9, "reward_id": 0, "reward_num": 1000 }
|
||||
]
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace SVSim.Bootstrap.Importers;
|
||||
|
||||
/// <summary>
|
||||
/// Idempotent upsert of <see cref="ArenaTwoPickReward"/> rows from
|
||||
/// <c>arena-two-pick-rewards.json</c>. Key = (WinCount, RewardType, RewardId).
|
||||
/// <c>arena-two-pick-rewards.json</c>. Key = (WinCount, RewardGroup, RewardType, RewardId, RewardNum).
|
||||
/// </summary>
|
||||
public class ArenaTwoPickRewardImporter
|
||||
{
|
||||
@@ -22,23 +22,25 @@ public class ArenaTwoPickRewardImporter
|
||||
|
||||
var seeds = SeedLoader.LoadList<ArenaTwoPickRewardSeed>(path);
|
||||
var existing = await context.ArenaTwoPickRewards
|
||||
.ToDictionaryAsync(r => (r.WinCount, r.RewardType, r.RewardId));
|
||||
.ToDictionaryAsync(r => (r.WinCount, r.RewardGroup, r.RewardType, r.RewardId, r.RewardNum));
|
||||
|
||||
int upserted = 0;
|
||||
foreach (var s in seeds)
|
||||
{
|
||||
if (existing.TryGetValue((s.WinCount, s.RewardType, s.RewardId), out var row))
|
||||
if (existing.TryGetValue((s.WinCount, s.RewardGroup, s.RewardType, s.RewardId, s.RewardNum), out var row))
|
||||
{
|
||||
row.RewardNum = s.RewardNum;
|
||||
row.Weight = s.Weight;
|
||||
}
|
||||
else
|
||||
{
|
||||
context.ArenaTwoPickRewards.Add(new ArenaTwoPickReward
|
||||
{
|
||||
WinCount = s.WinCount,
|
||||
RewardType = s.RewardType,
|
||||
RewardId = s.RewardId,
|
||||
RewardNum = s.RewardNum,
|
||||
WinCount = s.WinCount,
|
||||
RewardGroup = s.RewardGroup,
|
||||
Weight = s.Weight,
|
||||
RewardType = s.RewardType,
|
||||
RewardId = s.RewardId,
|
||||
RewardNum = s.RewardNum,
|
||||
});
|
||||
}
|
||||
upserted++;
|
||||
|
||||
@@ -4,8 +4,10 @@ namespace SVSim.Bootstrap.Models.Seed;
|
||||
|
||||
public class ArenaTwoPickRewardSeed
|
||||
{
|
||||
[JsonPropertyName("win_count")] public int WinCount { get; set; }
|
||||
[JsonPropertyName("reward_type")] public int RewardType { get; set; }
|
||||
[JsonPropertyName("reward_id")] public long RewardId { get; set; }
|
||||
[JsonPropertyName("reward_num")] public int RewardNum { get; set; }
|
||||
[JsonPropertyName("win_count")] public int WinCount { get; set; }
|
||||
[JsonPropertyName("reward_group")] public int RewardGroup { get; set; }
|
||||
[JsonPropertyName("weight")] public int Weight { get; set; } = 1;
|
||||
[JsonPropertyName("reward_type")] public int RewardType { get; set; }
|
||||
[JsonPropertyName("reward_id")] public long RewardId { get; set; }
|
||||
[JsonPropertyName("reward_num")] public int RewardNum { get; set; }
|
||||
}
|
||||
|
||||
4043
SVSim.Database/Migrations/20260531174101_AddArenaTwoPickRewardGroupAndWeight.Designer.cs
generated
Normal file
4043
SVSim.Database/Migrations/20260531174101_AddArenaTwoPickRewardGroupAndWeight.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddArenaTwoPickRewardGroupAndWeight : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ArenaTwoPickRewards_WinCount_RewardType_RewardId",
|
||||
table: "ArenaTwoPickRewards");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "RewardGroup",
|
||||
table: "ArenaTwoPickRewards",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Weight",
|
||||
table: "ArenaTwoPickRewards",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ArenaTwoPickRewards_WinCount_RewardGroup_RewardType_RewardI~",
|
||||
table: "ArenaTwoPickRewards",
|
||||
columns: new[] { "WinCount", "RewardGroup", "RewardType", "RewardId", "RewardNum" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ArenaTwoPickRewards_WinCount_RewardGroup_RewardType_RewardI~",
|
||||
table: "ArenaTwoPickRewards");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RewardGroup",
|
||||
table: "ArenaTwoPickRewards");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Weight",
|
||||
table: "ArenaTwoPickRewards");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ArenaTwoPickRewards_WinCount_RewardType_RewardId",
|
||||
table: "ArenaTwoPickRewards",
|
||||
columns: new[] { "WinCount", "RewardType", "RewardId" },
|
||||
unique: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -458,6 +458,9 @@ namespace SVSim.Database.Migrations
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<int>("RewardGroup")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("RewardId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
@@ -467,6 +470,9 @@ namespace SVSim.Database.Migrations
|
||||
b.Property<int>("RewardType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Weight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("WinCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -474,7 +480,7 @@ namespace SVSim.Database.Migrations
|
||||
|
||||
b.HasIndex("WinCount");
|
||||
|
||||
b.HasIndex("WinCount", "RewardType", "RewardId")
|
||||
b.HasIndex("WinCount", "RewardGroup", "RewardType", "RewardId", "RewardNum")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ArenaTwoPickRewards");
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace SVSim.Database.Models;
|
||||
/// <c>SVSim.Bootstrap/Data/seeds/arena-two-pick-rewards.json</c>.
|
||||
/// </summary>
|
||||
[Index(nameof(WinCount))]
|
||||
[Index(nameof(WinCount), nameof(RewardType), nameof(RewardId), IsUnique = true)]
|
||||
[Index(nameof(WinCount), nameof(RewardGroup), nameof(RewardType), nameof(RewardId), nameof(RewardNum), IsUnique = true)]
|
||||
public class ArenaTwoPickReward
|
||||
{
|
||||
public long Id { get; set; }
|
||||
@@ -18,12 +18,24 @@ public class ArenaTwoPickReward
|
||||
/// <summary>0..MaxWins. Run ends at LossCount==2 or WinCount==MAX(WinCount).</summary>
|
||||
public int WinCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Groups rows into independent pick buckets. At finish/retire time one row is
|
||||
/// weighted-picked per group. Default 0 keeps legacy rows in a single group.
|
||||
/// </summary>
|
||||
public int RewardGroup { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Relative probability weight for this row within its <see cref="RewardGroup"/>.
|
||||
/// Weight == 0 rows are excluded from picking. Default 1.
|
||||
/// </summary>
|
||||
public int Weight { get; set; } = 1;
|
||||
|
||||
/// <summary><see cref="UserGoodsType"/> on the wire (e.g. Item=4, Rupy=9).</summary>
|
||||
public int RewardType { get; set; }
|
||||
|
||||
/// <summary>Item id for Item; 0 for currencies.</summary>
|
||||
public long RewardId { get; set; }
|
||||
|
||||
/// <summary>Count (e.g. ticket quantity or rupy amount).</summary>
|
||||
/// <summary>Count (e.g. ticket quantity or rupy amount). 0 = "no reward" outcome.</summary>
|
||||
public int RewardNum { get; set; }
|
||||
}
|
||||
|
||||
@@ -310,27 +310,54 @@ public class ArenaTwoPickService : IArenaTwoPickService
|
||||
.ToDictionaryAsync(i => i.Id, i => i.Type);
|
||||
|
||||
var deltas = new List<TwoPickRewardReceivedDto>();
|
||||
foreach (var r in rewardRows)
|
||||
var picks = new List<SVSim.Database.Models.ArenaTwoPickReward>();
|
||||
|
||||
// Group by RewardGroup, weighted-pick one row per group (Weight==0 excluded).
|
||||
foreach (var group in rewardRows.GroupBy(r => r.RewardGroup))
|
||||
{
|
||||
var goodsType = (SVSim.Database.Enums.UserGoodsType)r.RewardType;
|
||||
await _grants.ApplyAsync(viewer, goodsType, r.RewardId, r.RewardNum);
|
||||
var pickable = group.Where(r => r.Weight > 0).ToList();
|
||||
if (pickable.Count == 0) continue;
|
||||
var pick = WeightedPick(pickable, _rng);
|
||||
picks.Add(pick);
|
||||
|
||||
// Skip when the rolled outcome is "nothing" (RewardNum == 0).
|
||||
if (pick.RewardNum <= 0) continue;
|
||||
|
||||
var goodsType = (SVSim.Database.Enums.UserGoodsType)pick.RewardType;
|
||||
await _grants.ApplyAsync(viewer, goodsType, pick.RewardId, pick.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,
|
||||
RewardType = pick.RewardType,
|
||||
RewardDetailId = pick.RewardId,
|
||||
RewardCount = pick.RewardNum,
|
||||
ItemType = itemTypeById.TryGetValue((int)pick.RewardId, out var t) ? t : 0,
|
||||
IsUsable = true,
|
||||
});
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var postStates = ComputePostStateRewardList(rewardRows, viewer);
|
||||
// ComputePostStateRewardList reads from the picked rows only — same set the
|
||||
// grants were applied for — so the post-state list mirrors the deltas exactly.
|
||||
var postStates = ComputePostStateRewardList(picks.Where(p => p.RewardNum > 0).ToList(), viewer);
|
||||
|
||||
await _runs.DeleteAsync(viewerId);
|
||||
return new FinishResponseDto { Rewards = deltas, RewardList = postStates };
|
||||
}
|
||||
|
||||
private static SVSim.Database.Models.ArenaTwoPickReward WeightedPick(
|
||||
List<SVSim.Database.Models.ArenaTwoPickReward> rows, IRandom rng)
|
||||
{
|
||||
long total = rows.Sum(r => (long)r.Weight);
|
||||
long roll = rng.Next((int)Math.Min(total, int.MaxValue));
|
||||
long cum = 0;
|
||||
foreach (var r in rows)
|
||||
{
|
||||
cum += r.Weight;
|
||||
if (roll < cum) return r;
|
||||
}
|
||||
return rows[^1];
|
||||
}
|
||||
|
||||
private static List<RewardEntryDto> ComputePostStateRewardList(
|
||||
IReadOnlyList<SVSim.Database.Models.ArenaTwoPickReward> rows, SVSim.Database.Models.Viewer viewer)
|
||||
{
|
||||
|
||||
@@ -38,6 +38,11 @@ public class ArenaTwoPickRewardImporterTests
|
||||
var w5 = rows.Where(r => r.WinCount == 5).ToList();
|
||||
Assert.That(w5.Single(r => r.RewardType == 4).RewardNum, Is.EqualTo(1));
|
||||
Assert.That(w5.Single(r => r.RewardType == 9).RewardNum, Is.EqualTo(1000));
|
||||
|
||||
// New columns: all rows should have weight=1 and win5 should span 2 distinct groups.
|
||||
Assert.That(w5.All(r => r.Weight == 1), "all win5 rows should have Weight=1");
|
||||
Assert.That(w5.Select(r => r.RewardGroup).Distinct().Count(), Is.EqualTo(2),
|
||||
"win5 rewards split across 2 distinct groups (ticket + rupy)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -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