Merge progression-import-export: progression import/export + pack system rewrite

Two unrelated feature sets landed on the same dev branch this session:

1. Progression import/export (7 prior commits): owned cards + items +
   decks + tolerant numeric my_rotation_id parsing + literal-client-JSON
   wire-shape coverage.

2. Pack system rewrite (7 new commits): full-fidelity per-pack draw
   tables seeded from the 279 archived drawrates CSVs, replacing the
   pack->CardSet pool assumption. New EF entities, importer, sampler,
   IsEnabled admin gate on PackConfig, statistical sampler test,
   PackRateConfig marked Obsolete.

Tests: 648/648 green.
Bootstrap end-to-end: 279 PackDrawConfigs / 1973 SlotRates /
90800 CardWeights / 35 enabled + 244 disabled stubs in Packs.
This commit is contained in:
gamer147
2026-05-30 22:51:23 -04:00
54 changed files with 846585 additions and 671 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
[
{
"parent_gacha_id": 10001,
"base_pack_id": 10001,
"gacha_type": 1,
"pack_category": 0,
"poster_type": 0,
"commence_date": "2016-06-17 00:00:00",
"complete_date": "2026-06-30 23:59:59",
"sleeve_id": 0,
"special_sleeve_id": 0,
"override_draw_effect_pack_id": 0,
"override_ui_effect_pack_id": 0,
"gacha_detail": "STUB CLC",
"is_hide": false,
"is_new": false,
"is_pre_release": false,
"open_count_limit": 0,
"sales_period_time": null,
"gacha_point": null,
"child_gachas": [
{ "gacha_id": 100011, "type_detail": 2, "cost": 200, "card_count": 1 },
{ "gacha_id": 100012, "type_detail": 2, "cost": 1800, "card_count": 10 }
],
"banners": [],
"is_enabled": false
},
{
"parent_gacha_id": 95001,
"base_pack_id": 95001,
"gacha_type": 1,
"pack_category": 2,
"poster_type": 0,
"commence_date": "2016-06-17 00:00:00",
"complete_date": "2026-06-30 23:59:59",
"sleeve_id": 0,
"special_sleeve_id": 0,
"override_draw_effect_pack_id": 0,
"override_ui_effect_pack_id": 0,
"gacha_detail": "7th Anniv stub",
"is_hide": false,
"is_new": false,
"is_pre_release": false,
"open_count_limit": 0,
"sales_period_time": null,
"gacha_point": null,
"child_gachas": [
{ "gacha_id": 950011, "type_detail": 2, "cost": 200, "card_count": 1 },
{ "gacha_id": 950012, "type_detail": 2, "cost": 1800, "card_count": 10 }
],
"banners": [],
"is_enabled": false
}
]

View File

@@ -0,0 +1,102 @@
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 the per-pack draw table from
/// <c>seeds/pack-draw-config.json</c>, <c>pack-draw-slot-rates.json</c>, and
/// <c>pack-draw-card-weights.json</c>. Replaces wholesale per pack (config keyed on
/// pack_id; slot rates / card weights wiped and reinserted) — the upstream data is
/// post-shutdown closed, so we do not preserve hand-edits on these tables.
/// </summary>
public class PackDrawTableImporter
{
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
{
var configs = SeedLoader.LoadList<PackDrawConfigSeed>(Path.Combine(seedDir, "pack-draw-config.json"));
var slotRates = SeedLoader.LoadList<PackDrawSlotRateSeed>(Path.Combine(seedDir, "pack-draw-slot-rates.json"));
var cardWeights = SeedLoader.LoadList<PackDrawCardWeightSeed>(Path.Combine(seedDir, "pack-draw-card-weights.json"));
if (configs.Count == 0)
{
Console.WriteLine("[PackDrawTableImporter] No seed rows; skipping.");
return 0;
}
var seedPackIds = configs.Select(c => c.PackId).ToHashSet();
// Full-replace strategy: wipe rows for any pack in the seed, then reinsert.
await context.PackDrawCardWeights
.Where(w => seedPackIds.Contains(w.PackId))
.ExecuteDeleteAsync();
await context.PackDrawSlotRates
.Where(s => seedPackIds.Contains(s.PackId))
.ExecuteDeleteAsync();
var existingConfigs = await context.PackDrawConfigs
.Where(c => seedPackIds.Contains(c.Id))
.ToDictionaryAsync(c => c.Id);
foreach (var s in configs)
{
var row = existingConfigs.TryGetValue(s.PackId, out var ex)
? ex : new PackDrawConfigEntry { Id = s.PackId };
row.AnimationRatePct = s.AnimationRatePct;
row.HasBonusSlot = s.HasBonusSlot;
row.SpecialKind = s.SpecialKind;
row.ShortCode = s.ShortCode;
if (ex is null) context.PackDrawConfigs.Add(row);
}
foreach (var s in slotRates)
{
context.PackDrawSlotRates.Add(new PackDrawSlotRateEntry
{
PackId = s.PackId,
Slot = ParseSlot(s.Slot),
Tier = ParseTier(s.Tier),
RatePct = s.RatePct,
});
}
foreach (var s in cardWeights)
{
context.PackDrawCardWeights.Add(new PackDrawCardWeightEntry
{
PackId = s.PackId,
Slot = ParseSlot(s.Slot),
Tier = ParseTier(s.Tier),
CardId = s.CardId,
RatePct = s.RatePct,
IsLeader = s.IsLeader,
IsAltArt = s.IsAltArt,
});
}
await context.SaveChangesAsync();
Console.WriteLine($"[PackDrawTableImporter] {configs.Count} configs / {slotRates.Count} slot rates / {cardWeights.Count} card weights");
return configs.Count;
}
private static DrawSlot ParseSlot(string s) => s switch
{
"general" => DrawSlot.General,
"eighth" => DrawSlot.Eighth,
"bonus" => DrawSlot.Bonus,
_ => throw new InvalidDataException($"PackDrawTableImporter: unknown slot \"{s}\""),
};
private static DrawTier ParseTier(string s) => s switch
{
"bronze" => DrawTier.Bronze,
"silver" => DrawTier.Silver,
"gold" => DrawTier.Gold,
"legendary" => DrawTier.Legendary,
"special" => DrawTier.Special,
_ => throw new InvalidDataException($"PackDrawTableImporter: unknown tier \"{s}\""),
};
}

View File

@@ -61,6 +61,7 @@ public class PackImporter
ExchangeablePoint = s.GachaPoint.ExchangeablePoint,
IncreaseGachaPoint = s.GachaPoint.IncreaseGachaPoint,
};
pack.IsEnabled = s.IsEnabled;
// Owned collections -- clear and rehydrate (matches the previous wholesale-replace semantics).
pack.ChildGachas.Clear();
@@ -101,7 +102,75 @@ public class PackImporter
}
await context.SaveChangesAsync();
Console.WriteLine($"[PackImporter] +{created}/~{updated}");
return created + updated;
Console.WriteLine($"[PackImporter] capture: +{created}/~{updated}");
// Second pass: synthesized stubs from pack-stubs.json. Skip any pack_id that already
// exists from the live-capture pass (capture wins on conflict).
var stubs = SeedLoader.LoadList<PackSeed>(Path.Combine(seedDir, "pack-stubs.json"));
int stubsAdded = 0;
foreach (var s in stubs)
{
if (s.ParentGachaId == 0) continue;
if (existing.ContainsKey(s.ParentGachaId)) continue;
var pack = new PackConfigEntry
{
Id = s.ParentGachaId,
BasePackId = s.BasePackId,
GachaType = s.GachaType,
PackCategory = (PackCategory)s.PackCategory,
PosterType = s.PosterType,
CommenceDate = ParseWireDateTime(s.CommenceDate),
CompleteDate = ParseWireDateTime(s.CompleteDate),
SleeveId = s.SleeveId,
SpecialSleeveId = s.SpecialSleeveId,
OverrideDrawEffectPackId = s.OverrideDrawEffectPackId,
OverrideUiEffectPackId = s.OverrideUiEffectPackId,
GachaDetail = s.GachaDetail,
IsHide = s.IsHide,
IsNew = s.IsNew,
IsPreRelease = s.IsPreRelease,
OpenCountLimit = s.OpenCountLimit,
SalesPeriodTime = string.IsNullOrEmpty(s.SalesPeriodTime) ? null : ParseWireDateTime(s.SalesPeriodTime),
GachaPointConfig = s.GachaPoint is null ? null : new PackGachaPointConfig
{
ExchangeablePoint = s.GachaPoint.ExchangeablePoint,
IncreaseGachaPoint = s.GachaPoint.IncreaseGachaPoint,
},
IsEnabled = s.IsEnabled,
};
foreach (var c in s.ChildGachas)
{
pack.ChildGachas.Add(new PackChildGachaEntry
{
GachaId = c.GachaId,
TypeDetail = c.TypeDetail,
Cost = c.Cost,
CardCount = c.CardCount,
ItemId = c.ItemId,
IsDailySingle = c.IsDailySingle,
OverrideIncreaseGachaPoint = c.OverrideIncreaseGachaPoint,
PurchaseLimitCount = c.PurchaseLimitCount,
FreeGachaCampaignId = c.FreeGachaCampaignId,
CampaignName = c.CampaignName,
});
}
foreach (var b in s.Banners)
{
pack.Banners.Add(new PackBannerEntry
{
BannerName = b.BannerName,
DialogTitle = b.DialogTitle,
});
}
context.Packs.Add(pack);
existing[s.ParentGachaId] = pack;
stubsAdded++;
}
await context.SaveChangesAsync();
Console.WriteLine($"[PackImporter] stubs: +{stubsAdded}");
return created + updated + stubsAdded;
}
}

View File

@@ -0,0 +1,14 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
public sealed class PackDrawCardWeightSeed
{
[JsonPropertyName("pack_id")] public int PackId { get; set; }
[JsonPropertyName("slot")] public string Slot { get; set; } = "general";
[JsonPropertyName("tier")] public string Tier { get; set; } = "bronze";
[JsonPropertyName("card_id")] public long CardId { get; set; }
[JsonPropertyName("rate_pct")] public double? RatePct { get; set; }
[JsonPropertyName("is_leader")] public bool IsLeader { get; set; }
[JsonPropertyName("is_alt_art")] public bool IsAltArt { get; set; }
}

View File

@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
public sealed class PackDrawConfigSeed
{
[JsonPropertyName("pack_id")] public int PackId { get; set; }
[JsonPropertyName("short_code")] public string? ShortCode { get; set; }
[JsonPropertyName("animation_rate_pct")] public double AnimationRatePct { get; set; }
[JsonPropertyName("has_bonus_slot")] public bool HasBonusSlot { get; set; }
[JsonPropertyName("special_kind")] public string? SpecialKind { get; set; }
}

View File

@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
public sealed class PackDrawSlotRateSeed
{
[JsonPropertyName("pack_id")] public int PackId { get; set; }
[JsonPropertyName("slot")] public string Slot { get; set; } = "general";
[JsonPropertyName("tier")] public string Tier { get; set; } = "bronze";
[JsonPropertyName("rate_pct")] public double RatePct { get; set; }
}

View File

@@ -24,6 +24,7 @@ public sealed class PackSeed
[JsonPropertyName("gacha_point")] public PackGachaPointSeed? GachaPoint { get; set; }
[JsonPropertyName("child_gachas")] public List<PackChildGachaSeed> ChildGachas { get; set; } = new();
[JsonPropertyName("banners")] public List<PackBannerSeed> Banners { get; set; } = new();
[JsonPropertyName("is_enabled")] public bool IsEnabled { get; set; } = true;
}
public sealed class PackGachaPointSeed

View File

@@ -116,6 +116,7 @@ public static class Program
await new DefaultDeckImporter().ImportAsync(context, opts.SeedDir);
await new PackImporter().ImportAsync(context, opts.SeedDir);
await new PackDrawTableImporter().ImportAsync(context, opts.SeedDir);
// BuildDeck pipeline: series CSV → catalog JSON → package CSV. Catalog must run after
// series CSV (FK on products → series) and before package CSV (so the catalog-side

View File

@@ -0,0 +1,8 @@
namespace SVSim.Database.Enums;
public enum DrawSlot
{
General = 0,
Eighth = 1,
Bonus = 2,
}

View File

@@ -0,0 +1,16 @@
namespace SVSim.Database.Enums;
/// <summary>
/// Per-draw page tier the slot rolls into. Distinct from card-master <see cref="Rarity"/>:
/// for the four base values they line up, but <c>Special</c> covers the per-pack
/// "Leader Card" / "Limited-Time Leader" tiers — its cards are typically Rarity.Legendary
/// with the IsLeader printing flag set.
/// </summary>
public enum DrawTier
{
Bronze = 0,
Silver = 1,
Gold = 2,
Legendary = 3,
Special = 4,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,107 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddPackDrawTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsEnabled",
table: "Packs",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "PackDrawCardWeights",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
PackId = table.Column<int>(type: "integer", nullable: false),
Slot = table.Column<int>(type: "integer", nullable: false),
Tier = table.Column<int>(type: "integer", nullable: false),
CardId = table.Column<long>(type: "bigint", nullable: false),
RatePct = table.Column<double>(type: "double precision", nullable: true),
IsLeader = table.Column<bool>(type: "boolean", nullable: false),
IsAltArt = table.Column<bool>(type: "boolean", nullable: false),
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_PackDrawCardWeights", x => x.Id);
});
migrationBuilder.CreateTable(
name: "PackDrawConfigs",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false),
AnimationRatePct = table.Column<double>(type: "double precision", nullable: false),
HasBonusSlot = table.Column<bool>(type: "boolean", nullable: false),
SpecialKind = table.Column<string>(type: "text", nullable: true),
ShortCode = table.Column<string>(type: "text", 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_PackDrawConfigs", x => x.Id);
});
migrationBuilder.CreateTable(
name: "PackDrawSlotRates",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
PackId = table.Column<int>(type: "integer", nullable: false),
Slot = table.Column<int>(type: "integer", nullable: false),
Tier = table.Column<int>(type: "integer", nullable: false),
RatePct = table.Column<double>(type: "double precision", nullable: false),
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_PackDrawSlotRates", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_PackDrawCardWeights_PackId_Slot_Tier",
table: "PackDrawCardWeights",
columns: new[] { "PackId", "Slot", "Tier" });
migrationBuilder.CreateIndex(
name: "IX_PackDrawSlotRates_PackId_Slot_Tier",
table: "PackDrawSlotRates",
columns: new[] { "PackId", "Slot", "Tier" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PackDrawCardWeights");
migrationBuilder.DropTable(
name: "PackDrawConfigs");
migrationBuilder.DropTable(
name: "PackDrawSlotRates");
migrationBuilder.DropColumn(
name: "IsEnabled",
table: "Packs");
}
}
}

