diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index 8c9596b..8d5d46d 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/IMatchContextBuilder.cs b/SVSim.EmulatedEntrypoint/Services/IMatchContextBuilder.cs new file mode 100644 index 0000000..41f93a9 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Services/IMatchContextBuilder.cs @@ -0,0 +1,18 @@ +using SVSim.BattleNode.Bridge; + +namespace SVSim.EmulatedEntrypoint.Services; + +/// +/// Per-mode assembler for the battle-node MatchContext. Each multiplayer mode that +/// fronts a do_matching endpoint adds one method here that reads its mode-specific +/// state (TK2 run, current-deck pointer, open-room set_deck, ...) and produces a +/// MatchContext for the bridge. +/// +public interface IMatchContextBuilder +{ + /// + /// Build a context from the viewer's active TK2 run + viewer cosmetics + config. + /// Throws on missing run / incomplete draft. + /// + Task BuildForTwoPickAsync(long viewerId); +} diff --git a/SVSim.EmulatedEntrypoint/Services/MatchContextBuilder.cs b/SVSim.EmulatedEntrypoint/Services/MatchContextBuilder.cs new file mode 100644 index 0000000..ef62467 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Services/MatchContextBuilder.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using SVSim.BattleNode.Bridge; +using SVSim.Database.Models.Config; +using SVSim.Database.Repositories.Viewer; +using SVSim.Database.Services; + +namespace SVSim.EmulatedEntrypoint.Services; + +public class MatchContextBuilder : IMatchContextBuilder +{ + private readonly IArenaTwoPickRunRepository _runs; + private readonly IViewerRepository _viewers; + private readonly IGameConfigService _config; + + public MatchContextBuilder( + IArenaTwoPickRunRepository runs, + IViewerRepository viewers, + IGameConfigService config) + { + _runs = runs; + _viewers = viewers; + _config = config; + } + + public async Task BuildForTwoPickAsync(long viewerId) + { + var run = await _runs.GetByViewerIdAsync(viewerId) + ?? throw new ArenaTwoPickException("arena_two_pick_no_active_run"); + + var deck = JsonSerializer.Deserialize>(run.SelectedCardIdsJson) ?? new(); + if (deck.Count < 30) + throw new ArenaTwoPickException("arena_two_pick_draft_incomplete"); + + var viewer = await _viewers.LoadForMatchContextAsync(viewerId) + ?? throw new ArenaTwoPickException("arena_two_pick_no_active_run"); + + var challenge = _config.Get(); + var defaults = _config.Get(); + + var emblemId = viewer.Info.SelectedEmblem.Id != 0 + ? viewer.Info.SelectedEmblem.Id.ToString() + : defaults.EmblemId.ToString(); + var degreeId = viewer.Info.SelectedDegree.Id != 0 + ? viewer.Info.SelectedDegree.Id.ToString() + : defaults.DegreeId.ToString(); + var charaId = run.LeaderSkinId != 0 + ? run.LeaderSkinId.ToString() + : run.ClassId.ToString(); + + return new MatchContext( + SelfDeckCardIds: deck, + ClassId: run.ClassId.ToString(), + CharaId: charaId, + // Hardcoded v1; see spec §Deferred plumbing. + CardMasterName: "card_master_node_10015", + CountryCode: viewer.Info.CountryCode ?? string.Empty, + UserName: viewer.DisplayName, + // TK2-specific cosmetic source; other modes will use the deck row's SleeveId. + SleeveId: challenge.TwoPickSleeveId.ToString(), + EmblemId: emblemId, + DegreeId: degreeId, + // Hardcoded v1; needs equipped-MyPageBackground lookup (see spec §Deferred). + FieldId: 43, + IsOfficial: viewer.Info.IsOfficial ? 1 : 0, + BattleType: 11); + } +} diff --git a/SVSim.UnitTests/Services/MatchContextBuilderTests.cs b/SVSim.UnitTests/Services/MatchContextBuilderTests.cs new file mode 100644 index 0000000..c84e425 --- /dev/null +++ b/SVSim.UnitTests/Services/MatchContextBuilderTests.cs @@ -0,0 +1,156 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.BattleNode.Bridge; +using SVSim.Database; +using SVSim.Database.Models; +using SVSim.EmulatedEntrypoint.Services; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Services; + +[TestFixture] +public class MatchContextBuilderTests +{ + [Test] + public async Task BuildForTwoPick_returns_context_from_run_state() + { + await using var factory = new SVSimTestFactory(); + var vid = await factory.SeedViewerAsync(); + var deck = Enumerable.Range(1, 30).Select(i => 100_011_000L + i).ToList(); + int emblemId, degreeId; + + using (var seedScope = factory.Services.CreateScope()) + { + var db = seedScope.ServiceProvider.GetRequiredService(); + var emblem = await db.Emblems.FirstAsync(); + var degree = await db.Degrees.FirstAsync(); + emblemId = emblem.Id; + degreeId = degree.Id; + + var viewer = await db.Viewers.FindAsync(vid); + viewer!.DisplayName = "Drafter"; + viewer.Info.CountryCode = "KOR"; + viewer.Info.IsOfficial = false; + viewer.Info.SelectedEmblem = emblem; + viewer.Info.SelectedDegree = degree; + db.ViewerArenaTwoPickRuns.Add(new ViewerArenaTwoPickRun + { + ViewerId = vid, + EntryId = 1, + ClassId = 5, + LeaderSkinId = 5_000_001L, + SelectedCardIdsJson = JsonSerializer.Serialize(deck), + IsSelectCompleted = true, + MaxBattleCount = 5, + CandidateClassIdsJson = "[1,2,3]", + PendingPickSetsJson = "[]", + ResultListJson = "[]", + NextCandidateId = 1, + }); + await db.SaveChangesAsync(); + } + + using var scope = factory.Services.CreateScope(); + var builder = scope.ServiceProvider.GetRequiredService(); + var ctx = await builder.BuildForTwoPickAsync(vid); + + Assert.That(ctx.SelfDeckCardIds, Is.EqualTo(deck)); + Assert.That(ctx.ClassId, Is.EqualTo("5")); + Assert.That(ctx.CharaId, Is.EqualTo("5000001")); // LeaderSkinId set + Assert.That(ctx.CountryCode, Is.EqualTo("KOR")); + Assert.That(ctx.UserName, Is.EqualTo("Drafter")); + Assert.That(ctx.EmblemId, Is.EqualTo(emblemId.ToString())); + Assert.That(ctx.DegreeId, Is.EqualTo(degreeId.ToString())); + Assert.That(ctx.IsOfficial, Is.EqualTo(0)); + Assert.That(ctx.BattleType, Is.EqualTo(11)); + // Hardcoded v1 fixtures (see spec §Deferred plumbing) + Assert.That(ctx.CardMasterName, Is.EqualTo("card_master_node_10015")); + Assert.That(ctx.FieldId, Is.EqualTo(43)); + // Sleeve comes from ChallengeConfig.TwoPickSleeveId — ShippedDefaults is 0. + Assert.That(ctx.SleeveId, Is.EqualTo("0")); + } + + [Test] + public async Task BuildForTwoPick_throws_when_no_run() + { + await using var factory = new SVSimTestFactory(); + var vid = await factory.SeedViewerAsync(); + + using var scope = factory.Services.CreateScope(); + var builder = scope.ServiceProvider.GetRequiredService(); + + var ex = Assert.ThrowsAsync(() => builder.BuildForTwoPickAsync(vid)); + Assert.That(ex!.ErrorCode, Is.EqualTo("arena_two_pick_no_active_run")); + } + + [Test] + public async Task BuildForTwoPick_throws_when_draft_incomplete() + { + await using var factory = new SVSimTestFactory(); + var vid = await factory.SeedViewerAsync(); + + using (var seedScope = factory.Services.CreateScope()) + { + var db = seedScope.ServiceProvider.GetRequiredService(); + db.ViewerArenaTwoPickRuns.Add(new ViewerArenaTwoPickRun + { + ViewerId = vid, + EntryId = 1, + ClassId = 1, + SelectedCardIdsJson = JsonSerializer.Serialize(new long[] { 100_011_001L, 100_011_002L }), + IsSelectCompleted = false, + CandidateClassIdsJson = "[1,2,3]", + PendingPickSetsJson = "[]", + ResultListJson = "[]", + MaxBattleCount = 5, + NextCandidateId = 1, + }); + await db.SaveChangesAsync(); + } + + using var scope = factory.Services.CreateScope(); + var builder = scope.ServiceProvider.GetRequiredService(); + + var ex = Assert.ThrowsAsync(() => builder.BuildForTwoPickAsync(vid)); + Assert.That(ex!.ErrorCode, Is.EqualTo("arena_two_pick_draft_incomplete")); + } + + [Test] + public async Task BuildForTwoPick_falls_back_to_default_loadout_when_unequipped() + { + await using var factory = new SVSimTestFactory(); + var vid = await factory.SeedViewerAsync(); + var deck = Enumerable.Range(1, 30).Select(i => 100_011_000L + i).ToList(); + + using (var seedScope = factory.Services.CreateScope()) + { + var db = seedScope.ServiceProvider.GetRequiredService(); + // No SelectedEmblem / SelectedDegree set → default (Id=0) nav rows. + db.ViewerArenaTwoPickRuns.Add(new ViewerArenaTwoPickRun + { + ViewerId = vid, + EntryId = 1, + ClassId = 1, + LeaderSkinId = 0, // No skin chosen → CharaId == ClassId + SelectedCardIdsJson = JsonSerializer.Serialize(deck), + IsSelectCompleted = true, + CandidateClassIdsJson = "[1,2,3]", + PendingPickSetsJson = "[]", + ResultListJson = "[]", + MaxBattleCount = 5, + NextCandidateId = 1, + }); + await db.SaveChangesAsync(); + } + + using var scope = factory.Services.CreateScope(); + var builder = scope.ServiceProvider.GetRequiredService(); + var ctx = await builder.BuildForTwoPickAsync(vid); + + // DefaultLoadoutConfig.ShippedDefaults: EmblemId=100000000, DegreeId=300003 + Assert.That(ctx.EmblemId, Is.EqualTo("100000000")); + Assert.That(ctx.DegreeId, Is.EqualTo("300003")); + Assert.That(ctx.CharaId, Is.EqualTo("1")); // falls back to ClassId + } +}