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); } }