View File

@@ -1500,6 +1500,9 @@ namespace SVSim.Database.Migrations
b.Property<int>("GachaType")
.HasColumnType("integer");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<bool>("IsHide")
.HasColumnType("boolean");
@@ -1538,6 +1541,110 @@ namespace SVSim.Database.Migrations
b.ToTable("Packs");
});
modelBuilder.Entity("SVSim.Database.Models.PackDrawCardWeightEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("CardId")
.HasColumnType("bigint");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsAltArt")
.HasColumnType("boolean");
b.Property<bool>("IsLeader")
.HasColumnType("boolean");
b.Property<int>("PackId")
.HasColumnType("integer");
b.Property<double?>("RatePct")
.HasColumnType("double precision");
b.Property<int>("Slot")
.HasColumnType("integer");
b.Property<int>("Tier")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("PackId", "Slot", "Tier");
b.ToTable("PackDrawCardWeights");
});
modelBuilder.Entity("SVSim.Database.Models.PackDrawConfigEntry", b =>
{
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<double>("AnimationRatePct")
.HasColumnType("double precision");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<bool>("HasBonusSlot")
.HasColumnType("boolean");
b.Property<string>("ShortCode")
.HasColumnType("text");
b.Property<string>("SpecialKind")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("PackDrawConfigs");
});
modelBuilder.Entity("SVSim.Database.Models.PackDrawSlotRateEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<int>("PackId")
.HasColumnType("integer");
b.Property<double>("RatePct")
.HasColumnType("double precision");
b.Property<int>("Slot")
.HasColumnType("integer");
b.Property<int>("Tier")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("PackId", "Slot", "Tier")
.IsUnique();
b.ToTable("PackDrawSlotRates");
});
modelBuilder.Entity("SVSim.Database.Models.PaymentItemEntry", b =>
{
b.Property<int>("Id")

View File

@@ -6,6 +6,7 @@ namespace SVSim.Database.Models.Config;
/// <see cref="ShippedDefaults"/>, not in the initialiser — see PerSlot docstring.
/// </summary>
[ConfigSection("PackRates")]
[Obsolete("PackRateConfig is no longer consulted by PackOpenService — per-pack rates come from PackDrawTable. Retire once v1 stabilizes.")]
public class PackRateConfig
{
/// <summary>

View File

@@ -33,6 +33,13 @@ public class PackConfigEntry : BaseEntity<int>
public int OpenCountLimit { get; set; }
/// <summary>
/// Server admin gate. True for live-capture-derived rows; false for synthesized stubs
/// (operator opt-in per pack). Filtered in PackRepository.GetActivePacks; distinct from
/// the wire-mirror IsHide.
/// </summary>
public bool IsEnabled { get; set; } = true;
public PackGachaPointConfig? GachaPointConfig { get; set; }
public List<PackBannerEntry> Banners { get; set; } = new();

View File

@@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations.Schema;
using SVSim.Database.Common;
using SVSim.Database.Enums;
namespace SVSim.Database.Models;
/// <summary>
/// Per-card-rate fact: which card prints in which (pack, slot, tier) at what rate.
/// RatePct is nullable for rate-less "Guaranteed Leader Card" rows (sampler uses
/// "uniform over (pool minus owned)" in that case).
/// </summary>
public class PackDrawCardWeightEntry : BaseEntity<long>
{
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public override long Id { get; set; }
public int PackId { get; set; }
public DrawSlot Slot { get; set; }
public DrawTier Tier { get; set; }
public long CardId { get; set; }
public double? RatePct { get; set; }
public bool IsLeader { get; set; }
public bool IsAltArt { get; set; }
}

View File

@@ -0,0 +1,16 @@
using SVSim.Database.Common;
namespace SVSim.Database.Models;
/// <summary>
/// One row per pack covered by drawrates data. PK is the pack id (matches PackConfigEntry.Id
/// for live-capture rows; standalone for archive-only packs). Weak relationship — PackDraw rows
/// exist for all archived packs even when no PackConfigEntry is enabled.
/// </summary>
public class PackDrawConfigEntry : BaseEntity<int>
{
public double AnimationRatePct { get; set; }
public bool HasBonusSlot { get; set; }
public string? SpecialKind { get; set; }
public string? ShortCode { get; set; }
}

View File

@@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations.Schema;
using SVSim.Database.Common;
using SVSim.Database.Enums;
namespace SVSim.Database.Models;
/// <summary>
/// Per (pack, slot, tier) rate. Natural key (PackId, Slot, Tier) is enforced via unique index.
/// Id is auto-generated — override BaseEntity's [DatabaseGenerated(None)] default.
/// </summary>
public class PackDrawSlotRateEntry : BaseEntity<long>
{
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public override long Id { get; set; }
public int PackId { get; set; }
public DrawSlot Slot { get; set; }
public DrawTier Tier { get; set; }
public double RatePct { get; set; }
}

View File

@@ -12,7 +12,7 @@ public class PackRepository : IPackRepository
await _db.Packs
.Include(p => p.ChildGachas)
.Include(p => p.Banners)
.Where(p => p.CommenceDate <= now && p.CompleteDate >= now)
.Where(p => p.IsEnabled && p.CommenceDate <= now && p.CompleteDate >= now)
// parent_gacha_id DESC matches the prod /pack/info wire order. The tutorial pack
// UI runs with controls locked and auto-selects the FIRST entry in
// pack_config_list, so the legendary starter pack (99047) MUST be index 0 for the

View File

@@ -0,0 +1,7 @@
namespace SVSim.Database.Repositories.PackDrawTables;
public interface IPackDrawTableRepository
{
/// <summary>Returns the draw table for <paramref name="packId"/>, or null if not seeded.</summary>
Task<PackDrawTable?> GetAsync(int packId);
}

View File

@@ -0,0 +1,14 @@
using SVSim.Database.Models;
namespace SVSim.Database.Repositories.PackDrawTables;
/// <summary>
/// All draw data for a single pack: per-pack config + slot rates + per-card weights.
/// Loaded as one unit by <see cref="IPackDrawTableRepository.GetAsync"/>.
/// </summary>
public sealed class PackDrawTable
{
public required PackDrawConfigEntry Config { get; init; }
public required IReadOnlyList<PackDrawSlotRateEntry> SlotRates { get; init; }
public required IReadOnlyList<PackDrawCardWeightEntry> CardWeights { get; init; }
}

View File

@@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore;
namespace SVSim.Database.Repositories.PackDrawTables;
public class PackDrawTableRepository : IPackDrawTableRepository
{
private readonly SVSimDbContext _db;
public PackDrawTableRepository(SVSimDbContext db) { _db = db; }
public async Task<PackDrawTable?> GetAsync(int packId)
{
var config = await _db.PackDrawConfigs.FirstOrDefaultAsync(c => c.Id == packId);
if (config is null) return null;
var slotRates = await _db.PackDrawSlotRates
.Where(s => s.PackId == packId)
.ToListAsync();
var cardWeights = await _db.PackDrawCardWeights
.Where(w => w.PackId == packId)
.ToListAsync();
return new PackDrawTable
{
Config = config,
SlotRates = slotRates,
CardWeights = cardWeights,
};
}
}

View File

@@ -68,6 +68,9 @@ public class SVSimDbContext : DbContext
public DbSet<SpecialDeckFormatEntry> SpecialDeckFormats => Set<SpecialDeckFormatEntry>();
public DbSet<PaymentItemEntry> PaymentItems => Set<PaymentItemEntry>();
public DbSet<PackConfigEntry> Packs => Set<PackConfigEntry>();
public DbSet<PackDrawConfigEntry> PackDrawConfigs => Set<PackDrawConfigEntry>();
public DbSet<PackDrawSlotRateEntry> PackDrawSlotRates => Set<PackDrawSlotRateEntry>();
public DbSet<PackDrawCardWeightEntry> PackDrawCardWeights => Set<PackDrawCardWeightEntry>();
public DbSet<BuildDeckSeriesEntry> BuildDeckSeries => Set<BuildDeckSeriesEntry>();
public DbSet<BuildDeckProductEntry> BuildDeckProducts => Set<BuildDeckProductEntry>();
public DbSet<StoryDeckEntry> StoryDecks => Set<StoryDeckEntry>();
@@ -146,6 +149,15 @@ public class SVSimDbContext : DbContext
modelBuilder.Entity<PackConfigEntry>().OwnsMany(p => p.ChildGachas);
modelBuilder.Entity<PackConfigEntry>().OwnsMany(p => p.Banners);
modelBuilder.Entity<PackDrawSlotRateEntry>(e =>
{
e.HasIndex(x => new { x.PackId, x.Slot, x.Tier }).IsUnique();
});
modelBuilder.Entity<PackDrawCardWeightEntry>(e =>
{
e.HasIndex(x => new { x.PackId, x.Slot, x.Tier });
});
modelBuilder.Entity<Viewer>().OwnsMany(v => v.PackOpenCounts);
// OwnedCardEntry and OwnedItemEntry use composite PK (ViewerId, Id) where Id is auto-

View File

@@ -1,11 +1,12 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Viewer;
using SVSim.EmulatedEntrypoint.Extensions;
using SVSim.EmulatedEntrypoint.Infrastructure;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Admin;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Admin;
@@ -20,11 +21,14 @@ public class AdminController : SVSimController
{
private readonly IViewerRepository _viewerRepository;
private readonly SVSimDbContext _dbContext;
private readonly ILogger<AdminController> _logger;
public AdminController(IViewerRepository viewerRepository, SVSimDbContext dbContext)
public AdminController(IViewerRepository viewerRepository, SVSimDbContext dbContext,
ILogger<AdminController> logger)
{
_viewerRepository = viewerRepository;
_dbContext = dbContext;
_logger = logger;
}
/// <summary>
@@ -81,6 +85,9 @@ public class AdminController : SVSimController
.Include(v => v.Degrees)
.Include(v => v.LeaderSkins)
.Include(v => v.MyPageBackgrounds)
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.Items).ThenInclude(i => i.Item)
.Include(v => v.Decks).ThenInclude(d => d.Cards)
.FirstAsync(v => v.Id == viewerId);
if (request.DisplayName is not null) viewer.DisplayName = request.DisplayName;
@@ -124,25 +131,145 @@ public class AdminController : SVSimController
}
}
// Clone the 8 starter decks into the viewer when freshly created — workaround for a
// client-side NRE in the deck-edit menu (DeckListUI.IsVisibleCreateNewButton at
// decompile Wizard/DeckListUI.cs:316 unconditionally reads `_deckGroup.DeckFormat`, but
// _deckGroup is null when GetCustomDeckGroup() finds no matching CustomDeck group in
// DeckGroupDataBase — which is exactly what happens for a fresh viewer). Prod players
// acquire decks via tutorial; we shortcut by seeding the 8 defaults at import time.
// See docs/audits/deck-edit-empty-decklist-nre-2026-05-23.md for the full background.
if (wasCreated)
// Accumulates distinct card_ids referenced by the import (owned list + deck lists)
// that aren't in our card master. Surfaced in the response and logged after save.
var skippedCardIds = new HashSet<long>();
if (request.OwnedCards is not null)
{
await CloneDefaultDecksToViewerAsync(viewer);
var wanted = request.OwnedCards
.GroupBy(c => c.CardId)
.Select(g => g.First())
.ToList();
var ids = wanted.Select(c => c.CardId).ToList();
var cardMaster = await _dbContext.Cards
.Where(c => ids.Contains(c.Id))
.ToDictionaryAsync(c => c.Id);
viewer.Cards.Clear();
foreach (var c in wanted)
{
if (!cardMaster.TryGetValue(c.CardId, out var card))
{
skippedCardIds.Add(c.CardId);
continue;
}
viewer.Cards.Add(new OwnedCardEntry
{
Card = card,
Count = Math.Clamp(c.Count, 1, OwnedCardEntry.MaxCopies),
IsProtected = c.IsProtected,
});
}
}
if (request.Items is not null)
{
var wanted = request.Items
.GroupBy(i => i.ItemId)
.Select(g => g.First())
.ToList();
var ids = wanted.Select(i => i.ItemId).ToList();
var itemMaster = await _dbContext.Items
.Where(i => ids.Contains(i.Id))
.ToDictionaryAsync(i => i.Id);
viewer.Items.Clear();
foreach (var i in wanted)
{
if (!itemMaster.TryGetValue(i.ItemId, out var item)) continue; // unknown master id
viewer.Items.Add(new OwnedItemEntry { Item = item, Count = i.Count, Viewer = viewer });
}
}
if (request.Decks is not null)
{
var allDeckCardIds = request.Decks
.Where(d => d.CardIdArray is not null)
.SelectMany(d => d.CardIdArray!)
.Distinct()
.ToList();
var deckCardMaster = await _dbContext.Cards
.Where(c => allDeckCardIds.Contains(c.Id))
.ToDictionaryAsync(c => c.Id);
var classes = await _dbContext.Classes.Include(c => c.LeaderSkins).ToDictionaryAsync(c => c.Id);
var sleeves = await _dbContext.Sleeves.ToDictionaryAsync(s => (long)s.Id);
var leaderSkins = await _dbContext.LeaderSkins.ToDictionaryAsync(s => s.Id);
var defaultSleeve = await _dbContext.Sleeves.FindAsync((int)DefaultSleeveId);
var latestMyRotationId = (await _dbContext.MyRotationSettings.AsNoTracking()
.Select(s => (int?)s.Id)
.OrderByDescending(id => id)
.FirstOrDefaultAsync())?.ToString();
_dbContext.RemoveRange(viewer.Decks);
viewer.Decks.Clear();
foreach (var d in request.Decks)
{
// A /load/index dump carries every deck slot, most of them empty placeholders
// (no cards). Skip them: the client manages empty slots itself (it's why the old
// default-deck cloning was removed), and importing empty MyRotation slots would
// otherwise persist decks with a bogus rotation id.
if ((d.CardIdArray?.Count ?? 0) == 0) continue;
Format format;
try { format = FormatExtensions.FromApi(d.DeckFormat); }
catch (ArgumentOutOfRangeException) { continue; } // skip unsupported wire format
if (!classes.TryGetValue(d.ClassId, out var classEntry)) continue;
SleeveEntry? sleeve = null;
if (d.SleeveId.HasValue) sleeves.TryGetValue(d.SleeveId.Value, out sleeve);
sleeve ??= defaultSleeve;
LeaderSkinEntry? leaderSkin = null;
if (d.LeaderSkinId.HasValue) leaderSkins.TryGetValue(d.LeaderSkinId.Value, out leaderSkin);
leaderSkin ??= classEntry.DefaultLeaderSkin ?? classEntry.LeaderSkins.FirstOrDefault();
if (sleeve is null || leaderSkin is null) continue;
var cards = (d.CardIdArray ?? new List<long>())
.GroupBy(id => id)
.Where(g =>
{
if (deckCardMaster.ContainsKey(g.Key)) return true;
skippedCardIds.Add(g.Key);
return false;
})
.Select(g => new DeckCard { Card = deckCardMaster[g.Key], Count = g.Count() })
.ToList();
viewer.Decks.Add(new ShadowverseDeckEntry
{
Name = d.DeckName ?? $"Deck {d.DeckNo}",
Number = d.DeckNo,
Format = format,
Class = classEntry,
Sleeve = sleeve,
LeaderSkin = leaderSkin,
RandomLeaderSkin = (d.IsRandomLeaderSkin ?? 0) != 0,
Cards = cards,
MyRotationId = format == Format.MyRotation ? (d.MyRotationId ?? latestMyRotationId) : null,
});
}
}
await _dbContext.SaveChangesAsync();
if (skippedCardIds.Count > 0)
{
_logger.LogWarning(
"ImportViewer (steam_id={SteamId}, viewer_id={ViewerId}): skipped {Count} unknown " +
"card_id(s) not present in the card master. Sample: [{Sample}]",
request.SteamId, viewer.Id, skippedCardIds.Count,
string.Join(", ", skippedCardIds.Take(20)));
}
return new ImportViewerResponse
{
ViewerId = viewer.Id,
ShortUdid = viewer.ShortUdid,
WasCreated = wasCreated
WasCreated = wasCreated,
SkippedCardCount = skippedCardIds.Count,
};
}
@@ -162,81 +289,8 @@ public class AdminController : SVSimController
}
/// <summary>
/// Default sleeve id used for cloned starter decks. Matches prod's wire shape — every
/// default_deck_list entry on /deck/info has <c>sleeve_id: 3000011</c>.
/// Fallback sleeve id used when an imported deck has no resolvable <c>sleeve_id</c>.
/// 3000011 is prod's default deck sleeve.
/// </summary>
private const long DefaultSleeveId = 3000011L;
/// <summary>
/// Formats we clone the starter decks into. Each format the player can open the deck-edit
/// menu for needs at least one CustomDeck group in <c>Data.DeckGroupDataBase</c>, otherwise
/// the client NREs on <c>_deckGroup.DeckFormat</c> in DeckListUI.IsVisibleCreateNewButton.
/// Rotation / Unlimited / MyRotation are the always-active base formats; PreRotation /
/// Crossover / Avatar are seasonal and gated by UI state — leave them empty for now (see
/// docs/audits/deck-edit-empty-decklist-nre-2026-05-23.md follow-ups).
/// </summary>
private static readonly Format[] SeededDeckFormats = { Format.Rotation, Format.Unlimited, Format.MyRotation };
/// <summary>
/// Materialize the 8 default decks into the viewer's deck collection, once per seeded format.
/// The tracked <paramref name="viewer"/> instance gets new ShadowverseDeckEntry rows added to
/// its Decks navigation; EF picks them up on the caller's SaveChangesAsync.
/// </summary>
private async Task CloneDefaultDecksToViewerAsync(Viewer viewer)
{
var defaultDecks = await _dbContext.DefaultDecks.AsNoTracking().OrderBy(d => d.Id).ToListAsync();
if (defaultDecks.Count == 0) return;
// Resolve nav-property entities once. Classes need LeaderSkins included for the
// DefaultLeaderSkin nav lookup. Cards are fetched in one bulk query keyed by id.
var classes = await _dbContext.Classes.Include(c => c.LeaderSkins).ToDictionaryAsync(c => c.Id);
var defaultSleeve = await _dbContext.Sleeves.FindAsync((int)DefaultSleeveId);
var allCardIds = defaultDecks
.SelectMany(d => JsonSerializer.Deserialize<List<long>>(d.CardIdArray, JsonbReadOptions.Instance) ?? new List<long>())
.Distinct()
.ToList();
var cards = await _dbContext.Cards.Where(c => allCardIds.Contains(c.Id)).ToDictionaryAsync(c => c.Id);
// Seeded MyRotation placeholder decks need a real rotation_id, otherwise the client's
// DeckData.GetMyRotationClassName NREs on `info.LastPackText` when the user clicks one
// (info is null because Data.MyRotationAllInfo.Get(null) returns null). Pick the highest
// rotation id available — it includes the most recent pack and therefore covers every
// class (including class_id=8 Nemesis, which requires last_pack >= 10007).
var latestMyRotationId = (await _dbContext.MyRotationSettings.AsNoTracking()
.Select(s => (int?)s.Id)
.OrderByDescending(id => id)
.FirstOrDefaultAsync())?.ToString();
foreach (var format in SeededDeckFormats)
{
int slot = 1;
foreach (var d in defaultDecks)
{
if (!classes.TryGetValue(d.ClassId, out var classEntry)) continue;
var leaderSkin = classEntry.DefaultLeaderSkin ?? classEntry.LeaderSkins.FirstOrDefault();
if (leaderSkin is null || defaultSleeve is null) continue;
var cardIdArray = JsonSerializer.Deserialize<List<long>>(d.CardIdArray, JsonbReadOptions.Instance) ?? new List<long>();
var deckCards = cardIdArray
.GroupBy(id => id)
.Where(g => cards.ContainsKey(g.Key))
.Select(g => new DeckCard { Card = cards[g.Key], Count = g.Count() })
.ToList();
viewer.Decks.Add(new ShadowverseDeckEntry
{
Name = d.DeckName,
Number = slot++,
Format = format,
Class = classEntry,
Sleeve = defaultSleeve,
LeaderSkin = leaderSkin,
RandomLeaderSkin = false,
Cards = deckCards,
MyRotationId = format == Format.MyRotation ? latestMyRotationId : null,
});
}
}
}
}

