Merge practice-default-decks: serve default/trial/leader-skin lists on practice/deck_list
This commit is contained in:
@@ -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> deckOptions)
|
||||
public DeckController(IDeckRepository deckRepository, SVSimDbContext dbContext, IDeckListBuilder deckListBuilder)
|
||||
{
|
||||
_deckRepository = deckRepository;
|
||||
_globalsRepository = globalsRepository;
|
||||
_dbContext = dbContext;
|
||||
_deckOptions = deckOptions.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pads a viewer's real deck list with empty-slot placeholders up to <see cref="DeckOptions.MaxDeckSlots"/>.
|
||||
/// Required because the client's <c>DeckUI.DeckViewData.CreateDeckViewList</c> only renders
|
||||
/// a "New Deck" tile when the response contains an entry whose <c>card_id_array</c> is empty —
|
||||
/// without padding, the player cannot create additional decks once any exist.
|
||||
/// </summary>
|
||||
private List<UserDeck> PadEmptySlots(List<UserDeck> realDecks)
|
||||
{
|
||||
var taken = realDecks.Select(d => d.DeckNumber).ToHashSet();
|
||||
var result = new List<UserDeck>(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<ActionResult<DeckListResponse>> 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<ActionResult<DeckListResponse>> MyList(DeckFormatRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
return await BuildDeckListResponseAsync(viewerId, AsFormat(request.DeckFormat));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared hydration for <c>/deck/info</c> and <c>/deck/my_list</c> — both endpoints return the
|
||||
/// same <see cref="DeckListResponse"/> DTO and the client's DeckInfoTask.Parse / DeckMyListTask.Parse
|
||||
/// are identical (both call <c>DeckGroupListData(jsonData, format)</c>).
|
||||
///
|
||||
/// Wire shape swaps based on the request format. When the client asks for All-format
|
||||
/// (<c>deck_format=0</c>), prod emits per-format keys (<c>user_deck_rotation</c>, etc.);
|
||||
/// for a specific format request, prod emits a single <c>user_deck_list</c>. The client's
|
||||
/// <c>DeckListUtility.ParseDeckInfoResponceData</c> branches on these two shapes, so the
|
||||
/// controller mirrors it exactly.
|
||||
/// </summary>
|
||||
private async Task<DeckListResponse> 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<List<long>>(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())
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -53,25 +53,19 @@ public class PracticeController : SVSimController
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// /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 <see cref="IDeckListBuilder"/>. 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.
|
||||
/// </summary>
|
||||
[HttpPost("deck_list")]
|
||||
public async Task<ActionResult<PracticeDeckListResponse>> DeckList(DeckFormatRequest request)
|
||||
public async Task<ActionResult<DeckListResponse>> 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<long>(),
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Practice;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class PracticeDeckListResponse
|
||||
{
|
||||
/// <summary>Card ids currently disabled for maintenance (client unions with global list).</summary>
|
||||
[JsonPropertyName("maintenance_card_list")]
|
||||
[Key("maintenance_card_list")] public List<long> MaintenanceCardList { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("user_deck_rotation")]
|
||||
[Key("user_deck_rotation")] public List<UserDeck>? UserDeckRotation { get; set; }
|
||||
[JsonPropertyName("user_deck_unlimited")]
|
||||
[Key("user_deck_unlimited")] public List<UserDeck>? 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.
|
||||
}
|
||||
@@ -100,6 +100,7 @@ public class Program
|
||||
builder.Services.AddScoped<IStoryMasterRepository, StoryMasterRepository>();
|
||||
builder.Services.AddScoped<IViewerStoryProgressRepository, ViewerStoryProgressRepository>();
|
||||
builder.Services.AddScoped<IStoryService, StoryService>();
|
||||
builder.Services.AddScoped<IDeckListBuilder, DeckListBuilder>();
|
||||
builder.Services.AddSingleton<IRandom, SystemRandom>();
|
||||
builder.Services.AddSingleton<PuzzleMissionEvaluator>();
|
||||
|
||||
|
||||
153
SVSim.EmulatedEntrypoint/Services/DeckListBuilder.cs
Normal file
153
SVSim.EmulatedEntrypoint/Services/DeckListBuilder.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the shared <see cref="DeckListResponse"/> consumed by the client's
|
||||
/// <c>DeckGroupListData(jsonData, format)</c>. Used by <c>/deck/info</c>, <c>/deck/my_list</c>,
|
||||
/// and <c>/practice/deck_list</c> — all three return the same wire shape (default decks +
|
||||
/// per-class leader-skin settings + the viewer's decks).
|
||||
///
|
||||
/// <para><paramref name="padEmptySlots"/> distinguishes the deck *builder* screens
|
||||
/// (<c>/deck/*</c>, which need empty "New Deck" tiles up to the slot cap) from the deck *select*
|
||||
/// screens (<c>/practice/deck_list</c>, where prod returns the real decks unpadded — confirmed by
|
||||
/// the 2026-05-29 practice capture returning empty user-deck arrays for a fresh account).</para>
|
||||
/// </summary>
|
||||
public interface IDeckListBuilder
|
||||
{
|
||||
Task<DeckListResponse> BuildAsync(long viewerId, Format requestFormat, bool padEmptySlots);
|
||||
|
||||
/// <summary>
|
||||
/// Pads a viewer's real deck list with empty-slot placeholders up to the slot cap. Exposed for
|
||||
/// deck-builder endpoints (e.g. <c>/deck/update</c>) that return a deck list directly rather
|
||||
/// than through <see cref="BuildAsync"/>.
|
||||
/// </summary>
|
||||
List<UserDeck> PadEmptySlots(List<UserDeck> 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> deckOptions)
|
||||
{
|
||||
_deckRepository = deckRepository;
|
||||
_globalsRepository = globalsRepository;
|
||||
_dbContext = dbContext;
|
||||
_deckOptions = deckOptions.Value;
|
||||
}
|
||||
|
||||
public async Task<DeckListResponse> 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<List<long>>(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<UserDeck> MaybePad(List<UserDeck> realDecks, bool pad) => pad ? PadEmptySlots(realDecks) : realDecks;
|
||||
|
||||
/// <summary>
|
||||
/// Pads a viewer's real deck list with empty-slot placeholders up to <see cref="DeckOptions.MaxDeckSlots"/>.
|
||||
/// Required on the deck *builder* screens because the client's
|
||||
/// <c>DeckUI.DeckViewData.CreateDeckViewList</c> only renders a "New Deck" tile when the response
|
||||
/// contains an entry whose <c>card_id_array</c> 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.
|
||||
/// </summary>
|
||||
public List<UserDeck> PadEmptySlots(List<UserDeck> realDecks)
|
||||
{
|
||||
var taken = realDecks.Select(d => d.DeckNumber).ToHashSet();
|
||||
var result = new List<UserDeck>(realDecks);
|
||||
for (int slot = 1; slot <= _deckOptions.MaxDeckSlots; slot++)
|
||||
{
|
||||
if (!taken.Contains(slot))
|
||||
{
|
||||
result.Add(UserDeck.CreateEmptySlot(slot));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user