Merge story-build-trial-decks: serve build/trial/default deck lists on get_deck_list

This commit is contained in:
gamer147
2026-05-29 11:22:05 -04:00
22 changed files with 5854 additions and 10 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Bootstrap.Models.Seed;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Idempotent upsert of story-deck presentation rows from <c>seeds/story-decks.json</c>.
/// Card lists are NOT imported here — they belong to BuildDeckProductEntry (deck_no == product_id),
/// so this importer should run AFTER BuildDeckImporter.ImportPackageAsync. Rows missing from the
/// seed are left intact.
/// </summary>
public class StoryDeckImporter
{
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
{
var seed = SeedLoader.LoadList<StoryDeckSeed>(Path.Combine(seedDir, "story-decks.json"));
if (seed.Count == 0)
{
Console.WriteLine("[StoryDeckImporter] No seed rows; skipping.");
return 0;
}
var existing = await context.StoryDecks.ToDictionaryAsync(e => e.Id);
int created = 0, updated = 0;
foreach (var s in seed)
{
if (s.DeckNo == 0) continue;
var entry = existing.TryGetValue(s.DeckNo, out var ex) ? ex : new StoryDeckEntry { DeckNo = s.DeckNo };
entry.Kind = string.Equals(s.Kind, "trial", StringComparison.OrdinalIgnoreCase)
? StoryDeckKind.Trial : StoryDeckKind.Build;
entry.ClassId = s.ClassId;
entry.DeckName = s.DeckName;
entry.SleeveId = s.SleeveId;
entry.LeaderSkinId = s.LeaderSkinId;
entry.IsRecommend = s.IsRecommend;
entry.OrderNum = s.OrderNum;
entry.EntryNo = s.EntryNo;
entry.DeckFormat = s.DeckFormat;
if (ex is null) { context.StoryDecks.Add(entry); existing[s.DeckNo] = entry; created++; }
else updated++;
}
await context.SaveChangesAsync();
Console.WriteLine($"[StoryDeckImporter] +{created}/~{updated}");
return created + updated;
}
}

View File

@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
public sealed class StoryDeckSeed
{
[JsonPropertyName("deck_no")] public int DeckNo { get; set; }
[JsonPropertyName("kind")] public string Kind { get; set; } = "build";
[JsonPropertyName("class_id")] public int ClassId { get; set; }
[JsonPropertyName("deck_name")] public string DeckName { get; set; } = "";
[JsonPropertyName("sleeve_id")] public int SleeveId { get; set; }
[JsonPropertyName("leader_skin_id")] public int LeaderSkinId { get; set; }
[JsonPropertyName("is_recommend")] public int IsRecommend { get; set; }
[JsonPropertyName("order_num")] public int OrderNum { get; set; }
[JsonPropertyName("entry_no")] public int EntryNo { get; set; }
[JsonPropertyName("deck_format")] public int? DeckFormat { get; set; }
}

View File

@@ -124,6 +124,7 @@ public static class Program
await buildDeck.ImportSeriesAsync(context, opts.ReferenceDataDir);
await buildDeck.ImportCatalogAsync(context, opts.SeedDir);
await buildDeck.ImportPackageAsync(context, opts.ReferenceDataDir);
await new StoryDeckImporter().ImportAsync(context, opts.SeedDir);
}
else
{

View File

@@ -0,0 +1,11 @@
namespace SVSim.Database.Enums;
/// <summary>
/// Which story deck-select group a prebuilt deck belongs to. Build = the named story decks
/// (build_deck_list); Trial = archetype trial decks (trial_deck_list). Stored as int.
/// </summary>
public enum StoryDeckKind
{
Build = 0,
Trial = 1,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddStoryDeck : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "StoryDecks",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false),
DeckNo = table.Column<int>(type: "integer", nullable: false),
Kind = table.Column<int>(type: "integer", nullable: false),
ClassId = table.Column<int>(type: "integer", nullable: false),
DeckName = table.Column<string>(type: "text", nullable: false),
SleeveId = table.Column<int>(type: "integer", nullable: false),
LeaderSkinId = table.Column<int>(type: "integer", nullable: false),
IsRecommend = table.Column<int>(type: "integer", nullable: false),
OrderNum = table.Column<int>(type: "integer", nullable: false),
EntryNo = table.Column<int>(type: "integer", nullable: false),
DeckFormat = table.Column<int>(type: "integer", nullable: true),
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_StoryDecks", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "StoryDecks");
}
}
}