View File

@@ -5,6 +5,7 @@ using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Pack;
using SVSim.Database.Repositories.PackDrawTables;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Pack;
@@ -25,7 +26,8 @@ public class PackController : SVSimController
private readonly IPackRepository _packs;
private readonly PackOpenService _opener;
private readonly ICardPoolProvider _pools;
private readonly IPackDrawTableRepository _drawTables;
private readonly ICardFoilLookup _foils;
private readonly IRandom _rng;
private readonly SVSimDbContext _db;
private readonly ICardAcquisitionService _acquisition;
@@ -36,7 +38,8 @@ public class PackController : SVSimController
public PackController(
IPackRepository packs,
PackOpenService opener,
ICardPoolProvider pools,
IPackDrawTableRepository drawTables,
ICardFoilLookup foils,
IRandom rng,
SVSimDbContext db,
ICardAcquisitionService acquisition,
@@ -46,7 +49,8 @@ public class PackController : SVSimController
{
_packs = packs;
_opener = opener;
_pools = pools;
_drawTables = drawTables;
_foils = foils;
_rng = rng;
_db = db;
_acquisition = acquisition;
@@ -343,7 +347,28 @@ public class PackController : SVSimController
// Draw + persist. DAILY single overrides packNumber to 1 (it's a one-card open).
int drawCount = child.IsDailySingle ? 1 : packNumber;
var draw = _opener.Draw(pack, _pools, drawCount, request.ExcludeCardIds, _rng);
var drawTable = await _drawTables.GetAsync(pack.Id);
if (drawTable is null)
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "pack_draw_table_missing" });
// Owned card_ids for the rate-less Guaranteed-Leader-Card branch. Project to longs to
// avoid pulling viewer.Cards entities into memory. Shadow-FK access (EF.Property) per
// the project_ef_nav_include_pitfall memory.
var ownedCardIds = await _db.Viewers
.Where(v => v.Id == viewerId)
.SelectMany(v => v.Cards)
.Select(c => (long)EF.Property<int>(c, "CardId"))
.ToListAsync();
var draw = _opener.Draw(
drawTable,
pack,
drawCount,
request.ExcludeCardIds ?? Array.Empty<long>(),
ownedCardIds,
_foils,
_rng);
var grant = await _acquisition.GrantManyAsync(viewerId, draw.Cards.Select(c => c.CardId));
// Accrue gacha points (skip tutorial path — the starter pack isn't a real open).

View File

@@ -0,0 +1,29 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common;
/// <summary>
/// Reads a JSON string OR number as a nullable string, tolerating prod's polymorphic id fields.
/// <c>rotation_id</c> on a /load/index <c>UserDeck</c> is a numeric string ("10008") for real
/// MyRotation decks but a bare number (<c>0</c>) for unset slots — and the global
/// <c>AllowReadingFromString</c> only covers the string→number direction, not number→string, so a
/// plain <c>string?</c> property 400s on the numeric form. Null stays null; numbers serialize via
/// invariant culture so a captured <c>0</c> round-trips to <c>"0"</c>.
/// </summary>
public sealed class FlexibleStringConverter : JsonConverter<string?>
{
public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
reader.TokenType switch
{
JsonTokenType.Null => null,
JsonTokenType.String => reader.GetString(),
JsonTokenType.Number when reader.TryGetInt64(out var n) => n.ToString(CultureInfo.InvariantCulture),
JsonTokenType.Number => reader.GetDouble().ToString(CultureInfo.InvariantCulture),
_ => throw new JsonException($"Unexpected token {reader.TokenType} for a string-or-number field.")
};
public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options) =>
writer.WriteStringValue(value);
}

View File

@@ -1,4 +1,5 @@
using System.Text.Json.Serialization;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Admin;
@@ -26,6 +27,29 @@ public class ImportViewerRequest
[JsonPropertyName("owned_mypage_background_ids")] public List<int>? OwnedMyPageBackgroundIds { get; set; }
[JsonPropertyName("classes")] public List<ImportClassData>? Classes { get; set; }
[JsonPropertyName("owned_cards")] public List<ImportCard>? OwnedCards { get; set; }
[JsonPropertyName("items")] public List<ImportItem>? Items { get; set; }
[JsonPropertyName("decks")] public List<ImportDeck>? Decks { get; set; }
}
public class ImportDeck
{
[JsonPropertyName("deck_format")] public int DeckFormat { get; set; } // wire code; map via FormatExtensions.FromApi
[JsonPropertyName("deck_no")] public int DeckNo { get; set; }
[JsonPropertyName("deck_name")] public string? DeckName { get; set; }
[JsonPropertyName("class_id")] public int ClassId { get; set; }
[JsonPropertyName("card_id_array")] public List<long>? CardIdArray { get; set; }
[JsonPropertyName("sleeve_id")] public long? SleeveId { get; set; }
[JsonPropertyName("leader_skin_id")] public int? LeaderSkinId { get; set; }
[JsonPropertyName("is_random_leader_skin")] public int? IsRandomLeaderSkin { get; set; }
// Prod emits rotation_id as a numeric string ("10008") for real MyRotation decks but a bare
// number (0) for unset slots; FlexibleStringConverter accepts either (a plain string? 400s on
// the numeric form because AllowReadingFromString only covers string→number).
[JsonPropertyName("my_rotation_id")] [JsonConverter(typeof(FlexibleStringConverter))]
public string? MyRotationId { get; set; }
}
public class ImportCurrency
@@ -41,3 +65,16 @@ public class ImportClassData
[JsonPropertyName("level")] public int Level { get; set; }
[JsonPropertyName("exp")] public int Exp { get; set; }
}
public class ImportCard
{
[JsonPropertyName("card_id")] public long CardId { get; set; }
[JsonPropertyName("count")] public int Count { get; set; }
[JsonPropertyName("is_protected")] public bool IsProtected { get; set; }
}
public class ImportItem
{
[JsonPropertyName("item_id")] public int ItemId { get; set; }
[JsonPropertyName("count")] public int Count { get; set; }
}

View File

@@ -7,4 +7,5 @@ public class ImportViewerResponse
[JsonPropertyName("viewer_id")] public long ViewerId { get; set; }
[JsonPropertyName("short_udid")] public long ShortUdid { get; set; }
[JsonPropertyName("was_created")] public bool WasCreated { get; set; }
[JsonPropertyName("skipped_card_count")] public int SkippedCardCount { get; set; }
}

View File

@@ -74,12 +74,13 @@ public class Program
builder.Services.AddTransient<IPuzzleCatalogRepository, PuzzleCatalogRepository>();
builder.Services.AddTransient<IDeckRepository, DeckRepository>();
builder.Services.AddTransient<IPackRepository, PackRepository>();
builder.Services.AddScoped<SVSim.Database.Repositories.PackDrawTables.IPackDrawTableRepository, SVSim.Database.Repositories.PackDrawTables.PackDrawTableRepository>();
builder.Services.AddTransient<IBuildDeckRepository, BuildDeckRepository>();
// Scoped (not Singleton) to avoid the singleton-depends-on-scoped-DbContext lifecycle
// pitfall. Cost: one indexed single-row query per section per request — trivial. No
// in-process cache today; the IGameConfigService interface is shaped to allow one later.
builder.Services.AddScoped<SVSim.Database.Services.IGameConfigService, GameConfigService>();
builder.Services.AddScoped<ICardPoolProvider, DbCardPoolProvider>();
builder.Services.AddScoped<ICardFoilLookup, DbCardFoilLookup>();
builder.Services.AddScoped<PackOpenService>();
builder.Services.AddScoped<IGachaPointService, GachaPointService>();
builder.Services.AddScoped<ICardAcquisitionService, CardAcquisitionService>();

View File

@@ -0,0 +1,13 @@
using SVSim.Database;
using SVSim.Database.Models;
namespace SVSim.EmulatedEntrypoint.Services;
public class DbCardFoilLookup : ICardFoilLookup
{
private readonly SVSimDbContext _db;
public DbCardFoilLookup(SVSimDbContext db) { _db = db; }
public ShadowverseCardEntry? TryGetFoilTwin(long baseCardId) =>
_db.Cards.FirstOrDefault(c => c.Id == baseCardId + 1 && c.IsFoil);
}

View File

