From d550f66481e3bb669f42cf98984ca2039edbb0cb Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 10:59:05 -0400 Subject: [PATCH] feat(svc): EntryAsync (ticket debit + run insert + candidate classes) Co-Authored-By: Claude Sonnet 4.6 --- .../Services/ArenaTwoPickService.cs | 87 ++++++++++- .../Services/ArenaTwoPickServiceEntryTests.cs | 140 ++++++++++++++++++ 2 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 SVSim.UnitTests/Services/ArenaTwoPickServiceEntryTests.cs diff --git a/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickService.cs b/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickService.cs index b766fc9..50d10b4 100644 --- a/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickService.cs +++ b/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickService.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Microsoft.EntityFrameworkCore; using SVSim.Database; using SVSim.Database.Models; using SVSim.Database.Repositories.Globals; @@ -56,7 +57,91 @@ public class ArenaTwoPickService : IArenaTwoPickService return dto; } - public Task EntryAsync(long viewerId, int consumeItemType) => throw new NotImplementedException(); + public async Task EntryAsync(long viewerId, int consumeItemType) + { + if (await _runs.GetByViewerIdAsync(viewerId) is not null) + throw new ArenaTwoPickException("arena_two_pick_already_in_progress"); + + const long ticketItemId = 80001; + + var viewer = await LoadViewerForGrantsAsync(viewerId); + var ticket = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId); + int postStateTickets; + if (_entitlements.IsFreeplay) + { + postStateTickets = ticket?.Count ?? 0; + } + else + { + if (ticket is null || ticket.Count < 1) + throw new ArenaTwoPickException("insufficient_ticket"); + ticket.Count -= 1; + postStateTickets = ticket.Count; + } + + var aCfg = _config.Get(); + var maxWins = Math.Max(1, await _rewards.GetMaxWinCountAsync()); + var candidates = SampleCandidateClasses(aCfg.AllowedClassIds, _rng); + + var run = new ViewerArenaTwoPickRun + { + ViewerId = viewerId, + EntryId = 0, + RewardScheduleId = aCfg.RewardScheduleId, + ChallengeId = aCfg.ChallengeId, + MaxBattleCount = maxWins, + ClassId = 0, + LeaderSkinId = 0, + CandidateClassIdsJson = JsonSerializer.Serialize(candidates), + SelectTurn = 0, + IsSelectCompleted = false, + SelectedCardIdsJson = "[]", + PendingPickSetsJson = "[]", + NextCandidateId = 1, + ResultListJson = "[]", + WinCount = 0, + LossCount = 0, + IsRetire = false, + }; + await _runs.UpsertAsync(run); + run.EntryId = run.Id; + await _runs.UpsertAsync(run); + await _db.SaveChangesAsync(); + + return new EntryResponseDto + { + EntryInfo = ProjectEntryInfo(run, viewerId), + RewardList = new List + { + new RewardEntryDto { RewardType = 4, RewardId = ticketItemId, RewardNum = postStateTickets }, + }, + CandidateClassIds = candidates, + BattleResults = new BattleResultsDto { WinCount = 0, ResultList = new List() }, + }; + } + + private static List SampleCandidateClasses(List allowed, IRandom rng) + { + if (allowed.Count < 3) + throw new InvalidOperationException("ArenaTwoPickConfig.AllowedClassIds needs ≥3 entries"); + var shuffled = allowed.OrderBy(_ => rng.Next(int.MaxValue)).ToList(); + return shuffled.Take(3).ToList(); + } + + private async Task LoadViewerForGrantsAsync(long viewerId) + { + return await _db.Viewers + .Include(v => v.Currency) + .Include(v => v.Items).ThenInclude(i => i.Item) + .Include(v => v.Cards) + .Include(v => v.Sleeves) + .Include(v => v.Emblems) + .Include(v => v.Degrees) + .Include(v => v.LeaderSkins) + .Include(v => v.MyPageBackgrounds) + .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 Task RetireAsync(long viewerId) => throw new NotImplementedException(); diff --git a/SVSim.UnitTests/Services/ArenaTwoPickServiceEntryTests.cs b/SVSim.UnitTests/Services/ArenaTwoPickServiceEntryTests.cs new file mode 100644 index 0000000..156e1ee --- /dev/null +++ b/SVSim.UnitTests/Services/ArenaTwoPickServiceEntryTests.cs @@ -0,0 +1,140 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Bootstrap.Importers; +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 ArenaTwoPickServiceEntryTests +{ + private const long TicketItemId = 80001; + + /// Minimal stub — EntryAsync never calls pool methods. + private sealed class NullCardPoolService : IArenaTwoPickCardPoolService + { + public List GeneratePickSetsForTurn(int classId, int turn, long startingPairId, IRandom rng) + => throw new NotSupportedException("pool not used in EntryAsync"); + } + + /// Minimal fake that exposes only . + 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<(SVSimDbContext db, IArenaTwoPickService svc, long viewerId)> SetupAsync( + int ticketCount, bool freeplay = false) + { + var factory = new SVSimTestFactory(); + var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + + var ticketItem = new ItemEntry { Id = (int)TicketItemId, Name = "TK2 Ticket" }; + db.Items.Add(ticketItem); + var viewer = new SVSim.Database.Models.Viewer + { + Id = 99, DisplayName = "X", + Currency = new ViewerCurrency(), + }; + viewer.Items.Add(new OwnedItemEntry { Item = ticketItem, Count = ticketCount }); + db.Viewers.Add(viewer); + await db.SaveChangesAsync(); + + var grants = scope.ServiceProvider.GetRequiredService(); + var config = scope.ServiceProvider.GetRequiredService(); + + // Seed reward catalog so GetMaxWinCountAsync returns 7. + await new ArenaTwoPickRewardImporter() + .ImportAsync(db, Path.Combine(AppContext.BaseDirectory, "Data", "seeds")); + + var svc = new ArenaTwoPickService( + new ArenaTwoPickRunRepository(db), + new ArenaTwoPickRewardRepository(db), + new NullCardPoolService(), + config, + scope.ServiceProvider.GetRequiredService(), + grants, + new FakeEntitlements { IsFreeplay = freeplay }, + new SystemRandom(seed: 1234), + db); + + return (db, svc, viewer.Id); + } + + [Test] + public async Task EntryAsync_with_ticket_debits_one_and_creates_run_in_class_select_state() + { + var (db, svc, viewerId) = await SetupAsync(ticketCount: 5); + await using var _ = db; + + var dto = await svc.EntryAsync(viewerId, consumeItemType: 3); + + Assert.That(dto.EntryInfo.Id, Is.GreaterThan(0)); + Assert.That(dto.EntryInfo.MaxBattleCount, Is.EqualTo(7)); + Assert.That(dto.CandidateClassIds.Count, Is.EqualTo(3)); + Assert.That(dto.RewardList.Count, Is.EqualTo(1)); + Assert.That(dto.RewardList[0].RewardType, Is.EqualTo(4)); + Assert.That(dto.RewardList[0].RewardId, Is.EqualTo(TicketItemId)); + Assert.That(dto.RewardList[0].RewardNum, Is.EqualTo(4), "post-state ticket count"); + + var run = await db.ViewerArenaTwoPickRuns.FirstAsync(r => r.ViewerId == viewerId); + Assert.That(run.ClassId, Is.EqualTo(0)); + Assert.That(run.MaxBattleCount, Is.EqualTo(7)); + + // Re-read viewer to verify ticket was debited. + var updated = await db.Viewers.Include(v => v.Items).ThenInclude(i => i.Item).FirstAsync(v => v.Id == viewerId); + var item = updated.Items.First(i => i.Item.Id == (int)TicketItemId); + Assert.That(item.Count, Is.EqualTo(4)); + } + + [Test] + public async Task EntryAsync_without_ticket_throws_insufficient_ticket() + { + var (db, svc, viewerId) = await SetupAsync(ticketCount: 0); + await using var _ = db; + + var ex = Assert.ThrowsAsync(() => svc.EntryAsync(viewerId, 3)); + Assert.That(ex!.ErrorCode, Is.EqualTo("insufficient_ticket")); + Assert.That(await db.ViewerArenaTwoPickRuns.AnyAsync(), Is.False); + } + + [Test] + public async Task EntryAsync_in_freeplay_skips_debit_and_emits_unchanged_count() + { + var (db, svc, viewerId) = await SetupAsync(ticketCount: 0, freeplay: true); + await using var _ = db; + + var dto = await svc.EntryAsync(viewerId, 3); + + Assert.That(dto.RewardList[0].RewardNum, Is.EqualTo(0), "unchanged in freeplay"); + var run = await db.ViewerArenaTwoPickRuns.FirstAsync(); + Assert.That(run, Is.Not.Null); + } + + [Test] + public async Task EntryAsync_while_run_active_throws_already_in_progress() + { + var (db, svc, viewerId) = await SetupAsync(ticketCount: 5); + await using var _ = db; + await svc.EntryAsync(viewerId, 3); + + var ex = Assert.ThrowsAsync(() => svc.EntryAsync(viewerId, 3)); + Assert.That(ex!.ErrorCode, Is.EqualTo("arena_two_pick_already_in_progress")); + } +}