View File

@@ -2240,6 +2240,53 @@ namespace SVSim.Database.Migrations
b.ToTable("SpotCardExchangeCatalog");
});
modelBuilder.Entity("SVSim.Database.Models.StoryDeckEntry", b =>
{
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<int>("ClassId")
.HasColumnType("integer");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<int?>("DeckFormat")
.HasColumnType("integer");
b.Property<string>("DeckName")
.IsRequired()
.HasColumnType("text");
b.Property<int>("DeckNo")
.HasColumnType("integer");
b.Property<int>("EntryNo")
.HasColumnType("integer");
b.Property<int>("IsRecommend")
.HasColumnType("integer");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<int>("LeaderSkinId")
.HasColumnType("integer");
b.Property<int>("OrderNum")
.HasColumnType("integer");
b.Property<int>("SleeveId")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("StoryDecks");
});
modelBuilder.Entity("SVSim.Database.Models.UnlimitedRestrictionEntry", b =>
{
b.Property<long>("Id")

View File

@@ -0,0 +1,28 @@
using SVSim.Database.Common;
using SVSim.Database.Enums;
namespace SVSim.Database.Models;
/// <summary>
/// Presentation metadata for a story-mode prebuilt/trial deck, as surfaced under
/// main_story/get_deck_list's build_deck_list / trial_deck_list. PK (DeckNo) equals the deck's
/// wire deck_no, which also equals BuildDeckProductEntry.Id — the 40-card list is read from that
/// product (single source of truth), NOT stored here. Sourced from
/// data_dumps/traffic_prod_trial_decks.ndjson via seeds/story-decks.json.
/// </summary>
public class StoryDeckEntry : BaseEntity<int>
{
public int DeckNo { get => Id; set => Id = value; } // == BuildDeckProductEntry.Id
public StoryDeckKind Kind { get; set; }
public int ClassId { get; set; }
public string DeckName { get; set; } = string.Empty;
public int SleeveId { get; set; }
public int LeaderSkinId { get; set; }
public int IsRecommend { get; set; }
public int OrderNum { get; set; }
public int EntryNo { get; set; }
/// <summary>Trial decks carry a deck_format on the wire; build decks do not (null).</summary>
public int? DeckFormat { get; set; }
}

View File

@@ -64,4 +64,38 @@ public class BuildDeckRepository : IBuildDeckRepository
await _db.SaveChangesAsync();
return row.PurchaseCount;
}
public async Task<List<StoryDeckView>> GetStoryDecksByClass(int classId)
{
var decks = await _db.StoryDecks.Where(d => d.ClassId == classId).ToListAsync();
if (decks.Count == 0) return new();
var ids = decks.Select(d => d.DeckNo).ToList();
var products = await _db.BuildDeckProducts
.Where(p => ids.Contains(p.Id))
.Include(p => p.Cards)
.AsSplitQuery()
.ToListAsync();
// Expand each product's owned card rows by Number into a flat card_id list (spots included —
// validated against the prod capture, 112/112 match).
var cardsById = products.ToDictionary(
p => p.Id,
p => p.Cards.SelectMany(c => Enumerable.Repeat(c.CardId, c.Number)).ToList());
return decks.Select(d => new StoryDeckView
{
DeckNo = d.DeckNo,
Kind = d.Kind,
ClassId = d.ClassId,
DeckName = d.DeckName,
SleeveId = d.SleeveId,
LeaderSkinId = d.LeaderSkinId,
IsRecommend = d.IsRecommend,
OrderNum = d.OrderNum,
EntryNo = d.EntryNo,
DeckFormat = d.DeckFormat,
CardIdArray = cardsById.TryGetValue(d.DeckNo, out var cards) ? cards : new(),
}).ToList();
}
}

View File

@@ -26,4 +26,11 @@ public interface IBuildDeckRepository
/// Returns the new total.
/// </summary>
Task<int> IncrementPurchaseCount(long viewerId, int productId);
/// <summary>
/// Story deck-select decks for a class: StoryDeckEntry presentation rows joined to the matching
/// BuildDeckProductEntry card lists (deck_no == product_id), expanded to a flat card_id array.
/// Returns build and trial decks together; the caller splits by Kind.
/// </summary>
Task<List<StoryDeckView>> GetStoryDecksByClass(int classId);
}