@@ -1,61 +0,0 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
namespace SVSim.EmulatedEntrypoint.Services;
public class DbCardPoolProvider : ICardPoolProvider
{
private readonly SVSimDbContext _db;
public DbCardPoolProvider(SVSimDbContext db) { _db = db; }
public IReadOnlyList<ShadowverseCardEntry> GetPool(PackConfigEntry pack)
{
switch (pack.PackCategory)
{
case PackCategory.None:
case PackCategory.LegendCardPack:
{
var pool = _db.CardSets
.Where(s => s.Id == pack.BasePackId)
.SelectMany(s => s.Cards)
.Where(c => !c.IsFoil)
.ToList();
if (pool.Count > 0) return pool;
// BasePackId 90001 (and the 9xxxx range generally) is a synthetic "Throwback
// Rotation" category that doesn't have a corresponding real card_set in the
// prod card master — its real pool is a curated subset of rotation-eligible
// older sets (AltersphereColosseum for 99047; see the gacha_detail string).
// We don't have that membership map, so fall back to all in-rotation cards.
// Broader pool than prod but produces a valid 8-card draw, which is what the
// tutorial flow needs to advance to step 100.
// TODO: import the real Throwback Rotation card-set membership and key the
// pool off that. Source data is in the client's pack-pool master, not yet
// captured.
return _db.CardSets
.Where(s => s.IsInRotation)
.SelectMany(s => s.Cards)
.Where(c => !c.IsFoil)
.Distinct()
.ToList();
}
case PackCategory.SpecialCardPack:
case PackCategory.LimitedSpecialCardPack:
return _db.CardSets
.Where(s => s.IsInRotation)
.SelectMany(s => s.Cards)
.Where(c => !c.IsFoil)
.Distinct()
.ToList();
default:
return Array.Empty<ShadowverseCardEntry>();
}
}
public ShadowverseCardEntry? TryGetFoilTwin(long baseCardId) =>
_db.Cards.FirstOrDefault(c => c.Id == baseCardId + 1 && c.IsFoil);
}

View File

@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.PackDrawTables;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Models.Dtos;
@@ -11,13 +12,13 @@ namespace SVSim.EmulatedEntrypoint.Services;
public sealed class GachaPointService : IGachaPointService
{
private readonly SVSimDbContext _db;
private readonly ICardPoolProvider _pools;
private readonly IPackDrawTableRepository _drawTables;
private readonly RewardGrantService _grants;
public GachaPointService(SVSimDbContext db, ICardPoolProvider pools, RewardGrantService grants)
public GachaPointService(SVSimDbContext db, IPackDrawTableRepository drawTables, RewardGrantService grants)
{
_db = db;
_pools = pools;
_drawTables = drawTables;
_grants = grants;
}
@@ -26,7 +27,8 @@ public sealed class GachaPointService : IGachaPointService
var pack = await _db.Packs.FirstOrDefaultAsync(p => p.Id == packId);
if (pack?.GachaPointConfig is null) return Array.Empty<GachaPointRewardDto>();
var pool = _pools.GetPool(pack);
var drawTable = await _drawTables.GetAsync(packId);
if (drawTable is null) return Array.Empty<GachaPointRewardDto>();
// EF Core 8 has no ToHashSetAsync on IQueryable — materialize via ToListAsync then hash.
var receivedCardIds = (await _db.Viewers
@@ -36,9 +38,11 @@ public sealed class GachaPointService : IGachaPointService
.Select(r => r.CardId)
.ToListAsync()).ToHashSet();
var legendaryCardIds = pool
.Where(c => c.Rarity == Rarity.Legendary && !c.IsFoil)
.Select(c => c.Id)
// Legendaries in the pack's draw table — exchange ignores foils (the alt-art foil
// printing is gated separately) and tiers other than Legendary.
var legendaryCardIds = drawTable.CardWeights
.Where(w => w.Tier == DrawTier.Legendary && !w.IsAltArt)
.Select(w => w.CardId)
.ToHashSet();
// Re-query legendaries with Class loaded — pool provider doesn't include navs,

View File

@@ -0,0 +1,13 @@
using SVSim.Database.Models;
namespace SVSim.EmulatedEntrypoint.Services;
/// <summary>
/// Looks up the foil printing of a card. The card-master convention is foil_id = base_id + 1
/// with the IsFoil flag set; leader-card / alt-art printings typically have no foil twin
/// (TryGetFoilTwin returns null and the sampler silently keeps the base).
/// </summary>
public interface ICardFoilLookup
{
ShadowverseCardEntry? TryGetFoilTwin(long baseCardId);
}

View File

@@ -1,17 +0,0 @@
using SVSim.Database.Models;
namespace SVSim.EmulatedEntrypoint.Services;
/// <summary>Resolves the card pool a pack draws from. Pure function over master data.</summary>
public interface ICardPoolProvider
{
IReadOnlyList<ShadowverseCardEntry> GetPool(PackConfigEntry pack);
/// <summary>
/// Returns the foil twin of <paramref name="baseCardId"/> if it exists in master data
/// (foil card_id = base card_id + 1 by the cards.json convention), else null. One DB
/// hit per call; expected ~0.64 calls per 8-card pack at the default 8% rate.
/// TODO(caching): folds into the broader caching wave once one exists.
/// </summary>
ShadowverseCardEntry? TryGetFoilTwin(long baseCardId);
}

View File

@@ -1,134 +1,152 @@
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Models.Config;
using SVSim.Database.Repositories.PackDrawTables;
using SVSim.Database.Services;
namespace SVSim.EmulatedEntrypoint.Services;
/// <summary>
/// Draws cards from a pack's pool using rates from <see cref="IGameConfigService"/>'s
/// <see cref="PackRateConfig"/>. Slot rarity selection is unified through one
/// <see cref="PickRarity"/> + <see cref="ResolveWeights"/> pair — what was previously a
/// hardcoded slot-1-7 vs slot-8 split now reads from <c>PackRateConfig.PerSlot</c>.
///
/// The "legendary-special slot-8 forced Legendary" rule stays in code (structural category
/// promise, not a tunable rate).
/// Draws cards from a pack's per-pack draw table. Slot tier and per-card weights are sampled
/// directly from the seeded data. The bonus slot fires once at the end of a 10-pack open
/// when <see cref="PackDrawConfigEntry.HasBonusSlot"/> is set.
/// </summary>
public class PackOpenService
{
private const int CardsPerPack = 8;
private readonly PackRateConfig _rates;
public PackOpenService(IGameConfigService config)
{
_rates = config.Get<PackRateConfig>();
}
public DrawResult Draw(
PackDrawTable drawTable,
PackConfigEntry pack,
ICardPoolProvider pools,
int packNumber,
IReadOnlyCollection<long> excludeCardIds,
IReadOnlyCollection<long> ownedCardIds,
ICardFoilLookup foilLookup,
IRandom rng)
{
var pool = pools.GetPool(pack);
if (pool.Count == 0)
{
throw new InvalidOperationException(
$"PackOpenService: pool for pack {pack.Id} (category {pack.PackCategory}) is empty.");
}
var poolByRarity = pool
.Where(c => !excludeCardIds.Contains(c.Id))
.GroupBy(c => c.Rarity)
var byKey = drawTable.CardWeights
.GroupBy(w => (w.Slot, w.Tier))
.ToDictionary(g => g.Key, g => g.ToList());
bool isLegendarySpecial =
pack.PackCategory == PackCategory.SpecialCardPack ||
pack.PackCategory == PackCategory.LimitedSpecialCardPack;
var slotRatesByKey = drawTable.SlotRates
.GroupBy(s => s.Slot)
.ToDictionary(g => g.Key, g => g.ToList());
var slots = new List<DrawnCard>(packNumber * CardsPerPack + 1);
var slots = new List<DrawnCard>(packNumber * CardsPerPack);
for (int p = 0; p < packNumber; p++)
{
for (int s = 0; s < CardsPerPack; s++)
{
int slotNum = s + 1; // 1-based
Rarity rarity;
if (slotNum == CardsPerPack && isLegendarySpecial)
{
// Structural category rule (not a tunable rate).
rarity = Rarity.Legendary;
}
else
{
rarity = PickRarity(rng, ResolveWeights(slotNum));
}
var card = PickCardOfRarity(rarity, poolByRarity, rng);
// Per-card, per-slot animated upgrade. Applies independently of rarity, slot
// position, and pack category — including forced-Legendary slot-8 of specials.
if (rng.NextDouble() < _rates.AnimatedRate)
{
var foil = pools.TryGetFoilTwin(card.Id);
if (foil is not null) card = foil; // silently keep base if no twin exists
}
slots.Add(new DrawnCard(card.Id, card.Rarity));
int slotNum = s + 1;
var slot = slotNum == CardsPerPack ? DrawSlot.Eighth : DrawSlot.General;
var drawn = DrawOne(slot, drawTable, byKey, slotRatesByKey,
excludeCardIds, ownedCardIds, foilLookup, rng);
slots.Add(drawn);
}
}
if (drawTable.Config.HasBonusSlot && packNumber == 10)
{
var bonus = DrawOne(DrawSlot.Bonus, drawTable, byKey, slotRatesByKey,
excludeCardIds, ownedCardIds, foilLookup, rng);
slots.Add(bonus);
}
return new DrawResult(slots);
}
/// <summary>
/// Returns the rarity weights for the given 1-based slot. Looks for a per-slot override
/// keyed by <c>Slot == slotNum.ToString()</c>; falls back to the global Default.
///
/// NOTE: PerSlot is List&lt;SlotRarityWeights&gt; (not Dictionary) due to an EF Core 8
/// jsonb-mapping limitation. Per-pack overrides would extend this resolver to check a
/// per-pack collection first.
/// </summary>
private SlotRarityWeights ResolveWeights(int slotNum)
{
var slotKey = slotNum.ToString();
var perSlot = _rates.PerSlot.FirstOrDefault(s => s.Slot == slotKey);
return perSlot ?? _rates.Default;
}
private static Rarity PickRarity(IRandom rng, SlotRarityWeights w)
{
// Cumulative-band order: Legendary -> Gold -> Silver -> Bronze (catch-all).
// - When weights sum to <1.0 (SV Classic Default = 0.9994), the slack absorbs into
// Bronze via the catch-all — preserves historic behavior.
// - When weights sum to exactly 1.0 (SV Classic PerSlot[8] with Bronze=0), the catch-all
// never fires and Bronze=0 holds naturally.
double r = rng.NextDouble();
double cum = w.Legendary; if (r < cum) return Rarity.Legendary;
cum += w.Gold; if (r < cum) return Rarity.Gold;
cum += w.Silver; if (r < cum) return Rarity.Silver;
return Rarity.Bronze;
}
private static ShadowverseCardEntry PickCardOfRarity(
Rarity rarity,
Dictionary<Rarity, List<ShadowverseCardEntry>> poolByRarity,
private static DrawnCard DrawOne(
DrawSlot slot,
PackDrawTable drawTable,
Dictionary<(DrawSlot, DrawTier), List<PackDrawCardWeightEntry>> byKey,
Dictionary<DrawSlot, List<PackDrawSlotRateEntry>> slotRatesByKey,
IReadOnlyCollection<long> excludeCardIds,
IReadOnlyCollection<long> ownedCardIds,
ICardFoilLookup foilLookup,
IRandom rng)
{
// Fallback if the rolled rarity has no cards: walk down (and up) through all rarities.
// Order: rolled rarity first, then Legendary -> Gold -> Silver -> Bronze, deduped by
// LINQ Distinct. This handles both "no Legendaries" (fall down) and sparse pools that
// only contain a single rarity (fall up). Safety net for sparse master data.
Rarity[] fallback = new[] { rarity, Rarity.Legendary, Rarity.Gold, Rarity.Silver, Rarity.Bronze }
.Distinct().ToArray();
foreach (var r in fallback)
var slotRates = slotRatesByKey.TryGetValue(slot, out var sr) ? sr : new();
if (slotRates.Count == 0)
throw new InvalidOperationException(
$"PackOpenService: no slot rates for pack {drawTable.Config.Id} slot {slot}");
var tiers = slotRates.Select(r => r.Tier).ToList();
var tierWeights = slotRates.Select(r => r.RatePct).ToList();
var tier = WeightedPick.Pick(rng, tiers, tierWeights);
// For slot 8 (and bonus), drawrates pages often quote per-rarity slot rates but no per-card
// breakdown — the card pool is the same as the general slot's per-tier pool. Fall back to
// (General, tier) when (slot, tier) has no card weights.
if (!byKey.TryGetValue((slot, tier), out var rows) && slot != DrawSlot.General)
{
if (poolByRarity.TryGetValue(r, out var list) && list.Count > 0)
{
return list[rng.Next(list.Count)];
}
byKey.TryGetValue((DrawSlot.General, tier), out rows);
}
throw new InvalidOperationException("PackOpenService: pool empty after exclude filter.");
var pool = rows ?? new();
var filtered = pool.Where(w => !excludeCardIds.Contains(w.CardId)).ToList();
if (filtered.Count == 0)
return FallbackAcrossTiers(slot, byKey, excludeCardIds, foilLookup, rng, drawTable);
bool rateLess = filtered.All(w => w.RatePct == null);
PackDrawCardWeightEntry picked;
if (rateLess)
{
var unowned = filtered.Where(w => !ownedCardIds.Contains(w.CardId)).ToList();
var sourcePool = unowned.Count > 0 ? unowned : filtered;
picked = sourcePool[rng.Next(sourcePool.Count)];
}
else
{
var metered = filtered.Where(w => w.RatePct.HasValue).ToList();
if (metered.Count == 0)
return FallbackAcrossTiers(slot, byKey, excludeCardIds, foilLookup, rng, drawTable);
picked = WeightedPick.Pick(rng, metered, metered.Select(w => w.RatePct!.Value).ToList());
}
long cardId = picked.CardId;
if (drawTable.Config.AnimationRatePct > 0
&& rng.NextDouble() < drawTable.Config.AnimationRatePct / 100.0)
{
var foil = foilLookup.TryGetFoilTwin(cardId);
if (foil is not null) cardId = foil.Id;
}
var rarity = TierToRarity(picked);
return new DrawnCard(cardId, rarity);
}
private static DrawnCard FallbackAcrossTiers(
DrawSlot slot,
Dictionary<(DrawSlot, DrawTier), List<PackDrawCardWeightEntry>> byKey,
IReadOnlyCollection<long> excludeCardIds,
ICardFoilLookup foilLookup,
IRandom rng,
PackDrawTable drawTable)
{
foreach (var tier in new[] { DrawTier.Legendary, DrawTier.Gold, DrawTier.Silver, DrawTier.Bronze, DrawTier.Special })
{
if (!byKey.TryGetValue((slot, tier), out var rows)) continue;
var filtered = rows.Where(w => !excludeCardIds.Contains(w.CardId)).ToList();
if (filtered.Count == 0) continue;
var picked = filtered[rng.Next(filtered.Count)];
return new DrawnCard(picked.CardId, TierToRarity(picked));
}
throw new InvalidOperationException(
$"PackOpenService: pool empty after exclude filter for pack {drawTable.Config.Id} slot {slot}.");
}
private static Rarity TierToRarity(PackDrawCardWeightEntry w) => w.Tier switch
{
DrawTier.Bronze => Rarity.Bronze,
DrawTier.Silver => Rarity.Silver,
DrawTier.Gold => Rarity.Gold,
DrawTier.Legendary => Rarity.Legendary,
// Special tier cards typically have intrinsic Rarity.Legendary; the wire response
// surfaces Rarity as an int for client coloring and the card_id is the source of
// truth for what's granted.
DrawTier.Special => Rarity.Legendary,
_ => Rarity.Bronze,
};
}

View File

@@ -0,0 +1,30 @@
using SVSim.Database.Services;
namespace SVSim.EmulatedEntrypoint.Services;
/// <summary>
/// Generic cumulative-band weighted picker used by PackOpenService for tier-by-slot
/// and card-within-tier sampling. Renormalizes weights internally (sums &lt;1 absorb
/// into the last band; sums &gt;1 scale down).
/// </summary>
public static class WeightedPick
{
public static T Pick<T>(IRandom rng, IReadOnlyList<T> items, IReadOnlyList<double> weights)
{
if (items.Count == 0) throw new ArgumentException("WeightedPick: items is empty.");
if (items.Count != weights.Count) throw new ArgumentException("WeightedPick: items / weights length mismatch.");
double sum = 0;
for (int i = 0; i < weights.Count; i++) sum += weights[i];
if (sum <= 0) return items[rng.Next(items.Count)];
double r = rng.NextDouble() * sum;
double cum = 0;
for (int i = 0; i < items.Count - 1; i++)
{
cum += weights[i];
if (r < cum) return items[i];
}
return items[^1];
}
}

View File

@@ -115,4 +115,339 @@ public class AdminControllerTests
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}
[Test]
public async Task ImportViewer_imports_owned_cards_and_skips_unknown()
{
using var factory = new SVSimTestFactory();
using var client = factory.CreateClient();
// 10001001 is in the minimal test card set; 99999999 is not.
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
{
SteamId = 76_561_198_111_222_333UL,
OwnedCards = new List<ImportCard>
{
new() { CardId = 10001001L, Count = 2, IsProtected = true },
new() { CardId = 99999999L, Count = 1, IsProtected = false },
}
});
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
await response.Content.ReadAsStringAsync());
var body = await response.Content.ReadFromJsonAsync<ImportViewerResponse>(JsonOptions);
Assert.That(body!.SkippedCardCount, Is.EqualTo(1), "Unknown 99999999 must be skipped and counted.");
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var stored = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card)
.FirstAsync(v => v.Id == body.ViewerId);
Assert.That(stored.Cards.Count, Is.EqualTo(1), "Only the known card should be stored.");
var owned = stored.Cards.Single();
Assert.That(owned.Card.Id, Is.EqualTo(10001001L));
Assert.That(owned.Count, Is.EqualTo(2));
Assert.That(owned.IsProtected, Is.True);
}
[Test]
public async Task ImportViewer_clamps_card_count_to_max_copies()
{
using var factory = new SVSimTestFactory();
using var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
{
SteamId = 76_561_198_111_222_334UL,
OwnedCards = new List<ImportCard> { new() { CardId = 10001002L, Count = 5 } }
});
var body = await response.Content.ReadFromJsonAsync<ImportViewerResponse>(JsonOptions);
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var stored = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card)
.FirstAsync(v => v.Id == body!.ViewerId);
Assert.That(stored.Cards.Single().Count, Is.EqualTo(3),
"Count must clamp to OwnedCardEntry.MaxCopies (3).");
}
[Test]
public async Task ImportViewer_replaces_existing_card_collection()
{
using var factory = new SVSimTestFactory();
const ulong steamId = 76_561_198_111_222_335UL;
long viewerId = await factory.SeedViewerAsync(steamId: steamId);
await factory.SeedOwnedCardAsync(viewerId, 10001001L, count: 3);
using var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
{
SteamId = steamId,
OwnedCards = new List<ImportCard> { new() { CardId = 10001002L, Count = 1 } }
});
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
await response.Content.ReadAsStringAsync());
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var stored = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card)
.FirstAsync(v => v.Id == viewerId);
Assert.That(stored.Cards.Select(c => c.Card.Id), Is.EquivalentTo(new[] { 10001002L }),
"Full replace: the pre-seeded 10001001 must be gone, only 10001002 present.");
}
[Test]
public async Task ImportViewer_imports_items_and_replaces_existing()
{
using var factory = new SVSimTestFactory();
const ulong steamId = 76_561_198_111_222_336UL;
long viewerId = await factory.SeedViewerAsync(steamId: steamId);
// Registers the ItemEntry master row (70001) and gives an initial owned count to be replaced.
await factory.SeedOwnedItemAsync(viewerId, itemId: 70001, count: 1);
using var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
{
SteamId = steamId,
Items = new List<ImportItem>
{
new() { ItemId = 70001, Count = 5 },
new() { ItemId = 88888, Count = 9 }, // unknown master id -> skipped silently
}
});
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
await response.Content.ReadAsStringAsync());
Assert.That(await factory.GetOwnedItemCountAsync(viewerId, 70001), Is.EqualTo(5),
"Full replace: 70001 count updated to 5.");
Assert.That(await factory.GetOwnedItemCountAsync(viewerId, 88888), Is.EqualTo(0),
"Unknown item master id must not be inserted.");
}
[Test]
public async Task ImportViewer_imports_deck_with_correct_format_and_skips_unknown_cards()
{
using var factory = new SVSimTestFactory();
const ulong steamId = 76_561_198_111_222_337UL;
long viewerId = await factory.SeedViewerAsync(steamId: steamId);
int classId, leaderSkinId; long sleeveId;
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
classId = (await db.Classes.FirstAsync()).Id;
sleeveId = (await db.Sleeves.FirstAsync()).Id;
leaderSkinId = (await db.LeaderSkins.FirstAsync()).Id;
}
using var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
{
SteamId = steamId,
Decks = new List<ImportDeck>
{
new()
{
DeckFormat = 1, // wire Rotation
DeckNo = 1,
DeckName = "Imported Rotation",
ClassId = classId,
SleeveId = sleeveId,
LeaderSkinId = leaderSkinId,
CardIdArray = new List<long> { 10001001L, 10001001L, 99999999L }, // last is unknown
}
}
});
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
await response.Content.ReadAsStringAsync());
var body = await response.Content.ReadFromJsonAsync<ImportViewerResponse>(JsonOptions);
Assert.That(body!.SkippedCardCount, Is.EqualTo(1), "Unknown deck card 99999999 counts as skipped.");
using var scope2 = factory.Services.CreateScope();
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
var stored = await db2.Viewers
.Include(v => v.Decks).ThenInclude(d => d.Cards).ThenInclude(c => c.Card)
.FirstAsync(v => v.Id == viewerId);
var deck = stored.Decks.Single(d => d.Name == "Imported Rotation");
Assert.That(deck.Format, Is.EqualTo(Format.Rotation));
Assert.That(deck.Cards.Single().Card.Id, Is.EqualTo(10001001L));
Assert.That(deck.Cards.Single().Count, Is.EqualTo(2), "Two copies of 10001001 grouped.");
}
[Test]
public async Task ImportViewer_myrotation_deck_gets_rotation_id()
{
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync(); // populates MyRotationSettings
const ulong steamId = 76_561_198_111_222_338UL;
long viewerId = await factory.SeedViewerAsync(steamId: steamId);
int classId, leaderSkinId; long sleeveId;
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
classId = (await db.Classes.FirstAsync()).Id;
sleeveId = (await db.Sleeves.FirstAsync()).Id;
leaderSkinId = (await db.LeaderSkins.FirstAsync()).Id;
}
using var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
{
SteamId = steamId,
Decks = new List<ImportDeck>
{
new()
{
DeckFormat = 5, // wire MyRotation
DeckNo = 1,
DeckName = "Imported MyRot",
ClassId = classId,
SleeveId = sleeveId,
LeaderSkinId = leaderSkinId,
CardIdArray = new List<long> { 10001001L },
}
}
});
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
await response.Content.ReadAsStringAsync());
var body = await response.Content.ReadFromJsonAsync<ImportViewerResponse>(JsonOptions);
using var scope2 = factory.Services.CreateScope();
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
var deck = await db2.Set<SVSim.Database.Models.ShadowverseDeckEntry>()
.FirstAsync(d => d.Name == "Imported MyRot");
Assert.That(deck.Format, Is.EqualTo(Format.MyRotation));
Assert.That(deck.MyRotationId, Is.Not.Null.And.Not.Empty,
"MyRotation decks need a rotation id or the client NREs on click.");
}
[Test]
public async Task ImportViewer_fresh_user_has_no_decks_when_none_imported()
{
using var factory = new SVSimTestFactory();
using var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
{
SteamId = 76_561_198_111_222_339UL,
DisplayName = "No Decks"
});
var body = await response.Content.ReadFromJsonAsync<ImportViewerResponse>(JsonOptions);
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var stored = await db.Viewers.Include(v => v.Decks).FirstAsync(v => v.Id == body!.ViewerId);
Assert.That(stored.Decks, Is.Empty,
"Default-deck cloning was removed; a fresh viewer with no imported decks has none.");
}
[Test]
public async Task ImportViewer_binds_new_fields_from_literal_client_json()
{
using var factory = new SVSimTestFactory();
const ulong steamId = 76_561_198_111_222_340UL;
long viewerId = await factory.SeedViewerAsync(steamId: steamId);
await factory.SeedOwnedItemAsync(viewerId, itemId: 70001, count: 0); // register item master
int classId, leaderSkinId; long sleeveId;
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
classId = (await db.Classes.FirstAsync()).Id;
sleeveId = (await db.Sleeves.FirstAsync()).Id;
leaderSkinId = (await db.LeaderSkins.FirstAsync()).Id;
}
string json = $$"""
{
"steam_id": {{steamId}},
"owned_cards": [ { "card_id": 10001001, "count": 2, "is_protected": true } ],
"items": [ { "item_id": 70001, "count": 4 } ],
"decks": [ {
"deck_format": 1,
"deck_no": 2,
"deck_name": "Wire Deck",
"class_id": {{classId}},
"sleeve_id": {{sleeveId}},
"leader_skin_id": {{leaderSkinId}},
"is_random_leader_skin": 0,
"card_id_array": [10001001, 10001002]
} ]
}
""";
using var client = factory.CreateClient();
using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var response = await client.PostAsync("/admin/import_viewer", content);
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
await response.Content.ReadAsStringAsync());
using var scope2 = factory.Services.CreateScope();
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
var stored = await db2.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.Items).ThenInclude(i => i.Item)
.Include(v => v.Decks)
.FirstAsync(v => v.Id == viewerId);
Assert.That(stored.Cards.Any(c => c.Card.Id == 10001001L && c.Count == 2 && c.IsProtected), Is.True,
"owned_cards snake_case keys must bind (card_id/count/is_protected).");
Assert.That(stored.Items.Any(i => i.Item.Id == 70001 && i.Count == 4), Is.True,
"items snake_case keys must bind (item_id/count).");
Assert.That(stored.Decks.Any(d => d.Name == "Wire Deck" && d.Format == Format.Rotation), Is.True,
"decks snake_case keys must bind (deck_format/deck_no/class_id/card_id_array/...).");
}
[Test]
public async Task ImportViewer_tolerates_numeric_my_rotation_id_and_skips_empty_decks()
{
using var factory = new SVSimTestFactory();
const ulong steamId = 76_561_198_111_222_341UL;
long viewerId = await factory.SeedViewerAsync(steamId: steamId);
int classId, leaderSkinId; long sleeveId;
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
classId = (await db.Classes.FirstAsync()).Id;
sleeveId = (await db.Sleeves.FirstAsync()).Id;
leaderSkinId = (await db.LeaderSkins.FirstAsync()).Id;
}
// Mirrors a real prod dump: empty MyRotation slots carry "my_rotation_id": 0 (a NUMBER,
// not a string), and dozens of empty slots accompany the few real decks.
string json = $$"""
{
"steam_id": {{steamId}},
"decks": [
{ "deck_format": 2, "deck_no": 1, "deck_name": "Real", "class_id": {{classId}},
"sleeve_id": {{sleeveId}}, "leader_skin_id": {{leaderSkinId}},
"is_random_leader_skin": 0, "card_id_array": [10001001, 10001002] },
{ "deck_format": 5, "deck_no": 1, "deck_name": "", "class_id": 0,
"sleeve_id": 3000011, "leader_skin_id": 0, "is_random_leader_skin": 0,
"my_rotation_id": 0, "card_id_array": [] },
{ "deck_format": 1, "deck_no": 3, "deck_name": "", "class_id": 1,
"sleeve_id": 3000011, "leader_skin_id": 0, "is_random_leader_skin": 0,
"card_id_array": [] }
]
}
""";
using var client = factory.CreateClient();
using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var response = await client.PostAsync("/admin/import_viewer", content);
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
await response.Content.ReadAsStringAsync());
using var scope2 = factory.Services.CreateScope();
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
var stored = await db2.Viewers.Include(v => v.Decks).FirstAsync(v => v.Id == viewerId);
Assert.That(stored.Decks.Count, Is.EqualTo(1),
"Empty deck slots must be skipped; only the real (non-empty) deck imports.");
Assert.That(stored.Decks.Single().Name, Is.EqualTo("Real"));
}
}

