From 7eaf13893e5ff54cb1cd3057b9fc02318a837240 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 2 Jun 2026 01:13:19 -0400 Subject: [PATCH] feat(matching): MatchContextBuilder.BuildForRankBattleAsync for rank battles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling to BuildForTwoPickAsync. Routes through IDeckRepository.GetDeck to pull the viewer's deck #1 for the requested format (avoiding the viewer-graph nav-ref auto-load pitfall — DeckCard.Card silently ships card_id=0 via the default include path). Throws if the viewer has no deck for the format. Cosmetics fall back to DefaultLoadoutConfig defaults when unequipped, same shape as TK2. Used by RankBattleController in a later task to build self-context for /ai__rank_battle/start and to pair-up under /_rank_battle/do_matching. Co-Authored-By: Claude Opus 4.7 --- .../Services/IMatchContextBuilder.cs | 12 +++++ .../Services/MatchContextBuilder.cs | 47 +++++++++++++++++++ .../Services/MatchContextBuilderTests.cs | 36 ++++++++++++++ 3 files changed, 95 insertions(+) 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() {