View File

@@ -0,0 +1,22 @@
using SVSim.Database.Enums;
namespace SVSim.Database.Repositories.BuildDeck;
/// <summary>
/// A story-select deck ready for the wire: presentation metadata from StoryDeckEntry plus the
/// 40-card list expanded from the matching BuildDeckProductEntry. Plain projection, not an entity.
/// </summary>
public sealed class StoryDeckView
{
public int DeckNo { get; init; }
public StoryDeckKind Kind { get; init; }
public int ClassId { get; init; }
public string DeckName { get; init; } = string.Empty;
public int SleeveId { get; init; }
public int LeaderSkinId { get; init; }
public int IsRecommend { get; init; }
public int OrderNum { get; init; }
public int EntryNo { get; init; }
public int? DeckFormat { get; init; }
public List<long> CardIdArray { get; init; } = new();
}

View File

@@ -70,6 +70,7 @@ public class SVSimDbContext : DbContext
public DbSet<PackConfigEntry> Packs => Set<PackConfigEntry>();
public DbSet<BuildDeckSeriesEntry> BuildDeckSeries => Set<BuildDeckSeriesEntry>();
public DbSet<BuildDeckProductEntry> BuildDeckProducts => Set<BuildDeckProductEntry>();
public DbSet<StoryDeckEntry> StoryDecks => Set<StoryDeckEntry>();
public DbSet<SleeveShopSeriesEntry> SleeveShopSeries => Set<SleeveShopSeriesEntry>();
public DbSet<SleeveShopProductEntry> SleeveShopProducts => Set<SleeveShopProductEntry>();
public DbSet<ItemPurchaseCatalogEntry> ItemPurchaseCatalog => Set<ItemPurchaseCatalogEntry>();

View File

@@ -0,0 +1,27 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common;
/// <summary>
/// One archetype trial deck under <c>trial_deck_list</c> (DeckAttributeType.TrialDeck). Wire shape
/// from the 2026-05-29 main_story/get_deck_list capture. Distinct from build decks: carries
/// <c>deck_format</c> and no order_num/leader-skin-list. card_id_array only (no numbered card_id_N).
/// </summary>
[MessagePackObject]
public class TrialDeck
{
[JsonPropertyName("deck_no")] [Key("deck_no")] public int DeckNo { get; set; }
[JsonPropertyName("class_id")] [Key("class_id")] public int ClassId { get; set; }
[JsonPropertyName("sleeve_id")] [Key("sleeve_id")] public int SleeveId { get; set; }
[JsonPropertyName("leader_skin_id")] [Key("leader_skin_id")] public int LeaderSkinId { get; set; }
[JsonPropertyName("deck_name")] [Key("deck_name")] public string DeckName { get; set; } = string.Empty;
[JsonPropertyName("card_id_array")] [Key("card_id_array")] public List<long> CardIdArray { get; set; } = new();
[JsonPropertyName("is_complete_deck")] [Key("is_complete_deck")] public int IsCompleteDeck { get; set; } = 1;
[JsonPropertyName("restricted_card_exists")] [Key("restricted_card_exists")] public bool RestrictedCardExists { get; set; }
[JsonPropertyName("is_available_deck")] [Key("is_available_deck")] public int IsAvailableDeck { get; set; } = 1;
[JsonPropertyName("maintenance_card_ids")] [Key("maintenance_card_ids")] public List<long> MaintenanceCardIds { get; set; } = new();
[JsonPropertyName("is_include_un_possession_card")] [Key("is_include_un_possession_card")] public bool IsIncludeUnPossessionCard { get; set; }
[JsonPropertyName("deck_format")] [Key("deck_format")] public int DeckFormat { get; set; }
[JsonPropertyName("is_recommend")] [Key("is_recommend")] public int IsRecommend { get; set; }
}

View File

