From cbee0f9a5054cb2b3ee9f9cd060aa06d7f054a92 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 13 Jun 2026 12:32:56 -0400 Subject: [PATCH] feat(arena-colosseum): playable bracket + rank-match promotion (phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Models/Config/ColosseumRoundsConfig.cs | 30 ++- .../ArenaColosseumBattleController.cs | 175 +++++++++++++ .../Controllers/ArenaColosseumController.cs | 104 +++++++- .../BattleDoMatchingRequestDto.cs | 33 +++ .../ArenaColosseum/BattleFinishRequestDto.cs | 49 ++++ .../ArenaColosseum/ColosseumReceivedReward.cs | 26 ++ .../ArenaColosseum/FinishResponse.cs | 27 ++ SVSim.EmulatedEntrypoint/Program.cs | 7 + .../ColosseumProgressionService.cs | 69 +++++ .../IColosseumProgressionService.cs | 39 +++ .../Services/IMatchContextBuilder.cs | 9 + .../Services/MatchContextBuilder.cs | 57 ++++ .../ArenaColosseumBattleControllerTests.cs | 156 +++++++++++ ...olosseumControllerBracketTerminateTests.cs | 244 ++++++++++++++++++ .../ArenaColosseumEndToEndTests.cs | 232 +++++++++++++++++ .../ColosseumProgressionServiceTests.cs | 181 +++++++++++++ 16 files changed, 1436 insertions(+), 2 deletions(-) create mode 100644 SVSim.EmulatedEntrypoint/Controllers/ArenaColosseumBattleController.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/ArenaColosseum/BattleDoMatchingRequestDto.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/ArenaColosseum/BattleFinishRequestDto.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/ArenaColosseum/ColosseumReceivedReward.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaColosseum/FinishResponse.cs create mode 100644 SVSim.EmulatedEntrypoint/Services/ArenaColosseum/ColosseumProgressionService.cs create mode 100644 SVSim.EmulatedEntrypoint/Services/ArenaColosseum/IColosseumProgressionService.cs create mode 100644 SVSim.UnitTests/Controllers/ArenaColosseumBattleControllerTests.cs create mode 100644 SVSim.UnitTests/Controllers/ArenaColosseumControllerBracketTerminateTests.cs create mode 100644 SVSim.UnitTests/Integration/ArenaColosseumEndToEndTests.cs create mode 100644 SVSim.UnitTests/Services/ArenaColosseum/ColosseumProgressionServiceTests.cs 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(), + }, + new + { + RoundId = 3, + Groups = new[] { new { Group = "Final", MaxBattleCount = 5, BreakthroughNumber = 4, EntryNumber = 1_000 } }, + FinishRewards = new[] + { + new { Type = (int)UserGoodsType.Crystal, DetailId = 0L, Count = 1000, Name = "Final clear" }, + }, + RetireRewards = Array.Empty(), + }, + }, + ChampionRewards = new[] + { + new { Type = (int)UserGoodsType.Crystal, DetailId = 0L, Count = 5000, Name = "Champion Pack" }, + }, + }); + await UpsertConfigAsync(db, "ColosseumRounds", roundsJson); + } + + private static async Task UpsertConfigAsync(SVSimDbContext db, string section, string json) + { + var existing = await db.GameConfigs.FirstOrDefaultAsync(s => s.SectionName == section); + if (existing is null) + db.GameConfigs.Add(new GameConfigSection { SectionName = section, ValueJson = json }); + else + existing.ValueJson = json; + await db.SaveChangesAsync(); + } + + private static async Task SeedRunAsync( + SVSimTestFactory factory, long viewerId, int roundId, int winCount, int battleCount) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.ViewerArenaColosseumRuns.Add(new ViewerArenaColosseumRun + { + ViewerId = viewerId, + EntryId = 1001, + SeasonId = 42, + RoundId = roundId, + DeckFormat = Format.Rotation, + WinCount = winCount, + BattleCountThisRound = battleCount, + MaxBattleCountThisRound = 5, + BreakthroughNumberThisRound = roundId == 1 ? 3 : 4, + }); + await db.SaveChangesAsync(); + } + + [Test] + public async Task Finish_emits_champion_flag_when_round_3_cleared() + { + using var factory = new SVSimTestFactory(); + await ActivateSeasonWithRewardsAsync(factory); + var vid = await factory.SeedViewerAsync(); + await SeedRunAsync(factory, vid, roundId: 3, winCount: 4, battleCount: 4); + + using var client = factory.CreateAuthenticatedClient(vid); + var resp = await client.PostAsync("/arena_colosseum/finish", JsonContent.Create(Envelope)); + Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + var body = await resp.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + + Assert.That(root.GetProperty("colosseum_status").GetProperty("is_champion").GetBoolean(), Is.True); + Assert.That(root.GetProperty("colosseum_status").GetProperty("colosseum_name").GetString(), + Is.EqualTo("Test Cup")); + + // Final clear + champion bundle = 2 reward entries. + Assert.That(root.GetProperty("rewards").GetArrayLength(), Is.EqualTo(2)); + Assert.That(root.GetProperty("reward_list").GetArrayLength(), Is.EqualTo(2)); + } + + [Test] + public async Task Finish_grants_rewards_and_deletes_run() + { + using var factory = new SVSimTestFactory(); + await ActivateSeasonWithRewardsAsync(factory); + var vid = await factory.SeedViewerAsync(); + // Round 1 breakthrough advances to round 2 — bracket isn't finished. Use round 3 + + // exhausted battle cap WITHOUT breakthrough to terminate cleanly with round-end rewards. + await SeedRunAsync(factory, vid, roundId: 3, winCount: 2, battleCount: 5); + + ulong crystalsBefore; + using (var beforeScope = factory.Services.CreateScope()) + { + var beforeDb = beforeScope.ServiceProvider.GetRequiredService(); + crystalsBefore = (await beforeDb.Viewers.FirstAsync(v => v.Id == vid)).Currency.Crystals; + } + + using var client = factory.CreateAuthenticatedClient(vid); + var resp = await client.PostAsync("/arena_colosseum/finish", JsonContent.Create(Envelope)); + Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + using var verifyScope = factory.Services.CreateScope(); + var db = verifyScope.ServiceProvider.GetRequiredService(); + + var run = await db.ViewerArenaColosseumRuns.FirstOrDefaultAsync(r => r.ViewerId == vid); + Assert.That(run, Is.Null, "run row must be deleted on /finish"); + + var viewer = await db.Viewers.FirstAsync(v => v.Id == vid); + Assert.That(viewer.Currency.Crystals - crystalsBefore, Is.EqualTo(1000UL), + "Round 3 finish bundle is 1000 Crystal (not champion — battle cap hit without breakthrough)"); + } + + [Test] + public async Task Finish_rejects_when_bracket_still_in_progress() + { + using var factory = new SVSimTestFactory(); + await ActivateSeasonWithRewardsAsync(factory); + var vid = await factory.SeedViewerAsync(); + // Mid-round: 1 win, 1 battle, breakthrough is 3 — not yet eligible. + await SeedRunAsync(factory, vid, roundId: 1, winCount: 1, battleCount: 1); + + using var client = factory.CreateAuthenticatedClient(vid); + var resp = await client.PostAsync("/arena_colosseum/finish", JsonContent.Create(Envelope)); + Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + + var body = await resp.Content.ReadAsStringAsync(); + StringAssert.Contains("bracket_not_finished", body); + } + + [Test] + public async Task Retire_grants_round_capped_rewards_and_deletes_run() + { + using var factory = new SVSimTestFactory(); + await ActivateSeasonWithRewardsAsync(factory); + var vid = await factory.SeedViewerAsync(); + await SeedRunAsync(factory, vid, roundId: 1, winCount: 1, battleCount: 2); + + ulong rupeesBefore; + using (var beforeScope = factory.Services.CreateScope()) + { + var beforeDb = beforeScope.ServiceProvider.GetRequiredService(); + rupeesBefore = (await beforeDb.Viewers.FirstAsync(v => v.Id == vid)).Currency.Rupees; + } + + using var client = factory.CreateAuthenticatedClient(vid); + var resp = await client.PostAsync("/arena_colosseum/retire", JsonContent.Create(Envelope)); + Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + var body = await resp.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + Assert.That(root.GetProperty("rewards").GetArrayLength(), Is.EqualTo(1)); + Assert.That(root.GetProperty("rewards")[0].GetProperty("name").GetString(), Is.EqualTo("R1 retire")); + + using var verifyScope = factory.Services.CreateScope(); + var db = verifyScope.ServiceProvider.GetRequiredService(); + var run = await db.ViewerArenaColosseumRuns.FirstOrDefaultAsync(r => r.ViewerId == vid); + Assert.That(run, Is.Null, "run row must be deleted on /retire"); + + var viewer = await db.Viewers.FirstAsync(v => v.Id == vid); + Assert.That(viewer.Currency.Rupees - rupeesBefore, Is.EqualTo(50UL), + "Round 1 retire bundle adds 50 Rupy on top of starting balance"); + } + + [Test] + public async Task Retire_during_final_round_still_works_and_emits_status() + { + using var factory = new SVSimTestFactory(); + await ActivateSeasonWithRewardsAsync(factory); + var vid = await factory.SeedViewerAsync(); + // Round 3 has empty RetireRewards per config — client ignores rewards at FinalB anyway. + await SeedRunAsync(factory, vid, roundId: 3, winCount: 2, battleCount: 3); + + using var client = factory.CreateAuthenticatedClient(vid); + var resp = await client.PostAsync("/arena_colosseum/retire", 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("rewards").GetArrayLength(), Is.EqualTo(0), + "FinalB retire emits empty rewards per retire.md"); + Assert.That(doc.RootElement.GetProperty("colosseum_status").GetProperty("now_round_id").GetInt32(), + Is.EqualTo(3)); + } +} diff --git a/SVSim.UnitTests/Integration/ArenaColosseumEndToEndTests.cs b/SVSim.UnitTests/Integration/ArenaColosseumEndToEndTests.cs new file mode 100644 index 00000000..d2bc50d6 --- /dev/null +++ b/SVSim.UnitTests/Integration/ArenaColosseumEndToEndTests.cs @@ -0,0 +1,232 @@ +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.Integration; + +/// +/// Phase 2 ship gate: a viewer walks the full bracket — entry → register_deck → +/// (battle_finish × N) → /finish (champion path), plus a separate retire-mid-round +/// variant. Exercises every Phase 2 endpoint plus Phase 1's /entry + /register_deck. +/// +public class ArenaColosseumEndToEndTests +{ + private static readonly object Envelope = + new { viewer_id = "0", steam_id = 0, steam_session_ticket = "" }; + + private static async Task ConfigureSeasonAsync(SVSimTestFactory factory) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Season active, free entry allowed (so we don't have to seed currency). + var seasonJson = JsonSerializer.Serialize(new + { + IsColosseumPeriod = true, + SeasonId = 100, + ColosseumName = "E2E Cup", + DeckFormat = (int)Format.Rotation, + CrystalCost = 0, + RupyCost = 0, + TicketCost = 0, + IsAllowedFreeEntry = true, + FinalRoundEliminateCount = 100, + }); + await UpsertConfigAsync(db, "ColosseumSeason", seasonJson); + + // 3-round bracket. Round 1: BR=2 (clear with 2 wins). Round 2: BR=2. Round 3: BR=2. + var roundsJson = JsonSerializer.Serialize(new + { + Rounds = new[] + { + BuildRound(1, breakthrough: 2, finishCount: 50), + BuildRound(2, breakthrough: 2, finishCount: 100), + BuildRound(3, breakthrough: 2, finishCount: 500), + }, + ChampionRewards = new[] + { + new { Type = (int)UserGoodsType.Crystal, DetailId = 0L, Count = 9999, Name = "E2E Champion" }, + }, + }); + await UpsertConfigAsync(db, "ColosseumRounds", roundsJson); + } + + private static object BuildRound(int roundId, int breakthrough, int finishCount) => new + { + RoundId = roundId, + Groups = new[] + { + new + { + Group = $"R{roundId}", + MaxBattleCount = 5, + BreakthroughNumber = breakthrough, + EntryNumber = 100, + }, + }, + FinishRewards = new[] + { + new + { + Type = (int)UserGoodsType.Crystal, + DetailId = 0L, + Count = finishCount, + Name = $"R{roundId} finish", + }, + }, + RetireRewards = new object[] + { + new + { + Type = (int)UserGoodsType.Rupy, + DetailId = 0L, + Count = 10 * roundId, + Name = $"R{roundId} retire", + }, + }, + }; + + private static async Task UpsertConfigAsync(SVSimDbContext db, string section, string json) + { + var existing = await db.GameConfigs.FirstOrDefaultAsync(s => s.SectionName == section); + if (existing is null) + db.GameConfigs.Add(new GameConfigSection { SectionName = section, ValueJson = json }); + else + existing.ValueJson = json; + await db.SaveChangesAsync(); + } + + private static HttpContent BattleFinishPayload(int battleResult, int isRetire) => + JsonContent.Create(new + { + battle_result = battleResult, + is_retire = isRetire, + class_id = 1, + total_turn = 5, + evolve_count = 1, + enemy_evolve_count = 0, + recovery_data = "", + viewer_id = "0", + steam_id = 0, + steam_session_ticket = "", + }); + + private static async Task PromoteRunToRoundAsync( + SVSimTestFactory factory, long viewerId, int newRoundId) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var run = await db.ViewerArenaColosseumRuns.FirstAsync(r => r.ViewerId == viewerId); + run.RoundId = newRoundId; + run.WinCount = 0; + run.LossCount = 0; + run.BattleCountThisRound = 0; + run.ResultListJson = "[]"; + // Pull the per-round thresholds for the new round (mirrors what /entry copies on + // initial create — Phase 2 doesn't have a real intra-bracket promotion endpoint yet). + run.MaxBattleCountThisRound = 5; + run.BreakthroughNumberThisRound = 2; + await db.SaveChangesAsync(); + } + + [Test] + public async Task Full_three_round_bracket_walk_ends_as_champion() + { + using var factory = new SVSimTestFactory(); + await ConfigureSeasonAsync(factory); + var vid = await factory.SeedViewerAsync(); + await factory.SeedDeckAsync(vid, Format.Rotation, number: 1); + using var client = factory.CreateAuthenticatedClient(vid); + + // /entry (free) + var entryResp = await client.PostAsync("/arena_colosseum/entry", + JsonContent.Create(new { consume_item_type = 5, now_round_id = 1, viewer_id = "0", steam_id = 0, steam_session_ticket = "" })); + Assert.That(entryResp.StatusCode, Is.EqualTo(HttpStatusCode.OK), "entry must succeed"); + + // /register_deck + var registerResp = await client.PostAsync("/arena_colosseum/register_deck", + JsonContent.Create(new { deck_no_list = "[1]", is_published = true, viewer_id = "0", steam_id = 0, steam_session_ticket = "" })); + Assert.That(registerResp.StatusCode, Is.EqualTo(HttpStatusCode.OK), "register_deck must succeed"); + + // Round 1: 2 wins → clears breakthrough. + for (int i = 0; i < 2; i++) + { + var r = await client.PostAsync("/colosseum_battle/finish", BattleFinishPayload(battleResult: 1, isRetire: 0)); + Assert.That(r.StatusCode, Is.EqualTo(HttpStatusCode.OK), $"R1 battle {i} must succeed"); + } + await PromoteRunToRoundAsync(factory, vid, newRoundId: 2); + + // Round 2: 2 wins. + for (int i = 0; i < 2; i++) + { + var r = await client.PostAsync("/colosseum_battle/finish", BattleFinishPayload(battleResult: 1, isRetire: 0)); + Assert.That(r.StatusCode, Is.EqualTo(HttpStatusCode.OK), $"R2 battle {i} must succeed"); + } + await PromoteRunToRoundAsync(factory, vid, newRoundId: 3); + + // Round 3: 2 wins → champion path on /finish. + for (int i = 0; i < 2; i++) + { + var r = await client.PostAsync("/colosseum_battle/finish", BattleFinishPayload(battleResult: 1, isRetire: 0)); + Assert.That(r.StatusCode, Is.EqualTo(HttpStatusCode.OK), $"R3 battle {i} must succeed"); + } + + // /arena_colosseum/finish (champion path) + var finishResp = await client.PostAsync("/arena_colosseum/finish", JsonContent.Create(Envelope)); + Assert.That(finishResp.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + var body = await finishResp.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + Assert.That(root.GetProperty("colosseum_status").GetProperty("is_champion").GetBoolean(), Is.True); + // Final R3 finish reward + champion bundle = 2 entries. + Assert.That(root.GetProperty("reward_list").GetArrayLength(), Is.EqualTo(2)); + + using var verifyScope = factory.Services.CreateScope(); + var db = verifyScope.ServiceProvider.GetRequiredService(); + var run = await db.ViewerArenaColosseumRuns.FirstOrDefaultAsync(r => r.ViewerId == vid); + Assert.That(run, Is.Null, "champion run must be deleted on /finish"); + } + + [Test] + public async Task Retire_during_round_2_emits_round_capped_consolation() + { + using var factory = new SVSimTestFactory(); + await ConfigureSeasonAsync(factory); + var vid = await factory.SeedViewerAsync(); + await factory.SeedDeckAsync(vid, Format.Rotation, number: 1); + using var client = factory.CreateAuthenticatedClient(vid); + + // entry + register_deck + clear R1 + promote + await client.PostAsync("/arena_colosseum/entry", + JsonContent.Create(new { consume_item_type = 5, now_round_id = 1, viewer_id = "0", steam_id = 0, steam_session_ticket = "" })); + await client.PostAsync("/arena_colosseum/register_deck", + JsonContent.Create(new { deck_no_list = "[1]", is_published = false, viewer_id = "0", steam_id = 0, steam_session_ticket = "" })); + for (int i = 0; i < 2; i++) + await client.PostAsync("/colosseum_battle/finish", BattleFinishPayload(battleResult: 1, isRetire: 0)); + await PromoteRunToRoundAsync(factory, vid, newRoundId: 2); + + // One mid-round battle, then retire. + await client.PostAsync("/colosseum_battle/finish", BattleFinishPayload(battleResult: 2, isRetire: 0)); + + var retireResp = await client.PostAsync("/arena_colosseum/retire", JsonContent.Create(Envelope)); + Assert.That(retireResp.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + var body = await retireResp.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + var rewards = doc.RootElement.GetProperty("rewards"); + Assert.That(rewards.GetArrayLength(), Is.EqualTo(1)); + Assert.That(rewards[0].GetProperty("name").GetString(), Is.EqualTo("R2 retire"), + "retire payload must reflect the round the viewer was IN, not their starting round"); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + Assert.That(await db.ViewerArenaColosseumRuns.AnyAsync(r => r.ViewerId == vid), Is.False); + } +} diff --git a/SVSim.UnitTests/Services/ArenaColosseum/ColosseumProgressionServiceTests.cs b/SVSim.UnitTests/Services/ArenaColosseum/ColosseumProgressionServiceTests.cs new file mode 100644 index 00000000..9158c651 --- /dev/null +++ b/SVSim.UnitTests/Services/ArenaColosseum/ColosseumProgressionServiceTests.cs @@ -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; + +/// +/// 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. +/// +[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")); + } +}