diff --git a/SVSim.EmulatedEntrypoint/Services/IMatchContextBuilder.cs b/SVSim.EmulatedEntrypoint/Services/IMatchContextBuilder.cs index 41f93a9..a9da5d0 100644 --- a/SVSim.EmulatedEntrypoint/Services/IMatchContextBuilder.cs +++ b/SVSim.EmulatedEntrypoint/Services/IMatchContextBuilder.cs @@ -1,4 +1,5 @@ using SVSim.BattleNode.Bridge; +using SVSim.Database.Enums; namespace SVSim.EmulatedEntrypoint.Services; @@ -15,4 +16,15 @@ public interface IMatchContextBuilder /// Throws on missing run / incomplete draft. /// Task BuildForTwoPickAsync(long viewerId); + + /// + /// Build a context for a rank-battle viewer + format (rotation / unlimited). Pulls the + /// viewer's deck #1 for that format + viewer cosmetics. Throws if the viewer has no + /// deck registered for the format. + /// + /// + /// Deck-selection persistence (which deck number is "current" for this format) is a + /// separate concern; deck #1 is a placeholder until that lands. + /// + Task BuildForRankBattleAsync(long viewerId, Format format); } diff --git a/SVSim.EmulatedEntrypoint/Services/MatchContextBuilder.cs b/SVSim.EmulatedEntrypoint/Services/MatchContextBuilder.cs index ef62467..c7a1e1a 100644 --- a/SVSim.EmulatedEntrypoint/Services/MatchContextBuilder.cs +++ b/SVSim.EmulatedEntrypoint/Services/MatchContextBuilder.cs @@ -1,6 +1,8 @@ using System.Text.Json; using SVSim.BattleNode.Bridge; +using SVSim.Database.Enums; using SVSim.Database.Models.Config; +using SVSim.Database.Repositories.Deck; using SVSim.Database.Repositories.Viewer; using SVSim.Database.Services; @@ -10,15 +12,18 @@ public class MatchContextBuilder : IMatchContextBuilder { private readonly IArenaTwoPickRunRepository _runs; private readonly IViewerRepository _viewers; + private readonly IDeckRepository _decks; private readonly IGameConfigService _config; public MatchContextBuilder( IArenaTwoPickRunRepository runs, IViewerRepository viewers, + IDeckRepository decks, IGameConfigService config) { _runs = runs; _viewers = viewers; + _decks = decks; _config = config; } @@ -64,4 +69,46 @@ public class MatchContextBuilder : IMatchContextBuilder IsOfficial: viewer.Info.IsOfficial ? 1 : 0, BattleType: 11); } + + public async Task BuildForRankBattleAsync(long viewerId, Format format) + { + var viewer = await _viewers.LoadForMatchContextAsync(viewerId) + ?? throw new InvalidOperationException($"viewer {viewerId} not found"); + + // Per spec, deck-selection persistence (which deck number is "current" for this + // format) is a separate concern; #1 is a placeholder until that lands. IDeckRepository + // is the right path here — viewer-graph nav refs (DeckCard.Card) don't auto-load + // (see project_ef_nav_include_pitfall memory), which would silently ship card_id=0. + var deck = await _decks.GetDeck(viewerId, format, deckNo: 1) + ?? throw new InvalidOperationException($"viewer {viewerId} has no deck for format {format}"); + + 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 = deck.LeaderSkin.Id != 0 + ? deck.LeaderSkin.Id.ToString() + : deck.Class.Id.ToString(); + var sleeveId = deck.Sleeve.Id != 0 + ? deck.Sleeve.Id.ToString() + : defaults.SleeveId.ToString(); + var deckCardIds = deck.Cards.Select(c => c.Card.Id).ToList(); + + return new MatchContext( + SelfDeckCardIds: deckCardIds, + ClassId: deck.Class.Id.ToString(), + CharaId: charaId, + CardMasterName: "card_master_node_10015", + CountryCode: viewer.Info.CountryCode ?? string.Empty, + UserName: viewer.DisplayName, + SleeveId: sleeveId, + EmblemId: emblemId, + DegreeId: degreeId, + FieldId: 43, + IsOfficial: viewer.Info.IsOfficial ? 1 : 0, + BattleType: 11); + } } diff --git a/SVSim.UnitTests/Services/MatchContextBuilderTests.cs b/SVSim.UnitTests/Services/MatchContextBuilderTests.cs index c84e425..a2018c6 100644 --- a/SVSim.UnitTests/Services/MatchContextBuilderTests.cs +++ b/SVSim.UnitTests/Services/MatchContextBuilderTests.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using SVSim.BattleNode.Bridge; using SVSim.Database; +using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.EmulatedEntrypoint.Services; using SVSim.UnitTests.Infrastructure; @@ -116,6 +117,41 @@ public class MatchContextBuilderTests Assert.That(ex!.ErrorCode, Is.EqualTo("arena_two_pick_draft_incomplete")); } + [Test] + public async Task BuildForRankBattle_returns_MatchContext_with_format_specific_deck() + { + await using var factory = new SVSimTestFactory(); + var viewerId = await factory.SeedViewerAsync(displayName: "Ranker"); + await factory.SeedGlobalsAsync(); + await factory.SeedDeckAsync(viewerId, Format.Rotation, 1, "Rank Rotation Deck"); + + using var scope = factory.Services.CreateScope(); + var builder = scope.ServiceProvider.GetRequiredService(); + + var ctx = await builder.BuildForRankBattleAsync(viewerId, Format.Rotation); + + Assert.That(ctx.UserName, Is.EqualTo("Ranker")); + Assert.That(ctx.BattleType, Is.EqualTo(11), "BattleType=11 matches the prod rank-battle wire value (same as TK2)."); + Assert.That(ctx.ClassId, Is.Not.Null.And.Not.Empty, "ClassId from the selected deck's class."); + Assert.That(ctx.CardMasterName, Is.EqualTo("card_master_node_10015")); + Assert.That(ctx.FieldId, Is.EqualTo(43)); + } + + [Test] + public async Task BuildForRankBattle_throws_when_no_deck_for_format() + { + await using var factory = new SVSimTestFactory(); + var viewerId = await factory.SeedViewerAsync(); + await factory.SeedGlobalsAsync(); + // Intentionally no SeedDeckAsync for Rotation. + + using var scope = factory.Services.CreateScope(); + var builder = scope.ServiceProvider.GetRequiredService(); + + Assert.That(async () => await builder.BuildForRankBattleAsync(viewerId, Format.Rotation), + Throws.Exception); + } + [Test] public async Task BuildForTwoPick_falls_back_to_default_loadout_when_unequipped() {