@@ -1,4 +1,5 @@
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Deck;
@@ -53,11 +54,11 @@ public class DeckListResponse
[Key("user_leader_skin_setting_list")] public Dictionary<string, UserLeaderSkinSetting> UserLeaderSkinSettingList { get; set; } = new();
/// <summary>
/// Trial / tutorial-specific decks. Prod emits this on <c>/deck/info</c> (All format) but
/// OMITS the key entirely on <c>/deck/my_list</c> (specific-format) — controller mirrors that
/// asymmetry by leaving this null on specific-format responses. Empty array in the
/// 2026-05-23 prod capture; entry shape TBD.
/// Trial / archetype decks. Prod emits this on <c>/deck/info</c> (All format) but OMITS the key
/// entirely on <c>/deck/my_list</c> (specific-format) — controller mirrors that asymmetry by
/// leaving this null on specific-format responses. Emitted EMPTY on /deck/info (matches the
/// 2026-05-23 prod capture); story/get_deck_list is where trial decks are actually populated.
/// </summary>
[JsonPropertyName("trial_deck_list")]
[Key("trial_deck_list")] public List<UserDeck>? TrialDeckList { get; set; }
[Key("trial_deck_list")] public List<TrialDeck>? TrialDeckList { get; set; }
}

View File

@@ -1,5 +1,7 @@
using MessagePack;
using System.Text.Json.Serialization;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Story;
@@ -30,10 +32,36 @@ public class GetDeckListResponse
[JsonPropertyName("build_deck_list")]
[Key("build_deck_list")]
public List<BuildDeck> BuildDeckList { get; set; } = new();
[JsonPropertyName("trial_deck_list")]
[Key("trial_deck_list")]
public List<TrialDeck> TrialDeckList { get; set; } = new();
/// <summary>Global starter decks, keyed by deck_no string (prod ids 91-98, one per class).</summary>
[JsonPropertyName("default_deck_list")]
[Key("default_deck_list")]
public Dictionary<string, DefaultDeck> DefaultDeckList { get; set; } = new();
}
/// <summary>
/// One named prebuilt story deck under <c>build_deck_list</c> (DeckAttributeType.BuildDeck). Wire
/// shape from the 2026-05-29 capture. Emits card_id_array only — the numbered card_id_1..40 keys
/// prod also sends are omitted (default/trial entries omit them and parse fine).
/// </summary>
[MessagePackObject]
public class BuildDeck
{
// Placeholder — build decks return [] for v1 per spec.
[JsonPropertyName("deck_no")] [Key("deck_no")] public int DeckNo { get; set; }
[JsonPropertyName("order_num")] [Key("order_num")] public int OrderNum { get; set; }
[JsonPropertyName("class_id")] [Key("class_id")] public int ClassId { get; set; }
[JsonPropertyName("sleeve_id")] [Key("sleeve_id")] public int SleeveId { get; set; }
[JsonPropertyName("leader_skin_id")] [Key("leader_skin_id")] public int LeaderSkinId { get; set; }
[JsonPropertyName("entry_no")] [Key("entry_no")] public int EntryNo { get; set; }
[JsonPropertyName("create_deck_time")] [Key("create_deck_time")] public DateTime? CreateDeckTime { get; set; }
[JsonPropertyName("deck_name")] [Key("deck_name")] public string DeckName { get; set; } = string.Empty;
[JsonPropertyName("card_id_array")] [Key("card_id_array")] public List<long> CardIdArray { get; set; } = new();
[JsonPropertyName("is_complete_deck")] [Key("is_complete_deck")] public int IsCompleteDeck { get; set; } = 1;
[JsonPropertyName("is_available_deck")] [Key("is_available_deck")] public int IsAvailableDeck { get; set; } = 1;
[JsonPropertyName("maintenance_card_ids")] [Key("maintenance_card_ids")] public List<long> MaintenanceCardIds { get; set; } = new();
[JsonPropertyName("is_recommend")] [Key("is_recommend")] public int IsRecommend { get; set; }
}

View File

