fix(tk2): match original SV (5-battle cap, no loss limit)

User clarified: the 0..7 win reward tiers came from Shadowverse Worlds
Beyond (sequel), not the original game we're emulating. Original SV's
Take Two caps at 5 total battles played and has no loss limit (verified
on prod: queueing continues with 2+ losses).

- arena-two-pick-rewards.json: drop 6w + 7w tiers (12 rows remain).
- ArenaTwoPickConfig: remove MaxLosses property.
- ArenaTwoPickService: termination is now battlesPlayed >= maxBattles
  (5 from MAX(reward.WinCount)). RecordBattleResult no longer flips
  IsSelectCompleted on the 2nd loss.
- ResolveMaxBattleCountAsync empty-catalog default 7 → 5.
- Tests updated for the new counts (16 → 12 rows, max 7 → 5).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-31 12:47:43 -04:00
parent dc19289818
commit 6381e4da51
7 changed files with 31 additions and 32 deletions

View File

@@ -10,9 +10,5 @@
{ "win_count": 4, "reward_type": 4, "reward_id": 80001, "reward_num": 1 }, { "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": 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": 4, "reward_id": 80001, "reward_num": 1 },
{ "win_count": 5, "reward_type": 9, "reward_id": 0, "reward_num": 1000 }, { "win_count": 5, "reward_type": 9, "reward_id": 0, "reward_num": 1000 }
{ "win_count": 6, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
{ "win_count": 6, "reward_type": 9, "reward_id": 0, "reward_num": 1250 },
{ "win_count": 7, "reward_type": 4, "reward_id": 80001, "reward_num": 2 },
{ "win_count": 7, "reward_type": 9, "reward_id": 0, "reward_num": 1500 }
] ]

View File

@@ -10,7 +10,6 @@ public class ArenaTwoPickConfig
{ {
public int RewardScheduleId { get; set; } = 1; public int RewardScheduleId { get; set; } = 1;
public int ChallengeId { get; set; } = 1; public int ChallengeId { get; set; } = 1;
public int MaxLosses { get; set; } = 2;
public int ClassXpPerBattle { get; set; } = 100; public int ClassXpPerBattle { get; set; } = 100;
public int SpotPointsPerBattle { get; set; } = 10; public int SpotPointsPerBattle { get; set; } = 10;

View File

@@ -171,8 +171,8 @@ public class ArenaTwoPickService : IArenaTwoPickService
var rawMaxWins = await _rewards.GetMaxWinCountAsync(); var rawMaxWins = await _rewards.GetMaxWinCountAsync();
if (rawMaxWins == 0) if (rawMaxWins == 0)
{ {
Console.Error.WriteLine("[ArenaTwoPickService] ArenaTwoPickRewards catalog empty; defaulting MaxBattleCount=7. Run SVSim.Bootstrap to seed."); Console.Error.WriteLine("[ArenaTwoPickService] ArenaTwoPickRewards catalog empty; defaulting MaxBattleCount=5. Run SVSim.Bootstrap to seed.");
return 7; return 5;
} }
return rawMaxWins; return rawMaxWins;
} }
@@ -285,9 +285,12 @@ public class ArenaTwoPickService : IArenaTwoPickService
var run = await _runs.GetByViewerIdAsync(viewerId) var run = await _runs.GetByViewerIdAsync(viewerId)
?? throw new ArenaTwoPickException("arena_two_pick_no_active_run"); ?? throw new ArenaTwoPickException("arena_two_pick_no_active_run");
var maxWins = await _rewards.GetMaxWinCountAsync(); // Classic SV Take Two: run ends after MaxBattles total games played, regardless of the
var aCfg = _config.Get<SVSim.Database.Models.Config.ArenaTwoPickConfig>(); // win/loss split. No separate loss cap (Worlds Beyond's 2-loss rule does not apply here).
bool runOver = run.WinCount >= maxWins || run.LossCount >= aCfg.MaxLosses; // MaxBattles is derived from MAX(reward.WinCount), which is 5 for the live TK2 catalog.
var maxBattles = await ResolveMaxBattleCountAsync();
int battlesPlayed = run.WinCount + run.LossCount;
bool runOver = battlesPlayed >= maxBattles;
if (requireComplete && !runOver) if (requireComplete && !runOver)
throw new ArenaTwoPickException("arena_two_pick_run_not_complete"); throw new ArenaTwoPickException("arena_two_pick_run_not_complete");
@@ -339,11 +342,6 @@ public class ArenaTwoPickService : IArenaTwoPickService
results.Add(isWin); results.Add(isWin);
run.ResultListJson = JsonSerializer.Serialize(results); run.ResultListJson = JsonSerializer.Serialize(results);
if (isWin) run.WinCount += 1; else run.LossCount += 1; if (isWin) run.WinCount += 1; else run.LossCount += 1;
// Mark run complete if max losses reached (win-cap is handled by Finish/Retire).
if (run.LossCount >= aCfg.MaxLosses)
run.IsSelectCompleted = true;
await _runs.UpsertAsync(run); await _runs.UpsertAsync(run);
var viewer = await LoadViewerForGrantsAsync(viewerId); var viewer = await LoadViewerForGrantsAsync(viewerId);