View File

@@ -44,6 +44,7 @@ public class PackControllerGachaPointTests
});
await db.SaveChangesAsync();
}
await factory.SeedPackDrawTableFromSetAsync(10008, 10008);
using var client = factory.CreateAuthenticatedClient(viewerId);
var body = JsonBody("""{"odds_gacha_id":10008,"parent_gacha_id":10008,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""");
@@ -95,6 +96,7 @@ public class PackControllerGachaPointTests
});
await db.SaveChangesAsync();
}
await factory.SeedPackDrawTableFromSetAsync(10008, 10008);
using var client = factory.CreateAuthenticatedClient(viewerId);
var body = JsonBody("""{"odds_gacha_id":10008,"parent_gacha_id":10008,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""");
@@ -145,6 +147,7 @@ public class PackControllerGachaPointTests
viewer.GachaPointBalances.Add(new ViewerGachaPointBalance { PackId = 10008, Points = 500 });
await db.SaveChangesAsync();
}
await factory.SeedPackDrawTableFromSetAsync(10008, 10008);
using var client = factory.CreateAuthenticatedClient(viewerId);
var body = JsonBody("""{"card_id":108041010,"parent_gacha_id":10008,"odds_gacha_id":10008,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""");
@@ -213,6 +216,7 @@ public class PackControllerGachaPointTests
});
await db.SaveChangesAsync();
}
await factory.SeedPackDrawTableFromSetAsync(10008, 10008);
using var client = factory.CreateAuthenticatedClient(viewerId);
var body = JsonBody("""{"card_id":108041010,"parent_gacha_id":10008,"odds_gacha_id":10008,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""");

View File