@@ -1,12 +1,16 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using SVSim.Database;
using SVSim.Database.Entities.Story;
using SVSim.Database.Enums;
using SVSim.Database.Models.Config;
using SVSim.Database.Repositories.Deck;
using SVSim.Database.Repositories.BuildDeck;
using SVSim.Database.Services;
using SVSim.Database.Repositories.Story;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
using SVSim.EmulatedEntrypoint.Models.Dtos.Story;
namespace SVSim.EmulatedEntrypoint.Services;
@@ -19,6 +23,7 @@ public class StoryService : IStoryService
private readonly SVSimDbContext _db;
private readonly IGameConfigService _configService;
private readonly IDeckRepository _deckRepository;
private readonly IBuildDeckRepository _buildDecks;
private readonly ILogger<StoryService> _logger;
public StoryService(
@@ -28,6 +33,7 @@ public class StoryService : IStoryService
SVSimDbContext db,
IGameConfigService configService,
IDeckRepository deckRepository,
IBuildDeckRepository buildDecks,
ILogger<StoryService> logger)
{
_master = master;
@@ -36,6 +42,7 @@ public class StoryService : IStoryService
_db = db;
_configService = configService;
_deckRepository = deckRepository;
_buildDecks = buildDecks;
_logger = logger;
}
@@ -344,16 +351,86 @@ public class StoryService : IStoryService
{
var byFormat = await _deckRepository.GetDecksByFormats(
viewerId, new[] { SVSim.Database.Enums.Format.Rotation, SVSim.Database.Enums.Format.Unlimited });
return new GetDeckListResponse
var resp = new GetDeckListResponse
{
UserDeckRotation = byFormat[SVSim.Database.Enums.Format.Rotation]
.Select(d => new SVSim.EmulatedEntrypoint.Models.Dtos.UserDeck(d)).ToList(),
.Select(d => new UserDeck(d)).ToList(),
UserDeckUnlimited = byFormat[SVSim.Database.Enums.Format.Unlimited]
.Select(d => new SVSim.EmulatedEntrypoint.Models.Dtos.UserDeck(d)).ToList(),
BuildDeckList = new List<BuildDeck>(), // v1: empty
.Select(d => new UserDeck(d)).ToList(),
MaintenanceCardList = new List<long>(),
};
// The chapter's leader (CharaId == class_id 1-8 for standard classes) drives which
// prebuilt/trial decks the story deck-select shows. Non-class chapters (custom leaders,
// chara_id outside 1-8) get empty build/trial lists, matching prod.
var chapter = await _master.GetChapterByIdAsync(storyId);
int classId = chapter?.CharaId ?? 0;
if (classId is >= 1 and <= 8)
{
var storyDecks = await _buildDecks.GetStoryDecksByClass(classId);
resp.BuildDeckList = storyDecks
.Where(d => d.Kind == StoryDeckKind.Build)
.Select(ToBuildDeck).ToList();
resp.TrialDeckList = storyDecks
.Where(d => d.Kind == StoryDeckKind.Trial)
.Select(ToTrialDeck).ToList();
}
// default_deck_list — all 8 starter decks, keyed by deck_no string (same shape as /deck/info).
var defaults = await _db.DefaultDecks.OrderBy(d => d.Id).ToListAsync();
resp.DefaultDeckList = defaults.ToDictionary(
d => d.Id.ToString(),
d => new DefaultDeck
{
DeckNo = d.DeckNo,
ClassId = d.ClassId,
SleeveId = d.SleeveId,
LeaderSkinId = d.LeaderSkinId,
DeckName = d.DeckName,
CardIdArray = JsonSerializer.Deserialize<List<long>>(d.CardIdArray) ?? new(),
IsCompleteDeck = 1,
IsAvailableDeck = 1,
MaintenanceCardIds = new(),
});
return resp;
}
private static BuildDeck ToBuildDeck(StoryDeckView d) => new()
{
DeckNo = d.DeckNo,
OrderNum = d.OrderNum,
ClassId = d.ClassId,
SleeveId = d.SleeveId,
LeaderSkinId = d.LeaderSkinId,
EntryNo = d.EntryNo,
CreateDeckTime = null,
DeckName = d.DeckName,
CardIdArray = d.CardIdArray,
IsCompleteDeck = 1,
IsAvailableDeck = 1,
MaintenanceCardIds = new(),
IsRecommend = d.IsRecommend,
};
private static TrialDeck ToTrialDeck(StoryDeckView d) => new()
{
DeckNo = d.DeckNo,
ClassId = d.ClassId,
SleeveId = d.SleeveId,
LeaderSkinId = d.LeaderSkinId,
DeckName = d.DeckName,
CardIdArray = d.CardIdArray,
IsCompleteDeck = 1,
RestrictedCardExists = false,
IsAvailableDeck = 1,
MaintenanceCardIds = new(),
IsIncludeUnPossessionCard = false,
DeckFormat = d.DeckFormat ?? 0,
IsRecommend = d.IsRecommend,
};
public async Task<StartResponse> StartAsync(StoryApiType apiType, int[] storyIds, long viewerId)
{
var resp = new StartResponse();

View File

@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Bootstrap.Importers;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Importers;
public class StoryDeckImporterTests
{
private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds");
[Test]
public async Task Imports_story_decks_from_seed_file()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await new StoryDeckImporter().ImportAsync(db, SeedDir);
var decks = await db.StoryDecks.OrderBy(d => d.Id).ToListAsync();
Assert.That(decks.Count, Is.EqualTo(112), "53 build + 59 trial");
Assert.That(decks.Count(d => d.Kind == StoryDeckKind.Build), Is.EqualTo(53));
Assert.That(decks.Count(d => d.Kind == StoryDeckKind.Trial), Is.EqualTo(59));
var pureDevotion = decks.Single(d => d.DeckNo == 701);
Assert.That(pureDevotion.Kind, Is.EqualTo(StoryDeckKind.Build));
Assert.That(pureDevotion.ClassId, Is.EqualTo(1));
Assert.That(pureDevotion.DeckName, Is.EqualTo("Pure Devotion"));
Assert.That(pureDevotion.DeckFormat, Is.Null);
Assert.That(decks.Where(d => d.Kind == StoryDeckKind.Trial).All(d => d.DeckFormat != null), Is.True);
}
[Test]
public async Task Is_idempotent_on_rerun()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await new StoryDeckImporter().ImportAsync(db, SeedDir);
int before = await db.StoryDecks.CountAsync();
await new StoryDeckImporter().ImportAsync(db, SeedDir);
int after = await db.StoryDecks.CountAsync();
Assert.That(after, Is.EqualTo(before));
}
}