View File

@@ -18,25 +18,26 @@ public class ArenaTwoPickRewardImporterTests
} }
[Test] [Test]
public async Task Import_loads_all_16_rows_from_seed_file() public async Task Import_loads_all_12_rows_from_seed_file()
{ {
await using var db = await CreateContextAsync(); await using var db = await CreateContextAsync();
var importer = new ArenaTwoPickRewardImporter(); var importer = new ArenaTwoPickRewardImporter();
await importer.ImportAsync(db, FindSeedDir()); await importer.ImportAsync(db, FindSeedDir());
// 6 WinCount tiers (0..5) × 2 reward rows each = 12. Classic SV TK2 caps at 5 battles.
var rows = await db.ArenaTwoPickRewards.OrderBy(r => r.WinCount).ThenBy(r => r.RewardType).ToListAsync(); var rows = await db.ArenaTwoPickRewards.OrderBy(r => r.WinCount).ThenBy(r => r.RewardType).ToListAsync();
Assert.That(rows.Count, Is.EqualTo(16)); Assert.That(rows.Count, Is.EqualTo(12));
Assert.That(rows.Max(r => r.WinCount), Is.EqualTo(7)); Assert.That(rows.Max(r => r.WinCount), Is.EqualTo(5));
var w0 = rows.Where(r => r.WinCount == 0).ToList(); var w0 = rows.Where(r => r.WinCount == 0).ToList();
Assert.That(w0.Count, Is.EqualTo(2)); Assert.That(w0.Count, Is.EqualTo(2));
Assert.That(w0.Single(r => r.RewardType == 4).RewardNum, Is.EqualTo(1)); Assert.That(w0.Single(r => r.RewardType == 4).RewardNum, Is.EqualTo(1));
Assert.That(w0.Single(r => r.RewardType == 9).RewardNum, Is.EqualTo(100)); Assert.That(w0.Single(r => r.RewardType == 9).RewardNum, Is.EqualTo(100));
var w7 = rows.Where(r => r.WinCount == 7).ToList(); var w5 = rows.Where(r => r.WinCount == 5).ToList();
Assert.That(w7.Single(r => r.RewardType == 4).RewardNum, Is.EqualTo(2)); Assert.That(w5.Single(r => r.RewardType == 4).RewardNum, Is.EqualTo(1));
Assert.That(w7.Single(r => r.RewardType == 9).RewardNum, Is.EqualTo(1500)); Assert.That(w5.Single(r => r.RewardType == 9).RewardNum, Is.EqualTo(1000));
} }
[Test] [Test]
@@ -49,7 +50,7 @@ public class ArenaTwoPickRewardImporterTests
await importer.ImportAsync(db, FindSeedDir()); await importer.ImportAsync(db, FindSeedDir());
var count = await db.ArenaTwoPickRewards.CountAsync(); var count = await db.ArenaTwoPickRewards.CountAsync();
Assert.That(count, Is.EqualTo(16), "second import should upsert, not duplicate"); Assert.That(count, Is.EqualTo(12), "second import should upsert, not duplicate");
} }
private static string FindSeedDir() private static string FindSeedDir()

View File

@@ -26,7 +26,7 @@ public class ArenaTwoPickRewardRepositoryTests
await using var db = await SeededContextAsync(); await using var db = await SeededContextAsync();
var repo = new ArenaTwoPickRewardRepository(db); var repo = new ArenaTwoPickRewardRepository(db);
for (int w = 0; w <= 7; w++) for (int w = 0; w <= 5; w++)
{ {
var rows = await repo.GetRewardsByWinCountAsync(w); var rows = await repo.GetRewardsByWinCountAsync(w);
Assert.That(rows.Count, Is.EqualTo(2), $"WinCount={w} should have 2 reward rows"); Assert.That(rows.Count, Is.EqualTo(2), $"WinCount={w} should have 2 reward rows");
@@ -34,12 +34,12 @@ public class ArenaTwoPickRewardRepositoryTests
} }
[Test] [Test]
public async Task GetMaxWinCount_returns_7() public async Task GetMaxWinCount_returns_5()
{ {
await using var db = await SeededContextAsync(); await using var db = await SeededContextAsync();
var repo = new ArenaTwoPickRewardRepository(db); var repo = new ArenaTwoPickRewardRepository(db);
var max = await repo.GetMaxWinCountAsync(); var max = await repo.GetMaxWinCountAsync();
Assert.That(max, Is.EqualTo(7)); Assert.That(max, Is.EqualTo(5));
} }
[Test] [Test]

