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.
182 lines
6.5 KiB
C#
182 lines
6.5 KiB
C#
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
|
|
/// "< 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"));
|
|
}
|
|
}
|