View File

@@ -0,0 +1,66 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.BuildDeck;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Repositories;
public class StoryDeckRepositoryTests
{
[Test]
public async Task GetStoryDecksByClass_returns_decks_with_expanded_card_arrays()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
// FK: BuildDeckProducts requires a parent BuildDeckSeries row.
db.BuildDeckSeries.Add(new BuildDeckSeriesEntry { Id = 0 });
await db.SaveChangesAsync();
// Product 701 (class 1 build): 2x card 100, 1x card 200 = 3-card "deck".
db.BuildDeckProducts.Add(new BuildDeckProductEntry
{
Id = 701, SeriesId = 0, LeaderId = 1, DeckCode = "", ProductNameKey = "", IsEnabled = false,
Cards = new()
{
new BuildDeckProductCardEntry { CardId = 100, Number = 2, IsSpot = false },
new BuildDeckProductCardEntry { CardId = 200, Number = 1, IsSpot = false },
},
});
db.StoryDecks.Add(new StoryDeckEntry
{
DeckNo = 701, Kind = StoryDeckKind.Build, ClassId = 1, DeckName = "Pure Devotion",
SleeveId = 3000011, LeaderSkinId = 1, IsRecommend = 0, OrderNum = 0, EntryNo = 0, DeckFormat = null,
});
// A class-2 deck that must NOT be returned for class 1.
db.StoryDecks.Add(new StoryDeckEntry { DeckNo = 702, Kind = StoryDeckKind.Build, ClassId = 2, DeckName = "Other" });
await db.SaveChangesAsync();
var repo = new BuildDeckRepository(db);
var result = await repo.GetStoryDecksByClass(1);
Assert.That(result.Count, Is.EqualTo(1));
var deck = result[0];
Assert.That(deck.DeckNo, Is.EqualTo(701));
Assert.That(deck.DeckName, Is.EqualTo("Pure Devotion"));
Assert.That(deck.Kind, Is.EqualTo(StoryDeckKind.Build));
Assert.That(deck.CardIdArray.OrderBy(x => x), Is.EqualTo(new long[] { 100, 100, 200 }));
}
[Test]
public async Task GetStoryDecksByClass_returns_empty_for_class_with_no_decks()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var repo = new BuildDeckRepository(db);
var result = await repo.GetStoryDecksByClass(8);
Assert.That(result, Is.Empty);
}
}

View File

