diff --git a/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickService.cs b/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickService.cs index 50d10b4..770e6e4 100644 --- a/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickService.cs +++ b/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickService.cs @@ -142,8 +142,82 @@ public class ArenaTwoPickService : IArenaTwoPickService .AsSplitQuery() .FirstAsync(v => v.Id == viewerId); } - public Task ChooseClassAsync(long viewerId, int classId) => throw new NotImplementedException(); - public Task ChooseCardAsync(long viewerId, long selectedId) => throw new NotImplementedException(); + public async Task ChooseClassAsync(long viewerId, int classId) + { + var run = await _runs.GetByViewerIdAsync(viewerId) + ?? throw new ArenaTwoPickException("arena_two_pick_no_active_run"); + if (run.ClassId != 0) + throw new ArenaTwoPickException("arena_two_pick_invalid_state"); + var candidates = JsonSerializer.Deserialize>(run.CandidateClassIdsJson) ?? new(); + if (!candidates.Contains(classId)) + throw new ArenaTwoPickException("arena_two_pick_class_not_offered"); + + run.ClassId = classId; + run.LeaderSkinId = ResolveClassDefaultLeaderSkin(classId); + var pairs = _pool.GeneratePickSetsForTurn(classId, turn: 1, startingPairId: run.NextCandidateId, _rng); + run.NextCandidateId += pairs.Count; + run.SelectTurn = 1; + run.PendingPickSetsJson = JsonSerializer.Serialize(pairs); + await _runs.UpsertAsync(run); + + return new ClassChooseResponseDto + { + ClassInfo = ProjectClassInfo(run), + DeckInfo = ProjectDeckInfo(run), + CandidateCardList = pairs.Select(p => new CandidatePairDto + { + Id = p.Id, Turn = p.Turn, SetNum = p.SetNum, + CardId1 = p.CardId1, CardId2 = p.CardId2, + IsSelected = p.IsSelected ? 1 : 0, + }).ToList(), + }; + } + + // Placeholder: class default skin = class id. Matches the capture's "leader_skin_id":"1" when class_id=1. + private static long ResolveClassDefaultLeaderSkin(int classId) => classId; + + public async Task ChooseCardAsync(long viewerId, long selectedId) + { + var run = await _runs.GetByViewerIdAsync(viewerId) + ?? throw new ArenaTwoPickException("arena_two_pick_no_active_run"); + if (run.ClassId == 0 || run.IsSelectCompleted) + throw new ArenaTwoPickException("arena_two_pick_invalid_state"); + + var pending = JsonSerializer.Deserialize>(run.PendingPickSetsJson) ?? new(); + var pick = pending.FirstOrDefault(p => p.Id == selectedId) + ?? throw new ArenaTwoPickException("arena_two_pick_invalid_selection"); + + var selectedCards = JsonSerializer.Deserialize>(run.SelectedCardIdsJson) ?? new(); + selectedCards.Add(pick.CardId1); + selectedCards.Add(pick.CardId2); + run.SelectedCardIdsJson = JsonSerializer.Serialize(selectedCards); + + List? nextPairs = null; + if (run.SelectTurn < 15) + { + run.SelectTurn += 1; + nextPairs = _pool.GeneratePickSetsForTurn(run.ClassId, run.SelectTurn, run.NextCandidateId, _rng); + run.NextCandidateId += nextPairs.Count; + run.PendingPickSetsJson = JsonSerializer.Serialize(nextPairs); + } + else + { + run.IsSelectCompleted = true; + run.PendingPickSetsJson = "[]"; + } + await _runs.UpsertAsync(run); + + return new CardChooseResponseDto + { + DeckInfo = ProjectDeckInfo(run), + CandidateCardList = nextPairs?.Select(p => new CandidatePairDto + { + Id = p.Id, Turn = p.Turn, SetNum = p.SetNum, + CardId1 = p.CardId1, CardId2 = p.CardId2, + IsSelected = p.IsSelected ? 1 : 0, + }).ToList(), + }; + } public Task RetireAsync(long viewerId) => throw new NotImplementedException(); public Task FinishAsync(long viewerId) => throw new NotImplementedException(); public Task RecordBattleResultAsync(long viewerId, bool isWin) => throw new NotImplementedException(); diff --git a/SVSim.UnitTests/Services/ArenaTwoPickServiceDraftTests.cs b/SVSim.UnitTests/Services/ArenaTwoPickServiceDraftTests.cs new file mode 100644 index 0000000..52d2391 --- /dev/null +++ b/SVSim.UnitTests/Services/ArenaTwoPickServiceDraftTests.cs @@ -0,0 +1,161 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.Database.Repositories.Globals; +using SVSim.Database.Repositories.Viewer; +using SVSim.Database.Services; +using SVSim.EmulatedEntrypoint.Services; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Services; + +public class ArenaTwoPickServiceDraftTests +{ + private sealed class FakePool : IArenaTwoPickCardPoolService + { + public List GeneratePickSetsForTurn(int classId, int turn, long startingPairId, IRandom rng) => new() + { + new() { Id = startingPairId, Turn = turn, SetNum = 1, + CardId1 = 1000 + turn * 10 + 1, CardId2 = 1000 + turn * 10 + 2, IsSelected = false }, + new() { Id = startingPairId + 1, Turn = turn, SetNum = 2, + CardId1 = 2000 + turn * 10 + 1, CardId2 = 2000 + turn * 10 + 2, IsSelected = false }, + }; + } + + private sealed class FakeEntitlements : IViewerEntitlements + { + public bool IsFreeplay { get; init; } + + public long EffectiveBalance(SVSim.Database.Models.Viewer viewer, SpendCurrency currency) => 0; + public bool OwnsCard(SVSim.Database.Models.Viewer viewer, long cardId) => IsFreeplay; + public bool OwnsCosmetic(SVSim.Database.Models.Viewer viewer, CosmeticType type, int id) => IsFreeplay; + public Task> EffectiveOwnedCardsAsync(SVSim.Database.Models.Viewer viewer, CancellationToken ct = default) + => Task.FromResult>(new List()); + public Task EffectiveCosmeticsAsync(SVSim.Database.Models.Viewer viewer, CancellationToken ct = default) + => throw new NotSupportedException(); + } + + private static async Task<(IArenaTwoPickService, IArenaTwoPickRunRepository, long viewerId)> SetupWithActiveRunAsync(int classChosen = 0) + { + var factory = new SVSimTestFactory(); + var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + + var viewer = new SVSim.Database.Models.Viewer { Id = 7, DisplayName = "v", Currency = new ViewerCurrency() }; + db.Viewers.Add(viewer); + await db.SaveChangesAsync(); + + var runs = new ArenaTwoPickRunRepository(db); + await runs.UpsertAsync(new ViewerArenaTwoPickRun + { + ViewerId = 7, EntryId = 4242, + CandidateClassIdsJson = "[1,7,8]", + ClassId = classChosen, MaxBattleCount = 7, + SelectTurn = classChosen == 0 ? 0 : 1, + PendingPickSetsJson = classChosen == 0 + ? "[]" + : JsonSerializer.Serialize(new List + { + new() { Id = 1, Turn = 1, SetNum = 1, CardId1 = 11, CardId2 = 12 }, + new() { Id = 2, Turn = 1, SetNum = 2, CardId1 = 21, CardId2 = 22 }, + }), + NextCandidateId = classChosen == 0 ? 1 : 3, + CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, + }); + + var svc = new ArenaTwoPickService( + runs, + scope.ServiceProvider.GetRequiredService(), + new FakePool(), + scope.ServiceProvider.GetRequiredService(), + scope.ServiceProvider.GetRequiredService(), + scope.ServiceProvider.GetRequiredService(), + new FakeEntitlements(), + new SystemRandom(seed: 1), + db); + + return (svc, runs, 7); + } + + [Test] + public async Task ChooseClassAsync_persists_class_and_emits_first_pick_sets() + { + var (svc, runs, vid) = await SetupWithActiveRunAsync(); + var dto = await svc.ChooseClassAsync(vid, classId: 7); + + Assert.That(dto.ClassInfo.SelectedClassId, Is.EqualTo(7)); + Assert.That(dto.DeckInfo.SelectTurn, Is.EqualTo(1)); + Assert.That(dto.DeckInfo.IsSelectCompleted, Is.False); + Assert.That(dto.CandidateCardList.Count, Is.EqualTo(2)); + Assert.That(dto.CandidateCardList[0].Id, Is.EqualTo(1)); + Assert.That(dto.CandidateCardList[1].Id, Is.EqualTo(2)); + + var row = await runs.GetByViewerIdAsync(vid); + Assert.That(row!.ClassId, Is.EqualTo(7)); + Assert.That(row.NextCandidateId, Is.EqualTo(3)); + } + + [Test] + public async Task ChooseClassAsync_rejects_class_not_offered() + { + var (svc, _, vid) = await SetupWithActiveRunAsync(); + var ex = Assert.ThrowsAsync(() => svc.ChooseClassAsync(vid, classId: 4)); + Assert.That(ex!.ErrorCode, Is.EqualTo("arena_two_pick_class_not_offered")); + } + + [Test] + public async Task ChooseClassAsync_rejects_when_class_already_chosen() + { + var (svc, _, vid) = await SetupWithActiveRunAsync(classChosen: 1); + var ex = Assert.ThrowsAsync(() => svc.ChooseClassAsync(vid, classId: 1)); + Assert.That(ex!.ErrorCode, Is.EqualTo("arena_two_pick_invalid_state")); + } + + [Test] + public async Task ChooseCardAsync_appends_two_cards_and_advances_turn() + { + var (svc, runs, vid) = await SetupWithActiveRunAsync(classChosen: 1); + var dto = await svc.ChooseCardAsync(vid, selectedId: 2); + + Assert.That(dto.DeckInfo.SelectedCardIds, Is.EqualTo(new List { 21, 22 })); + Assert.That(dto.DeckInfo.SelectTurn, Is.EqualTo(2)); + Assert.That(dto.CandidateCardList!.Count, Is.EqualTo(2)); + + var row = await runs.GetByViewerIdAsync(vid); + Assert.That(row!.NextCandidateId, Is.EqualTo(5)); + } + + [Test] + public async Task ChooseCardAsync_at_turn_15_completes_the_draft_and_omits_pick_list() + { + var (svc, runs, vid) = await SetupWithActiveRunAsync(classChosen: 1); + // Fast-forward to turn 15 by writing the row directly. + var row = await runs.GetByViewerIdAsync(vid); + row!.SelectTurn = 15; + var pending = new List + { + new() { Id = 100, Turn = 15, SetNum = 1, CardId1 = 71, CardId2 = 72 }, + new() { Id = 101, Turn = 15, SetNum = 2, CardId1 = 81, CardId2 = 82 }, + }; + row.PendingPickSetsJson = JsonSerializer.Serialize(pending); + row.SelectedCardIdsJson = JsonSerializer.Serialize(Enumerable.Range(1, 28).Select(i => (long)i).ToList()); + await runs.UpsertAsync(row); + + var dto = await svc.ChooseCardAsync(vid, selectedId: 100); + Assert.That(dto.DeckInfo.IsSelectCompleted, Is.True); + Assert.That(dto.DeckInfo.SelectedCardIds.Count, Is.EqualTo(30)); + Assert.That(dto.CandidateCardList, Is.Null, "omitted on completion per spec"); + } + + [Test] + public async Task ChooseCardAsync_rejects_invalid_selected_id() + { + var (svc, _, vid) = await SetupWithActiveRunAsync(classChosen: 1); + var ex = Assert.ThrowsAsync(() => svc.ChooseCardAsync(vid, selectedId: 999)); + Assert.That(ex!.ErrorCode, Is.EqualTo("arena_two_pick_invalid_selection")); + } +}