@@ -369,6 +369,7 @@ public class PackControllerOpenTests
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
int parentGachaId = await SeedSingleLeaderCardPack(factory, LeaderCardId);
await factory.SeedPackDrawTableAsync(parentGachaId, LeaderCardId);
await SeedCosmeticMapping(factory, LeaderCardId, SkinId);
using (var scope = factory.Services.CreateScope())
@@ -507,6 +508,10 @@ public class PackControllerOpenTests
viewer.Currency.Rupees = 10000;
await db.SaveChangesAsync();
}
// 30 card stubs were seeded above (Ids 108041010..108041039); install a draw table
// pointing the pack at those so the sampler picks from real test cards.
var seededCardIds = Enumerable.Range(0, 30).Select(i => (long)(10804_1010 + i)).ToArray();
await factory.SeedPackDrawTableAsync(10008, seededCardIds);
using var client = factory.CreateAuthenticatedClient(viewerId);
var body = new StringContent(
@@ -588,6 +593,9 @@ public class PackControllerOpenTests
viewer.MissionData.TutorialState = 41; // pre-END so the tutorial path is allowed
await db.SaveChangesAsync();
}
// Install a draw table for 99047 pointing at the 30 seeded card stubs.
var seededCardIds = Enumerable.Range(0, 30).Select(i => (long)(99047_1010 + i)).ToArray();
await factory.SeedPackDrawTableAsync(99047, seededCardIds);
using var client = factory.CreateAuthenticatedClient(viewerId);
var body = new StringContent(

View File

@@ -97,6 +97,8 @@ public class PackControllerTests
});
await db.SaveChangesAsync();
}
// Install a draw table for 99047 pointing at the seeded starter cards.
await factory.SeedPackDrawTableAsync(99047, 90001001L, 90001002L, 90001003L);
using var client = factory.CreateAuthenticatedClient(viewerId);

View File

@@ -42,6 +42,8 @@ public class TutorialFlowEndToEndTests
});
await db.SaveChangesAsync();
}
// Install a draw table for 99047 pointing at the seeded starter cards.
await factory.SeedPackDrawTableAsync(99047, 90001001L, 90001002L, 90001003L);
using var client = factory.CreateAuthenticatedClient(viewerId);

View File

@@ -0,0 +1,51 @@
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 PackDrawTableImporterTests
{
private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds");
[Test]
public async Task Imports_config_slot_rates_and_card_weights()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await new PackDrawTableImporter().ImportAsync(db, SeedDir);
// Production seed is the source of truth in test output (no test-fixture overlay).
Assert.That(await db.PackDrawConfigs.CountAsync(), Is.GreaterThanOrEqualTo(200));
Assert.That(await db.PackDrawSlotRates.CountAsync(), Is.GreaterThanOrEqualTo(1000));
Assert.That(await db.PackDrawCardWeights.CountAsync(), Is.GreaterThanOrEqualTo(50_000));
// 98001 is a Guaranteed-Leader-Card bundle — bonus slot must contain rate-less
// Special-tier leader rows.
var bonus = await db.PackDrawCardWeights
.Where(w => w.PackId == 98001 && w.Slot == DrawSlot.Bonus)
.ToListAsync();
Assert.That(bonus.Count, Is.GreaterThan(0));
Assert.That(bonus.All(w => w.RatePct == null && w.IsLeader && w.Tier == DrawTier.Special), 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 PackDrawTableImporter().ImportAsync(db, SeedDir);
int before = await db.PackDrawCardWeights.CountAsync();
await new PackDrawTableImporter().ImportAsync(db, SeedDir);
int after = await db.PackDrawCardWeights.CountAsync();
Assert.That(after, Is.EqualTo(before));
}
}

View File

@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Bootstrap.Importers;
using SVSim.Database;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Importers;
public class PackImporterStubsTests
{
private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds");
[Test]
public async Task Live_capture_overrides_stub_on_conflict()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await new PackImporter().ImportAsync(db, SeedDir);
// 10001 is in both packs.json (no is_enabled -> defaults true) and pack-stubs.json
// (is_enabled=false). Live capture wins -> IsEnabled stays true and gacha_detail
// is the packs.json value, not "STUB CLC".
var live = await db.Packs.FirstAsync(p => p.Id == 10001);
Assert.That(live.IsEnabled, Is.True);
Assert.That(live.GachaDetail, Does.Not.Contain("STUB"));
}
[Test]
public async Task Stub_only_packs_are_inserted_with_IsEnabled_false()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await new PackImporter().ImportAsync(db, SeedDir);
// 95001 is stub-only -> inserted with IsEnabled=false and the stub's gacha_detail.
var stub = await db.Packs.FirstAsync(p => p.Id == 95001);
Assert.That(stub.IsEnabled, Is.False);
Assert.That(stub.GachaDetail, Is.EqualTo("7th Anniv stub"));
}
}

View File

@@ -70,10 +70,32 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
// IsInRotation so both standard-pack (by setId) and special-pack (rotation scan)
// tests see real data.
SeedMinimalCardSet(db);
SeedMinimalPackDrawTable(db);
return host;
}
/// <summary>
/// Seeds a minimal PackDrawConfig + slot rates + card weights for the test card-set's
/// cards (10001001/10001002/10001003) under pack id 10001. Lets PackController.Open
/// resolve a draw table without requiring tests to run the full PackDrawTableImporter.
/// </summary>
private static void SeedMinimalPackDrawTable(SVSimDbContext db)
{
if (db.PackDrawConfigs.Any())
return;
const int packId = 10001;
db.PackDrawConfigs.Add(new PackDrawConfigEntry { Id = packId, AnimationRatePct = 0 });
// Slot rates: uniform single-tier so any rng lands somewhere valid.
db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry { PackId = packId, Slot = DrawSlot.General, Tier = DrawTier.Bronze, RatePct = 100 });
db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry { PackId = packId, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, RatePct = 100 });
// Card weights for both slots.
db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry { PackId = packId, Slot = DrawSlot.General, Tier = DrawTier.Bronze, CardId = 10001001, RatePct = 100 });
db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry { PackId = packId, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, CardId = 10001001, RatePct = 100 });
db.SaveChanges();
}
private static void SeedMinimalCardSet(SVSimDbContext db)
{
if (db.CardSets.Any())
@@ -242,6 +264,59 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
await new DefaultDeckImporter().ImportAsync(ctx, seedDir);
await new PackImporter().ImportAsync(ctx, seedDir);
// PackDrawTableImporter is NOT called here — production draw tables reference real
// Cygames card_ids not present in the test's minimal card master. Tests that
// exercise /pack/open use SeedPackDrawTableAsync to install a stub draw table
// pointing to their seeded test cards.
}
/// <summary>
/// Installs a minimal PackDrawConfig + slot rates + per-card weights for <paramref name="packId"/>,
/// pointing the per-card weights at <paramref name="cardIds"/>. All cards land in the Bronze tier
/// at 100% rate; slot 1-7 and slot 8 both draw from the same pool. Use for tests that need
/// /pack/open to succeed against a custom seeded card pool.
/// </summary>
public Task SeedPackDrawTableAsync(int packId, params long[] cardIds)
=> SeedPackDrawTableAsync(packId, DrawTier.Bronze, cardIds);
/// <summary>
/// Convenience for gacha-point tests: picks Legendary cards from <paramref name="cardSetId"/>
/// (skipping foils) and seeds them as the draw table's Legendary tier for <paramref name="packId"/>.
/// </summary>
public async Task SeedPackDrawTableFromSetAsync(int packId, int cardSetId)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var legendaryIds = await db.CardSets
.Where(s => s.Id == cardSetId)
.SelectMany(s => s.Cards)
.Where(c => c.Rarity == SVSim.Database.Enums.Rarity.Legendary && !c.IsFoil)
.Select(c => c.Id)
.ToListAsync();
if (legendaryIds.Count > 0)
{
await SeedPackDrawTableAsync(packId, DrawTier.Legendary, legendaryIds.ToArray());
}
}
public async Task SeedPackDrawTableAsync(int packId, DrawTier tier, params long[] cardIds)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
if (await db.PackDrawConfigs.AnyAsync(c => c.Id == packId)) return;
db.PackDrawConfigs.Add(new PackDrawConfigEntry { Id = packId, AnimationRatePct = 0 });
db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry { PackId = packId, Slot = DrawSlot.General, Tier = tier, RatePct = 100 });
db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry { PackId = packId, Slot = DrawSlot.Eighth, Tier = tier, RatePct = 100 });
foreach (var cid in cardIds)
{
db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry { PackId = packId, Slot = DrawSlot.General, Tier = tier, CardId = cid, RatePct = 100.0 / cardIds.Length });
db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry { PackId = packId, Slot = DrawSlot.Eighth, Tier = tier, CardId = cid, RatePct = 100.0 / cardIds.Length });
}
await db.SaveChangesAsync();
}
/// <summary>

View File

@@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Bootstrap.Importers;
using SVSim.Database;
using SVSim.Database.Repositories.PackDrawTables;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Repositories;
public class PackDrawTableRepositoryTests
{
private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds");
[Test]
public async Task GetAsync_returns_null_when_pack_unseeded()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IPackDrawTableRepository>();
var table = await repo.GetAsync(123456);
Assert.That(table, Is.Null);
}
[Test]
public async Task GetAsync_returns_config_slot_rates_and_card_weights_for_seeded_pack()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var repo = scope.ServiceProvider.GetRequiredService<IPackDrawTableRepository>();
await new PackDrawTableImporter().ImportAsync(db, SeedDir);
var table = await repo.GetAsync(10000);
Assert.That(table, Is.Not.Null);
Assert.That(table!.Config.AnimationRatePct, Is.EqualTo(8.0));
Assert.That(table.SlotRates.Count, Is.GreaterThanOrEqualTo(4)); // bronze/silver/gold/legendary at minimum
Assert.That(table.CardWeights.Count, Is.GreaterThan(0));
Assert.That(table.CardWeights.All(w => w.PackId == 10000), Is.True);
}
}

View File

@@ -41,6 +41,33 @@ public class PackRepositoryTests
Assert.That(packs.Select(p => p.Id), Is.EquivalentTo(new[] { 10001 }));
}
[Test]
public async Task GetActivePacks_excludes_IsEnabled_false_rows()
{
using var factory = new SVSimTestFactory();
var now = new DateTime(2026, 5, 24, 12, 0, 0, DateTimeKind.Utc);
await SeedPack(factory, 10001, 10001, PackCategory.None, now.AddDays(-1), now.AddDays(1));
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.Packs.Add(new PackConfigEntry
{
Id = 10002, BasePackId = 10002, PackCategory = PackCategory.None,
CommenceDate = now.AddDays(-1), CompleteDate = now.AddDays(1),
GachaType = 1, GachaDetail = "disabled",
IsEnabled = false,
});
await db.SaveChangesAsync();
}
using var scopeRepo = factory.Services.CreateScope();
var repo = scopeRepo.ServiceProvider.GetRequiredService<IPackRepository>();
var packs = await repo.GetActivePacks(now);
Assert.That(packs.Select(p => p.Id), Is.EquivalentTo(new[] { 10001 }));
}
[Test]
public async Task GetPack_includes_child_gachas_and_banners()
{

View File

@@ -1,148 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.EmulatedEntrypoint.Services;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Services;
public class DbCardPoolProviderTests
{
[Test]
public async Task GetPool_for_standard_pack_returns_cards_of_matching_set()
{
using var factory = new SVSimTestFactory();
long anyCardId;
int setId;
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var setWithCards = await db.CardSets.Include(s => s.Cards)
.FirstAsync(s => s.Cards.Count > 0);
setId = setWithCards.Id;
anyCardId = setWithCards.Cards.First().Id;
}
using var scope2 = factory.Services.CreateScope();
var provider = scope2.ServiceProvider.GetRequiredService<ICardPoolProvider>();
var pool = provider.GetPool(new PackConfigEntry
{
Id = setId, BasePackId = setId, PackCategory = PackCategory.None
});
Assert.That(pool.Any(c => c.Id == anyCardId), Is.True);
}
[Test]
public void GetPool_for_legendary_special_returns_cards_from_rotation_sets()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var provider = scope.ServiceProvider.GetRequiredService<ICardPoolProvider>();
var pool = provider.GetPool(new PackConfigEntry
{
Id = 92001, BasePackId = 90001, PackCategory = PackCategory.SpecialCardPack
});
Assert.That(pool.Count, Is.GreaterThan(0));
}
[Test]
public void GetPool_for_skin_pack_returns_empty()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var provider = scope.ServiceProvider.GetRequiredService<ICardPoolProvider>();
var pool = provider.GetPool(new PackConfigEntry
{
Id = 70001, BasePackId = 70001, PackCategory = PackCategory.LeaderSkinPack
});
Assert.That(pool, Is.Empty);
}
[Test]
public async Task GetPool_excludes_foil_cards()
{
using var factory = new SVSimTestFactory();
long nonFoilId, foilId;
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
// Pick the highest-Id card so that id+1 is guaranteed unoccupied.
nonFoilId = await db.Cards.OrderByDescending(c => c.Id).Select(c => c.Id).FirstAsync();
foilId = nonFoilId + 1;
var foilCard = new ShadowverseCardEntry
{
Id = foilId, Name = $"Card {foilId}", Rarity = Rarity.Bronze, IsFoil = true,
};
// Add directly to the Cards DbSet and set the FK via shadow property,
// avoiding nav-collection tracker conflicts.
db.Cards.Add(foilCard);
db.Entry(foilCard).Property("ShadowverseCardSetEntryId").CurrentValue = 10001;
await db.SaveChangesAsync();
}
using var scope2 = factory.Services.CreateScope();
var provider = scope2.ServiceProvider.GetRequiredService<ICardPoolProvider>();
var pool = provider.GetPool(new PackConfigEntry
{
Id = 10001, BasePackId = 10001,
PackCategory = PackCategory.None,
});
Assert.That(pool.Any(c => c.Id == nonFoilId), Is.True, "non-foil must be in the pool");
Assert.That(pool.Any(c => c.Id == foilId), Is.False, "foil must be excluded from the pool");
}
[Test]
public async Task TryGetFoilTwin_returns_the_id_plus_one_foil_when_present()
{
using var factory = new SVSimTestFactory();
long nonFoilId, foilId;
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
// Pick the highest-Id card so that id+1 is guaranteed unoccupied.
nonFoilId = await db.Cards.OrderByDescending(c => c.Id).Select(c => c.Id).FirstAsync();
foilId = nonFoilId + 1;
var foilCard = new ShadowverseCardEntry
{
Id = foilId, Name = $"Card {foilId}", Rarity = Rarity.Bronze, IsFoil = true,
};
db.Cards.Add(foilCard);
db.Entry(foilCard).Property("ShadowverseCardSetEntryId").CurrentValue = 10001;
await db.SaveChangesAsync();
}
using var scope2 = factory.Services.CreateScope();
var provider = scope2.ServiceProvider.GetRequiredService<ICardPoolProvider>();
var twin = provider.TryGetFoilTwin(nonFoilId);
Assert.That(twin, Is.Not.Null);
Assert.That(twin!.Id, Is.EqualTo(foilId));
Assert.That(twin.IsFoil, Is.True);
}
[Test]
public async Task TryGetFoilTwin_returns_null_when_no_foil_at_id_plus_one()
{
using var factory = new SVSimTestFactory();
long anyCardId;
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
anyCardId = await db.Cards.OrderBy(c => c.Id).Select(c => c.Id).FirstAsync();
}
using var scope2 = factory.Services.CreateScope();
var provider = scope2.ServiceProvider.GetRequiredService<ICardPoolProvider>();
Assert.That(provider.TryGetFoilTwin(anyCardId), Is.Null,
"no foil seeded at anyCardId+1, so TryGetFoilTwin must return null");
}
}

