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);
+ }
+}