@@ -0,0 +1,85 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
using SVSim.EmulatedEntrypoint.Models.Dtos.Story;
namespace SVSim.UnitTests.Story;
public class GetDeckListWireShapeTests
{
// Mirror Program.cs: keys come from [JsonPropertyName]; null values are dropped.
private static readonly JsonSerializerOptions WireOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
[Test]
public void BuildDeck_serializes_with_prod_wire_keys()
{
var deck = new BuildDeck
{
DeckNo = 701, OrderNum = 0, ClassId = 1, SleeveId = 3000011, LeaderSkinId = 1,
EntryNo = 0, CreateDeckTime = null, DeckName = "Pure Devotion",
CardIdArray = new() { 115141020, 114141020 },
IsCompleteDeck = 1, IsAvailableDeck = 1, MaintenanceCardIds = new(), IsRecommend = 0,
};
using var doc = JsonDocument.Parse(JsonSerializer.Serialize(deck, WireOptions));
var root = doc.RootElement;
// Every key the client's BuildDeck branch reads must be present with the right name.
foreach (var key in new[] { "deck_no", "order_num", "class_id", "sleeve_id", "leader_skin_id",
"entry_no", "deck_name", "card_id_array", "is_complete_deck",
"is_available_deck", "maintenance_card_ids", "is_recommend" })
{
Assert.That(root.TryGetProperty(key, out _), Is.True, $"missing wire key: {key}");
}
Assert.That(root.GetProperty("deck_name").GetString(), Is.EqualTo("Pure Devotion"));
Assert.That(root.GetProperty("card_id_array").GetArrayLength(), Is.EqualTo(2));
// Numbered card_id_N keys are intentionally omitted.
Assert.That(root.TryGetProperty("card_id_1", out _), Is.False);
}
[Test]
public void TrialDeck_serializes_with_prod_wire_keys_including_deck_format()
{
var deck = new TrialDeck
{
DeckNo = 13001, ClassId = 1, SleeveId = 3000011, LeaderSkinId = 0,
DeckName = "Tempo Forestcraft", CardIdArray = new() { 130141020 },
IsCompleteDeck = 1, RestrictedCardExists = false, IsAvailableDeck = 1,
MaintenanceCardIds = new(), IsIncludeUnPossessionCard = false, DeckFormat = 1, IsRecommend = 1,
};
using var doc = JsonDocument.Parse(JsonSerializer.Serialize(deck, WireOptions));
var root = doc.RootElement;
foreach (var key in new[] { "deck_no", "class_id", "sleeve_id", "leader_skin_id", "deck_name",
"card_id_array", "is_complete_deck", "restricted_card_exists",
"is_available_deck", "maintenance_card_ids",
"is_include_un_possession_card", "deck_format", "is_recommend" })
{
Assert.That(root.TryGetProperty(key, out _), Is.True, $"missing wire key: {key}");
}
Assert.That(root.GetProperty("deck_format").GetInt32(), Is.EqualTo(1));
}
[Test]
public void GetDeckListResponse_default_deck_list_is_a_keyed_object()
{
var resp = new GetDeckListResponse();
resp.DefaultDeckList["91"] = new SVSim.EmulatedEntrypoint.Models.Dtos.DefaultDeck
{
DeckNo = 91, ClassId = 1, SleeveId = 3000011, LeaderSkinId = 0, DeckName = "Default",
CardIdArray = new() { 100111010 },
};
using var doc = JsonDocument.Parse(JsonSerializer.Serialize(resp, WireOptions));
var root = doc.RootElement;
Assert.That(root.GetProperty("default_deck_list").ValueKind, Is.EqualTo(JsonValueKind.Object));
Assert.That(root.GetProperty("default_deck_list").GetProperty("91").GetProperty("class_id").GetInt32(), Is.EqualTo(1));
Assert.That(root.GetProperty("build_deck_list").ValueKind, Is.EqualTo(JsonValueKind.Array));
Assert.That(root.GetProperty("trial_deck_list").ValueKind, Is.EqualTo(JsonValueKind.Array));
}
}

View File