View File

@@ -78,6 +78,7 @@ public class GachaPointServiceTests
GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = 400, IncreaseGachaPoint = 1 },
});
await db.SaveChangesAsync();
await factory.SeedPackDrawTableFromSetAsync(10008, 10008);
var svc = scope.ServiceProvider.GetRequiredService<IGachaPointService>();
var result = await svc.GetRewardsAsync(10008, viewerId);
@@ -125,6 +126,7 @@ public class GachaPointServiceTests
GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = 400, IncreaseGachaPoint = 1 },
});
await db.SaveChangesAsync();
await factory.SeedPackDrawTableFromSetAsync(10008, 10008);
var svc = scope.ServiceProvider.GetRequiredService<IGachaPointService>();
var result = await svc.GetRewardsAsync(10008, viewerId);
@@ -173,6 +175,7 @@ public class GachaPointServiceTests
GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = 400, IncreaseGachaPoint = 1 },
});
await db.SaveChangesAsync();
await factory.SeedPackDrawTableFromSetAsync(10008, 10008);
var svc = scope.ServiceProvider.GetRequiredService<IGachaPointService>();
var result = await svc.GetRewardsAsync(10008, viewerId);
@@ -219,6 +222,7 @@ public class GachaPointServiceTests
PackId = 10008, CardId = 108041010, ReceivedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
await factory.SeedPackDrawTableFromSetAsync(10008, 10008);
var svc = scope.ServiceProvider.GetRequiredService<IGachaPointService>();
var result = await svc.GetRewardsAsync(10008, viewerId);
@@ -515,6 +519,7 @@ public class GachaPointServiceTests
GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = 400, IncreaseGachaPoint = 1 },
});
await db.SaveChangesAsync();
await factory.SeedPackDrawTableFromSetAsync(10008, 10008);
var svc = scope.ServiceProvider.GetRequiredService<IGachaPointService>();
var result = await svc.GetRewardsAsync(10008, viewerId);
@@ -555,6 +560,7 @@ public class GachaPointServiceTests
GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = 400, IncreaseGachaPoint = 1 },
});
await db.SaveChangesAsync();
await factory.SeedPackDrawTableFromSetAsync(10099, 10099);
var svc = scope.ServiceProvider.GetRequiredService<IGachaPointService>();
var result = await svc.GetRewardsAsync(10099, viewerId);
@@ -586,6 +592,14 @@ public class GachaPointServiceTests
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = threshold, IncreaseGachaPoint = 1 },
});
// Draw table pointing at the seeded legendary so IPackDrawTableRepository.GetAsync
// surfaces it for GachaPointService.GetRewardsAsync / TryExchangeAsync.
db.PackDrawConfigs.Add(new PackDrawConfigEntry { Id = packId, AnimationRatePct = 0 });
db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry { PackId = packId, Slot = DrawSlot.General, Tier = DrawTier.Legendary, RatePct = 100 });
db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry
{
PackId = packId, Slot = DrawSlot.General, Tier = DrawTier.Legendary, CardId = 108041010, RatePct = 100,
});
db.SaveChanges();
}
}

View File

