Story emits story_chapter_finish:<main|limited|event>:<story_id>. Practice emits practice_win:<difficulty>:<enemy_class_id> on win only. Practice catalog rows use opponent NAMES (e.g. practice_win:elite:arisa) not numeric class_ids, so captured catalog rows won't match yet. The infrastructure is in place; bridging numeric→name is a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
118 lines
4.8 KiB
C#
118 lines
4.8 KiB
C#
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// /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.
|
|
/// </summary>
|
|
[HttpPost("info")]
|
|
public async Task<List<PracticeOpponent>> 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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// /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).
|
|
/// </summary>
|
|
[HttpPost("deck_list")]
|
|
public async Task<ActionResult<PracticeDeckListResponse>> 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<long>(),
|
|
UserDeckRotation = byFormat[Format.Rotation].Select(d => new UserDeck(d)).ToList(),
|
|
UserDeckUnlimited = byFormat[Format.Unlimited].Select(d => new UserDeck(d)).ToList(),
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// /practice/start — server is essentially a no-op for practice. Spec: empty body
|
|
/// response is fine; client tolerates missing mission_parameter.
|
|
/// </summary>
|
|
[HttpPost("start")]
|
|
public Task<PracticeStartResponse> Start(BaseRequest request)
|
|
{
|
|
return Task.FromResult(new PracticeStartResponse());
|
|
}
|
|
|
|
/// <summary>
|
|
/// /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.
|
|
/// </summary>
|
|
[HttpPost("finish")]
|
|
public async Task<PracticeFinishResponse> 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<string, object>(),
|
|
RewardList = new List<Models.Dtos.Common.Reward>()
|
|
};
|
|
}
|
|
}
|