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()
{