View File

@@ -87,7 +87,7 @@ public class ArenaTwoPickServiceEntryTests
var dto = await svc.EntryAsync(viewerId, consumeItemType: 3); var dto = await svc.EntryAsync(viewerId, consumeItemType: 3);
Assert.That(dto.EntryInfo.Id, Is.GreaterThan(0)); Assert.That(dto.EntryInfo.Id, Is.GreaterThan(0));
Assert.That(dto.EntryInfo.MaxBattleCount, Is.EqualTo(7)); Assert.That(dto.EntryInfo.MaxBattleCount, Is.EqualTo(5));
Assert.That(dto.CandidateClassIds.Count, Is.EqualTo(3)); Assert.That(dto.CandidateClassIds.Count, Is.EqualTo(3));
Assert.That(dto.RewardList.Count, Is.EqualTo(1)); Assert.That(dto.RewardList.Count, Is.EqualTo(1));
Assert.That(dto.RewardList[0].RewardType, Is.EqualTo(4)); Assert.That(dto.RewardList[0].RewardType, Is.EqualTo(4));
@@ -96,7 +96,7 @@ public class ArenaTwoPickServiceEntryTests
var run = await db.ViewerArenaTwoPickRuns.FirstAsync(r => r.ViewerId == viewerId); var run = await db.ViewerArenaTwoPickRuns.FirstAsync(r => r.ViewerId == viewerId);
Assert.That(run.ClassId, Is.EqualTo(0)); Assert.That(run.ClassId, Is.EqualTo(0));
Assert.That(run.MaxBattleCount, Is.EqualTo(7)); Assert.That(run.MaxBattleCount, Is.EqualTo(5));
// Re-read viewer to verify ticket was debited. // Re-read viewer to verify ticket was debited.
var updated = await db.Viewers.Include(v => v.Items).ThenInclude(i => i.Item).FirstAsync(v => v.Id == viewerId); var updated = await db.Viewers.Include(v => v.Items).ThenInclude(i => i.Item).FirstAsync(v => v.Id == viewerId);

View File

@@ -128,9 +128,11 @@ public class ArenaTwoPickServiceFinishTests
} }
[Test] [Test]
public async Task FinishAsync_at_2_losses_grants_loss_rewards_and_deletes_run() public async Task FinishAsync_at_5_total_battles_with_0_wins_grants_loss_rewards_and_deletes_run()
{ {
var (db, svc, vid) = await SetupWithRunAsync(winCount: 0, lossCount: 2); // Classic Take Two: run ends after 5 total battles played, regardless of W/L split.
// 0W 5L = floor-tier reward (1 ticket + 100 rupies).
var (db, svc, vid) = await SetupWithRunAsync(winCount: 0, lossCount: 5);
await using var _ = db; await using var _ = db;
var dto = await svc.FinishAsync(vid); var dto = await svc.FinishAsync(vid);
@@ -155,14 +157,17 @@ public class ArenaTwoPickServiceFinishTests
} }
[Test] [Test]
public async Task RecordBattleResultAsync_2nd_loss_terminates_run() public async Task RecordBattleResultAsync_increments_loss_without_terminating()
{ {
// No 2-loss cap (that's a Worlds Beyond rule). Run termination is purely battles-played
// based and handled at Finish/Retire time, not in RecordBattleResult.
var (db, svc, vid) = await SetupWithRunAsync(winCount: 0, lossCount: 1); var (db, svc, vid) = await SetupWithRunAsync(winCount: 0, lossCount: 1);
await using var _ = db; await using var _ = db;
await svc.RecordBattleResultAsync(vid, isWin: false); await svc.RecordBattleResultAsync(vid, isWin: false);
var run = await db.ViewerArenaTwoPickRuns.FirstAsync(); var run = await db.ViewerArenaTwoPickRuns.FirstAsync();
Assert.That(run.LossCount, Is.EqualTo(2)); Assert.That(run.LossCount, Is.EqualTo(2));
Assert.That(run.IsSelectCompleted, Is.True); // Run still alive — IsSelectCompleted only flips when the 30-card draft finishes.
Assert.That(run.IsSelectCompleted, Is.True, "the test setup pre-sets isSelectCompleted=true");
} }
} }