diff --git a/SVSim.Database/Models/Config/ColosseumRoundsConfig.cs b/SVSim.Database/Models/Config/ColosseumRoundsConfig.cs
index 922c3b82..c0744070 100644
--- a/SVSim.Database/Models/Config/ColosseumRoundsConfig.cs
+++ b/SVSim.Database/Models/Config/ColosseumRoundsConfig.cs
@@ -1,7 +1,10 @@
+using SVSim.Database.Enums;
+
namespace SVSim.Database.Models.Config;
///
-/// The 3-round bracket schedule for an active Colosseum season. Empty
+/// The 3-round bracket schedule for an active Colosseum season — and the per-round
+/// reward bundles paid out by /finish + /retire. Empty
/// is the default shipped state — lobby /event_info renders a benign payload with
/// no rounds active. Collection default is in per
/// feedback_config_defaults (property-initializer collection defaults silently empty out
@@ -12,9 +15,14 @@ public class ColosseumRoundsConfig
{
public List Rounds { get; set; } = new();
+ /// Champion-only reward bundle, paid alongside the final round's FinishRewards
+ /// when ColosseumProgressionService determines the viewer cleared the bracket.
+ public List ChampionRewards { get; set; } = new();
+
public static ColosseumRoundsConfig ShippedDefaults() => new()
{
Rounds = new(),
+ ChampionRewards = new(),
};
public class RoundEntry
@@ -29,6 +37,15 @@ public class ColosseumRoundsConfig
/// for late-joiners). The first entry is the canonical lookup used by
/// ColosseumProgressionService for the v1 single-bracket case.
public List Groups { get; set; } = new();
+
+ /// Paid by /finish when the viewer clears or otherwise ends this round
+ /// successfully. Empty = no bonus for this round.
+ public List FinishRewards { get; set; } = new();
+
+ /// Paid by /retire when the viewer abandons mid-round. Client ignores
+ /// these once run.RoundId >= FinalB, but server still emits them per
+ /// retire.md (log completeness).
+ public List RetireRewards { get; set; } = new();
}
public class GroupEntry
@@ -44,4 +61,15 @@ public class ColosseumRoundsConfig
/// Total bracket entries allotted to this group.
public int EntryNumber { get; set; }
}
+
+ public class RewardEntry
+ {
+ public UserGoodsType Type { get; set; }
+ public long DetailId { get; set; }
+ public int Count { get; set; }
+
+ /// Display name for the rewards[] receipt block. Defaults to empty;
+ /// client uses system-text lookups when blank.
+ public string Name { get; set; } = "";
+ }
}
diff --git a/SVSim.EmulatedEntrypoint/Controllers/ArenaColosseumBattleController.cs b/SVSim.EmulatedEntrypoint/Controllers/ArenaColosseumBattleController.cs
new file mode 100644
index 00000000..12762dc4
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Controllers/ArenaColosseumBattleController.cs
@@ -0,0 +1,175 @@
+using System.Text.Json;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using SVSim.BattleNode.Bridge;
+using SVSim.Database.Repositories.Viewer;
+using SVSim.EmulatedEntrypoint.Constants;
+using SVSim.EmulatedEntrypoint.Matching;
+using SVSim.EmulatedEntrypoint.Models.Dtos.ArenaColosseum;
+using SVSim.EmulatedEntrypoint.Security;
+using SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication;
+using SVSim.EmulatedEntrypoint.Services;
+using SVSim.EmulatedEntrypoint.Services.ArenaColosseum;
+
+namespace SVSim.EmulatedEntrypoint.Controllers;
+
+///
+/// Per-match URLs for the Colosseum bracket — the dual colosseum_battle/* +
+/// colosseum_rank_battle/* route family. Does NOT extend 's
+/// [Route("[controller]")] because we need explicit absolute routes for two prefixes
+/// off one controller (same pattern as ).
+///
+/// The URL is the bracket-phase signal per do-matching.md §"server-side bidirectional
+/// mapping"; flips to true once the
+/// node has signalled matching_state == 3008 on a prior do_matching.
+///
+///
+[ApiController]
+[Authorize(AuthenticationSchemes = SteamAuthenticationConstants.SchemeName)]
+public sealed class ArenaColosseumBattleController : ControllerBase
+{
+ /// Per FinishTaskBase.IsEffectiveErrorCode — code 3502 ("battle already finished")
+ /// is tolerated as a non-error retry, parsed the same as 1.
+ private const int BattleAlreadyFinishedResultCode = 3502;
+
+ private readonly IMatchContextBuilder _ctxBuilder;
+ private readonly IMatchingResolver _resolver;
+ private readonly IArenaColosseumRunRepository _runs;
+ private readonly IColosseumProgressionService _progression;
+ private readonly ILogger _log;
+
+ public ArenaColosseumBattleController(
+ IMatchContextBuilder ctxBuilder,
+ IMatchingResolver resolver,
+ IArenaColosseumRunRepository runs,
+ IColosseumProgressionService progression,
+ ILogger log)
+ {
+ _ctxBuilder = ctxBuilder;
+ _resolver = resolver;
+ _runs = runs;
+ _progression = progression;
+ _log = log;
+ }
+
+ private bool TryGetViewerId(out long viewerId)
+ {
+ viewerId = 0;
+ var claim = User.Claims.FirstOrDefault(c => c.Type == ShadowverseClaimTypes.ViewerIdClaim)?.Value;
+ return claim is not null && long.TryParse(claim, out viewerId);
+ }
+
+ [HttpPost("/colosseum_battle/do_matching")]
+ public Task DoMatchingPreRank(
+ [FromBody] ColosseumDoMatchingRequestDto req, CancellationToken ct)
+ => DoMatchingInternal(isRankUrl: false, req, ct);
+
+ [HttpPost("/colosseum_rank_battle/do_matching")]
+ public Task DoMatchingPostRank(
+ [FromBody] ColosseumDoMatchingRequestDto req, CancellationToken ct)
+ => DoMatchingInternal(isRankUrl: true, req, ct);
+
+ [HttpPost("/colosseum_battle/finish")]
+ public Task FinishPreRank(
+ [FromBody] ColosseumBattleFinishRequestDto req, CancellationToken ct)
+ => FinishInternal(isRankUrl: false, req, ct);
+
+ [HttpPost("/colosseum_rank_battle/finish")]
+ public Task FinishPostRank(
+ [FromBody] ColosseumBattleFinishRequestDto req, CancellationToken ct)
+ => FinishInternal(isRankUrl: true, req, ct);
+
+ private async Task DoMatchingInternal(
+ bool isRankUrl, ColosseumDoMatchingRequestDto req, CancellationToken ct)
+ {
+ if (!TryGetViewerId(out var vid)) return Unauthorized();
+
+ var run = await _runs.GetByViewerIdAsync(vid);
+ if (run is null)
+ {
+ return BadRequest(new { error = "arena_colosseum_no_active_run" });
+ }
+ if (isRankUrl != run.IsRankMatching)
+ {
+ return BadRequest(new
+ {
+ error = "colosseum_url_phase_mismatch",
+ is_rank_matching = run.IsRankMatching,
+ requested_rank_url = isRankUrl,
+ });
+ }
+
+ MatchContext ctx;
+ try
+ {
+ ctx = await _ctxBuilder.BuildForColosseumAsync(vid);
+ }
+ catch (InvalidOperationException ex)
+ {
+ _log.LogWarning(ex,
+ "Colosseum BuildForColosseumAsync failed for viewer {Vid}; returning 3001.", vid);
+ return Ok(new Models.Dtos.FreeBattle.DoMatchingResponseDto
+ {
+ MatchingState = 3001,
+ NodeServerUrl = "",
+ });
+ }
+
+ var mode = isRankUrl ? "colosseum_rank_battle" : "colosseum_battle";
+ var resolution = await _resolver.ResolveAsync(mode, new BattlePlayer(vid, ctx), ct);
+
+ // Promotion: server flips IsRankMatching once on the 3008 signal. Subsequent battle
+ // URLs must use the rank prefix. (Plan §"matching_state == 3008 is the promotion trigger".)
+ if (_progression.ShouldPromoteToRankMatching(run, resolution.MatchingState))
+ {
+ run.IsRankMatching = true;
+ await _runs.UpsertAsync(run);
+ }
+
+ return Ok(new Models.Dtos.FreeBattle.DoMatchingResponseDto
+ {
+ MatchingState = resolution.MatchingState,
+ BattleId = resolution.BattleId,
+ NodeServerUrl = resolution.NodeServerUrl,
+ });
+ }
+
+ private async Task FinishInternal(
+ bool isRankUrl, ColosseumBattleFinishRequestDto req, CancellationToken ct)
+ {
+ if (!TryGetViewerId(out var vid)) return Unauthorized();
+
+ var run = await _runs.GetByViewerIdAsync(vid);
+ if (run is null)
+ {
+ return BadRequest(new { error = "arena_colosseum_no_active_run" });
+ }
+
+ // Match-result tracking: 1 = win, 2 = loss, 0 = draw/abort. Record_list mirrors
+ // ColosseumTopTask.battle_results.result_list (1=win, 0=loss). is_retire surfaces
+ // as a non-counted result — does not advance the bracket.
+ bool counts = req.IsRetire == 0;
+ if (counts)
+ {
+ bool isWin = req.BattleResult == 1;
+ run.BattleCountThisRound += 1;
+ if (isWin) run.WinCount += 1;
+ else run.LossCount += 1;
+
+ var resultList = ParseIntList(run.ResultListJson);
+ resultList.Add(isWin ? 1 : 0);
+ run.ResultListJson = JsonSerializer.Serialize(resultList);
+ await _runs.UpsertAsync(run);
+ }
+
+ // result_code 3502 ("battle already finished") is the idempotent-retry tolerance per
+ // FinishTaskBase.IsEffectiveErrorCode. Server emits standard data; the translation
+ // middleware sets the wire result_code via data_headers from the response envelope.
+ return Ok(new ColosseumBattleFinishResponseDto { BattleResult = req.BattleResult });
+ }
+
+ private static List ParseIntList(string json) =>
+ string.IsNullOrEmpty(json)
+ ? new()
+ : JsonSerializer.Deserialize>(json) ?? new();
+}
diff --git a/SVSim.EmulatedEntrypoint/Controllers/ArenaColosseumController.cs b/SVSim.EmulatedEntrypoint/Controllers/ArenaColosseumController.cs
index 45c8fefe..1dc1ab5b 100644
--- a/SVSim.EmulatedEntrypoint/Controllers/ArenaColosseumController.cs
+++ b/SVSim.EmulatedEntrypoint/Controllers/ArenaColosseumController.cs
@@ -12,6 +12,7 @@ using SVSim.EmulatedEntrypoint.Models.Dtos.Common.ArenaTwoPick;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ArenaColosseum;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaColosseum;
+using SVSim.EmulatedEntrypoint.Services.ArenaColosseum;
namespace SVSim.EmulatedEntrypoint.Controllers;
@@ -28,17 +29,20 @@ public class ArenaColosseumController : SVSimController
private readonly IArenaColosseumRunRepository _runs;
private readonly IInventoryService _inventory;
private readonly IDeckRepository _decks;
+ private readonly IColosseumProgressionService _progression;
public ArenaColosseumController(
IGameConfigService config,
IArenaColosseumRunRepository runs,
IInventoryService inventory,
- IDeckRepository decks)
+ IDeckRepository decks,
+ IColosseumProgressionService progression)
{
_config = config;
_runs = runs;
_inventory = inventory;
_decks = decks;
+ _progression = progression;
}
[HttpPost("top")]
@@ -247,6 +251,104 @@ public class ArenaColosseumController : SVSimController
return Ok(new { });
}
+ [HttpPost("finish")]
+ public async Task Finish([FromBody] BaseRequest _)
+ {
+ if (!TryGetViewerId(out var vid)) return Unauthorized();
+ var run = await _runs.GetByViewerIdAsync(vid);
+ if (run is null) return BadRequest(new { error = "no_active_run" });
+
+ var rounds = _config.Get();
+ var decision = _progression.DecideAdvancement(run, rounds);
+ if (!decision.IsBracketEnd)
+ {
+ return BadRequest(new { error = "bracket_not_finished" });
+ }
+
+ run.IsChampion = decision.IsChampion;
+ var rewardEntries = _progression.BuildFinishRewards(run, rounds);
+ var (wireRewards, wireRewardList) = await GrantRewardsAsync(vid, rewardEntries);
+
+ await _runs.DeleteAsync(vid);
+
+ return Ok(new FinishResponse
+ {
+ Rewards = wireRewards,
+ RewardList = wireRewardList,
+ ColosseumStatus = new ColosseumOwnStatus
+ {
+ NowRoundId = run.RoundId,
+ IsChampion = decision.IsChampion ? true : null,
+ ColosseumName = decision.IsChampion ? rounds.Rounds.Count > 0
+ ? _config.Get().ColosseumName
+ : null : null,
+ },
+ });
+ }
+
+ [HttpPost("retire")]
+ public async Task Retire([FromBody] BaseRequest _)
+ {
+ if (!TryGetViewerId(out var vid)) return Unauthorized();
+ var run = await _runs.GetByViewerIdAsync(vid);
+ if (run is null) return BadRequest(new { error = "no_active_run" });
+
+ var rounds = _config.Get();
+ var rewardEntries = _progression.BuildRetireRewards(run, rounds);
+ var (wireRewards, wireRewardList) = await GrantRewardsAsync(vid, rewardEntries);
+
+ await _runs.DeleteAsync(vid);
+
+ return Ok(new FinishResponse
+ {
+ Rewards = wireRewards,
+ RewardList = wireRewardList,
+ ColosseumStatus = new ColosseumOwnStatus
+ {
+ NowRoundId = run.RoundId,
+ RestEntryNum = 0,
+ },
+ });
+ }
+
+ ///
+ /// Grant the bundle through IInventoryTransaction.GrantAsync per
+ /// feedback_reward_grant_service — single dispatch table for every UserGoodsType.
+ /// Returns the two wire forms (rich rewards receipt + wallet-delta reward_list).
+ ///
+ private async Task<(List, List)> GrantRewardsAsync(
+ long viewerId, IReadOnlyList bundle)
+ {
+ var receipts = new List();
+ var deltas = new List();
+ if (bundle.Count == 0) return (receipts, deltas);
+
+ await using var tx = await _inventory.BeginAsync(viewerId);
+ foreach (var entry in bundle)
+ {
+ var granted = await tx.GrantAsync(entry.Type, entry.DetailId, entry.Count);
+ receipts.Add(new ColosseumReceivedReward
+ {
+ RewardNumber = entry.Count,
+ RewardType = (int)entry.Type,
+ RewardDetailId = entry.DetailId,
+ Name = entry.Name,
+ });
+ // GrantAsync returns one or more (RewardType, RewardId, RewardNum) tuples — the
+ // post-state-total semantics are owned by the inventory commit, which the wallet-delta
+ // reflection inherits via tx.CommitAsync below.
+ var first = granted.FirstOrDefault();
+ deltas.Add(new RewardEntryDto
+ {
+ RewardType = first is null ? (int)entry.Type : (int)first.RewardType,
+ RewardId = first?.RewardId ?? entry.DetailId,
+ RewardNum = first?.RewardNum ?? entry.Count,
+ });
+ }
+ await tx.CommitAsync();
+ return (receipts, deltas);
+ }
+
private static int ResolveServerRoundId(ColosseumSeasonConfig season) => 1;
private async Task DebitCrystalAsync(IInventoryTransaction tx, int cost)
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/ArenaColosseum/BattleDoMatchingRequestDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/ArenaColosseum/BattleDoMatchingRequestDto.cs
new file mode 100644
index 00000000..447d06d7
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/ArenaColosseum/BattleDoMatchingRequestDto.cs
@@ -0,0 +1,33 @@
+using System.Text.Json.Serialization;
+using MessagePack;
+using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
+
+namespace SVSim.EmulatedEntrypoint.Models.Dtos.ArenaColosseum;
+
+///
+/// POST /colosseum_battle/do_matching + POST /colosseum_rank_battle/do_matching.
+/// Standard DoMatchingParam wire shape — same fields as rank/free-battle's variant.
+/// Per do-matching.md, post-promotion the client forces need_init = 0, but the
+/// server tolerates either value (URL is the routing signal).
+///
+[MessagePackObject]
+public sealed class ColosseumDoMatchingRequestDto : BaseRequest
+{
+ [JsonPropertyName("need_init")] [Key("need_init")]
+ public int NeedInit { get; set; }
+
+ [JsonPropertyName("card_master_hash")] [Key("card_master_hash")]
+ public string? CardMasterHash { get; set; }
+
+ [JsonPropertyName("log")] [Key("log")]
+ public int Log { get; set; }
+
+ [JsonPropertyName("use_stage_select")] [Key("use_stage_select")]
+ public int UseStageSelect { get; set; }
+
+ [JsonPropertyName("excluded_field_id_list")] [Key("excluded_field_id_list")]
+ public List ExcludedFieldIdList { get; set; } = new();
+
+ [JsonPropertyName("is_default_skin")] [Key("is_default_skin")]
+ public int IsDefaultSkin { get; set; }
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/ArenaColosseum/BattleFinishRequestDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/ArenaColosseum/BattleFinishRequestDto.cs
new file mode 100644
index 00000000..32c766e6
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/ArenaColosseum/BattleFinishRequestDto.cs
@@ -0,0 +1,49 @@
+using System.Text.Json.Serialization;
+using MessagePack;
+using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
+
+namespace SVSim.EmulatedEntrypoint.Models.Dtos.ArenaColosseum;
+
+///
+/// POST /colosseum_battle/finish + POST /colosseum_rank_battle/finish request
+/// body — the per-match finish, NOT the bracket-end /arena_colosseum/finish.
+/// Standard BattleFinishParam shape inherited from FinishTaskBase.
+///
+[MessagePackObject]
+public sealed class ColosseumBattleFinishRequestDto : BaseRequest
+{
+ [JsonPropertyName("battle_result")] [Key("battle_result")]
+ public int BattleResult { get; set; }
+
+ [JsonPropertyName("is_retire")] [Key("is_retire")]
+ public int IsRetire { get; set; }
+
+ [JsonPropertyName("recovery_data")] [Key("recovery_data")]
+ public string? RecoveryData { get; set; }
+
+ [JsonPropertyName("class_id")] [Key("class_id")]
+ public int ClassId { get; set; }
+
+ [JsonPropertyName("total_turn")] [Key("total_turn")]
+ public int TotalTurn { get; set; }
+
+ [JsonPropertyName("evolve_count")] [Key("evolve_count")]
+ public int EvolveCount { get; set; }
+
+ [JsonPropertyName("enemy_evolve_count")] [Key("enemy_evolve_count")]
+ public int EnemyEvolveCount { get; set; }
+}
+
+///
+/// colosseum_battle/finish response. Per battle-finish.md, the client maps this to
+/// ColosseumBattleFinishDetail which is an empty MatchFinishBase subclass —
+/// no Colosseum-specific fields beyond the shared rank-battle-finish superset. Phase 2 v1
+/// emits the minimum required to keep the client's BattleFinishResponsProcessing
+/// happy.
+///
+[MessagePackObject(keyAsPropertyName: true)]
+public sealed class ColosseumBattleFinishResponseDto
+{
+ [JsonPropertyName("battle_result")] [Key("battle_result")]
+ public int BattleResult { get; set; }
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/ArenaColosseum/ColosseumReceivedReward.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/ArenaColosseum/ColosseumReceivedReward.cs
new file mode 100644
index 00000000..224cf3cc
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/ArenaColosseum/ColosseumReceivedReward.cs
@@ -0,0 +1,26 @@
+using System.Text.Json.Serialization;
+using MessagePack;
+
+namespace SVSim.EmulatedEntrypoint.Models.Dtos.ArenaColosseum;
+
+///
+/// Per-entry shape for the rewards array on /finish + /retire. Richer
+/// than RewardEntryDto (which is the reward_list shape) — carries a display
+/// name. Distinct from the wallet-delta block: client renders rewards in the
+/// post-bracket popup while reward_list drives the silent wallet update.
+///
+[MessagePackObject]
+public sealed class ColosseumReceivedReward
+{
+ [JsonPropertyName("reward_number")] [Key("reward_number")]
+ public int RewardNumber { get; set; }
+
+ [JsonPropertyName("reward_type")] [Key("reward_type")]
+ public int RewardType { get; set; }
+
+ [JsonPropertyName("reward_detail_id")] [Key("reward_detail_id")]
+ public long RewardDetailId { get; set; }
+
+ [JsonPropertyName("name")] [Key("name")]
+ public string Name { get; set; } = "";
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaColosseum/FinishResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaColosseum/FinishResponse.cs
new file mode 100644
index 00000000..a4f646ef
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaColosseum/FinishResponse.cs
@@ -0,0 +1,27 @@
+using System.Text.Json.Serialization;
+using MessagePack;
+using SVSim.EmulatedEntrypoint.Models.Dtos.ArenaColosseum;
+using SVSim.EmulatedEntrypoint.Models.Dtos.Common.ArenaTwoPick;
+
+namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaColosseum;
+
+///
+/// POST /arena_colosseum/finish and POST /arena_colosseum/retire. Wire-identical
+/// shape per finish.md §"Wire-shared with /retire" — endpoints differ in side-effects only:
+/// /finish emits per-round-completion + champion bonus and marks the entry champion;
+/// /retire emits the round-capped consolation. The client renders rewards in the
+/// post-bracket popup, applies wallet deltas via reward_list, and updates the
+/// status block via colosseum_status.
+///
+[MessagePackObject]
+public sealed class FinishResponse
+{
+ [JsonPropertyName("rewards")] [Key("rewards")]
+ public List Rewards { get; set; } = new();
+
+ [JsonPropertyName("reward_list")] [Key("reward_list")]
+ public List RewardList { get; set; } = new();
+
+ [JsonPropertyName("colosseum_status")] [Key("colosseum_status")]
+ public ColosseumOwnStatus ColosseumStatus { get; set; } = new();
+}
diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs
index f2edf596..16a7ab6b 100644
--- a/SVSim.EmulatedEntrypoint/Program.cs
+++ b/SVSim.EmulatedEntrypoint/Program.cs
@@ -107,6 +107,8 @@ public class Program
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
@@ -158,6 +160,11 @@ public class Program
// (see Shadowverse_Code_2026-05-23/ApiType.cs), so PvpOnly: park forever, no AI fallback.
new ModePolicy("rotation_free_battle", PolicyKind.PvpOnly),
new ModePolicy("unlimited_free_battle", PolicyKind.PvpOnly),
+ // Colosseum (Grand Prix). Pre-promotion + post-promotion (rank) URLs each map
+ // to their own pair-up mode — the URL IS the signal per do-matching.md, and the
+ // node session has no way to ask the client "which bracket are you in?".
+ new ModePolicy("colosseum_battle", PolicyKind.PvpOnly),
+ new ModePolicy("colosseum_rank_battle", PolicyKind.PvpOnly),
}));
builder.Services.AddSingleton();
// Single resolver shared by every /do_matching family controller. Owns the
diff --git a/SVSim.EmulatedEntrypoint/Services/ArenaColosseum/ColosseumProgressionService.cs b/SVSim.EmulatedEntrypoint/Services/ArenaColosseum/ColosseumProgressionService.cs
new file mode 100644
index 00000000..a07abfe4
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Services/ArenaColosseum/ColosseumProgressionService.cs
@@ -0,0 +1,69 @@
+using SVSim.Database.Models;
+using SVSim.Database.Models.Config;
+
+namespace SVSim.EmulatedEntrypoint.Services.ArenaColosseum;
+
+public class ColosseumProgressionService : IColosseumProgressionService
+{
+ /// Colosseum-specific node signal — see do-matching.md §matching_state 3008.
+ public const int PromoteToRankMatchingState = 3008;
+
+ public bool ShouldPromoteToRankMatching(ViewerArenaColosseumRun run, int matchingState) =>
+ matchingState == PromoteToRankMatchingState && !run.IsRankMatching;
+
+ public AdvancementDecision DecideAdvancement(ViewerArenaColosseumRun run, ColosseumRoundsConfig rounds)
+ {
+ var currentRound = rounds.Rounds.FirstOrDefault(r => r.RoundId == run.RoundId);
+ var currentGroup = currentRound?.Groups.FirstOrDefault();
+ var maxRoundId = rounds.Rounds.Count == 0 ? run.RoundId : rounds.Rounds.Max(r => r.RoundId);
+
+ // No matching round config (e.g. content not seeded) — treat current state as terminal
+ // so the controller doesn't loop forever. Champion=false because we can't tell.
+ if (currentGroup is null)
+ {
+ return new AdvancementDecision(run.RoundId, IsBracketEnd: true, IsChampion: false);
+ }
+
+ // Cleared the breakthrough threshold this round.
+ if (run.WinCount >= currentGroup.BreakthroughNumber)
+ {
+ bool isFinal = run.RoundId >= maxRoundId;
+ return new AdvancementDecision(
+ NextRoundId: isFinal ? run.RoundId : run.RoundId + 1,
+ IsBracketEnd: isFinal,
+ IsChampion: isFinal);
+ }
+
+ // Exhausted the per-round battle cap without clearing — bracket ends at current round.
+ if (run.BattleCountThisRound >= currentGroup.MaxBattleCount)
+ {
+ return new AdvancementDecision(run.RoundId, IsBracketEnd: true, IsChampion: false);
+ }
+
+ // Mid-round, still playing.
+ return new AdvancementDecision(run.RoundId, IsBracketEnd: false, IsChampion: false);
+ }
+
+ public IReadOnlyList BuildRetireRewards(
+ ViewerArenaColosseumRun run, ColosseumRoundsConfig rounds)
+ {
+ var round = rounds.Rounds.FirstOrDefault(r => r.RoundId == run.RoundId);
+ return round?.RetireRewards ?? new();
+ }
+
+ public IReadOnlyList BuildFinishRewards(
+ ViewerArenaColosseumRun run, ColosseumRoundsConfig rounds)
+ {
+ var round = rounds.Rounds.FirstOrDefault(r => r.RoundId == run.RoundId);
+ var bundle = new List();
+ if (round is not null)
+ {
+ bundle.AddRange(round.FinishRewards);
+ }
+ if (run.IsChampion)
+ {
+ bundle.AddRange(rounds.ChampionRewards);
+ }
+ return bundle;
+ }
+}
diff --git a/SVSim.EmulatedEntrypoint/Services/ArenaColosseum/IColosseumProgressionService.cs b/SVSim.EmulatedEntrypoint/Services/ArenaColosseum/IColosseumProgressionService.cs
new file mode 100644
index 00000000..9c58afc0
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Services/ArenaColosseum/IColosseumProgressionService.cs
@@ -0,0 +1,39 @@
+using SVSim.Database.Enums;
+using SVSim.Database.Models;
+using SVSim.Database.Models.Config;
+
+namespace SVSim.EmulatedEntrypoint.Services.ArenaColosseum;
+
+///
+/// Pure-logic decisions for the Colosseum bracket lifecycle. Reads a
+/// + snapshot and
+/// returns advancement / promotion / reward decisions. Side-effectful (debits, grants,
+/// run-row writes) live on the controllers — this service just computes.
+///
+public interface IColosseumProgressionService
+{
+ /// True when the node signal matching_state == 3008 indicates the run
+ /// has been promoted to the ranked bracket and we haven't already flipped the flag.
+ /// Subsequent battle URLs route to colosseum_rank_battle/*.
+ bool ShouldPromoteToRankMatching(ViewerArenaColosseumRun run, int matchingState);
+
+ /// Triggered post-match-finish when wins/losses cross thresholds. Returns the
+ /// next round id (or current if no change), whether the bracket has ended, and the
+ /// champion flag for the final-round-cleared case.
+ AdvancementDecision DecideAdvancement(ViewerArenaColosseumRun run, ColosseumRoundsConfig rounds);
+
+ /// Bundle to grant on /retire. Reads
+ /// for the run's
+ /// . Empty when no matching round.
+ IReadOnlyList BuildRetireRewards(
+ ViewerArenaColosseumRun run, ColosseumRoundsConfig rounds);
+
+ /// Bundle to grant on /finish. Combines the current round's
+ /// with
+ /// when the run is a champion.
+ IReadOnlyList BuildFinishRewards(
+ ViewerArenaColosseumRun run, ColosseumRoundsConfig rounds);
+}
+
+/// Output of .
+public sealed record AdvancementDecision(int NextRoundId, bool IsBracketEnd, bool IsChampion);
diff --git a/SVSim.EmulatedEntrypoint/Services/IMatchContextBuilder.cs b/SVSim.EmulatedEntrypoint/Services/IMatchContextBuilder.cs
index 84c9a83c..a40efd0a 100644
--- a/SVSim.EmulatedEntrypoint/Services/IMatchContextBuilder.cs
+++ b/SVSim.EmulatedEntrypoint/Services/IMatchContextBuilder.cs
@@ -24,4 +24,13 @@ public interface IMatchContextBuilder
/// no deck at that slot.
///
Task BuildForRankBattleAsync(long viewerId, Format format, int deckNo);
+
+ ///
+ /// Build a context for an Arena Colosseum bracket match — reads the active
+ /// ViewerArenaColosseumRun's registered deck slot (single-deck v1) via
+ /// IDeckRepository.GetDeck (NOT viewer-graph traversal per
+ /// project_ef_nav_include_pitfall). Throws when the run is missing or no deck
+ /// has been registered yet.
+ ///
+ Task BuildForColosseumAsync(long viewerId);
}
diff --git a/SVSim.EmulatedEntrypoint/Services/MatchContextBuilder.cs b/SVSim.EmulatedEntrypoint/Services/MatchContextBuilder.cs
index 9b827c4e..f4b5e9f7 100644
--- a/SVSim.EmulatedEntrypoint/Services/MatchContextBuilder.cs
+++ b/SVSim.EmulatedEntrypoint/Services/MatchContextBuilder.cs
@@ -11,17 +11,20 @@ namespace SVSim.EmulatedEntrypoint.Services;
public class MatchContextBuilder : IMatchContextBuilder
{
private readonly IArenaTwoPickRunRepository _runs;
+ private readonly IArenaColosseumRunRepository _colosseumRuns;
private readonly IViewerRepository _viewers;
private readonly IDeckRepository _decks;
private readonly IGameConfigService _config;
public MatchContextBuilder(
IArenaTwoPickRunRepository runs,
+ IArenaColosseumRunRepository colosseumRuns,
IViewerRepository viewers,
IDeckRepository decks,
IGameConfigService config)
{
_runs = runs;
+ _colosseumRuns = colosseumRuns;
_viewers = viewers;
_decks = decks;
_config = config;
@@ -120,4 +123,58 @@ public class MatchContextBuilder : IMatchContextBuilder
IsOfficial: viewer.Info.IsOfficial ? 1 : 0,
BattleModeId: BattleModes.TakeTwo);
}
+
+ public async Task BuildForColosseumAsync(long viewerId)
+ {
+ var run = await _colosseumRuns.GetByViewerIdAsync(viewerId)
+ ?? throw new InvalidOperationException("arena_colosseum_no_active_run");
+
+ // v1 single-deck slot — Round-3 multi-deck format is deferred per plan.
+ var deckNos = JsonSerializer.Deserialize>(run.RegisteredDeckNoListJson) ?? new();
+ if (deckNos.Count == 0)
+ {
+ throw new InvalidOperationException("arena_colosseum_no_deck_registered");
+ }
+ var deckNo = deckNos[0];
+
+ var viewer = await _viewers.LoadForMatchContextAsync(viewerId)
+ ?? throw new InvalidOperationException($"viewer {viewerId} not found");
+
+ var deck = await _decks.GetDeck(viewerId, run.DeckFormat, deckNo)
+ ?? throw new InvalidOperationException(
+ $"viewer {viewerId} has no deck #{deckNo} for format {run.DeckFormat}");
+
+ var defaults = _config.Get();
+ var emblemId = viewer.Info.SelectedEmblem.Id != 0
+ ? viewer.Info.SelectedEmblem.Id.ToString()
+ : defaults.EmblemId.ToString();
+ var degreeId = viewer.Info.SelectedDegree.Id != 0
+ ? viewer.Info.SelectedDegree.Id.ToString()
+ : defaults.DegreeId.ToString();
+ var charaId = run.LeaderSkinId != 0
+ ? run.LeaderSkinId.ToString()
+ : deck.LeaderSkin.Id != 0
+ ? deck.LeaderSkin.Id.ToString()
+ : deck.Class.Id.ToString();
+ var sleeveId = deck.Sleeve.Id != 0
+ ? deck.Sleeve.Id.ToString()
+ : defaults.SleeveId.ToString();
+ var deckCardIds = deck.Cards
+ .SelectMany(c => Enumerable.Repeat(c.Card.Id, c.Count))
+ .ToList();
+
+ return new MatchContext(
+ SelfDeckCardIds: deckCardIds,
+ ClassId: (CardClass)deck.Class.Id,
+ CharaId: charaId,
+ CardMasterName: "card_master_node_10015",
+ CountryCode: viewer.Info.CountryCode ?? string.Empty,
+ UserName: viewer.DisplayName,
+ SleeveId: sleeveId,
+ EmblemId: emblemId,
+ DegreeId: degreeId,
+ FieldId: 43,
+ IsOfficial: viewer.Info.IsOfficial ? 1 : 0,
+ BattleModeId: BattleModes.TakeTwo);
+ }
}
diff --git a/SVSim.UnitTests/Controllers/ArenaColosseumBattleControllerTests.cs b/SVSim.UnitTests/Controllers/ArenaColosseumBattleControllerTests.cs
new file mode 100644
index 00000000..07cc21a7
--- /dev/null
+++ b/SVSim.UnitTests/Controllers/ArenaColosseumBattleControllerTests.cs
@@ -0,0 +1,156 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using SVSim.Database;
+using SVSim.Database.Enums;
+using SVSim.Database.Models;
+using SVSim.UnitTests.Infrastructure;
+
+namespace SVSim.UnitTests.Controllers;
+
+///
+/// Per-match battle URL coverage — verifies the dual colosseum_battle/* +
+/// colosseum_rank_battle/* dispatch matches run.IsRankMatching, that
+/// battle/finish bumps the per-round counters, and that the 3008 promotion
+/// trigger flips the run's rank flag.
+///
+public class ArenaColosseumBattleControllerTests
+{
+ private static readonly object Envelope =
+ new { viewer_id = "0", steam_id = 0, steam_session_ticket = "" };
+
+ private static async Task SeedRunAsync(SVSimTestFactory factory, long viewerId, bool isRankMatching = false)
+ {
+ using var scope = factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.ViewerArenaColosseumRuns.Add(new ViewerArenaColosseumRun
+ {
+ ViewerId = viewerId,
+ EntryId = 1001,
+ SeasonId = 42,
+ RoundId = 1,
+ DeckFormat = Format.Rotation,
+ MaxBattleCountThisRound = 5,
+ BreakthroughNumberThisRound = 4,
+ IsRankMatching = isRankMatching,
+ RegisteredDeckNoListJson = "[3]",
+ });
+ await db.SaveChangesAsync();
+ }
+
+ [Test]
+ public async Task DoMatching_pre_rank_rejects_when_run_is_rank_matching()
+ {
+ using var factory = new SVSimTestFactory();
+ var vid = await factory.SeedViewerAsync();
+ await SeedRunAsync(factory, vid, isRankMatching: true);
+ await factory.SeedDeckAsync(vid, Format.Rotation, number: 3);
+ using var client = factory.CreateAuthenticatedClient(vid);
+
+ var resp = await client.PostAsync("/colosseum_battle/do_matching", JsonContent.Create(Envelope));
+ Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
+ var body = await resp.Content.ReadAsStringAsync();
+ StringAssert.Contains("colosseum_url_phase_mismatch", body);
+ }
+
+ [Test]
+ public async Task DoMatching_post_rank_rejects_when_run_is_pre_rank()
+ {
+ using var factory = new SVSimTestFactory();
+ var vid = await factory.SeedViewerAsync();
+ await SeedRunAsync(factory, vid, isRankMatching: false);
+ await factory.SeedDeckAsync(vid, Format.Rotation, number: 3);
+ using var client = factory.CreateAuthenticatedClient(vid);
+
+ var resp = await client.PostAsync("/colosseum_rank_battle/do_matching", JsonContent.Create(Envelope));
+ Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
+ var body = await resp.Content.ReadAsStringAsync();
+ StringAssert.Contains("colosseum_url_phase_mismatch", body);
+ }
+
+ [Test]
+ public async Task DoMatching_returns_3001_when_no_deck_registered()
+ {
+ using var factory = new SVSimTestFactory();
+ var vid = await factory.SeedViewerAsync();
+
+ using (var scope = factory.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.ViewerArenaColosseumRuns.Add(new ViewerArenaColosseumRun
+ {
+ ViewerId = vid,
+ EntryId = 1001,
+ SeasonId = 42,
+ RoundId = 1,
+ DeckFormat = Format.Rotation,
+ RegisteredDeckNoListJson = "[]",
+ });
+ await db.SaveChangesAsync();
+ }
+
+ using var client = factory.CreateAuthenticatedClient(vid);
+ var resp = await client.PostAsync("/colosseum_battle/do_matching", JsonContent.Create(Envelope));
+ Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+
+ var body = await resp.Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(body);
+ Assert.That(doc.RootElement.GetProperty("matching_state").GetInt32(), Is.EqualTo(3001));
+ }
+
+ [Test]
+ public async Task BattleFinish_win_advances_counters_and_appends_result_list()
+ {
+ using var factory = new SVSimTestFactory();
+ var vid = await factory.SeedViewerAsync();
+ await SeedRunAsync(factory, vid);
+ using var client = factory.CreateAuthenticatedClient(vid);
+
+ var req = new
+ {
+ battle_result = 1, is_retire = 0, class_id = 1,
+ total_turn = 5, evolve_count = 1, enemy_evolve_count = 0,
+ recovery_data = "",
+ viewer_id = "0", steam_id = 0, steam_session_ticket = "",
+ };
+ var resp = await client.PostAsync("/colosseum_battle/finish", JsonContent.Create(req));
+ Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+
+ using var scope = factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var run = await db.ViewerArenaColosseumRuns.FirstAsync(r => r.ViewerId == vid);
+ Assert.That(run.WinCount, Is.EqualTo(1));
+ Assert.That(run.LossCount, Is.EqualTo(0));
+ Assert.That(run.BattleCountThisRound, Is.EqualTo(1));
+ Assert.That(run.ResultListJson, Is.EqualTo("[1]"));
+ }
+
+ [Test]
+ public async Task BattleFinish_retire_does_not_advance_counters()
+ {
+ using var factory = new SVSimTestFactory();
+ var vid = await factory.SeedViewerAsync();
+ await SeedRunAsync(factory, vid);
+ using var client = factory.CreateAuthenticatedClient(vid);
+
+ var req = new
+ {
+ battle_result = 2, is_retire = 1, class_id = 1,
+ total_turn = 3, evolve_count = 0, enemy_evolve_count = 0,
+ recovery_data = "",
+ viewer_id = "0", steam_id = 0, steam_session_ticket = "",
+ };
+ var resp = await client.PostAsync("/colosseum_battle/finish", JsonContent.Create(req));
+ Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+
+ using var scope = factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var run = await db.ViewerArenaColosseumRuns.FirstAsync(r => r.ViewerId == vid);
+ Assert.That(run.WinCount, Is.EqualTo(0));
+ Assert.That(run.LossCount, Is.EqualTo(0));
+ Assert.That(run.BattleCountThisRound, Is.EqualTo(0),
+ "is_retire=1 must not bump the per-round battle counter");
+ }
+}
diff --git a/SVSim.UnitTests/Controllers/ArenaColosseumControllerBracketTerminateTests.cs b/SVSim.UnitTests/Controllers/ArenaColosseumControllerBracketTerminateTests.cs
new file mode 100644
index 00000000..017ca313
--- /dev/null
+++ b/SVSim.UnitTests/Controllers/ArenaColosseumControllerBracketTerminateTests.cs
@@ -0,0 +1,244 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using SVSim.Database;
+using SVSim.Database.Enums;
+using SVSim.Database.Models;
+using SVSim.UnitTests.Infrastructure;
+
+namespace SVSim.UnitTests.Controllers;
+
+///
+/// Bracket-end /finish + /retire coverage. Locks the wire shape (rewards + reward_list +
+/// colosseum_status) and verifies side-effects: rewards granted through the inventory
+/// service, run row deleted, champion flag flipped on final-round clear.
+///
+public class ArenaColosseumControllerBracketTerminateTests
+{
+ private static readonly object Envelope =
+ new { viewer_id = "0", steam_id = 0, steam_session_ticket = "" };
+
+ /// Three-round config with reward bundles on every round + a champion bundle.
+ private static async Task ActivateSeasonWithRewardsAsync(SVSimTestFactory factory)
+ {
+ using var scope = factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var seasonJson = JsonSerializer.Serialize(new
+ {
+ IsColosseumPeriod = true,
+ SeasonId = 42,
+ ColosseumName = "Test Cup",
+ DeckFormat = (int)Format.Rotation,
+ FinalRoundEliminateCount = 1000,
+ });
+ await UpsertConfigAsync(db, "ColosseumSeason", seasonJson);
+
+ var roundsJson = JsonSerializer.Serialize(new
+ {
+ Rounds = new[]
+ {
+ new
+ {
+ RoundId = 1,
+ Groups = new[] { new { Group = "", MaxBattleCount = 5, BreakthroughNumber = 3, EntryNumber = 100_000 } },
+ FinishRewards = new[]
+ {
+ new { Type = (int)UserGoodsType.Crystal, DetailId = 0L, Count = 100, Name = "R1 finish" },
+ },
+ RetireRewards = new object[]
+ {
+ new { Type = (int)UserGoodsType.Rupy, DetailId = 0L, Count = 50, Name = "R1 retire" },
+ },
+ },
+ new
+ {
+ RoundId = 2,
+ Groups = new[] { new { Group = "Group A", MaxBattleCount = 5, BreakthroughNumber = 4, EntryNumber = 10_000 } },
+ FinishRewards = new[]
+ {
+ new { Type = (int)UserGoodsType.Crystal, DetailId = 0L, Count = 250, Name = "R2 finish" },
+ },
+ RetireRewards = Array.Empty