@@ -0,0 +1,74 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using SVSim.Database;
using SVSim.Database.Entities.Story;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.EmulatedEntrypoint.Services;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Story;
[TestFixture]
public class StoryDeckListServiceTests
{
[Test]
public async Task GetDeckList_populates_build_trial_and_default_for_chapter_class()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
// StoryChapter.SectionId has an enforced FK to StorySection; seed the parent row first.
db.StorySections.Add(new StorySection { Id = 1, StoryApiType = StoryApiType.Main });
// Chapter 14 is a class-1 (Forestcraft) chapter.
db.StoryChapters.Add(new StoryChapter { StoryId = 14, SectionId = 1, CharaId = 1, ChapterId = "14" });
// One class-1 build deck (701) + one class-1 trial deck (13001), each with a 1-card product.
// BuildDeckProductEntry has an enforced FK SeriesId -> BuildDeckSeries; seed the parent first.
db.BuildDeckSeries.Add(new BuildDeckSeriesEntry { Id = 0 });
db.BuildDeckProducts.Add(new BuildDeckProductEntry { Id = 701, Cards = new() { new BuildDeckProductCardEntry { CardId = 100, Number = 1 } } });
db.BuildDeckProducts.Add(new BuildDeckProductEntry { Id = 13001, Cards = new() { new BuildDeckProductCardEntry { CardId = 200, Number = 1 } } });
db.StoryDecks.Add(new StoryDeckEntry { DeckNo = 701, Kind = StoryDeckKind.Build, ClassId = 1, DeckName = "Pure Devotion", DeckFormat = null });
db.StoryDecks.Add(new StoryDeckEntry { DeckNo = 13001, Kind = StoryDeckKind.Trial, ClassId = 1, DeckName = "Tempo Forestcraft", DeckFormat = 1 });
db.DefaultDecks.Add(new DefaultDeckEntry { Id = 91, ClassId = 1, SleeveId = 3000011, LeaderSkinId = 0, DeckName = "Default", CardIdArray = "[100,100,100]" });
await db.SaveChangesAsync();
var service = scope.ServiceProvider.GetRequiredService<IStoryService>();
var resp = await service.GetDeckListAsync(StoryApiType.Main, storyId: 14, viewerId: 1);
Assert.That(resp.BuildDeckList.Count, Is.EqualTo(1));
Assert.That(resp.BuildDeckList[0].DeckNo, Is.EqualTo(701));
Assert.That(resp.BuildDeckList[0].DeckName, Is.EqualTo("Pure Devotion"));
Assert.That(resp.BuildDeckList[0].CardIdArray, Is.EqualTo(new long[] { 100 }));
Assert.That(resp.TrialDeckList.Count, Is.EqualTo(1));
Assert.That(resp.TrialDeckList[0].DeckNo, Is.EqualTo(13001));
Assert.That(resp.TrialDeckList[0].DeckFormat, Is.EqualTo(1));
Assert.That(resp.DefaultDeckList.ContainsKey("91"), Is.True);
Assert.That(resp.DefaultDeckList["91"].CardIdArray, Is.EqualTo(new long[] { 100, 100, 100 }));
}
[Test]
public async Task GetDeckList_returns_empty_build_trial_for_non_class_chapter()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
// StoryChapter.SectionId has an enforced FK to StorySection; seed the parent row first.
db.StorySections.Add(new StorySection { Id = 17, StoryApiType = StoryApiType.Main });
// chara_id 0 -> custom-leader / non-class chapter.
db.StoryChapters.Add(new StoryChapter { StoryId = 500, SectionId = 17, CharaId = 0, ChapterId = "500" });
await db.SaveChangesAsync();
var service = scope.ServiceProvider.GetRequiredService<IStoryService>();
var resp = await service.GetDeckListAsync(StoryApiType.Main, storyId: 500, viewerId: 1);
Assert.That(resp.BuildDeckList, Is.Empty);
Assert.That(resp.TrialDeckList, Is.Empty);
}
}

View File

@@ -35,6 +35,7 @@ public class StoryServiceTests
db: db,
configService: StoryServiceTestHelpers.NewConfigService(),
deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,
buildDecks: new Mock<SVSim.Database.Repositories.BuildDeck.IBuildDeckRepository>().Object,
logger: NullLogger<StoryService>.Instance);
}
@@ -72,6 +73,7 @@ public class StoryServiceTests
db: db,
configService: StoryServiceTestHelpers.NewConfigService(),
deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,
buildDecks: new Mock<SVSim.Database.Repositories.BuildDeck.IBuildDeckRepository>().Object,
logger: NullLogger<StoryService>.Instance);
}
@@ -404,6 +406,7 @@ public class StoryServiceTests
db: db,
configService: StoryServiceTestHelpers.NewConfigService(),
deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,
buildDecks: new Mock<SVSim.Database.Repositories.BuildDeck.IBuildDeckRepository>().Object,
logger: NullLogger<StoryService>.Instance);
}