feat(arena-colosseum): playable bracket + rank-match promotion (phase 2)

Closes 4 more arena-colosseum spec shapes (6/16 → 10/16): the dual-prefix
battle URLs (colosseum_battle/* and colosseum_rank_battle/*) for both
do_matching and per-match finish, plus the bracket-end /finish + /retire
pair on /arena_colosseum.

* ColosseumProgressionService: pure-logic decisions for win-threshold
  advancement, 3008 promotion trigger, and per-round reward bundles.
  Extends ColosseumRoundsConfig with FinishRewards/RetireRewards per
  round + a top-level ChampionRewards list.
* MatchContextBuilder.BuildForColosseumAsync: lifts the registered deck
  via IDeckRepository (NOT viewer-graph) per project_ef_nav_include_pitfall.
* ArenaColosseumBattleController: dual route prefixes off one controller
  via explicit absolute [HttpPost("/...")] routes — does NOT extend
  SVSimController's [Route("[controller]")]. Same pattern as FreeBattle.
  URL-vs-IsRankMatching mismatch returns 400 with a phase-mismatch error;
  no-deck triggers the standard 3001 matchmaking-illegal state. 3008
  flips run.IsRankMatching exactly once via the progression service.
* /arena_colosseum/finish + /retire share FinishResponse (rewards +
  reward_list + colosseum_status). Champion finish appends ChampionRewards
  and surfaces colosseum_status.is_champion = true. Both delete the run
  row after granting via IInventoryTransaction.GrantAsync.
* DI: ModePolicy entries for "colosseum_battle" and "colosseum_rank_battle".
* Tests: 8 progression-service unit tests (pure logic), 5 battle-controller
  HTTP tests, 5 bracket-terminate HTTP tests, 2 full-bracket E2E walks
  (champion path + retire mid-round). Full suite: 1331/1331.
This commit is contained in:
gamer147
2026-06-13 12:32:56 -04:00
parent 110867358c
commit cbee0f9a50
16 changed files with 1436 additions and 2 deletions

View File

@@ -0,0 +1,181 @@
using NUnit.Framework;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Models.Config;
using SVSim.EmulatedEntrypoint.Services.ArenaColosseum;
namespace SVSim.UnitTests.Services.ArenaColosseum;
/// <summary>
/// Pure-logic tests for the bracket-advancement / promotion / reward-bundle service.
/// No DB, no controllers, no HTTP — these are the only place to lock the spec README's
/// "&lt; FinalB" cap rule + the 3008 promotion trigger semantics.
/// </summary>
[TestFixture]
public class ColosseumProgressionServiceTests
{
private static ColosseumRoundsConfig BuildThreeRoundConfig() => new()
{
Rounds = new()
{
new ColosseumRoundsConfig.RoundEntry
{
RoundId = 1,
Groups = new() { new() { Group = "", MaxBattleCount = 5, BreakthroughNumber = 3, EntryNumber = 100_000 } },
FinishRewards = new()
{
new() { Type = UserGoodsType.Crystal, DetailId = 0, Count = 100, Name = "Round 1 bonus" },
},
RetireRewards = new()
{
new() { Type = UserGoodsType.Rupy, DetailId = 0, Count = 50, Name = "Consolation" },
},
},
new ColosseumRoundsConfig.RoundEntry
{
RoundId = 2,
Groups = new() { new() { Group = "Group A", MaxBattleCount = 5, BreakthroughNumber = 4, EntryNumber = 10_000 } },
FinishRewards = new()
{
new() { Type = UserGoodsType.Crystal, DetailId = 0, Count = 250, Name = "Round 2 bonus" },
},
},
new ColosseumRoundsConfig.RoundEntry
{
RoundId = 3,
Groups = new() { new() { Group = "Final", MaxBattleCount = 5, BreakthroughNumber = 4, EntryNumber = 1_000 } },
FinishRewards = new()
{
new() { Type = UserGoodsType.Crystal, DetailId = 0, Count = 1000, Name = "Final clear" },
},
},
},
ChampionRewards = new()
{
new() { Type = UserGoodsType.Item, DetailId = 5, Count = 1, Name = "Champion Pack" },
},
};
[Test]
public void Win_threshold_advances_round_1_to_round_2()
{
var svc = new ColosseumProgressionService();
var rounds = BuildThreeRoundConfig();
var run = new ViewerArenaColosseumRun { RoundId = 1, WinCount = 3, BattleCountThisRound = 3 };
var decision = svc.DecideAdvancement(run, rounds);
Assert.That(decision.NextRoundId, Is.EqualTo(2));
Assert.That(decision.IsBracketEnd, Is.False);
Assert.That(decision.IsChampion, Is.False);
}
[Test]
public void Loss_cap_ends_bracket_at_current_round()
{
var svc = new ColosseumProgressionService();
var rounds = BuildThreeRoundConfig();
var run = new ViewerArenaColosseumRun
{
RoundId = 2,
WinCount = 3, // one short of breakthrough (4)
BattleCountThisRound = 5, // hit the cap
LossCount = 2,
};
var decision = svc.DecideAdvancement(run, rounds);
Assert.That(decision.NextRoundId, Is.EqualTo(2));
Assert.That(decision.IsBracketEnd, Is.True);
Assert.That(decision.IsChampion, Is.False);
}
[Test]
public void Final_round_breakthrough_marks_champion()
{
var svc = new ColosseumProgressionService();
var rounds = BuildThreeRoundConfig();
var run = new ViewerArenaColosseumRun
{
RoundId = 3,
WinCount = 4, // hit breakthrough on the final round
BattleCountThisRound = 4,
};
var decision = svc.DecideAdvancement(run, rounds);
Assert.That(decision.NextRoundId, Is.EqualTo(3));
Assert.That(decision.IsBracketEnd, Is.True);
Assert.That(decision.IsChampion, Is.True);
}
[Test]
public void ShouldPromoteToRankMatching_flips_once_on_3008()
{
var svc = new ColosseumProgressionService();
var run = new ViewerArenaColosseumRun { IsRankMatching = false };
Assert.That(svc.ShouldPromoteToRankMatching(run, 3004), Is.False,
"3004 SUCCEEDED is not the promotion signal");
Assert.That(svc.ShouldPromoteToRankMatching(run, 3008), Is.True,
"3008 is the colosseum-specific promotion trigger per do-matching.md");
run.IsRankMatching = true;
Assert.That(svc.ShouldPromoteToRankMatching(run, 3008), Is.False,
"already promoted — no second flip");
}
[Test]
public void BuildRetireRewards_returns_round_specific_bundle()
{
var svc = new ColosseumProgressionService();
var rounds = BuildThreeRoundConfig();
var run = new ViewerArenaColosseumRun { RoundId = 1 };
var rewards = svc.BuildRetireRewards(run, rounds);
Assert.That(rewards.Count, Is.EqualTo(1));
Assert.That(rewards[0].Type, Is.EqualTo(UserGoodsType.Rupy));
Assert.That(rewards[0].Count, Is.EqualTo(50));
}
[Test]
public void BuildRetireRewards_returns_empty_when_round_has_none()
{
var svc = new ColosseumProgressionService();
var rounds = BuildThreeRoundConfig();
// Round 2 has no RetireRewards configured — server still emits an empty list per spec.
var run = new ViewerArenaColosseumRun { RoundId = 2 };
var rewards = svc.BuildRetireRewards(run, rounds);
Assert.That(rewards, Is.Empty);
}
[Test]
public void BuildFinishRewards_appends_champion_bundle_when_champion()
{
var svc = new ColosseumProgressionService();
var rounds = BuildThreeRoundConfig();
var run = new ViewerArenaColosseumRun { RoundId = 3, IsChampion = true };
var rewards = svc.BuildFinishRewards(run, rounds);
Assert.That(rewards.Count, Is.EqualTo(2), "round 3 finish + champion bundle");
Assert.That(rewards.Any(r => r.Name == "Final clear"), Is.True);
Assert.That(rewards.Any(r => r.Name == "Champion Pack"), Is.True);
}
[Test]
public void BuildFinishRewards_omits_champion_bundle_when_not_champion()
{
var svc = new ColosseumProgressionService();
var rounds = BuildThreeRoundConfig();
var run = new ViewerArenaColosseumRun { RoundId = 1, IsChampion = false };
var rewards = svc.BuildFinishRewards(run, rounds);
Assert.That(rewards.Count, Is.EqualTo(1));
Assert.That(rewards[0].Name, Is.EqualTo("Round 1 bonus"));
}
}