diff --git a/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickService.cs b/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickService.cs index 770e6e4..261fa24 100644 --- a/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickService.cs +++ b/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickService.cs @@ -139,6 +139,7 @@ public class ArenaTwoPickService : IArenaTwoPickService .Include(v => v.Degrees) .Include(v => v.LeaderSkins) .Include(v => v.MyPageBackgrounds) + .Include(v => v.Classes).ThenInclude(c => c.Class) .AsSplitQuery() .FirstAsync(v => v.Id == viewerId); } @@ -218,9 +219,111 @@ public class ArenaTwoPickService : IArenaTwoPickService }).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(); + public Task RetireAsync(long viewerId) => GrantRunRewardsAndDeleteAsync(viewerId, requireComplete: false); + + public Task FinishAsync(long viewerId) => GrantRunRewardsAndDeleteAsync(viewerId, requireComplete: true); + + private async Task GrantRunRewardsAndDeleteAsync(long viewerId, bool requireComplete) + { + var run = await _runs.GetByViewerIdAsync(viewerId) + ?? throw new ArenaTwoPickException("arena_two_pick_no_active_run"); + + var maxWins = await _rewards.GetMaxWinCountAsync(); + var aCfg = _config.Get(); + bool runOver = run.WinCount >= maxWins || run.LossCount >= aCfg.MaxLosses; + if (requireComplete && !runOver) + throw new ArenaTwoPickException("arena_two_pick_run_not_complete"); + + var rewardRows = await _rewards.GetRewardsByWinCountAsync(run.WinCount); + var viewer = await LoadViewerForGrantsAsync(viewerId); + + var deltas = new List(); + foreach (var r in rewardRows) + { + var goodsType = (SVSim.Database.Enums.UserGoodsType)r.RewardType; + await _grants.ApplyAsync(viewer, goodsType, r.RewardId, r.RewardNum); + // Rewards = deltas (per-grant amounts), not post-state totals. + deltas.Add(new RewardEntryDto { RewardType = r.RewardType, RewardId = r.RewardId, RewardNum = r.RewardNum }); + } + await _db.SaveChangesAsync(); + + var postStates = ComputePostStateRewardList(rewardRows, viewer); + + await _runs.DeleteAsync(viewerId); + return new FinishResponseDto { Rewards = deltas, RewardList = postStates }; + } + + private static List ComputePostStateRewardList( + IReadOnlyList rows, SVSim.Database.Models.Viewer viewer) + { + var entries = new List(); + foreach (var r in rows) + { + int postState = r.RewardType switch + { + (int)SVSim.Database.Enums.UserGoodsType.Rupy => (int)viewer.Currency!.Rupees, + (int)SVSim.Database.Enums.UserGoodsType.Crystal => (int)viewer.Currency!.Crystals, + (int)SVSim.Database.Enums.UserGoodsType.RedEther => (int)viewer.Currency!.RedEther, + (int)SVSim.Database.Enums.UserGoodsType.Item => viewer.Items.FirstOrDefault(i => i.Item.Id == (int)r.RewardId)?.Count ?? r.RewardNum, + _ => r.RewardNum, + }; + entries.Add(new RewardEntryDto { RewardType = r.RewardType, RewardId = r.RewardId, RewardNum = postState }); + } + return entries; + } + + public async Task RecordBattleResultAsync(long viewerId, bool isWin) + { + var run = await _runs.GetByViewerIdAsync(viewerId) + ?? throw new ArenaTwoPickException("arena_two_pick_no_active_run"); + + var aCfg = _config.Get(); + var results = JsonSerializer.Deserialize>(run.ResultListJson) ?? new(); + results.Add(isWin); + run.ResultListJson = JsonSerializer.Serialize(results); + if (isWin) run.WinCount += 1; else run.LossCount += 1; + + // Mark run complete if max losses reached (win-cap is handled by Finish/Retire). + if (run.LossCount >= aCfg.MaxLosses) + run.IsSelectCompleted = true; + + await _runs.UpsertAsync(run); + + var viewer = await LoadViewerForGrantsAsync(viewerId); + int before = (int)(viewer.Currency?.SpotPoints ?? 0); + + int newClassXp = GrantClassXp(viewer, run.ClassId, aCfg.ClassXpPerBattle); + int classLevel = ResolveClassLevel(viewer, run.ClassId); + + viewer.Currency!.SpotPoints += (ulong)aCfg.SpotPointsPerBattle; + int after = (int)viewer.Currency.SpotPoints; + await _db.SaveChangesAsync(); + + return new BattleFinishResultDto + { + BattleResult = isWin ? 1 : 0, + GetClassExperience = aCfg.ClassXpPerBattle, + ClassExperience = newClassXp, + ClassLevel = classLevel, + BeforeSpotPoint = before, + AddSpotPoint = aCfg.SpotPointsPerBattle, + AfterSpotPoint = after, + }; + } + + private static int GrantClassXp(SVSim.Database.Models.Viewer viewer, int classId, int xp) + { + var row = viewer.Classes.FirstOrDefault(c => c.Class.Id == classId); + if (row is null) return 0; + row.Exp += xp; + return row.Exp; + } + + private static int ResolveClassLevel(SVSim.Database.Models.Viewer viewer, int classId) + { + var row = viewer.Classes.FirstOrDefault(c => c.Class.Id == classId); + return row is null ? 1 : row.Level; + } // --- projection helpers (kept internal so test subclasses could exercise if needed) --- diff --git a/SVSim.UnitTests/Services/ArenaTwoPickServiceFinishTests.cs b/SVSim.UnitTests/Services/ArenaTwoPickServiceFinishTests.cs new file mode 100644 index 0000000..e767887 --- /dev/null +++ b/SVSim.UnitTests/Services/ArenaTwoPickServiceFinishTests.cs @@ -0,0 +1,167 @@ +using System.Text.Json; +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 ArenaTwoPickServiceFinishTests +{ + private const long TicketItemId = 80001; + + 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 sealed class FakePool : IArenaTwoPickCardPoolService + { + public List GeneratePickSetsForTurn(int classId, int turn, long startingPairId, IRandom rng) => new(); + } + + private static async Task<(SVSimDbContext db, IArenaTwoPickService svc, long viewerId)> SetupWithRunAsync( + int winCount, int lossCount, bool isSelectCompleted = true) + { + 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 = 7, DisplayName = "v", + Currency = new ViewerCurrency { Rupees = 50 }, + }; + viewer.Items.Add(new OwnedItemEntry { Item = ticketItem, Count = 5 }); + + // Seed a ViewerClassData for class 1 so battle/finish XP path works. + var classEntry = await db.Classes.FirstOrDefaultAsync(c => c.Id == 1); + if (classEntry is null) + { + classEntry = new ClassEntry { Id = 1, Name = "Class1" }; + db.Classes.Add(classEntry); + } + viewer.Classes.Add(new ViewerClassData { Class = classEntry, Level = 1, Exp = 0 }); + db.Viewers.Add(viewer); + await db.SaveChangesAsync(); + + await new ArenaTwoPickRewardImporter() + .ImportAsync(db, Path.Combine(AppContext.BaseDirectory, "Data", "seeds")); + + var runs = new ArenaTwoPickRunRepository(db); + var pickList = Enumerable.Range(0, isSelectCompleted ? 30 : 4).Select(i => (long)(100000 + i)).ToList(); + await runs.UpsertAsync(new ViewerArenaTwoPickRun + { + ViewerId = 7, EntryId = 4242, + CandidateClassIdsJson = "[1,7,8]", + ClassId = 1, LeaderSkinId = 1, MaxBattleCount = 7, + SelectTurn = isSelectCompleted ? 15 : 2, + IsSelectCompleted = isSelectCompleted, + SelectedCardIdsJson = JsonSerializer.Serialize(pickList), + PendingPickSetsJson = "[]", + WinCount = winCount, LossCount = lossCount, + ResultListJson = JsonSerializer.Serialize( + Enumerable.Repeat(true, winCount).Concat(Enumerable.Repeat(false, lossCount)).ToList()), + CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, + }); + + var svc = new ArenaTwoPickService( + runs, + new ArenaTwoPickRewardRepository(db), + new FakePool(), + scope.ServiceProvider.GetRequiredService(), + scope.ServiceProvider.GetRequiredService(), + scope.ServiceProvider.GetRequiredService(), + new FakeEntitlements(), + new SystemRandom(seed: 1), + db); + + return (db, svc, 7L); + } + + [Test] + public async Task RetireAsync_at_3_wins_grants_1_ticket_700_rupies_and_deletes_run() + { + var (db, svc, vid) = await SetupWithRunAsync(winCount: 3, lossCount: 1); + await using var _ = db; + + var dto = await svc.RetireAsync(vid); + + Assert.That(dto.Rewards.Count, Is.EqualTo(2)); + Assert.That(dto.Rewards.Single(r => r.RewardType == 4).RewardNum, Is.EqualTo(1)); + Assert.That(dto.Rewards.Single(r => r.RewardType == 9).RewardNum, Is.EqualTo(700)); + + Assert.That(dto.RewardList.Single(r => r.RewardType == 4).RewardNum, Is.EqualTo(6), "5 + 1"); + Assert.That(dto.RewardList.Single(r => r.RewardType == 9).RewardNum, Is.EqualTo(750), "50 + 700"); + + var rowAfter = await db.ViewerArenaTwoPickRuns.FirstOrDefaultAsync(r => r.ViewerId == vid); + Assert.That(rowAfter, Is.Null); + } + + [Test] + public async Task FinishAsync_rejects_when_run_not_complete() + { + var (db, svc, vid) = await SetupWithRunAsync(winCount: 3, lossCount: 1); + await using var _ = db; + var ex = Assert.ThrowsAsync(() => svc.FinishAsync(vid)); + Assert.That(ex!.ErrorCode, Is.EqualTo("arena_two_pick_run_not_complete")); + } + + [Test] + public async Task FinishAsync_at_2_losses_grants_loss_rewards_and_deletes_run() + { + var (db, svc, vid) = await SetupWithRunAsync(winCount: 0, lossCount: 2); + await using var _ = db; + + var dto = await svc.FinishAsync(vid); + Assert.That(dto.Rewards.Single(r => r.RewardType == 9).RewardNum, Is.EqualTo(100)); + Assert.That(await db.ViewerArenaTwoPickRuns.AnyAsync(), Is.False); + } + + [Test] + public async Task RecordBattleResultAsync_win_increments_win_and_grants_xp_and_spot_points() + { + var (db, svc, vid) = await SetupWithRunAsync(winCount: 1, lossCount: 0); + await using var _ = db; + + var result = await svc.RecordBattleResultAsync(vid, isWin: true); + + Assert.That(result.BattleResult, Is.EqualTo(1)); + Assert.That(result.GetClassExperience, Is.EqualTo(100)); + Assert.That(result.AddSpotPoint, Is.EqualTo(10)); + var run = await db.ViewerArenaTwoPickRuns.FirstAsync(r => r.ViewerId == vid); + Assert.That(run.WinCount, Is.EqualTo(2)); + Assert.That(JsonSerializer.Deserialize>(run.ResultListJson)!.Count, Is.EqualTo(2)); + } + + [Test] + public async Task RecordBattleResultAsync_2nd_loss_terminates_run() + { + var (db, svc, vid) = await SetupWithRunAsync(winCount: 0, lossCount: 1); + await using var _ = db; + + await svc.RecordBattleResultAsync(vid, isWin: false); + var run = await db.ViewerArenaTwoPickRuns.FirstAsync(); + Assert.That(run.LossCount, Is.EqualTo(2)); + Assert.That(run.IsSelectCompleted, Is.True); + } +}