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:
gamer147
2026-05-31 13:44:33 -04:00
parent 8e017c9d10
commit fc504af496
10 changed files with 4410 additions and 36 deletions

View File

@@ -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 }
]

View File

@@ -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,20 +22,22 @@ 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,
RewardGroup = s.RewardGroup,
Weight = s.Weight,
RewardType = s.RewardType,
RewardId = s.RewardId,
RewardNum = s.RewardNum,

View File

@@ -5,6 +5,8 @@ namespace SVSim.Bootstrap.Models.Seed;
public class ArenaTwoPickRewardSeed
{
[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; }

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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");

View File

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

View File

@@ -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,
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)
{

View File

@@ -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]

View File

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