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_group": 1, "weight": 1, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
|
||||||
{ "win_count": 0, "reward_type": 9, "reward_id": 0, "reward_num": 100 },
|
{ "win_count": 0, "reward_group": 2, "weight": 1, "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_group": 1, "weight": 1, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
|
||||||
{ "win_count": 1, "reward_type": 9, "reward_id": 0, "reward_num": 300 },
|
{ "win_count": 1, "reward_group": 2, "weight": 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_group": 1, "weight": 1, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
|
||||||
{ "win_count": 2, "reward_type": 9, "reward_id": 0, "reward_num": 500 },
|
{ "win_count": 2, "reward_group": 2, "weight": 1, "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_group": 1, "weight": 1, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
|
||||||
{ "win_count": 3, "reward_type": 9, "reward_id": 0, "reward_num": 700 },
|
{ "win_count": 3, "reward_group": 2, "weight": 1, "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_group": 1, "weight": 1, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
|
||||||
{ "win_count": 4, "reward_type": 9, "reward_id": 0, "reward_num": 850 },
|
{ "win_count": 4, "reward_group": 2, "weight": 1, "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_group": 1, "weight": 1, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
|
||||||
{ "win_count": 5, "reward_type": 9, "reward_id": 0, "reward_num": 1000 }
|
{ "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>
|
/// <summary>
|
||||||
/// Idempotent upsert of <see cref="ArenaTwoPickReward"/> rows from
|
/// 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>
|
/// </summary>
|
||||||
public class ArenaTwoPickRewardImporter
|
public class ArenaTwoPickRewardImporter
|
||||||
{
|
{
|
||||||
@@ -22,20 +22,22 @@ public class ArenaTwoPickRewardImporter
|
|||||||
|
|
||||||
var seeds = SeedLoader.LoadList<ArenaTwoPickRewardSeed>(path);
|
var seeds = SeedLoader.LoadList<ArenaTwoPickRewardSeed>(path);
|
||||||
var existing = await context.ArenaTwoPickRewards
|
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;
|
int upserted = 0;
|
||||||
foreach (var s in seeds)
|
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
|
else
|
||||||
{
|
{
|
||||||
context.ArenaTwoPickRewards.Add(new ArenaTwoPickReward
|
context.ArenaTwoPickRewards.Add(new ArenaTwoPickReward
|
||||||
{
|
{
|
||||||
WinCount = s.WinCount,
|
WinCount = s.WinCount,
|
||||||
|
RewardGroup = s.RewardGroup,
|
||||||
|
Weight = s.Weight,
|
||||||
RewardType = s.RewardType,
|
RewardType = s.RewardType,
|
||||||
RewardId = s.RewardId,
|
RewardId = s.RewardId,
|
||||||
RewardNum = s.RewardNum,
|
RewardNum = s.RewardNum,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ namespace SVSim.Bootstrap.Models.Seed;
|
|||||||
public class ArenaTwoPickRewardSeed
|
public class ArenaTwoPickRewardSeed
|
||||||
{
|
{
|
||||||
[JsonPropertyName("win_count")] public int WinCount { 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_type")] public int RewardType { get; set; }
|
||||||
[JsonPropertyName("reward_id")] public long RewardId { get; set; }
|
[JsonPropertyName("reward_id")] public long RewardId { get; set; }
|
||||||
[JsonPropertyName("reward_num")] public int RewardNum { 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"));
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("RewardGroup")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<long>("RewardId")
|
b.Property<long>("RewardId")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
@@ -467,6 +470,9 @@ namespace SVSim.Database.Migrations
|
|||||||
b.Property<int>("RewardType")
|
b.Property<int>("RewardType")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Weight")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<int>("WinCount")
|
b.Property<int>("WinCount")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
@@ -474,7 +480,7 @@ namespace SVSim.Database.Migrations
|
|||||||
|
|
||||||
b.HasIndex("WinCount");
|
b.HasIndex("WinCount");
|
||||||
|
|
||||||
b.HasIndex("WinCount", "RewardType", "RewardId")
|
b.HasIndex("WinCount", "RewardGroup", "RewardType", "RewardId", "RewardNum")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("ArenaTwoPickRewards");
|
b.ToTable("ArenaTwoPickRewards");
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ namespace SVSim.Database.Models;
|
|||||||
/// <c>SVSim.Bootstrap/Data/seeds/arena-two-pick-rewards.json</c>.
|
/// <c>SVSim.Bootstrap/Data/seeds/arena-two-pick-rewards.json</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Index(nameof(WinCount))]
|
[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 class ArenaTwoPickReward
|
||||||
{
|
{
|
||||||
public long Id { get; set; }
|
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>
|
/// <summary>0..MaxWins. Run ends at LossCount==2 or WinCount==MAX(WinCount).</summary>
|
||||||
public int WinCount { get; set; }
|
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>
|
/// <summary><see cref="UserGoodsType"/> on the wire (e.g. Item=4, Rupy=9).</summary>
|
||||||
public int RewardType { get; set; }
|
public int RewardType { get; set; }
|
||||||
|
|
||||||
/// <summary>Item id for Item; 0 for currencies.</summary>
|
/// <summary>Item id for Item; 0 for currencies.</summary>
|
||||||
public long RewardId { get; set; }
|
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; }
|
public int RewardNum { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -310,27 +310,54 @@ public class ArenaTwoPickService : IArenaTwoPickService
|
|||||||
.ToDictionaryAsync(i => i.Id, i => i.Type);
|
.ToDictionaryAsync(i => i.Id, i => i.Type);
|
||||||
|
|
||||||
var deltas = new List<TwoPickRewardReceivedDto>();
|
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;
|
var pickable = group.Where(r => r.Weight > 0).ToList();
|
||||||
await _grants.ApplyAsync(viewer, goodsType, r.RewardId, r.RewardNum);
|
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
|
deltas.Add(new TwoPickRewardReceivedDto
|
||||||
{
|
{
|
||||||
RewardType = r.RewardType,
|
RewardType = pick.RewardType,
|
||||||
RewardDetailId = r.RewardId,
|
RewardDetailId = pick.RewardId,
|
||||||
RewardCount = r.RewardNum,
|
RewardCount = pick.RewardNum,
|
||||||
ItemType = itemTypeById.TryGetValue((int)r.RewardId, out var t) ? t : 0,
|
ItemType = itemTypeById.TryGetValue((int)pick.RewardId, out var t) ? t : 0,
|
||||||
IsUsable = true,
|
IsUsable = true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await _db.SaveChangesAsync();
|
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);
|
await _runs.DeleteAsync(viewerId);
|
||||||
return new FinishResponseDto { Rewards = deltas, RewardList = postStates };
|
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(
|
private static List<RewardEntryDto> ComputePostStateRewardList(
|
||||||
IReadOnlyList<SVSim.Database.Models.ArenaTwoPickReward> rows, SVSim.Database.Models.Viewer viewer)
|
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();
|
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 == 4).RewardNum, Is.EqualTo(1));
|
||||||
Assert.That(w5.Single(r => r.RewardType == 9).RewardNum, Is.EqualTo(1000));
|
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]
|
[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