diff --git a/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickController.cs b/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickController.cs index 109b1c87..1a836526 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickController.cs @@ -49,7 +49,11 @@ public class ArenaTwoPickController : SVSimController public async Task Finish([FromBody] FinishRequest _) { if (!TryGetViewerId(out var vid)) return Unauthorized(); - return await GuardAsync(() => _svc.FinishAsync(vid)); + return await GuardAsync(async () => + { + var outcome = await _svc.FinishAsync(vid); + return outcome.Response; + }); } private async Task GuardAsync(Func> action) diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/RunFinishOutcome.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/RunFinishOutcome.cs new file mode 100644 index 00000000..e5a06fe0 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/RunFinishOutcome.cs @@ -0,0 +1,9 @@ +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick; + +/// +/// What returns to +/// ArenaTwoPickController.Finish. The wire response is ; +/// is a controller-side signal for the +/// challenge_full_clear mission emit and is never serialized. +/// +public sealed record RunFinishOutcome(FinishResponseDto Response, bool WasFullClear); diff --git a/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickService.cs b/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickService.cs index 2bf22974..d782330b 100644 --- a/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickService.cs +++ b/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickService.cs @@ -281,11 +281,27 @@ public class ArenaTwoPickService : IArenaTwoPickService }).ToList(), }; } - public Task RetireAsync(long viewerId) => GrantRunRewardsAndDeleteAsync(viewerId, requireComplete: false); + public async Task RetireAsync(long viewerId) + { + var (response, _) = await GrantRunRewardsAndDeleteAsync(viewerId, requireComplete: false); + return response; + } - public Task FinishAsync(long viewerId) => GrantRunRewardsAndDeleteAsync(viewerId, requireComplete: true); + public async Task FinishAsync(long viewerId) + { + var (response, winCount) = await GrantRunRewardsAndDeleteAsync(viewerId, requireComplete: true); + // TK2 is 5 battles per run; full clear == 5 wins == 0 losses. Reference the reward + // catalog's max WinCount rather than a magic 5 so a future rules-config change tracks. + int maxBattles = await ResolveMaxBattleCountAsync(); + return new RunFinishOutcome(response, WasFullClear: winCount >= maxBattles); + } - private async Task GrantRunRewardsAndDeleteAsync(long viewerId, bool requireComplete) + /// + /// Grants rewards for the given run tier and deletes the run row. Returns the wire + /// response DTO plus the run's final WinCount — the latter feeds + /// 's full-clear detection but isn't exposed on the wire. + /// + private async Task<(FinishResponseDto Response, int WinCount)> GrantRunRewardsAndDeleteAsync(long viewerId, bool requireComplete) { var run = await _runs.GetByViewerIdAsync(viewerId) ?? throw new ArenaTwoPickException("arena_two_pick_no_active_run"); @@ -345,8 +361,9 @@ public class ArenaTwoPickService : IArenaTwoPickService .Select(g => new RewardEntryDto { RewardType = (int)g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum }) .ToList(); + int winCountAtFinish = run.WinCount; await _runs.DeleteAsync(viewerId); - return new FinishResponseDto { Rewards = deltas, RewardList = postStates }; + return (new FinishResponseDto { Rewards = deltas, RewardList = postStates }, winCountAtFinish); } private static SVSim.Database.Models.ArenaTwoPickReward WeightedPick( diff --git a/SVSim.EmulatedEntrypoint/Services/IArenaTwoPickService.cs b/SVSim.EmulatedEntrypoint/Services/IArenaTwoPickService.cs index defccda1..98e24c3e 100644 --- a/SVSim.EmulatedEntrypoint/Services/IArenaTwoPickService.cs +++ b/SVSim.EmulatedEntrypoint/Services/IArenaTwoPickService.cs @@ -9,7 +9,12 @@ public interface IArenaTwoPickService Task ChooseClassAsync(long viewerId, int classId); Task ChooseCardAsync(long viewerId, long selectedId); Task RetireAsync(long viewerId); - Task FinishAsync(long viewerId); + /// + /// Ends a completed TK2 run (5 battles played) and grants the reward tier for + /// run.WinCount. Returns both the wire-shape rewards and a controller-side + /// signal for mission emit (). + /// + Task FinishAsync(long viewerId); Task RecordBattleResultAsync(long viewerId, bool isWin); } diff --git a/SVSim.UnitTests/Services/ArenaTwoPickServiceFinishTests.cs b/SVSim.UnitTests/Services/ArenaTwoPickServiceFinishTests.cs index af972336..76729ca6 100644 --- a/SVSim.UnitTests/Services/ArenaTwoPickServiceFinishTests.cs +++ b/SVSim.UnitTests/Services/ArenaTwoPickServiceFinishTests.cs @@ -123,11 +123,24 @@ public class ArenaTwoPickServiceFinishTests var (db, svc, vid) = await SetupWithRunAsync(winCount: 0, lossCount: 5); await using var _ = db; - var dto = await svc.FinishAsync(vid); - Assert.That(dto.Rewards.Single(r => r.RewardType == 9).RewardCount, Is.EqualTo(100)); + var outcome = await svc.FinishAsync(vid); + Assert.That(outcome.Response.Rewards.Single(r => r.RewardType == 9).RewardCount, Is.EqualTo(100)); + Assert.That(outcome.WasFullClear, Is.False, "0W 5L is not a full clear"); Assert.That(await db.ViewerArenaTwoPickRuns.AnyAsync(), Is.False); } + [Test] + public async Task FinishAsync_signals_full_clear_when_run_ended_with_5_wins() + { + // Full clear = 5W 0L (max battles = 5). Controller consumes this to fire the + // challenge_full_clear mission event. + var (db, svc, vid) = await SetupWithRunAsync(winCount: 5, lossCount: 0); + await using var _ = db; + + var outcome = await svc.FinishAsync(vid); + Assert.That(outcome.WasFullClear, Is.True); + } + [Test] public async Task RecordBattleResultAsync_win_increments_win_and_grants_xp_and_spot_points() {