using Microsoft.AspNetCore.Mvc; using SVSim.Database.Enums; using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.Database.Repositories.Deck; using SVSim.Database.Repositories.Globals; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Common; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Practice; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Practice; using SVSim.EmulatedEntrypoint.Services; namespace SVSim.EmulatedEntrypoint.Controllers; public class PracticeController : SVSimController { private readonly IDeckRepository _deckRepository; private readonly IGlobalsRepository _globalsRepository; private readonly IMissionProgressService _missionProgress; public PracticeController( IDeckRepository deckRepository, IGlobalsRepository globalsRepository, IMissionProgressService missionProgress) { _deckRepository = deckRepository; _globalsRepository = globalsRepository; _missionProgress = missionProgress; } /// /// /practice/info — returns the AI opponent catalog. Response data is a JSON array /// directly (not wrapped in an object), per spec. Backed by PracticeOpponents table, /// seeded by SVSim.Bootstrap from seeds/practice-opponents.json. /// [HttpPost("info")] public async Task> Info(BaseRequest request) { var rows = await _globalsRepository.GetPracticeOpponents(); return rows.Select(e => new PracticeOpponent { PracticeId = e.PracticeId, TextId = e.TextId, ClassId = e.ClassId, CharaId = e.CharaId, DegreeId = e.DegreeId, AiDeckLevel = e.AiDeckLevel, AiLogicLevel = e.AiLogicLevel, AiMaxLife = e.AiMaxLife, Battle3dFieldId = e.Battle3dFieldId, IsMaintenance = e.IsMaintenance, IsCampaignPractice = e.IsCampaignPractice, }).ToList(); } /// /// /practice/deck_list — returns viewer's decks scoped by format (always Format.All /// per spec, server can ignore the request field). Fetched via IDeckRepository so the /// DeckCard.Card navigation is Included; going through the heavier viewer-graph query /// drops that ThenInclude and ships 40 zeros instead of real card ids, which then /// NREs the client's SBattleLoad.InitPlayer (CardCreator returns null on id=0). /// [HttpPost("deck_list")] public async Task> DeckList(DeckFormatRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); var byFormat = await _deckRepository.GetDecksByFormats(viewerId, new[] { Format.Rotation, Format.Unlimited }); return new PracticeDeckListResponse { MaintenanceCardList = new List(), UserDeckRotation = byFormat[Format.Rotation].Select(d => new UserDeck(d)).ToList(), UserDeckUnlimited = byFormat[Format.Unlimited].Select(d => new UserDeck(d)).ToList(), }; } /// /// /practice/start — server is essentially a no-op for practice. Spec: empty body /// response is fine; client tolerates missing mission_parameter. /// [HttpPost("start")] public Task Start(BaseRequest request) { return Task.FromResult(new PracticeStartResponse()); } /// /// /practice/finish — accept the recovery_data blob without validation; return zero /// XP / no rewards. Class XP bookkeeping is deferred until a per-class XP store exists. /// [HttpPost("finish")] public async Task Finish(PracticeFinishRequest request) { // Mission/achievement progress hook. Catalog rows for practice_win achievements use // opponent NAMES (e.g. "practice_win:elite:arisa") — we only have numeric class_id / // difficulty here, so we emit numeric forms. Bridging numeric→name to match captured // catalog rows is a follow-up; the infrastructure is in place. if (request.IsWin == 1 && TryGetViewerId(out long viewerId)) { await _missionProgress.RecordEventAsync(viewerId, new[] { "practice_win", $"practice_win:{request.Difficulty}", $"practice_win:{request.Difficulty}:{request.EnemyClassId}", }); } return new PracticeFinishResponse { GetClassExperience = 0, ClassExperience = 0, ClassLevel = 1, AchievedInfo = new Dictionary(), RewardList = new List() }; } }