using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using SVSim.BattleNode.Bridge; using SVSim.Database.Enums; using SVSim.Database.Services.Friend; using SVSim.Database.Services.Replay; using SVSim.EmulatedEntrypoint.Constants; using SVSim.EmulatedEntrypoint.Extensions; using SVSim.EmulatedEntrypoint.Matching; using SVSim.EmulatedEntrypoint.Models.Dtos.FreeBattle; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication; using SVSim.EmulatedEntrypoint.Services; namespace SVSim.EmulatedEntrypoint.Controllers; /// /// Free battle family — casual / unranked human PvP. No AI variant exists in the client /// (see ApiType.cs), so the controller has no /start, no AI /finish branch, and no /// rank-progression fields on the response. Multi-prefix URLs (rotation_free_battle/, /// unlimited_free_battle/, free_battle/force_finish) use explicit absolute route /// attributes per action — does not extend SVSimController's [Route("[controller]")]. /// /// Mirrors RankBattleController for now; consolidation deferred — see the design doc. /// [ApiController] [Authorize(AuthenticationSchemes = SteamAuthenticationConstants.SchemeName)] public sealed class FreeBattleController : ControllerBase { private readonly IMatchingResolver _resolver; private readonly IMatchContextBuilder _ctxBuilder; private readonly IBattleContextStore _battleContextStore; private readonly IBattleHistoryWriter _historyWriter; private readonly IPlayedTogetherWriter _playedTogetherWriter; private readonly ILogger _log; public FreeBattleController( IMatchingResolver resolver, IMatchContextBuilder ctxBuilder, IBattleContextStore battleContextStore, IBattleHistoryWriter historyWriter, IPlayedTogetherWriter playedTogetherWriter, ILogger log) { _resolver = resolver; _ctxBuilder = ctxBuilder; _battleContextStore = battleContextStore; _historyWriter = historyWriter; _playedTogetherWriter = playedTogetherWriter; _log = log; } private bool TryGetViewerId(out long viewerId) { viewerId = 0; var claim = User.Claims.FirstOrDefault(c => c.Type == ShadowverseClaimTypes.ViewerIdClaim)?.Value; return claim is not null && long.TryParse(claim, out viewerId); } [HttpPost("/rotation_free_battle/do_matching")] public Task DoMatchingRotation([FromBody] DoMatchingRequestDto req, CancellationToken ct) => DoMatchingInternal("rotation_free_battle", Format.Rotation, req, ct); [HttpPost("/unlimited_free_battle/do_matching")] public Task DoMatchingUnlimited([FromBody] DoMatchingRequestDto req, CancellationToken ct) => DoMatchingInternal("unlimited_free_battle", Format.Unlimited, req, ct); /// /// Shared finish handler — FreeBattleFinishTask sends the same wire shape across /// all six format URLs (only rotation + unlimited are routed here in v1). /// [HttpPost("/rotation_free_battle/finish")] [HttpPost("/unlimited_free_battle/finish")] public async Task Finish([FromBody] FreeBattleFinishRequestDto req, CancellationToken ct) { if (!TryGetViewerId(out var vid)) return Unauthorized(); var ctx = _battleContextStore.TakeFor(vid); bool isWin = req.BattleResult == 1; await _historyWriter.RecordAsync(vid, ctx, isWin, ct); // Played-together only fires for human PvP. Free battle has no AI variant, so // any non-null ctx with an OpponentViewerId > 0 is a real PvP pair. if (ctx is { OpponentViewerId: > 0 }) { await _playedTogetherWriter.RecordAsync( vid, ctx.OpponentViewerId, new BattleParticipationContext( PlayedMode: 0, // Wire battle_type: 3 = free battle (per docs/api-spec/common/types.ts.md // — NetworkDefine.ServerBattleType.Free). BattleType: 3, DeckFormat: ctx.DeckFormat, TwoPickType: ctx.TwoPickType), ct); } return Ok(new FreeBattleFinishResponseDto { BattleResult = req.BattleResult, // All other fields default to 0 (ClassLevel defaults to 1). }); } /// /// Defensive no-op for the family-wide force_finish URL. Per /// Shadowverse_Code_2026-05-23/ApiType.cs the URL is registry-only — no client task /// constructs it. Returning empty {} matches the rank-battle treatment. /// [HttpPost("/free_battle/force_finish")] public IActionResult ForceFinish([FromBody] BaseRequest _) { if (!TryGetViewerId(out var _u)) return Unauthorized(); return Ok(new { }); } private async Task DoMatchingInternal( string mode, Format format, DoMatchingRequestDto req, CancellationToken ct) { if (!TryGetViewerId(out var vid)) return Unauthorized(); MatchContext ctx; try { // BuildForRankBattleAsync is name-misleading — it builds a generic MatchContext // from (viewer, format, deckNo). Rename slated for cleanup at consolidation time. ctx = await _ctxBuilder.BuildForRankBattleAsync(vid, format, req.DeckNo); } catch (InvalidOperationException ex) { // Most likely cause: viewer has no deck at that slot for this format. Surface // as 3001 RC_BATTLE_MATCHING_ILLEGAL — the client shows the standard // matchmaking-error dialog rather than retrying forever. _log.LogWarning(ex, "BuildForRankBattleAsync failed for viewer {Vid} format {Fmt} deckNo {DeckNo}; returning 3001.", vid, format, req.DeckNo); return Ok(new DoMatchingResponseDto { MatchingState = 3001, NodeServerUrl = "" }); } var r = await _resolver.ResolveAsync(mode, new BattlePlayer(vid, ctx), ct); return Ok(new DoMatchingResponseDto { MatchingState = r.MatchingState, BattleId = r.BattleId, NodeServerUrl = r.NodeServerUrl, // Placeholder per spec — per-battle card-master split is deferred. CardMasterId = 0, }); } }