From 2d675aa35db069a38a02a9009ad0a4b36d3aa475 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 29 May 2026 12:01:36 -0400 Subject: [PATCH] feat(practice): serve default/trial/leader-skin lists on practice/deck_list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit practice/deck_list returns the same wire shape as /deck/info (the client parses both via DeckGroupListData), but only ever sent user decks — so a fresh account saw no default decks and couldn't start a practice match. Extract the /deck/info hydration into a shared IDeckListBuilder used by /deck/info, /deck/my_list, and /practice/deck_list. Practice passes padEmptySlots:false (deck *select*, not builder) — matches the prod practice capture, which returns real decks unpadded plus the 8 per-class default decks and per-class leader-skin settings. Retire the near-duplicate PracticeDeckListResponse DTO in favor of the shared DeckListResponse. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Controllers/DeckController.cs | 121 +------------- .../Controllers/PracticeController.cs | 34 ++-- .../Practice/PracticeDeckListResponse.cs | 25 --- SVSim.EmulatedEntrypoint/Program.cs | 1 + .../Services/DeckListBuilder.cs | 153 ++++++++++++++++++ .../Controllers/PracticeControllerTests.cs | 27 ++++ 6 files changed, 203 insertions(+), 158 deletions(-) delete mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Practice/PracticeDeckListResponse.cs create mode 100644 SVSim.EmulatedEntrypoint/Services/DeckListBuilder.cs diff --git a/SVSim.EmulatedEntrypoint/Controllers/DeckController.cs b/SVSim.EmulatedEntrypoint/Controllers/DeckController.cs index e96663c..f7a0dd4 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/DeckController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/DeckController.cs @@ -14,48 +14,21 @@ using SVSim.EmulatedEntrypoint.Models.Dtos.Common; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Common; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Deck; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Deck; +using SVSim.EmulatedEntrypoint.Services; namespace SVSim.EmulatedEntrypoint.Controllers; public class DeckController : SVSimController { private readonly IDeckRepository _deckRepository; - private readonly IGlobalsRepository _globalsRepository; private readonly SVSimDbContext _dbContext; - private readonly DeckOptions _deckOptions; + private readonly IDeckListBuilder _deckListBuilder; - private static readonly System.Text.Json.JsonSerializerOptions JsonbReadOptions = new() - { - PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.SnakeCaseLower, - NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString, - }; - - public DeckController(IDeckRepository deckRepository, IGlobalsRepository globalsRepository, SVSimDbContext dbContext, IOptions deckOptions) + public DeckController(IDeckRepository deckRepository, SVSimDbContext dbContext, IDeckListBuilder deckListBuilder) { _deckRepository = deckRepository; - _globalsRepository = globalsRepository; _dbContext = dbContext; - _deckOptions = deckOptions.Value; - } - - /// - /// Pads a viewer's real deck list with empty-slot placeholders up to . - /// Required because the client's DeckUI.DeckViewData.CreateDeckViewList only renders - /// a "New Deck" tile when the response contains an entry whose card_id_array is empty — - /// without padding, the player cannot create additional decks once any exist. - /// - private List PadEmptySlots(List realDecks) - { - var taken = realDecks.Select(d => d.DeckNumber).ToHashSet(); - var result = new List(realDecks); - for (int slot = 1; slot <= _deckOptions.MaxDeckSlots; slot++) - { - if (!taken.Contains(slot)) - { - result.Add(UserDeck.CreateEmptySlot(slot)); - } - } - return result; + _deckListBuilder = deckListBuilder; } // Request deck_format fields arrive as wire ints (MessagePack-CSharp doesn't honor STJ @@ -68,93 +41,15 @@ public class DeckController : SVSimController public async Task> Info(DeckInfoRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); - return await BuildDeckListResponseAsync(viewerId, AsFormat(request.DeckFormat)); + // Deck builder screen: pad empty "New Deck" slots so the player can create more decks. + return await _deckListBuilder.BuildAsync(viewerId, AsFormat(request.DeckFormat), padEmptySlots: true); } [HttpPost("my_list")] public async Task> MyList(DeckFormatRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); - return await BuildDeckListResponseAsync(viewerId, AsFormat(request.DeckFormat)); - } - - /// - /// Shared hydration for /deck/info and /deck/my_list — both endpoints return the - /// same DTO and the client's DeckInfoTask.Parse / DeckMyListTask.Parse - /// are identical (both call DeckGroupListData(jsonData, format)). - /// - /// Wire shape swaps based on the request format. When the client asks for All-format - /// (deck_format=0), prod emits per-format keys (user_deck_rotation, etc.); - /// for a specific format request, prod emits a single user_deck_list. The client's - /// DeckListUtility.ParseDeckInfoResponceData branches on these two shapes, so the - /// controller mirrors it exactly. - /// - private async Task BuildDeckListResponseAsync(long viewerId, Format requestFormat) - { - var defaultDecks = await _globalsRepository.GetDefaultDecks(); - - // user_leader_skin_setting_list is PER-VIEWER (the wire `user_` prefix is honest, despite - // the misleading docstring on DefaultLeaderSkinSetting). Source it from the viewer's - // ViewerClassData rows, matching how /load/index's user_class_list reads them. The global - // DefaultLeaderSkinSettings table is now used only as initial seed values for fresh - // viewers (ViewerRepository.RegisterViewer); the per-class current skin is on - // viewer.Classes[i].LeaderSkin and gets mutated by /leader_skin/update. - var viewerClasses = await _dbContext.Viewers - .Where(v => v.Id == viewerId) - .SelectMany(v => v.Classes) - .Select(c => new { c.Class.Id, LeaderSkinId = c.LeaderSkin.Id }) - .ToListAsync(); - - var response = new DeckListResponse - { - DefaultDeckList = defaultDecks.ToDictionary( - d => d.Id.ToString(), - d => new DefaultDeck - { - DeckNo = d.DeckNo, - ClassId = d.ClassId, - SleeveId = d.SleeveId, - LeaderSkinId = d.LeaderSkinId, - DeckName = d.DeckName, - CardIdArray = System.Text.Json.JsonSerializer.Deserialize>(d.CardIdArray, JsonbReadOptions) ?? new(), - // TODO(deck-stub): wire from real per-deck state once user maintenance / availability tracking lands. - // Prod emits is_complete_deck=1, is_available_deck=1, maintenance_card_ids=[] for the 8 starter decks. - IsCompleteDeck = 1, - IsAvailableDeck = 1, - MaintenanceCardIds = new(), - }), - UserLeaderSkinSettingList = viewerClasses.ToDictionary( - vc => vc.Id.ToString(), - vc => new UserLeaderSkinSetting - { - ClassId = vc.Id, - IsRandomLeaderSkin = 0, // random-skin mode (per-class shuffle pool) not yet persisted - LeaderSkinId = vc.LeaderSkinId, - }), - MaintenanceCardList = new(), // sourced from same place as /load/index when wired - }; - - if (requestFormat == Format.All) - { - // Prod's All-format response emits these three per-format lists (each [] for fresh viewers). - // The PreRotation / Crossover / Avatar siblings exist in client code but prod omits them - // for our profile; we mirror that omission and leave the nullable DTO fields unset. - var formats = new[] { Format.Rotation, Format.Unlimited, Format.MyRotation }; - var byFormat = await _deckRepository.GetDecksByFormats(viewerId, formats); - response.UserDeckRotation = PadEmptySlots(byFormat[Format.Rotation].Select(d => new UserDeck(d)).ToList()); - response.UserDeckUnlimited = PadEmptySlots(byFormat[Format.Unlimited].Select(d => new UserDeck(d)).ToList()); - response.UserDeckMyRotation = PadEmptySlots(byFormat[Format.MyRotation].Select(d => new UserDeck(d)).ToList()); - // trial_deck_list is prod-emitted on /deck/info (All format) but omitted on /deck/my_list - // (specific format). Empty array in the 2026-05-23 prod capture. - response.TrialDeckList = new(); - } - else - { - var decks = await _deckRepository.GetDecks(viewerId, requestFormat); - response.UserDeckList = PadEmptySlots(decks.Select(d => new UserDeck(d)).ToList()); - } - - return response; + return await _deckListBuilder.BuildAsync(viewerId, AsFormat(request.DeckFormat), padEmptySlots: true); } [HttpPost("get_empty_deck_number")] @@ -201,7 +96,7 @@ public class DeckController : SVSimController var decks = await _deckRepository.GetDecks(viewerId, format); return new DeckUpdateResponse { - UserDeckList = PadEmptySlots(decks.Select(d => new UserDeck(d)).ToList()) + UserDeckList = _deckListBuilder.PadEmptySlots(decks.Select(d => new UserDeck(d)).ToList()) }; } diff --git a/SVSim.EmulatedEntrypoint/Controllers/PracticeController.cs b/SVSim.EmulatedEntrypoint/Controllers/PracticeController.cs index 7a31345..0f47320 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/PracticeController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/PracticeController.cs @@ -1,11 +1,11 @@ using Microsoft.AspNetCore.Mvc; using SVSim.Database.Enums; using SVSim.EmulatedEntrypoint.Models.Dtos; -using SVSim.Database.Repositories.Deck; using SVSim.Database.Repositories.Globals; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Common; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Practice; +using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Deck; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Practice; using SVSim.EmulatedEntrypoint.Services; @@ -13,18 +13,18 @@ namespace SVSim.EmulatedEntrypoint.Controllers; public class PracticeController : SVSimController { - private readonly IDeckRepository _deckRepository; private readonly IGlobalsRepository _globalsRepository; private readonly IMissionProgressService _missionProgress; + private readonly IDeckListBuilder _deckListBuilder; public PracticeController( - IDeckRepository deckRepository, IGlobalsRepository globalsRepository, - IMissionProgressService missionProgress) + IMissionProgressService missionProgress, + IDeckListBuilder deckListBuilder) { - _deckRepository = deckRepository; _globalsRepository = globalsRepository; _missionProgress = missionProgress; + _deckListBuilder = deckListBuilder; } /// @@ -53,25 +53,19 @@ public class PracticeController : SVSimController } /// - /// /practice/deck_list — returns viewer's decks scoped by format (always Format.All - /// per spec, server can ignore the request field). Fetched via IDeckRepository so the - /// DeckCard.Card navigation is Included; going through the heavier viewer-graph query - /// drops that ThenInclude and ships 40 zeros instead of real card ids, which then - /// NREs the client's SBattleLoad.InitPlayer (CardCreator returns null on id=0). + /// /practice/deck_list — same wire shape as /deck/info (the client parses both via + /// DeckGroupListData), so it shares . Always All-format per spec. + /// Unlike /deck/info this is a deck *select* screen, so empty "New Deck" slots are NOT padded + /// (padEmptySlots: false) — prod's practice capture returns the viewer's real decks unpadded, + /// plus the 8 per-class default decks and per-class leader-skin settings. The builder loads + /// decks via IDeckRepository (DeckCard.Card Included), so card_id_array carries real ids rather + /// than the 40 zeros that NRE the client's SBattleLoad.InitPlayer. /// [HttpPost("deck_list")] - public async Task> DeckList(DeckFormatRequest request) + public async Task> DeckList(DeckFormatRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); - - var byFormat = await _deckRepository.GetDecksByFormats(viewerId, new[] { Format.Rotation, Format.Unlimited }); - - return new PracticeDeckListResponse - { - MaintenanceCardList = new List(), - UserDeckRotation = byFormat[Format.Rotation].Select(d => new UserDeck(d)).ToList(), - UserDeckUnlimited = byFormat[Format.Unlimited].Select(d => new UserDeck(d)).ToList(), - }; + return await _deckListBuilder.BuildAsync(viewerId, Format.All, padEmptySlots: false); } /// diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Practice/PracticeDeckListResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Practice/PracticeDeckListResponse.cs deleted file mode 100644 index 2743073..0000000 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Practice/PracticeDeckListResponse.cs +++ /dev/null @@ -1,25 +0,0 @@ -using MessagePack; -using System.Text.Json.Serialization; - -namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Practice; - -/// -/// Same shape consumed by DeckGroupListData(jsonData, Format.All). Per-format keys are -/// conditional 窶・omit (don't send empty arrays) for formats the server doesn't enable. -/// -[MessagePackObject] -public class PracticeDeckListResponse -{ - /// Card ids currently disabled for maintenance (client unions with global list). - [JsonPropertyName("maintenance_card_list")] - [Key("maintenance_card_list")] public List MaintenanceCardList { get; set; } = new(); - - [JsonPropertyName("user_deck_rotation")] - [Key("user_deck_rotation")] public List? UserDeckRotation { get; set; } - [JsonPropertyName("user_deck_unlimited")] - [Key("user_deck_unlimited")] public List? UserDeckUnlimited { get; set; } - - // The remaining format keys (pre_rotation, crossover, my_rotation, avatar, default_deck_list, - // trial_deck_list, crossover_trial_deck_list, build_deck_list, user_leader_skin_setting_list) - // are all conditional 窶・added when those formats are enabled. -} diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index b586ea2..43391b8 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -100,6 +100,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/SVSim.EmulatedEntrypoint/Services/DeckListBuilder.cs b/SVSim.EmulatedEntrypoint/Services/DeckListBuilder.cs new file mode 100644 index 0000000..8c36e9e --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Services/DeckListBuilder.cs @@ -0,0 +1,153 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Repositories.Deck; +using SVSim.Database.Repositories.Globals; +using SVSim.EmulatedEntrypoint.Configuration; +using SVSim.EmulatedEntrypoint.Models.Dtos; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common; +using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Deck; + +namespace SVSim.EmulatedEntrypoint.Services; + +/// +/// Builds the shared consumed by the client's +/// DeckGroupListData(jsonData, format). Used by /deck/info, /deck/my_list, +/// and /practice/deck_list — all three return the same wire shape (default decks + +/// per-class leader-skin settings + the viewer's decks). +/// +/// distinguishes the deck *builder* screens +/// (/deck/*, which need empty "New Deck" tiles up to the slot cap) from the deck *select* +/// screens (/practice/deck_list, where prod returns the real decks unpadded — confirmed by +/// the 2026-05-29 practice capture returning empty user-deck arrays for a fresh account). +/// +public interface IDeckListBuilder +{ + Task BuildAsync(long viewerId, Format requestFormat, bool padEmptySlots); + + /// + /// Pads a viewer's real deck list with empty-slot placeholders up to the slot cap. Exposed for + /// deck-builder endpoints (e.g. /deck/update) that return a deck list directly rather + /// than through . + /// + List PadEmptySlots(List realDecks); +} + +public class DeckListBuilder : IDeckListBuilder +{ + private readonly IDeckRepository _deckRepository; + private readonly IGlobalsRepository _globalsRepository; + private readonly SVSimDbContext _dbContext; + private readonly DeckOptions _deckOptions; + + private static readonly System.Text.Json.JsonSerializerOptions JsonbReadOptions = new() + { + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.SnakeCaseLower, + NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString, + }; + + public DeckListBuilder( + IDeckRepository deckRepository, + IGlobalsRepository globalsRepository, + SVSimDbContext dbContext, + IOptions deckOptions) + { + _deckRepository = deckRepository; + _globalsRepository = globalsRepository; + _dbContext = dbContext; + _deckOptions = deckOptions.Value; + } + + public async Task BuildAsync(long viewerId, Format requestFormat, bool padEmptySlots) + { + var defaultDecks = await _globalsRepository.GetDefaultDecks(); + + // user_leader_skin_setting_list is PER-VIEWER (the wire `user_` prefix is honest, despite + // the misleading docstring on DefaultLeaderSkinSetting). Source it from the viewer's + // ViewerClassData rows, matching how /load/index's user_class_list reads them. The global + // DefaultLeaderSkinSettings table is now used only as initial seed values for fresh + // viewers (ViewerRepository.RegisterViewer); the per-class current skin is on + // viewer.Classes[i].LeaderSkin and gets mutated by /leader_skin/update. + var viewerClasses = await _dbContext.Viewers + .Where(v => v.Id == viewerId) + .SelectMany(v => v.Classes) + .Select(c => new { c.Class.Id, LeaderSkinId = c.LeaderSkin.Id }) + .ToListAsync(); + + var response = new DeckListResponse + { + DefaultDeckList = defaultDecks.ToDictionary( + d => d.Id.ToString(), + d => new DefaultDeck + { + DeckNo = d.DeckNo, + ClassId = d.ClassId, + SleeveId = d.SleeveId, + LeaderSkinId = d.LeaderSkinId, + DeckName = d.DeckName, + CardIdArray = System.Text.Json.JsonSerializer.Deserialize>(d.CardIdArray, JsonbReadOptions) ?? new(), + // TODO(deck-stub): wire from real per-deck state once user maintenance / availability tracking lands. + // Prod emits is_complete_deck=1, is_available_deck=1, maintenance_card_ids=[] for the 8 starter decks. + IsCompleteDeck = 1, + IsAvailableDeck = 1, + MaintenanceCardIds = new(), + }), + UserLeaderSkinSettingList = viewerClasses.ToDictionary( + vc => vc.Id.ToString(), + vc => new UserLeaderSkinSetting + { + ClassId = vc.Id, + IsRandomLeaderSkin = 0, // random-skin mode (per-class shuffle pool) not yet persisted + LeaderSkinId = vc.LeaderSkinId, + }), + MaintenanceCardList = new(), // sourced from same place as /load/index when wired + }; + + if (requestFormat == Format.All) + { + // Prod's All-format response emits these three per-format lists (each [] for fresh viewers). + // The PreRotation / Crossover / Avatar siblings exist in client code but prod omits them + // for our profile; we mirror that omission and leave the nullable DTO fields unset. + var formats = new[] { Format.Rotation, Format.Unlimited, Format.MyRotation }; + var byFormat = await _deckRepository.GetDecksByFormats(viewerId, formats); + response.UserDeckRotation = MaybePad(byFormat[Format.Rotation].Select(d => new UserDeck(d)).ToList(), padEmptySlots); + response.UserDeckUnlimited = MaybePad(byFormat[Format.Unlimited].Select(d => new UserDeck(d)).ToList(), padEmptySlots); + response.UserDeckMyRotation = MaybePad(byFormat[Format.MyRotation].Select(d => new UserDeck(d)).ToList(), padEmptySlots); + // trial_deck_list is prod-emitted on /deck/info (All format) but omitted on /deck/my_list + // (specific format). Empty array in the 2026-05-23 prod capture. + response.TrialDeckList = new(); + } + else + { + var decks = await _deckRepository.GetDecks(viewerId, requestFormat); + response.UserDeckList = MaybePad(decks.Select(d => new UserDeck(d)).ToList(), padEmptySlots); + } + + return response; + } + + private List MaybePad(List realDecks, bool pad) => pad ? PadEmptySlots(realDecks) : realDecks; + + /// + /// Pads a viewer's real deck list with empty-slot placeholders up to . + /// Required on the deck *builder* screens because the client's + /// DeckUI.DeckViewData.CreateDeckViewList only renders a "New Deck" tile when the response + /// contains an entry whose card_id_array is empty — without padding, the player cannot + /// create additional decks once any exist. Deck *select* screens (practice) skip padding: prod + /// returns the real decks unpadded there. + /// + public List PadEmptySlots(List realDecks) + { + var taken = realDecks.Select(d => d.DeckNumber).ToHashSet(); + var result = new List(realDecks); + for (int slot = 1; slot <= _deckOptions.MaxDeckSlots; slot++) + { + if (!taken.Contains(slot)) + { + result.Add(UserDeck.CreateEmptySlot(slot)); + } + } + return result; + } +} diff --git a/SVSim.UnitTests/Controllers/PracticeControllerTests.cs b/SVSim.UnitTests/Controllers/PracticeControllerTests.cs index ddf97ac..2b8bba4 100644 --- a/SVSim.UnitTests/Controllers/PracticeControllerTests.cs +++ b/SVSim.UnitTests/Controllers/PracticeControllerTests.cs @@ -138,6 +138,33 @@ public class PracticeControllerTests } } + [Test] + public async Task DeckList_exposes_the_eight_default_decks() + { + // Prod's practice/deck_list returns the same shape as /deck/info, including the 8 per-class + // starter decks under default_deck_list (keyed by deck_no "91".."98"). Without them, a fresh + // account has no decks to pick and can't start a practice match. + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); // imports the 8 default decks + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var response = await client.PostAsync("/practice/deck_list", + new StringContent(DeckFormatRequestJson(Format.All), Encoding.UTF8, "application/json")); + + var body = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + + using var doc = JsonDocument.Parse(body); + var defaults = doc.RootElement.GetProperty("default_deck_list"); + Assert.That(defaults.ValueKind, Is.EqualTo(JsonValueKind.Object)); + foreach (var key in new[] { "91", "92", "93", "94", "95", "96", "97", "98" }) + { + Assert.That(defaults.TryGetProperty(key, out _), Is.True, $"missing default deck {key}"); + } + Assert.That(defaults.GetProperty("91").GetProperty("class_id").GetInt32(), Is.GreaterThan(0)); + } + [Test] public async Task DeckList_empty_when_viewer_has_none() {