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

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