@@ -1,6 +1,6 @@
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Models.Config;
using SVSim.Database.Repositories.PackDrawTables;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Services;
@@ -8,272 +8,219 @@ namespace SVSim.UnitTests.Services;
public class PackOpenServiceTests
{
/// <summary>Deterministic RNG that returns the supplied doubles in order, cycling.</summary>
private sealed class ScriptedRandom : IRandom
{
private readonly double[] _seq; private int _i;
public ScriptedRandom(params double[] seq) { _seq = seq; }
public double NextDouble() { var v = _seq[_i++ % _seq.Length]; return v; }
public double NextDouble() => _seq[_i++ % _seq.Length];
public int Next(int maxExclusive) => (int)(NextDouble() * maxExclusive);
}
/// <summary>Simple in-memory pool keyed by rarity for slot-distribution tests.</summary>
private sealed class StubPool : ICardPoolProvider
private sealed class NoFoil : ICardFoilLookup
{
private readonly IReadOnlyList<ShadowverseCardEntry> _cards;
public StubPool(IReadOnlyList<ShadowverseCardEntry> cards) { _cards = cards; }
public IReadOnlyList<ShadowverseCardEntry> GetPool(PackConfigEntry _) => _cards;
public ShadowverseCardEntry? TryGetFoilTwin(long baseCardId) => null;
}
/// <summary>
/// Test stub that returns a single pre-built section. Only handles <see cref="PackRateConfig"/>
/// (the type <see cref="PackOpenService"/> reads in its ctor); other section types throw so a
/// future test that needs them must extend this stub explicitly.
/// </summary>
private sealed class StubConfig : IGameConfigService
private static PackConfigEntry StandardPack(int id = 10000) => new()
{
private readonly PackRateConfig _rates;
public StubConfig(PackRateConfig rates) { _rates = rates; }
public T Get<T>() where T : class, new()
{
if (typeof(T) == typeof(PackRateConfig)) return (T)(object)_rates;
throw new NotImplementedException($"StubConfig: unhandled section type {typeof(T)}.");
}
}
private static PackOpenService MakeService(PackRateConfig rates) => new(new StubConfig(rates));
private static List<ShadowverseCardEntry> MakeFourCards() => new()
{
new ShadowverseCardEntry { Id = 1, Rarity = Rarity.Bronze },
new ShadowverseCardEntry { Id = 2, Rarity = Rarity.Silver },
new ShadowverseCardEntry { Id = 3, Rarity = Rarity.Gold },
new ShadowverseCardEntry { Id = 4, Rarity = Rarity.Legendary },
Id = id, BasePackId = id, PackCategory = PackCategory.None,
};
private static PackConfigEntry StandardPack() => new()
private static PackDrawTable AllBronzeTable() => new()
{
Id = 10001, BasePackId = 10001, PackCategory = PackCategory.None,
Config = new PackDrawConfigEntry { Id = 10000, AnimationRatePct = 0 },
SlotRates = new[]
{
new PackDrawSlotRateEntry { PackId = 10000, Slot = DrawSlot.General, Tier = DrawTier.Bronze, RatePct = 100.0 },
new PackDrawSlotRateEntry { PackId = 10000, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, RatePct = 100.0 },
},
CardWeights = new[]
{
new PackDrawCardWeightEntry { PackId = 10000, Slot = DrawSlot.General, Tier = DrawTier.Bronze, CardId = 1, RatePct = 70 },
new PackDrawCardWeightEntry { PackId = 10000, Slot = DrawSlot.General, Tier = DrawTier.Bronze, CardId = 2, RatePct = 30 },
new PackDrawCardWeightEntry { PackId = 10000, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, CardId = 1, RatePct = 100 },
},
};
[Test]
public void Draw_returns_eight_cards_for_one_pack()
{
var svc = MakeService(PackRateConfig.ShippedDefaults());
var pool = new StubPool(MakeFourCards());
var rng = new ScriptedRandom(0.5); // anything in Bronze/Silver range for non-slot-8
var svc = new PackOpenService();
var rng = new ScriptedRandom(0.1);
var result = svc.Draw(StandardPack(), pool, packNumber: 1, excludeCardIds: Array.Empty<long>(), rng: rng);
var result = svc.Draw(AllBronzeTable(), StandardPack(), 1,
excludeCardIds: Array.Empty<long>(), ownedCardIds: Array.Empty<long>(),
new NoFoil(), rng);
Assert.That(result.Cards.Count, Is.EqualTo(8));
Assert.That(result.Cards.All(c => c.CardId == 1), Is.True);
}
[Test]
public void Draw_picks_card_by_per_card_weight_within_tier()
{
var svc = new PackOpenService();
// Tier roll always lands in Bronze (only tier). Card pick rng=0.8 -> within Bronze
// band > 0.7 -> card 2. Slot 8 has only card 1 in its pool so it always picks card 1.
var rng = new ScriptedRandom(0.0, 0.8);
var result = svc.Draw(AllBronzeTable(), StandardPack(), 1,
Array.Empty<long>(), Array.Empty<long>(), new NoFoil(), rng);
Assert.That(result.Cards.Take(7).All(c => c.CardId == 2), Is.True, "slots 1-7 should pick card 2");
Assert.That(result.Cards[7].CardId, Is.EqualTo(1), "slot 8 pool only contains card 1");
}
[Test]
public void Draw_rate_less_branch_picks_only_unowned()
{
var pack = new PackConfigEntry { Id = 98001, BasePackId = 98001, PackCategory = PackCategory.SpecialCardPack };
var table = new PackDrawTable
{
Config = new PackDrawConfigEntry { Id = 98001, AnimationRatePct = 0, HasBonusSlot = true, SpecialKind = "leader_card" },
SlotRates = new[]
{
new PackDrawSlotRateEntry { PackId = 98001, Slot = DrawSlot.General, Tier = DrawTier.Bronze, RatePct = 100.0 },
new PackDrawSlotRateEntry { PackId = 98001, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, RatePct = 100.0 },
new PackDrawSlotRateEntry { PackId = 98001, Slot = DrawSlot.Bonus, Tier = DrawTier.Special, RatePct = 100.0 },
},
CardWeights = new[]
{
new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.General, Tier = DrawTier.Bronze, CardId = 10, RatePct = 100 },
new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, CardId = 10, RatePct = 100 },
new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.Bonus, Tier = DrawTier.Special, CardId = 300, RatePct = null, IsLeader = true },
new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.Bonus, Tier = DrawTier.Special, CardId = 301, RatePct = null, IsLeader = true },
new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.Bonus, Tier = DrawTier.Special, CardId = 302, RatePct = null, IsLeader = true },
},
};
var svc = new PackOpenService();
var rng = new ScriptedRandom(0.1);
var result = svc.Draw(table, pack, packNumber: 10,
excludeCardIds: Array.Empty<long>(),
ownedCardIds: new long[] { 300, 301 },
new NoFoil(), rng);
Assert.That(result.Cards.Count, Is.EqualTo(81)); // 10 packs * 8 + 1 bonus
var bonus = result.Cards[^1];
Assert.That(bonus.CardId, Is.EqualTo(302));
}
[Test]
public void Draw_rate_less_falls_back_to_full_pool_when_all_owned()
{
var pack = new PackConfigEntry { Id = 98001, BasePackId = 98001 };
var table = new PackDrawTable
{
Config = new PackDrawConfigEntry { Id = 98001, AnimationRatePct = 0, HasBonusSlot = true },
SlotRates = new[]
{
new PackDrawSlotRateEntry { PackId = 98001, Slot = DrawSlot.General, Tier = DrawTier.Bronze, RatePct = 100.0 },
new PackDrawSlotRateEntry { PackId = 98001, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, RatePct = 100.0 },
new PackDrawSlotRateEntry { PackId = 98001, Slot = DrawSlot.Bonus, Tier = DrawTier.Special, RatePct = 100.0 },
},
CardWeights = new[]
{
new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.General, Tier = DrawTier.Bronze, CardId = 10, RatePct = 100 },
new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, CardId = 10, RatePct = 100 },
new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.Bonus, Tier = DrawTier.Special, CardId = 300, RatePct = null, IsLeader = true },
new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.Bonus, Tier = DrawTier.Special, CardId = 301, RatePct = null, IsLeader = true },
},
};
var svc = new PackOpenService();
var rng = new ScriptedRandom(0.1);
var result = svc.Draw(table, pack, packNumber: 10,
excludeCardIds: Array.Empty<long>(),
ownedCardIds: new long[] { 300, 301 },
new NoFoil(), rng);
var bonus = result.Cards[^1];
Assert.That(bonus.CardId, Is.AnyOf(300L, 301L));
}
[Test]
[Category("Slow")]
public void Draw_observed_tier_rates_track_seed_within_half_a_percent()
{
// Synthetic Classic pack: Bronze=76.5/Silver=16/Gold=6/Legendary=1.5 in general slots;
// slot 8 is Silver=92.5/Gold=6/Legendary=1.5 (no Bronze).
var table = new PackDrawTable
{
Config = new PackDrawConfigEntry { Id = 10000, AnimationRatePct = 0 },
SlotRates = new[]
{
new PackDrawSlotRateEntry { PackId = 10000, Slot = DrawSlot.General, Tier = DrawTier.Bronze, RatePct = 76.5 },
new PackDrawSlotRateEntry { PackId = 10000, Slot = DrawSlot.General, Tier = DrawTier.Silver, RatePct = 16.0 },
new PackDrawSlotRateEntry { PackId = 10000, Slot = DrawSlot.General, Tier = DrawTier.Gold, RatePct = 6.0 },
new PackDrawSlotRateEntry { PackId = 10000, Slot = DrawSlot.General, Tier = DrawTier.Legendary, RatePct = 1.5 },
new PackDrawSlotRateEntry { PackId = 10000, Slot = DrawSlot.Eighth, Tier = DrawTier.Silver, RatePct = 92.5 },
new PackDrawSlotRateEntry { PackId = 10000, Slot = DrawSlot.Eighth, Tier = DrawTier.Gold, RatePct = 6.0 },
new PackDrawSlotRateEntry { PackId = 10000, Slot = DrawSlot.Eighth, Tier = DrawTier.Legendary, RatePct = 1.5 },
},
CardWeights = new[]
{
new PackDrawCardWeightEntry { PackId = 10000, Slot = DrawSlot.General, Tier = DrawTier.Bronze, CardId = 1, RatePct = 76.5 },
new PackDrawCardWeightEntry { PackId = 10000, Slot = DrawSlot.General, Tier = DrawTier.Silver, CardId = 2, RatePct = 16.0 },
new PackDrawCardWeightEntry { PackId = 10000, Slot = DrawSlot.General, Tier = DrawTier.Gold, CardId = 3, RatePct = 6.0 },
new PackDrawCardWeightEntry { PackId = 10000, Slot = DrawSlot.General, Tier = DrawTier.Legendary, CardId = 4, RatePct = 1.5 },
},
};
var svc = new PackOpenService();
var rng = new SystemRandom(42);
var pack = new PackConfigEntry { Id = 10000 };
int totalSlots = 200_000;
int bronze = 0, silver = 0, gold = 0, legendary = 0;
// 25_000 packs * 7 general slots = 175_000 general-slot observations.
for (int i = 0; i < totalSlots / 8; i++)
{
var r = svc.Draw(table, pack, 1, Array.Empty<long>(), Array.Empty<long>(), new NoFoil(), rng);
for (int s = 0; s < 7; s++)
{
switch (r.Cards[s].Rarity)
{
case Rarity.Bronze: bronze++; break;
case Rarity.Silver: silver++; break;
case Rarity.Gold: gold++; break;
case Rarity.Legendary: legendary++; break;
}
}
}
double n = bronze + silver + gold + legendary;
Assert.That(100 * bronze / n, Is.EqualTo(76.5).Within(0.5));
Assert.That(100 * silver / n, Is.EqualTo(16.0).Within(0.5));
Assert.That(100 * gold / n, Is.EqualTo(6.0).Within(0.5));
Assert.That(100 * legendary / n, Is.EqualTo(1.5).Within(0.5));
}
[Test]
public void Draw_does_not_emit_bonus_for_packNumber_less_than_10()
{
var pack = new PackConfigEntry { Id = 98001 };
var table = new PackDrawTable
{
Config = new PackDrawConfigEntry { Id = 98001, HasBonusSlot = true, AnimationRatePct = 0 },
SlotRates = new[]
{
new PackDrawSlotRateEntry { PackId = 98001, Slot = DrawSlot.General, Tier = DrawTier.Bronze, RatePct = 100 },
new PackDrawSlotRateEntry { PackId = 98001, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, RatePct = 100 },
new PackDrawSlotRateEntry { PackId = 98001, Slot = DrawSlot.Bonus, Tier = DrawTier.Special, RatePct = 100 },
},
CardWeights = new[]
{
new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.General, Tier = DrawTier.Bronze, CardId = 1, RatePct = 100 },
new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, CardId = 1, RatePct = 100 },
new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.Bonus, Tier = DrawTier.Special, CardId = 999, RatePct = null, IsLeader = true },
},
};
var svc = new PackOpenService();
var rng = new ScriptedRandom(0.1);
var result = svc.Draw(table, pack, packNumber: 1,
Array.Empty<long>(), Array.Empty<long>(), new NoFoil(), rng);
Assert.That(result.Cards.Count, Is.EqualTo(8));
}
[Test]
public void Draw_slot_8_never_returns_bronze_for_standard_pack()
{
// PackRateConfig.ShippedDefaults() includes the SV Classic slot-8 "Silver-or-better
// guarantee" entry (PerSlot Bronze=0). Same shape the runtime seeder writes to GameConfigs.
var svc = MakeService(PackRateConfig.ShippedDefaults());
var pool = new StubPool(MakeFourCards());
for (int trial = 0; trial < 1000; trial++)
{
var result = svc.Draw(StandardPack(), pool, 1, Array.Empty<long>(), new SystemRandom(trial));
Assert.That(result.Cards[7].Rarity, Is.Not.EqualTo(Rarity.Bronze),
$"slot 8 must never be Bronze (trial {trial})");
}
}
[Test]
public void Draw_legendary_special_forces_slot_8_to_legendary()
{
var svc = MakeService(PackRateConfig.ShippedDefaults());
var pool = new StubPool(MakeFourCards());
var pack = new PackConfigEntry { Id = 92001, BasePackId = 90001, PackCategory = PackCategory.SpecialCardPack };
for (int trial = 0; trial < 100; trial++)
{
var result = svc.Draw(pack, pool, 1, Array.Empty<long>(), new SystemRandom(trial));
Assert.That(result.Cards[7].Rarity, Is.EqualTo(Rarity.Legendary),
$"legendary-special pack slot 8 must be Legendary (trial {trial})");
}
}
[Test]
public void Draw_distribution_over_10k_slots_1_to_7_matches_declared_rates_within_2_percent()
{
var svc = MakeService(PackRateConfig.ShippedDefaults());
var pool = new StubPool(MakeFourCards());
var counts = new Dictionary<Rarity, int>
{
{ Rarity.Bronze, 0 }, { Rarity.Silver, 0 }, { Rarity.Gold, 0 }, { Rarity.Legendary, 0 }
};
var rng = new SystemRandom(seed: 42);
const int packs = 10_000;
for (int i = 0; i < packs; i++)
{
var r = svc.Draw(StandardPack(), pool, 1, Array.Empty<long>(), rng);
// Only look at slots 0..6 (the unrestricted rarity slots)
for (int s = 0; s < 7; s++) counts[r.Cards[s].Rarity]++;
}
int total = packs * 7;
double bronze = counts[Rarity.Bronze] / (double)total;
double silver = counts[Rarity.Silver] / (double)total;
double gold = counts[Rarity.Gold] / (double)total;
double leg = counts[Rarity.Legendary] / (double)total;
Assert.That(bronze, Is.EqualTo(0.6744).Within(0.02), $"bronze rate {bronze:P}");
Assert.That(silver, Is.EqualTo(0.2500).Within(0.02), $"silver rate {silver:P}");
Assert.That(gold, Is.EqualTo(0.0600).Within(0.01), $"gold rate {gold:P}");
Assert.That(leg, Is.EqualTo(0.0150).Within(0.01), $"legendary rate {leg:P}");
}
[Test]
public void Draw_excludes_listed_card_ids()
{
var svc = MakeService(PackRateConfig.ShippedDefaults());
// Pool with two bronze cards; exclude one — every Bronze slot must pick the other.
var pool = new StubPool(new List<ShadowverseCardEntry>
{
new() { Id = 1, Rarity = Rarity.Bronze },
new() { Id = 99, Rarity = Rarity.Bronze },
new() { Id = 2, Rarity = Rarity.Silver },
});
var rng = new SystemRandom(seed: 7);
var result = svc.Draw(StandardPack(), pool, 1, excludeCardIds: new long[] { 1 }, rng: rng);
foreach (var c in result.Cards.Where(x => x.Rarity == Rarity.Bronze))
{
Assert.That(c.CardId, Is.EqualTo(99), "excluded card 1 must never appear in Bronze slot");
}
}
[Test]
public void Draw_per_slot_override_is_applied_for_that_slot_and_default_for_others()
{
// Config: slot 3 is forced to Legendary; everything else uses Default.
// PerSlot is a List<SlotRarityWeights> with a Slot string key (no Dictionary<int,T> of
// complex types under jsonb-friendly serialisation — see Task 5 notes).
var rates = PackRateConfig.ShippedDefaults();
rates.PerSlot.Add(new SlotRarityWeights
{
Slot = "3",
Bronze = 0, Silver = 0, Gold = 0, Legendary = 1.0,
});
var svc = MakeService(rates);
var pool = new StubPool(MakeFourCards());
for (int trial = 0; trial < 50; trial++)
{
var result = svc.Draw(StandardPack(), pool, 1, Array.Empty<long>(), new SystemRandom(trial));
Assert.That(result.Cards[2].Rarity, Is.EqualTo(Rarity.Legendary),
$"slot 3 must be Legendary under PerSlot[3] override (trial {trial})");
}
}
/// <summary>StubPool variant that also implements TryGetFoilTwin via the SAME id+1 convention
/// as the DB-backed provider, but keyed off an injected dictionary so tests stay hermetic.</summary>
private sealed class StubPoolWithFoils : ICardPoolProvider
{
private readonly IReadOnlyList<ShadowverseCardEntry> _pool;
private readonly Dictionary<long, ShadowverseCardEntry> _foilsByBaseId;
public StubPoolWithFoils(IReadOnlyList<ShadowverseCardEntry> pool, Dictionary<long, ShadowverseCardEntry> foilsByBaseId)
{
_pool = pool;
_foilsByBaseId = foilsByBaseId;
}
public IReadOnlyList<ShadowverseCardEntry> GetPool(PackConfigEntry _) => _pool;
public ShadowverseCardEntry? TryGetFoilTwin(long baseCardId) =>
_foilsByBaseId.TryGetValue(baseCardId, out var f) ? f : null;
}
[Test]
public void Draw_animated_rate_upgrades_about_8_percent_of_slots_within_tolerance()
{
// One bronze card with a foil twin; rate = 0.08; ~8% of 8000 slots should be foil.
var bronze = new ShadowverseCardEntry { Id = 1, Rarity = Rarity.Bronze, IsFoil = false };
var bronzeFoil = new ShadowverseCardEntry { Id = 2, Rarity = Rarity.Bronze, IsFoil = true };
var pools = new StubPoolWithFoils(
new List<ShadowverseCardEntry> { bronze },
new Dictionary<long, ShadowverseCardEntry> { [bronze.Id] = bronzeFoil });
var svc = MakeService(PackRateConfig.ShippedDefaults()); // default AnimatedRate = 0.08
const int packs = 1_000; // 8000 slots
int foilCount = 0;
var rng = new SystemRandom(seed: 7);
for (int i = 0; i < packs; i++)
{
var r = svc.Draw(StandardPack(), pools, 1, Array.Empty<long>(), rng);
foilCount += r.Cards.Count(c => c.CardId == bronzeFoil.Id);
}
double rate = foilCount / (double)(packs * 8);
Assert.That(rate, Is.EqualTo(0.08).Within(0.015),
$"observed animated rate {rate:P} outside the ±1.5% tolerance of 8%");
}
[Test]
public void Draw_animated_upgrade_silently_keeps_base_when_no_foil_twin_exists()
{
var bronze = new ShadowverseCardEntry { Id = 1, Rarity = Rarity.Bronze, IsFoil = false };
var pools = new StubPoolWithFoils(
new List<ShadowverseCardEntry> { bronze },
new Dictionary<long, ShadowverseCardEntry>()); // no foils
// Force the animated roll to always hit by setting AnimatedRate = 1.0.
var rates = PackRateConfig.ShippedDefaults();
rates.AnimatedRate = 1.0;
var svc = MakeService(rates);
var r = svc.Draw(StandardPack(), pools, 1, Array.Empty<long>(), new SystemRandom(seed: 1));
foreach (var c in r.Cards)
{
Assert.That(c.CardId, Is.EqualTo(bronze.Id),
"no foil twin available; every slot must keep the base card despite 100% animated rate");
}
}
[Test]
public void Draw_animated_upgrade_applies_to_slot_8_including_legendary_specials()
{
var leg = new ShadowverseCardEntry { Id = 4, Rarity = Rarity.Legendary, IsFoil = false };
var legFoil = new ShadowverseCardEntry { Id = 5, Rarity = Rarity.Legendary, IsFoil = true };
var pools = new StubPoolWithFoils(
new List<ShadowverseCardEntry> { leg },
new Dictionary<long, ShadowverseCardEntry> { [leg.Id] = legFoil });
var rates = PackRateConfig.ShippedDefaults();
rates.AnimatedRate = 1.0; // every slot upgrades
var svc = MakeService(rates);
var specialPack = new PackConfigEntry { Id = 92001, BasePackId = 90001, PackCategory = PackCategory.SpecialCardPack };
var r = svc.Draw(specialPack, pools, 1, Array.Empty<long>(), new SystemRandom(seed: 3));
// Slot 8 is forced Legendary by the structural rule; with AnimatedRate=1.0 it must be the foil legendary.
Assert.That(r.Cards[7].CardId, Is.EqualTo(legFoil.Id),
"legendary-special slot 8 must be the foil-legendary when animated rate is forced to 1.0");
}
/// <summary>
/// Regression guard for the 2026-05-24 slot-8-ignores-PerSlot-override bug. Invariant: a
/// freshly-constructed <see cref="PackRateConfig"/> has an EMPTY PerSlot list. The original
/// trigger (EF Core 8's <c>OwnsMany</c>+<c>ToJson</c> jsonb materialisation appending rows
/// onto whatever the parent's ctor produced — leaving two slot-8 entries where the seeded one
/// silently won <see cref="PackOpenService.ResolveWeights"/>'s <c>FirstOrDefault</c>) is gone
/// now (config goes through <c>IGameConfigService</c> + STJ, which replaces correctly). The
/// invariant stays because any future config layer that hydrates into a pre-initialised
/// collection (custom deserialiser, ORM, manual Add loop) would resurrect the same failure
/// mode. Defaults for collections live in <see cref="PackRateConfig.ShippedDefaults"/>.
/// </summary>
[Test]
public void PackRateConfig_PerSlot_defaults_to_empty_to_avoid_jsonb_append_bug()
{
Assert.That(new PackRateConfig().PerSlot, Is.Empty,
"PackRateConfig.PerSlot must default to empty — see test docstring for why.");
}
}

View File

@@ -0,0 +1,63 @@
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.UnitTests.Services;
public class WeightedPickTests
{
private sealed class ScriptedRandom : IRandom
{
private readonly double[] _seq; private int _i;
public ScriptedRandom(params double[] seq) { _seq = seq; }
public double NextDouble() => _seq[_i++ % _seq.Length];
public int Next(int maxExclusive) => (int)(NextDouble() * maxExclusive);
}
[Test]
public void Picks_first_band_when_rng_low()
{
var items = new[] { "a", "b", "c" };
var weights = new[] { 0.5, 0.3, 0.2 };
var rng = new ScriptedRandom(0.1);
var picked = WeightedPick.Pick(rng, items, weights);
Assert.That(picked, Is.EqualTo("a"));
}
[Test]
public void Picks_middle_band()
{
var items = new[] { "a", "b", "c" };
var weights = new[] { 0.5, 0.3, 0.2 };
var rng = new ScriptedRandom(0.7);
var picked = WeightedPick.Pick(rng, items, weights);
Assert.That(picked, Is.EqualTo("b"));
}
[Test]
public void Renormalizes_when_weights_dont_sum_to_one()
{
var items = new[] { "a", "b" };
var weights = new[] { 50.0, 50.0 };
var rng = new ScriptedRandom(0.4);
var picked = WeightedPick.Pick(rng, items, weights);
Assert.That(picked, Is.EqualTo("a"));
}
[Test]
public void Falls_through_to_last_item_when_rng_exceeds_sum_minus_epsilon()
{
var items = new[] { "a", "b" };
var weights = new[] { 0.5, 0.5 };
var rng = new ScriptedRandom(0.999);
var picked = WeightedPick.Pick(rng, items, weights);
Assert.That(picked, Is.EqualTo("b"));
}
}