diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/BattleFinishResultDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/BattleFinishResultDto.cs new file mode 100644 index 0000000..7712eac --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/BattleFinishResultDto.cs @@ -0,0 +1,17 @@ +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick; + +/// +/// What the battle controller needs from the service to build the standard battle-finish +/// envelope (class XP delta + post-state, spot points before/add/after, battle_result echo). +/// Achieved-info is built by the controller from IMissionAssembler. +/// +public class BattleFinishResultDto +{ + public int BattleResult { get; set; } + public int GetClassExperience { get; set; } + public int ClassExperience { get; set; } + public int ClassLevel { get; set; } + public int BeforeSpotPoint { get; set; } + public int AddSpotPoint { get; set; } + public int AfterSpotPoint { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/CardChooseResponseDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/CardChooseResponseDto.cs new file mode 100644 index 0000000..8c82c0d --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/CardChooseResponseDto.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; +using MessagePack; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common.ArenaTwoPick; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick; + +[MessagePackObject] +public class CardChooseResponseDto +{ + [JsonPropertyName("deck_info")] [Key("deck_info")] + public DeckInfoDto DeckInfo { get; set; } = new(); + + [JsonPropertyName("candidate_card_list")] [Key("candidate_card_list")] + public List? CandidateCardList { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/ClassChooseResponseDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/ClassChooseResponseDto.cs new file mode 100644 index 0000000..85a775c --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/ClassChooseResponseDto.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; +using MessagePack; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common.ArenaTwoPick; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick; + +[MessagePackObject] +public class ClassChooseResponseDto +{ + [JsonPropertyName("class_info")] [Key("class_info")] + public ClassInfoDto ClassInfo { get; set; } = new(); + + [JsonPropertyName("deck_info")] [Key("deck_info")] + public DeckInfoDto DeckInfo { get; set; } = new(); + + [JsonPropertyName("candidate_card_list")] [Key("candidate_card_list")] + public List CandidateCardList { get; set; } = new(); +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/EntryResponseDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/EntryResponseDto.cs new file mode 100644 index 0000000..286c233 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/EntryResponseDto.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; +using MessagePack; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common.ArenaTwoPick; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick; + +[MessagePackObject] +public class EntryResponseDto +{ + [JsonPropertyName("entry_info")] [Key("entry_info")] + public EntryInfoDto EntryInfo { get; set; } = new(); + + [JsonPropertyName("reward_list")] [Key("reward_list")] + public List RewardList { get; set; } = new(); + + [JsonPropertyName("candidate_class_ids")] [Key("candidate_class_ids")] + public List CandidateClassIds { get; set; } = new(); + + [JsonPropertyName("battle_results")] [Key("battle_results")] + public BattleResultsDto BattleResults { get; set; } = new(); +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/FinishResponseDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/FinishResponseDto.cs new file mode 100644 index 0000000..4ac0a13 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/FinishResponseDto.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; +using MessagePack; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common.ArenaTwoPick; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick; + +[MessagePackObject] +public class FinishResponseDto +{ + /// Per-grant deltas — drives "+N received" popup. + [JsonPropertyName("rewards")] [Key("rewards")] + public List Rewards { get; set; } = new(); + + /// Post-state totals — drives PlayerStaticData.UpdateHaveUserGoodsNumByJsonData. + [JsonPropertyName("reward_list")] [Key("reward_list")] + public List RewardList { get; set; } = new(); +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/TopResponseDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/TopResponseDto.cs new file mode 100644 index 0000000..1380067 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/TopResponseDto.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; +using MessagePack; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common.ArenaTwoPick; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick; + +[MessagePackObject] +public class TopResponseDto +{ + [JsonPropertyName("entry_info")] [JsonIgnore(Condition = JsonIgnoreCondition.Never)] [Key("entry_info")] + public EntryInfoDto? EntryInfo { get; set; } + + [JsonPropertyName("battle_results")] [Key("battle_results")] + public BattleResultsDto? BattleResults { get; set; } + + [JsonPropertyName("class_info")] [Key("class_info")] + public ClassInfoDto? ClassInfo { get; set; } + + [JsonPropertyName("deck_info")] [Key("deck_info")] + public DeckInfoDto? DeckInfo { get; set; } + + [JsonPropertyName("leader_skin_id")] [JsonConverter(typeof(StringifiedLongConverter))] [Key("leader_skin_id")] + public long? LeaderSkinId { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index 9a200b9..f93bc55 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -105,6 +105,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickService.cs b/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickService.cs new file mode 100644 index 0000000..b766fc9 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickService.cs @@ -0,0 +1,113 @@ +using System.Text.Json; +using SVSim.Database; +using SVSim.Database.Models; +using SVSim.Database.Repositories.Globals; +using SVSim.Database.Repositories.Viewer; +using SVSim.Database.Services; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common.ArenaTwoPick; +using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick; + +namespace SVSim.EmulatedEntrypoint.Services; + +public class ArenaTwoPickService : IArenaTwoPickService +{ + private readonly IArenaTwoPickRunRepository _runs; + private readonly IArenaTwoPickRewardRepository _rewards; + private readonly IArenaTwoPickCardPoolService _pool; + private readonly IGameConfigService _config; + private readonly IViewerRepository _viewers; + private readonly RewardGrantService _grants; + private readonly IViewerEntitlements _entitlements; + private readonly IRandom _rng; + private readonly SVSimDbContext _db; + + public ArenaTwoPickService( + IArenaTwoPickRunRepository runs, + IArenaTwoPickRewardRepository rewards, + IArenaTwoPickCardPoolService pool, + IGameConfigService config, + IViewerRepository viewers, + RewardGrantService grants, + IViewerEntitlements entitlements, + IRandom rng, + SVSimDbContext db) + { + _runs = runs; _rewards = rewards; _pool = pool; _config = config; + _viewers = viewers; _grants = grants; _entitlements = entitlements; _rng = rng; _db = db; + } + + public async Task GetTopAsync(long viewerId) + { + var run = await _runs.GetByViewerIdAsync(viewerId); + if (run is null) return new TopResponseDto { EntryInfo = null }; + + var dto = new TopResponseDto + { + EntryInfo = ProjectEntryInfo(run, viewerId), + BattleResults = ProjectBattleResults(run), + }; + if (run.ClassId != 0) + { + dto.ClassInfo = ProjectClassInfo(run); + dto.DeckInfo = ProjectDeckInfo(run); + if (run.WinCount > 0 || run.LossCount > 0) + dto.LeaderSkinId = run.LeaderSkinId; + } + return dto; + } + + public Task EntryAsync(long viewerId, int consumeItemType) => throw new NotImplementedException(); + public Task ChooseClassAsync(long viewerId, int classId) => throw new NotImplementedException(); + public Task ChooseCardAsync(long viewerId, long selectedId) => throw new NotImplementedException(); + 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(); + + // --- projection helpers (kept internal so test subclasses could exercise if needed) --- + + internal static EntryInfoDto ProjectEntryInfo(ViewerArenaTwoPickRun run, long viewerId) => new() + { + Id = run.EntryId, + ViewerId = viewerId, + RewardScheduleId = run.RewardScheduleId, + ChallengeId = run.ChallengeId, + MaxBattleCount = run.MaxBattleCount, + LeaderSkinId = run.LeaderSkinId, + IsRetire = run.IsRetire ? 1 : 0, + }; + + internal static BattleResultsDto ProjectBattleResults(ViewerArenaTwoPickRun run) + { + var bools = JsonSerializer.Deserialize>(run.ResultListJson) ?? new(); + return new() + { + ResultList = bools.Select(b => b ? 1 : 0).ToList(), + WinCount = run.WinCount, + }; + } + + internal static ClassInfoDto ProjectClassInfo(ViewerArenaTwoPickRun run) + { + var ids = JsonSerializer.Deserialize>(run.CandidateClassIdsJson) ?? new(); + return new() + { + ClassId1 = ids.ElementAtOrDefault(0), + ClassId2 = ids.ElementAtOrDefault(1), + ClassId3 = ids.ElementAtOrDefault(2), + SelectedClassId = run.ClassId, + }; + } + + internal static DeckInfoDto ProjectDeckInfo(ViewerArenaTwoPickRun run) + { + var cards = JsonSerializer.Deserialize>(run.SelectedCardIdsJson) ?? new(); + return new() + { + TwoPickEntryId = run.EntryId, + ClassId = run.ClassId, + IsSelectCompleted = run.IsSelectCompleted, + SelectedCardIds = cards, + SelectTurn = run.SelectTurn == 0 ? 1 : run.SelectTurn, + }; + } +} diff --git a/SVSim.EmulatedEntrypoint/Services/IArenaTwoPickService.cs b/SVSim.EmulatedEntrypoint/Services/IArenaTwoPickService.cs new file mode 100644 index 0000000..defccda --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Services/IArenaTwoPickService.cs @@ -0,0 +1,20 @@ +using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick; + +namespace SVSim.EmulatedEntrypoint.Services; + +public interface IArenaTwoPickService +{ + Task GetTopAsync(long viewerId); + Task EntryAsync(long viewerId, int consumeItemType); + Task ChooseClassAsync(long viewerId, int classId); + Task ChooseCardAsync(long viewerId, long selectedId); + Task RetireAsync(long viewerId); + Task FinishAsync(long viewerId); + Task RecordBattleResultAsync(long viewerId, bool isWin); +} + +public class ArenaTwoPickException : Exception +{ + public string ErrorCode { get; } + public ArenaTwoPickException(string errorCode) : base(errorCode) { ErrorCode = errorCode; } +} diff --git a/SVSim.UnitTests/Services/ArenaTwoPickServiceTopTests.cs b/SVSim.UnitTests/Services/ArenaTwoPickServiceTopTests.cs new file mode 100644 index 0000000..b990886 --- /dev/null +++ b/SVSim.UnitTests/Services/ArenaTwoPickServiceTopTests.cs @@ -0,0 +1,87 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using SVSim.Database; +using SVSim.Database.Models; +using SVSim.Database.Repositories.Viewer; +using SVSim.EmulatedEntrypoint.Services; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Services; + +public class ArenaTwoPickServiceTopTests +{ + private static async Task<(SVSimDbContext, IArenaTwoPickRunRepository)> SetupAsync() + { + var factory = new SVSimTestFactory(); + var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + return (db, new ArenaTwoPickRunRepository(db)); + } + + [Test] + public async Task GetTopAsync_returns_null_entry_info_when_no_run() + { + var (db, runRepo) = await SetupAsync(); + await using var _ = db; + var svc = BuildService(db, runRepo); + + var dto = await svc.GetTopAsync(viewerId: 99); + Assert.That(dto.EntryInfo, Is.Null); + Assert.That(dto.ClassInfo, Is.Null); + Assert.That(dto.DeckInfo, Is.Null); + } + + [Test] + public async Task GetTopAsync_after_entry_omits_class_info_and_deck_info() + { + var (db, runRepo) = await SetupAsync(); + await using var _ = db; + await runRepo.UpsertAsync(new ViewerArenaTwoPickRun + { + ViewerId = 99, EntryId = 1234, RewardScheduleId = 1, ChallengeId = 1, + MaxBattleCount = 7, ClassId = 0, + CandidateClassIdsJson = "[1,7,8]", + CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, + }); + var svc = BuildService(db, runRepo); + + var dto = await svc.GetTopAsync(viewerId: 99); + Assert.That(dto.EntryInfo, Is.Not.Null); + Assert.That(dto.EntryInfo!.Id, Is.EqualTo(1234)); + Assert.That(dto.ClassInfo, Is.Null); + Assert.That(dto.DeckInfo, Is.Null); + Assert.That(dto.BattleResults!.WinCount, Is.EqualTo(0)); + } + + [Test] + public async Task GetTopAsync_during_card_select_emits_class_and_deck_info() + { + var (db, runRepo) = await SetupAsync(); + await using var _ = db; + await runRepo.UpsertAsync(new ViewerArenaTwoPickRun + { + ViewerId = 99, EntryId = 1234, MaxBattleCount = 7, ClassId = 1, LeaderSkinId = 1, + CandidateClassIdsJson = "[1,7,8]", + SelectTurn = 5, IsSelectCompleted = false, + SelectedCardIdsJson = "[100111010,100121010,100131010,100141010,100114010,100124010,100134010,100144010]", + CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, + }); + var svc = BuildService(db, runRepo); + + var dto = await svc.GetTopAsync(viewerId: 99); + Assert.That(dto.ClassInfo, Is.Not.Null); + Assert.That(dto.ClassInfo!.ClassId1, Is.EqualTo(1)); + Assert.That(dto.DeckInfo, Is.Not.Null); + Assert.That(dto.DeckInfo!.SelectTurn, Is.EqualTo(5)); + Assert.That(dto.DeckInfo.SelectedCardIds.Count, Is.EqualTo(8)); + } + + private static IArenaTwoPickService BuildService(SVSimDbContext db, IArenaTwoPickRunRepository runRepo) + { + // GetTopAsync only uses _runs — every other dep can be null! because the test path + // never touches them. The 9th positional arg (db) is required from Task 13 onward. + return new ArenaTwoPickService(runRepo, null!, null!, null!, null!, null!, null!, null!, db); + } +}