Pack opening

This commit is contained in:
gamer147
2026-05-24 02:03:13 -04:00
parent bdff142d16
commit 79209bd70b
41 changed files with 37320 additions and 0 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,48 @@
{
"data_headers": { "sid": "fixture", "short_udid": 1, "viewer_id": 1, "servertime": 1779591187, "result_code": 1 },
"data": {
"pack_config_list": [
{
"parent_gacha_id": 10001, "base_pack_id": 10001, "override_draw_effect_pack_id": 10001,
"override_ui_effect_pack_id": 10001, "gacha_type": 1, "sleeve_id": 3000011, "special_sleeve_id": 0,
"commence_date": "2015-04-01 00:00:00", "complete_date": "2030-12-31 23:59:59",
"cardpack_banner_list": [], "gacha_detail": "A pack contains 8 cards, including at least one silver, gold, or legendary card.",
"child_gacha_info": [
{ "gacha_id": 100002, "type_detail": 2, "cost": 100, "count": 8, "override_increase_gacha_point": "1" },
{ "gacha_id": 200001, "type_detail": 3, "cost": 50, "count": 8, "override_increase_gacha_point": "1", "is_daily_single": true },
{ "gacha_id": 400002, "type_detail": 7, "cost": 100, "count": 8, "override_increase_gacha_point": "1" }
],
"open_count": 0, "open_count_limit": 0, "is_hide": 0, "pack_category": 0,
"gacha_point": { "pack_id": "10001", "gacha_point": 0, "increase_gacha_point": "1", "exchangeable_gacha_point": 400, "is_exchangeable_gacha_point": false },
"is_pre_release": false, "exists_purchase_reward": false, "is_new": false, "sales_period_info": [], "poster_type": 0
},
{
"parent_gacha_id": 92001, "base_pack_id": 90001, "override_draw_effect_pack_id": 90001,
"override_ui_effect_pack_id": 90001, "gacha_type": 1, "sleeve_id": 5090001, "special_sleeve_id": 0,
"commence_date": "2017-06-14 10:00:00", "complete_date": "2030-12-31 23:59:59",
"cardpack_banner_list": [], "gacha_detail": "A pack contains 8 cards, including at least one leader card!",
"child_gacha_info": [
{ "gacha_id": 920002, "type_detail": 5, "cost": 1, "count": 8, "item_id": "92001", "item_number": 0 }
],
"open_count": 0, "open_count_limit": 0, "is_hide": 1, "pack_category": 1, "gacha_point": null,
"is_pre_release": false, "exists_purchase_reward": false, "is_new": false, "sales_period_info": [], "poster_type": 0
},
{
"parent_gacha_id": 16015, "base_pack_id": 10015, "override_draw_effect_pack_id": 10015,
"override_ui_effect_pack_id": 10015, "gacha_type": 1, "sleeve_id": 5010015, "special_sleeve_id": 0,
"commence_date": "2017-07-01 03:00:00", "complete_date": "2030-12-31 23:59:59",
"cardpack_banner_list": [
{ "banner_name": "card_pack_711331010_dialog", "dialog_title": "Dia_BuyCard_005_Title" }
],
"gacha_detail": "A pack contains 8 cards, including at least one silver, gold, or legendary card.",
"child_gacha_info": [
{ "gacha_id": 160152, "type_detail": 2, "cost": 100, "count": 8, "override_increase_gacha_point": "1" },
{ "gacha_id": 460152, "type_detail": 7, "cost": 100, "count": 8, "override_increase_gacha_point": "1" }
],
"open_count": 0, "open_count_limit": 0, "is_hide": 0, "pack_category": 0,
"gacha_point": { "pack_id": "10015", "gacha_point": 0, "increase_gacha_point": "1", "exchangeable_gacha_point": 400, "is_exchangeable_gacha_point": false },
"is_pre_release": false, "exists_purchase_reward": false, "is_new": false, "sales_period_info": [], "poster_type": 0
}
]
}
}

View File

@@ -1,6 +1,7 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using static SVSim.Bootstrap.Importers.ImporterBase;
@@ -29,6 +30,7 @@ public class GlobalsImporter
JsonElement? deckInfo = LoadCapture(capturesDir, "deck-info");
JsonElement? paymentItemList = LoadCapture(capturesDir, "payment-item-list");
JsonElement? practiceInfo = LoadCapture(capturesDir, "practice-info");
JsonElement? packInfo = LoadCapture(capturesDir, "pack-info");
int total = 0;
@@ -75,6 +77,11 @@ public class GlobalsImporter
total += await ImportPracticeOpponents(context, practiceInfo.Value);
}
if (packInfo.HasValue)
{
total += await ImportPacks(context, packInfo.Value);
}
await context.SaveChangesAsync();
Console.WriteLine($"[GlobalsImporter] Done: {total} total rows changed.");
return total;
@@ -700,6 +707,122 @@ public class GlobalsImporter
private static decimal ParseDecimal(string s) =>
decimal.TryParse(s, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var d) ? d : 0m;
// ---------- Pack catalog ----------
/// <summary>
/// Imports /pack/info's pack_config_list into PackConfigEntry rows. The capture's <c>data</c>
/// element wraps an object with a <c>pack_config_list</c> array; iterate that. Owned children
/// (child_gacha_info, cardpack_banner_list) are replaced wholesale on re-runs — diffing
/// owned collections by composite keys is more code than it's worth for catalog updates.
/// </summary>
private async Task<int> ImportPacks(SVSimDbContext context, JsonElement packData)
{
if (!packData.TryGetProperty("pack_config_list", out var list) || list.ValueKind != JsonValueKind.Array)
{
Console.Error.WriteLine("[GlobalsImporter] pack-info capture missing 'pack_config_list'");
return 0;
}
var existing = await context.Packs
.Include(p => p.ChildGachas)
.Include(p => p.Banners)
.ToDictionaryAsync(p => p.Id);
int created = 0, updated = 0;
foreach (var el in list.EnumerateArray())
{
int parentId = GetInt(el, "parent_gacha_id");
if (parentId == 0) continue;
var pack = existing.TryGetValue(parentId, out var ex) ? ex : new PackConfigEntry { Id = parentId };
pack.BasePackId = GetInt(el, "base_pack_id");
pack.GachaType = GetInt(el, "gacha_type");
pack.PackCategory = (PackCategory)GetInt(el, "pack_category");
pack.PosterType = GetInt(el, "poster_type");
pack.CommenceDate = ParseWireDateTime(GetString(el, "commence_date"));
pack.CompleteDate = ParseWireDateTime(GetString(el, "complete_date"));
pack.SleeveId = GetInt(el, "sleeve_id");
pack.SpecialSleeveId = GetInt(el, "special_sleeve_id");
pack.OverrideDrawEffectPackId = GetInt(el, "override_draw_effect_pack_id");
pack.OverrideUiEffectPackId = GetInt(el, "override_ui_effect_pack_id");
pack.GachaDetail = GetString(el, "gacha_detail");
pack.IsHide = GetBool(el, "is_hide");
pack.IsNew = GetBool(el, "is_new");
pack.IsPreRelease = GetBool(el, "is_pre_release");
pack.OpenCountLimit = GetInt(el, "open_count_limit");
// sales_period_info is `{}` when set (object with sales_period_time) and `[]` when unset
if (el.TryGetProperty("sales_period_info", out var spi) && spi.ValueKind == JsonValueKind.Object)
{
var raw = GetString(spi, "sales_period_time");
pack.SalesPeriodTime = string.IsNullOrEmpty(raw) ? null : ParseWireDateTime(raw);
}
else
{
pack.SalesPeriodTime = null;
}
// gacha_point is null when the pack doesn't participate
if (el.TryGetProperty("gacha_point", out var gp) && gp.ValueKind == JsonValueKind.Object)
{
pack.GachaPointConfig = new PackGachaPointConfig
{
ExchangeablePoint = GetInt(gp, "exchangeable_gacha_point"),
IncreaseGachaPoint = GetInt(gp, "increase_gacha_point"),
};
}
else
{
pack.GachaPointConfig = null;
}
// Replace owned collections wholesale.
pack.ChildGachas.Clear();
if (el.TryGetProperty("child_gacha_info", out var cg) && cg.ValueKind == JsonValueKind.Array)
{
foreach (var c in cg.EnumerateArray())
{
pack.ChildGachas.Add(new PackChildGachaEntry
{
GachaId = GetInt(c, "gacha_id"),
TypeDetail = GetInt(c, "type_detail"),
Cost = GetInt(c, "cost"),
CardCount = GetInt(c, "count", 8),
ItemId = c.TryGetProperty("item_id", out var ii) && ii.ValueKind != JsonValueKind.Null
? GetLong(c, "item_id") : (long?)null,
IsDailySingle = GetBool(c, "is_daily_single"),
OverrideIncreaseGachaPoint = GetInt(c, "override_increase_gacha_point"),
PurchaseLimitCount = GetInt(c, "purchase_limit_count"),
FreeGachaCampaignId = c.TryGetProperty("free_gacha_campaign_id", out var fc) && fc.ValueKind != JsonValueKind.Null
? GetInt(c, "free_gacha_campaign_id") : (int?)null,
CampaignName = c.TryGetProperty("campaign_name", out var cn) && cn.ValueKind == JsonValueKind.String
? cn.GetString() : null,
});
}
}
pack.Banners.Clear();
if (el.TryGetProperty("cardpack_banner_list", out var bl) && bl.ValueKind == JsonValueKind.Array)
{
foreach (var b in bl.EnumerateArray())
{
pack.Banners.Add(new PackBannerEntry
{
BannerName = GetString(b, "banner_name"),
DialogTitle = GetString(b, "dialog_title"),
});
}
}
if (ex is null) { context.Packs.Add(pack); created++; }
else updated++;
}
Console.WriteLine($"[GlobalsImporter] Packs: +{created}/~{updated}");
return created + updated;
}
// ---------- Practice Opponents ----------
/// <summary>

View File

@@ -0,0 +1,17 @@
namespace SVSim.Database.Enums;
/// <summary>
/// Mirrors <c>Wizard.PackCategory</c> in the decompiled client
/// (<c>Shadowverse_Code/Wizard/PackCategory.cs</c>). Wire value = (int)enum.
/// </summary>
public enum PackCategory
{
None = 0,
LegendCardPack = 1,
SpecialCardPack = 2,
LimitedSpecialCardPack = 3,
FreePackLeaderSkin = 4,
RotationStarterCardPack = 5,
LeaderSkinPack = 6,
LegendAndLeaderSkinSinglePack = 7,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,135 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddPackCatalog : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Packs",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false),
BasePackId = table.Column<int>(type: "integer", nullable: false),
GachaType = table.Column<int>(type: "integer", nullable: false),
PackCategory = table.Column<int>(type: "integer", nullable: false),
PosterType = table.Column<int>(type: "integer", nullable: false),
CommenceDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CompleteDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
SalesPeriodTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
SleeveId = table.Column<int>(type: "integer", nullable: false),
SpecialSleeveId = table.Column<int>(type: "integer", nullable: false),
OverrideDrawEffectPackId = table.Column<int>(type: "integer", nullable: false),
OverrideUiEffectPackId = table.Column<int>(type: "integer", nullable: false),
GachaDetail = table.Column<string>(type: "text", nullable: false),
IsHide = table.Column<bool>(type: "boolean", nullable: false),
IsNew = table.Column<bool>(type: "boolean", nullable: false),
IsPreRelease = table.Column<bool>(type: "boolean", nullable: false),
OpenCountLimit = table.Column<int>(type: "integer", nullable: false),
GachaPointConfig_ExchangeablePoint = table.Column<int>(type: "integer", nullable: true),
GachaPointConfig_IncreaseGachaPoint = table.Column<int>(type: "integer", nullable: true),
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Packs", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ViewerPackOpenCount",
columns: table => new
{
ViewerId = table.Column<long>(type: "bigint", nullable: false),
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
PackId = table.Column<int>(type: "integer", nullable: false),
OpenCount = table.Column<int>(type: "integer", nullable: false),
LastDailyFreeAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ViewerPackOpenCount", x => new { x.ViewerId, x.Id });
table.ForeignKey(
name: "FK_ViewerPackOpenCount_Viewers_ViewerId",
column: x => x.ViewerId,
principalTable: "Viewers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PackBannerEntry",
columns: table => new
{
PackConfigEntryId = table.Column<int>(type: "integer", nullable: false),
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
BannerName = table.Column<string>(type: "text", nullable: false),
DialogTitle = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PackBannerEntry", x => new { x.PackConfigEntryId, x.Id });
table.ForeignKey(
name: "FK_PackBannerEntry_Packs_PackConfigEntryId",
column: x => x.PackConfigEntryId,
principalTable: "Packs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PackChildGachaEntry",
columns: table => new
{
PackConfigEntryId = table.Column<int>(type: "integer", nullable: false),
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
GachaId = table.Column<int>(type: "integer", nullable: false),
TypeDetail = table.Column<int>(type: "integer", nullable: false),
Cost = table.Column<int>(type: "integer", nullable: false),
CardCount = table.Column<int>(type: "integer", nullable: false),
ItemId = table.Column<long>(type: "bigint", nullable: true),
IsDailySingle = table.Column<bool>(type: "boolean", nullable: false),
OverrideIncreaseGachaPoint = table.Column<int>(type: "integer", nullable: false),
PurchaseLimitCount = table.Column<int>(type: "integer", nullable: false),
FreeGachaCampaignId = table.Column<int>(type: "integer", nullable: true),
CampaignName = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PackChildGachaEntry", x => new { x.PackConfigEntryId, x.Id });
table.ForeignKey(
name: "FK_PackChildGachaEntry_Packs_PackConfigEntryId",
column: x => x.PackConfigEntryId,
principalTable: "Packs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PackBannerEntry");
migrationBuilder.DropTable(
name: "PackChildGachaEntry");
migrationBuilder.DropTable(
name: "ViewerPackOpenCount");
migrationBuilder.DropTable(
name: "Packs");
}
}
}

View File

@@ -25422,6 +25422,71 @@ namespace SVSim.Database.Migrations
b.ToTable("MyRotationSettings");
});
modelBuilder.Entity("SVSim.Database.Models.PackConfigEntry", b =>
{
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<int>("BasePackId")
.HasColumnType("integer");
b.Property<DateTime>("CommenceDate")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CompleteDate")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<string>("GachaDetail")
.IsRequired()
.HasColumnType("text");
b.Property<int>("GachaType")
.HasColumnType("integer");
b.Property<bool>("IsHide")
.HasColumnType("boolean");
b.Property<bool>("IsNew")
.HasColumnType("boolean");
b.Property<bool>("IsPreRelease")
.HasColumnType("boolean");
b.Property<int>("OpenCountLimit")
.HasColumnType("integer");
b.Property<int>("OverrideDrawEffectPackId")
.HasColumnType("integer");
b.Property<int>("OverrideUiEffectPackId")
.HasColumnType("integer");
b.Property<int>("PackCategory")
.HasColumnType("integer");
b.Property<int>("PosterType")
.HasColumnType("integer");
b.Property<DateTime?>("SalesPeriodTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("SleeveId")
.HasColumnType("integer");
b.Property<int>("SpecialSleeveId")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Packs");
});
modelBuilder.Entity("SVSim.Database.Models.PaymentItemEntry", b =>
{
b.Property<int>("Id")
@@ -34209,6 +34274,110 @@ namespace SVSim.Database.Migrations
b.Navigation("Class");
});
modelBuilder.Entity("SVSim.Database.Models.PackConfigEntry", b =>
{
b.OwnsMany("SVSim.Database.Models.PackBannerEntry", "Banners", b1 =>
{
b1.Property<int>("PackConfigEntryId")
.HasColumnType("integer");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<string>("BannerName")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("DialogTitle")
.IsRequired()
.HasColumnType("text");
b1.HasKey("PackConfigEntryId", "Id");
b1.ToTable("PackBannerEntry");
b1.WithOwner()
.HasForeignKey("PackConfigEntryId");
});
b.OwnsMany("SVSim.Database.Models.PackChildGachaEntry", "ChildGachas", b1 =>
{
b1.Property<int>("PackConfigEntryId")
.HasColumnType("integer");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<string>("CampaignName")
.HasColumnType("text");
b1.Property<int>("CardCount")
.HasColumnType("integer");
b1.Property<int>("Cost")
.HasColumnType("integer");
b1.Property<int?>("FreeGachaCampaignId")
.HasColumnType("integer");
b1.Property<int>("GachaId")
.HasColumnType("integer");
b1.Property<bool>("IsDailySingle")
.HasColumnType("boolean");
b1.Property<long?>("ItemId")
.HasColumnType("bigint");
b1.Property<int>("OverrideIncreaseGachaPoint")
.HasColumnType("integer");
b1.Property<int>("PurchaseLimitCount")
.HasColumnType("integer");
b1.Property<int>("TypeDetail")
.HasColumnType("integer");
b1.HasKey("PackConfigEntryId", "Id");
b1.ToTable("PackChildGachaEntry");
b1.WithOwner()
.HasForeignKey("PackConfigEntryId");
});
b.OwnsOne("SVSim.Database.Models.PackGachaPointConfig", "GachaPointConfig", b1 =>
{
b1.Property<int>("PackConfigEntryId")
.HasColumnType("integer");
b1.Property<int>("ExchangeablePoint")
.HasColumnType("integer");
b1.Property<int>("IncreaseGachaPoint")
.HasColumnType("integer");
b1.HasKey("PackConfigEntryId");
b1.ToTable("Packs");
b1.WithOwner()
.HasForeignKey("PackConfigEntryId");
});
b.Navigation("Banners");
b.Navigation("ChildGachas");
b.Navigation("GachaPointConfig");
});
modelBuilder.Entity("SVSim.Database.Models.ShadowverseCardEntry", b =>
{
b.HasOne("SVSim.Database.Models.ClassEntry", "Class")
@@ -34588,6 +34757,34 @@ namespace SVSim.Database.Migrations
.HasForeignKey("ViewerId");
});
b.OwnsMany("SVSim.Database.Models.ViewerPackOpenCount", "PackOpenCounts", b1 =>
{
b1.Property<long>("ViewerId")
.HasColumnType("bigint");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<DateTime?>("LastDailyFreeAt")
.HasColumnType("timestamp with time zone");
b1.Property<int>("OpenCount")
.HasColumnType("integer");
b1.Property<int>("PackId")
.HasColumnType("integer");
b1.HasKey("ViewerId", "Id");
b1.ToTable("ViewerPackOpenCount");
b1.WithOwner()
.HasForeignKey("ViewerId");
});
b.Navigation("Cards");
b.Navigation("Classes");
@@ -34603,6 +34800,8 @@ namespace SVSim.Database.Migrations
b.Navigation("MissionData")
.IsRequired();
b.Navigation("PackOpenCounts");
b.Navigation("SocialAccountConnections");
});

View File

@@ -0,0 +1,11 @@
using Microsoft.EntityFrameworkCore;
namespace SVSim.Database.Models;
/// <summary>One entry of <c>cardpack_banner_list</c> in /pack/info. Owned by PackConfigEntry.</summary>
[Owned]
public class PackBannerEntry
{
public string BannerName { get; set; } = string.Empty;
public string DialogTitle { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
namespace SVSim.Database.Models;
/// <summary>
/// One sub-option inside a pack (single-open / 10-open / ticket / daily-free).
/// Wire shape: one entry of <c>child_gacha_info</c> in /pack/info. Owned by PackConfigEntry.
/// <c>TypeDetail</c> corresponds to <c>GachaUI.CardPackType</c>:
/// 1=CRYSTAL, 2=CRYSTAL_MULTI, 3=DAILY, 4=TICKET, 5=TICKET_MULTI, 6=RUPY, 7=RUPY_MULTI,
/// 8=CRYSTAL_SPECIAL, 9=CRYSTAL_SELECT_SKIN, 10=FREE_PACKS, 11=FREE_PACK_WITH_SKIN,
/// 12=ROTATION_STARTER_PACK, 13=CRYSTAL_ACQUIRE_SKIN_CARD_PACK.
/// </summary>
[Owned]
public class PackChildGachaEntry
{
public int GachaId { get; set; }
public int TypeDetail { get; set; }
public int Cost { get; set; }
public int CardCount { get; set; }
public long? ItemId { get; set; }
public bool IsDailySingle { get; set; }
public int OverrideIncreaseGachaPoint { get; set; }
public int PurchaseLimitCount { get; set; }
public int? FreeGachaCampaignId { get; set; }
public string? CampaignName { get; set; }
}

View File

@@ -0,0 +1,40 @@
using SVSim.Database.Common;
using SVSim.Database.Enums;
namespace SVSim.Database.Models;
/// <summary>
/// One row of /pack/info's <c>pack_config_list</c>. PK = <c>parent_gacha_id</c> (the wire id the
/// client treats as "this pack"). Child gachas and banners are owned collections — replaced
/// wholesale on importer re-runs.
/// </summary>
public class PackConfigEntry : BaseEntity<int>
{
public int BasePackId { get; set; }
public int GachaType { get; set; }
public PackCategory PackCategory { get; set; }
public int PosterType { get; set; }
public DateTime CommenceDate { get; set; }
public DateTime CompleteDate { get; set; }
public DateTime? SalesPeriodTime { get; set; }
public int SleeveId { get; set; }
public int SpecialSleeveId { get; set; }
public int OverrideDrawEffectPackId { get; set; }
public int OverrideUiEffectPackId { get; set; }
public string GachaDetail { get; set; } = string.Empty;
public bool IsHide { get; set; }
public bool IsNew { get; set; }
public bool IsPreRelease { get; set; }
public int OpenCountLimit { get; set; }
public PackGachaPointConfig? GachaPointConfig { get; set; }
public List<PackBannerEntry> Banners { get; set; } = new();
public List<PackChildGachaEntry> ChildGachas { get; set; } = new();
}

View File

@@ -0,0 +1,16 @@
using Microsoft.EntityFrameworkCore;
namespace SVSim.Database.Models;
/// <summary>
/// Per-pack gacha-point exchange config. Owned by <see cref="PackConfigEntry"/>; null when the
/// pack does not participate in gacha-point exchange. Wire shape (from /pack/info):
/// <c>{"pack_id":"10001","gacha_point":0,"increase_gacha_point":"1","exchangeable_gacha_point":400,"is_exchangeable_gacha_point":false}</c>.
/// v1 only persists the static catalog values; per-viewer accrual is deferred.
/// </summary>
[Owned]
public class PackGachaPointConfig
{
public int ExchangeablePoint { get; set; }
public int IncreaseGachaPoint { get; set; }
}

View File

@@ -55,6 +55,8 @@ public class Viewer : BaseEntity<long>
public List<MyPageBackgroundEntry> MyPageBackgrounds { get; set; } = new List<MyPageBackgroundEntry>();
public List<ViewerPackOpenCount> PackOpenCounts { get; set; } = new List<ViewerPackOpenCount>();
#endregion
#region Navigation Properties

View File

@@ -0,0 +1,17 @@
using Microsoft.EntityFrameworkCore;
namespace SVSim.Database.Models;
/// <summary>
/// Per-viewer, per-pack open counter. Owned collection on <see cref="Viewer"/>.
/// <c>PackId</c> = parent_gacha_id. <c>LastDailyFreeAt</c> is null until the viewer first opens
/// a DAILY (type_detail=3) child gacha; thereafter the controller compares it against the daily
/// reset boundary to decide whether the free open is available again.
/// </summary>
[Owned]
public class ViewerPackOpenCount
{
public int PackId { get; set; }
public int OpenCount { get; set; }
public DateTime? LastDailyFreeAt { get; set; }
}

View File

@@ -0,0 +1,13 @@
using SVSim.Database.Models;
namespace SVSim.Database.Repositories.Pack;
public interface IPackRepository
{
Task<List<PackConfigEntry>> GetActivePacks(DateTime now);
Task<PackConfigEntry?> GetPack(int parentGachaId);
Task<Dictionary<int, ViewerPackOpenCount>> GetOpenCountsForViewer(long viewerId);
Task IncrementOpenCount(long viewerId, int parentGachaId, int by);
Task MarkDailyFreeUsed(long viewerId, int parentGachaId, DateTime when);
Task GrantCardsToViewer(long viewerId, IEnumerable<long> cardIds);
}

View File

@@ -0,0 +1,90 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Database.Models;
namespace SVSim.Database.Repositories.Pack;
public class PackRepository : IPackRepository
{
private readonly SVSimDbContext _db;
public PackRepository(SVSimDbContext db) { _db = db; }
public async Task<List<PackConfigEntry>> GetActivePacks(DateTime now) =>
await _db.Packs
.Include(p => p.ChildGachas)
.Include(p => p.Banners)
.Where(p => p.CommenceDate <= now && p.CompleteDate >= now)
.ToListAsync();
public async Task<PackConfigEntry?> GetPack(int parentGachaId) =>
await _db.Packs
.Include(p => p.ChildGachas)
.Include(p => p.Banners)
.FirstOrDefaultAsync(p => p.Id == parentGachaId);
public async Task<Dictionary<int, ViewerPackOpenCount>> GetOpenCountsForViewer(long viewerId)
{
var viewer = await _db.Viewers
.Include(v => v.PackOpenCounts)
.FirstOrDefaultAsync(v => v.Id == viewerId);
return viewer?.PackOpenCounts.ToDictionary(p => p.PackId) ?? new();
}
public async Task IncrementOpenCount(long viewerId, int parentGachaId, int by)
{
var viewer = await _db.Viewers
.Include(v => v.PackOpenCounts)
.FirstAsync(v => v.Id == viewerId);
var row = viewer.PackOpenCounts.FirstOrDefault(p => p.PackId == parentGachaId);
if (row is null)
{
viewer.PackOpenCounts.Add(new ViewerPackOpenCount { PackId = parentGachaId, OpenCount = by });
}
else
{
row.OpenCount += by;
}
await _db.SaveChangesAsync();
}
public async Task MarkDailyFreeUsed(long viewerId, int parentGachaId, DateTime when)
{
var viewer = await _db.Viewers
.Include(v => v.PackOpenCounts)
.FirstAsync(v => v.Id == viewerId);
var row = viewer.PackOpenCounts.FirstOrDefault(p => p.PackId == parentGachaId);
if (row is null)
{
viewer.PackOpenCounts.Add(new ViewerPackOpenCount { PackId = parentGachaId, OpenCount = 0, LastDailyFreeAt = when });
}
else
{
row.LastDailyFreeAt = when;
}
await _db.SaveChangesAsync();
}
public async Task GrantCardsToViewer(long viewerId, IEnumerable<long> cardIds)
{
var viewer = await _db.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.FirstAsync(v => v.Id == viewerId);
var byId = viewer.Cards.ToDictionary(c => c.Card.Id);
foreach (var grp in cardIds.GroupBy(id => id))
{
if (byId.TryGetValue(grp.Key, out var existing))
{
existing.Count += grp.Count();
}
else
{
// Look up the card by id and attach it so we don't insert a phantom Card row.
var card = await _db.Cards.FirstAsync(c => c.Id == grp.Key);
var owned = new OwnedCardEntry { Card = card, Count = grp.Count(), IsProtected = false };
viewer.Cards.Add(owned);
byId[grp.Key] = owned;
}
}
await _db.SaveChangesAsync();
}
}

View File

@@ -56,6 +56,7 @@ public class SVSimDbContext : DbContext
public DbSet<MasterPointRankingPeriodEntry> MasterPointRankingPeriods => Set<MasterPointRankingPeriodEntry>();
public DbSet<SpecialDeckFormatEntry> SpecialDeckFormats => Set<SpecialDeckFormatEntry>();
public DbSet<PaymentItemEntry> PaymentItems => Set<PaymentItemEntry>();
public DbSet<PackConfigEntry> Packs => Set<PackConfigEntry>();
public DbSet<MaintenanceCardEntry> MaintenanceCards => Set<MaintenanceCardEntry>();
public DbSet<FeatureMaintenanceEntry> FeatureMaintenances => Set<FeatureMaintenanceEntry>();
public DbSet<PreReleaseInfo> PreReleaseInfos => Set<PreReleaseInfo>();
@@ -107,6 +108,10 @@ public class SVSimDbContext : DbContext
.Property(v => v.ShortUdid)
.UseSequence("ShortUdidSequence");
modelBuilder.Entity<PackConfigEntry>().OwnsMany(p => p.ChildGachas);
modelBuilder.Entity<PackConfigEntry>().OwnsMany(p => p.Banners);
modelBuilder.Entity<Viewer>().OwnsMany(v => v.PackOpenCounts);
new BaseDataSeeder().Seed(modelBuilder);
new DefaultSettingsSeeder().Seed(modelBuilder);

View File

@@ -0,0 +1,233 @@
using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Pack;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Pack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Pack;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// /pack/* — card-pack shop catalog and pack opening. Tutorial aliases (/tutorial/pack_info,
/// /tutorial/pack_open) are out of scope for v1.
/// </summary>
[Route("pack")]
public class PackController : SVSimController
{
private const string WireDateFormat = "yyyy-MM-dd HH:mm:ss";
private readonly IPackRepository _packs;
private readonly PackOpenService _opener;
private readonly ICardPoolProvider _pools;
private readonly IRandom _rng;
private readonly SVSimDbContext _db;
public PackController(
IPackRepository packs,
PackOpenService opener,
ICardPoolProvider pools,
IRandom rng,
SVSimDbContext db)
{
_packs = packs;
_opener = opener;
_pools = pools;
_rng = rng;
_db = db;
}
[HttpPost("info")]
public async Task<ActionResult<PackInfoResponse>> Info(BaseRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var packs = await _packs.GetActivePacks(DateTime.UtcNow);
var openCounts = await _packs.GetOpenCountsForViewer(viewerId);
return new PackInfoResponse
{
PackConfigList = packs.Select(p => ToDto(p, openCounts)).ToList(),
};
}
private static PackConfigDto ToDto(PackConfigEntry p, IReadOnlyDictionary<int, ViewerPackOpenCount> openCounts)
{
int openCount = openCounts.TryGetValue(p.Id, out var oc) ? oc.OpenCount : 0;
return new PackConfigDto
{
ParentGachaId = p.Id,
BasePackId = p.BasePackId,
OverrideDrawEffectPackId = p.OverrideDrawEffectPackId,
OverrideUiEffectPackId = p.OverrideUiEffectPackId,
GachaType = p.GachaType,
SleeveId = p.SleeveId,
SpecialSleeveId = p.SpecialSleeveId,
CommenceDate = p.CommenceDate.ToString(WireDateFormat, CultureInfo.InvariantCulture),
CompleteDate = p.CompleteDate.ToString(WireDateFormat, CultureInfo.InvariantCulture),
CardpackBannerList = p.Banners.Select(b => new PackBannerDto
{
BannerName = b.BannerName,
DialogTitle = b.DialogTitle,
}).ToList(),
GachaDetail = p.GachaDetail,
ChildGachaInfo = p.ChildGachas.Select(c => new PackChildGachaDto
{
GachaId = c.GachaId,
TypeDetail = c.TypeDetail,
Cost = c.Cost,
Count = c.CardCount,
ItemId = c.ItemId?.ToString(CultureInfo.InvariantCulture),
IsDailySingle = c.IsDailySingle,
OverrideIncreaseGachaPoint = c.OverrideIncreaseGachaPoint.ToString(CultureInfo.InvariantCulture),
}).ToList(),
OpenCount = openCount,
OpenCountLimit = p.OpenCountLimit,
IsHide = p.IsHide ? 1 : 0,
PackCategory = (int)p.PackCategory,
GachaPoint = p.GachaPointConfig is null ? null : new PackGachaPointDto
{
PackId = p.BasePackId.ToString(CultureInfo.InvariantCulture),
GachaPoint = 0,
IncreaseGachaPoint = p.GachaPointConfig.IncreaseGachaPoint.ToString(CultureInfo.InvariantCulture),
ExchangeableGachaPoint = p.GachaPointConfig.ExchangeablePoint,
IsExchangeableGachaPoint = false,
},
IsPreRelease = p.IsPreRelease,
ExistsPurchaseReward = false,
IsNew = p.IsNew,
PosterType = p.PosterType,
SalesPeriodInfo = new(), // emit `{}` per the DTO docstring
};
}
[HttpPost("open")]
public async Task<ActionResult<PackOpenResponse>> Open(PackOpenRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
// Reject paths up front — class_id/target_card_id overloads aren't implemented.
if (request.ClassId.HasValue)
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "starter_overload_not_implemented" });
if (request.TargetCardId.HasValue)
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "skin_overload_not_implemented" });
var pack = await _packs.GetPack(request.ParentGachaId);
if (pack is null) return NotFound(new { error = "unknown_pack" });
// Skin / starter / leader-skin packs aren't drawn in v1 regardless of child type.
if (pack.PackCategory is PackCategory.LeaderSkinPack
or PackCategory.FreePackLeaderSkin
or PackCategory.RotationStarterCardPack
or PackCategory.LegendAndLeaderSkinSinglePack)
{
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "skin_starter_category_not_implemented" });
}
var child = pack.ChildGachas.FirstOrDefault(c => c.GachaId == request.GachaId);
if (child is null)
return BadRequest(new { error = "unknown_child_gacha" });
// Note: request.GachaType is the PARENT pack's gacha_type (a routing/disambiguation field),
// NOT the child's type_detail. Prod traffic confirms the client sends gacha_type=1 even
// when buying a RUPY_MULTI (type_detail=7) child. The gacha_id alone disambiguates the
// child; gacha_type validation against child.TypeDetail would falsely reject every buy.
// Supported currency types in v1: CRYSTAL_MULTI=2, DAILY=3, RUPY_MULTI=7. Ticket flows
// (TICKET=4, TICKET_MULTI=5) and the rest are explicitly out of scope.
if (child.TypeDetail is not (2 or 3 or 7))
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "currency_path_not_implemented" });
var viewer = await _db.Viewers.Include(v => v.PackOpenCounts).FirstAsync(v => v.Id == viewerId);
int packNumber = Math.Max(1, request.PackNumber);
// Currency check + deduction
switch (child.TypeDetail)
{
case 2: // CRYSTAL_MULTI
{
ulong cost = (ulong)child.Cost * (ulong)packNumber;
if (viewer.Currency.Crystals < cost)
return BadRequest(new { error = "insufficient_crystals" });
viewer.Currency.Crystals -= cost;
break;
}
case 7: // RUPY_MULTI
{
ulong cost = (ulong)child.Cost * (ulong)packNumber;
if (viewer.Currency.Rupees < cost)
return BadRequest(new { error = "insufficient_rupees" });
viewer.Currency.Rupees -= cost;
break;
}
case 3: // DAILY single — once per UTC day
{
// TODO(daily-reset): no project-wide daily-reset convention exists yet. Using UTC
// midnight; revisit when the global reset boundary is settled.
var now = DateTime.UtcNow;
var existing = viewer.PackOpenCounts.FirstOrDefault(p => p.PackId == pack.Id);
if (existing?.LastDailyFreeAt is DateTime last && last.Date == now.Date)
return BadRequest(new { error = "daily_free_already_claimed" });
ulong cost = (ulong)child.Cost * (ulong)packNumber;
if (cost > 0 && viewer.Currency.Rupees < cost)
return BadRequest(new { error = "insufficient_rupees" });
if (cost > 0) viewer.Currency.Rupees -= cost;
break;
}
}
await _db.SaveChangesAsync();
// Increment open count + mark daily-free timestamp where relevant
await _packs.IncrementOpenCount(viewerId, pack.Id, packNumber);
if (child.TypeDetail == 3)
{
await _packs.MarkDailyFreeUsed(viewerId, pack.Id, DateTime.UtcNow);
}
// 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);
await _packs.GrantCardsToViewer(viewerId, draw.Cards.Select(c => c.CardId));
// Build reward_list with post-state totals. The client's PlayerStaticData.UpdateHaveUserGoodsNum
// does direct assignment (`UserRupyCount = reward_num`, owned-count = reward_num), so we
// emit the new totals — not deltas. Without these the on-screen rupee/crystal/collection
// counts stay stale until the next /mypage/refresh or restart.
var rewardList = new List<RewardListEntry>();
var postViewer = await _db.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.FirstAsync(v => v.Id == viewerId);
if (child.TypeDetail == 2)
{
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)postViewer.Currency.Crystals });
}
else if (child.TypeDetail == 7 || child.TypeDetail == 3)
{
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)postViewer.Currency.Rupees });
}
var drawnCardIds = draw.Cards.Select(c => c.CardId).Distinct().ToHashSet();
foreach (var owned in postViewer.Cards.Where(c => drawnCardIds.Contains(c.Card.Id)))
{
rewardList.Add(new RewardListEntry { RewardType = 5, RewardId = owned.Card.Id, RewardNum = owned.Count });
}
return new PackOpenResponse
{
PackList = draw.Cards.Select(c => new CardPackEntryDto
{
CardId = c.CardId,
Rarity = (int)c.Rarity,
Number = 1,
}).ToList(),
RewardList = rewardList,
};
}
}

View File

@@ -0,0 +1,16 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
[MessagePackObject]
public class PackBannerDto
{
[JsonPropertyName("banner_name")]
[Key("banner_name")]
public string BannerName { get; set; } = string.Empty;
[JsonPropertyName("dialog_title")]
[Key("dialog_title")]
public string DialogTitle { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,43 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
[MessagePackObject]
public class PackChildGachaDto
{
[JsonPropertyName("gacha_id")]
[Key("gacha_id")]
public int GachaId { get; set; }
[JsonPropertyName("type_detail")]
[Key("type_detail")]
public int TypeDetail { get; set; }
[JsonPropertyName("cost")]
[Key("cost")]
public int Cost { get; set; }
[JsonPropertyName("count")]
[Key("count")]
public int Count { get; set; } = 8;
/// <summary>Stringified on the wire when present (prod sends "10001" not 10001).</summary>
[JsonPropertyName("item_id")]
[Key("item_id")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ItemId { get; set; }
[JsonPropertyName("item_number")]
[Key("item_number")]
public int ItemNumber { get; set; }
[JsonPropertyName("is_daily_single")]
[Key("is_daily_single")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool IsDailySingle { get; set; }
[JsonPropertyName("override_increase_gacha_point")]
[Key("override_increase_gacha_point")]
public string OverrideIncreaseGachaPoint { get; set; } = "0";
}

View File

@@ -0,0 +1,110 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
[MessagePackObject]
public class PackConfigDto
{
[JsonPropertyName("parent_gacha_id")]
[Key("parent_gacha_id")]
public int ParentGachaId { get; set; }
[JsonPropertyName("base_pack_id")]
[Key("base_pack_id")]
public int BasePackId { get; set; }
[JsonPropertyName("override_draw_effect_pack_id")]
[Key("override_draw_effect_pack_id")]
public int OverrideDrawEffectPackId { get; set; }
[JsonPropertyName("override_ui_effect_pack_id")]
[Key("override_ui_effect_pack_id")]
public int OverrideUiEffectPackId { get; set; }
[JsonPropertyName("gacha_type")]
[Key("gacha_type")]
public int GachaType { get; set; }
[JsonPropertyName("sleeve_id")]
[Key("sleeve_id")]
public int SleeveId { get; set; } = 3000011;
[JsonPropertyName("special_sleeve_id")]
[Key("special_sleeve_id")]
public int SpecialSleeveId { get; set; }
[JsonPropertyName("commence_date")]
[Key("commence_date")]
public string CommenceDate { get; set; } = string.Empty;
[JsonPropertyName("complete_date")]
[Key("complete_date")]
public string CompleteDate { get; set; } = string.Empty;
[JsonPropertyName("cardpack_banner_list")]
[Key("cardpack_banner_list")]
public List<PackBannerDto> CardpackBannerList { get; set; } = new();
[JsonPropertyName("gacha_detail")]
[Key("gacha_detail")]
public string GachaDetail { get; set; } = string.Empty;
[JsonPropertyName("child_gacha_info")]
[Key("child_gacha_info")]
public List<PackChildGachaDto> ChildGachaInfo { get; set; } = new();
[JsonPropertyName("open_count")]
[Key("open_count")]
public int OpenCount { get; set; }
[JsonPropertyName("open_count_limit")]
[Key("open_count_limit")]
public int OpenCountLimit { get; set; }
[JsonPropertyName("is_hide")]
[Key("is_hide")]
public int IsHide { get; set; }
[JsonPropertyName("pack_category")]
[Key("pack_category")]
public int PackCategory { get; set; }
/// <summary>
/// Null when the pack has no gacha-point participation. The key MUST be present on the wire
/// (explicit null) — client at PackInfoTask.cs:126 does <c>if (jsonData2["gacha_point"] != null)</c>,
/// a direct LitJson key access that throws KeyNotFoundException when the key is absent
/// (only protects against null *value*, not missing *key*). Override the global
/// WhenWritingNull per [[project_wire_null_policy]] memory.
/// </summary>
[JsonPropertyName("gacha_point")]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
[Key("gacha_point")]
public PackGachaPointDto? GachaPoint { get; set; }
[JsonPropertyName("is_pre_release")]
[Key("is_pre_release")]
public bool IsPreRelease { get; set; }
[JsonPropertyName("exists_purchase_reward")]
[Key("exists_purchase_reward")]
public bool ExistsPurchaseReward { get; set; }
[JsonPropertyName("is_new")]
[Key("is_new")]
public bool IsNew { get; set; }
/// <summary>
/// Prod sends an object <c>{"sales_period_time":"..."}</c> when set and an array <c>[]</c>
/// when unset. v1 always emits an empty object when the field is null on the entity —
/// matches the active-window case and the client tolerates both shapes via
/// <c>ShopExpirtyInfo</c>'s LitJson parser. Revisit if a capture proves otherwise.
/// </summary>
[JsonPropertyName("sales_period_info")]
[Key("sales_period_info")]
public Dictionary<string, string?> SalesPeriodInfo { get; set; } = new();
[JsonPropertyName("poster_type")]
[Key("poster_type")]
public int PosterType { get; set; }
}

View File

@@ -0,0 +1,32 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
/// <summary>
/// gacha_point block inside /pack/info entries. Prod ships strings for pack_id/increase_gacha_point;
/// mirror exactly per project_wire_key_serialization.
/// </summary>
[MessagePackObject]
public class PackGachaPointDto
{
[JsonPropertyName("pack_id")]
[Key("pack_id")]
public string PackId { get; set; } = "0";
[JsonPropertyName("gacha_point")]
[Key("gacha_point")]
public int GachaPoint { get; set; }
[JsonPropertyName("increase_gacha_point")]
[Key("increase_gacha_point")]
public string IncreaseGachaPoint { get; set; } = "0";
[JsonPropertyName("exchangeable_gacha_point")]
[Key("exchangeable_gacha_point")]
public int ExchangeableGachaPoint { get; set; }
[JsonPropertyName("is_exchangeable_gacha_point")]
[Key("is_exchangeable_gacha_point")]
public bool IsExchangeableGachaPoint { get; set; }
}

View File

@@ -0,0 +1,43 @@
using System.Text.Json.Serialization;
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Pack;
/// <summary>
/// Inbound /pack/open body. Accepts ALL three client-side overloads in one DTO — fields
/// for Starter (<c>class_id</c>) and Skin (<c>target_card_id</c>) are nullable so we can
/// reject those overloads in the controller without a custom binder.
/// See <c>Wizard/PackOpenTask.cs</c> for the three SetParameter variants.
/// </summary>
[MessagePackObject]
public class PackOpenRequest : BaseRequest
{
[JsonPropertyName("parent_gacha_id")]
[Key("parent_gacha_id")]
public int ParentGachaId { get; set; }
[JsonPropertyName("gacha_id")]
[Key("gacha_id")]
public int GachaId { get; set; }
[JsonPropertyName("gacha_type")]
[Key("gacha_type")]
public int GachaType { get; set; }
[JsonPropertyName("pack_number")]
[Key("pack_number")]
public int PackNumber { get; set; }
[JsonPropertyName("exclude_card_ids")]
[Key("exclude_card_ids")]
public long[] ExcludeCardIds { get; set; } = Array.Empty<long>();
[JsonPropertyName("class_id")]
[Key("class_id")]
public int? ClassId { get; set; }
[JsonPropertyName("target_card_id")]
[Key("target_card_id")]
public long? TargetCardId { get; set; }
}

View File

@@ -0,0 +1,12 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Pack;
[MessagePackObject]
public class PackInfoResponse
{
[JsonPropertyName("pack_config_list")]
[Key("pack_config_list")]
public List<PackConfigDto> PackConfigList { get; set; } = new();
}

View File

@@ -0,0 +1,46 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Pack;
[MessagePackObject]
public class PackOpenResponse
{
[JsonPropertyName("pack_list")]
[Key("pack_list")]
public List<CardPackEntryDto> PackList { get; set; } = new();
[JsonPropertyName("reward_list")]
[Key("reward_list")]
public List<RewardListEntry> RewardList { get; set; } = new();
[JsonPropertyName("rewards")]
[Key("rewards")]
public List<object> Rewards { get; set; } = new();
[JsonPropertyName("is_special_effect")]
[Key("is_special_effect")]
public bool IsSpecialEffect { get; set; }
/// <summary>Empty array literal — matches prod when no missions completed.</summary>
[JsonPropertyName("mission_result")]
[Key("mission_result")]
public List<object> MissionResult { get; set; } = new();
}
[MessagePackObject]
public class CardPackEntryDto
{
[JsonPropertyName("card_id")]
[Key("card_id")]
public long CardId { get; set; }
[JsonPropertyName("rarity")]
[Key("rarity")]
public int Rarity { get; set; }
/// <summary>Always 1 per drawn slot — matches prod sample shape.</summary>
[JsonPropertyName("number")]
[Key("number")]
public int Number { get; set; } = 1;
}

View File

@@ -0,0 +1,31 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
/// <summary>
/// One entry of <c>reward_list</c> on /pack/open (and other grant-emitting endpoints).
/// Client at <c>PlayerStaticData.UpdateHaveUserGoodsNumByJsonData</c> reads these and writes
/// <c>UserRupyCount = reward_num</c>, <c>UserCrystalCount = reward_num</c>, etc. —
/// <b>reward_num is the NEW POST-STATE TOTAL, not a delta</b>. Without these entries the
/// client's cached currency/collection counts stay stale until a full refresh (mypage, restart).
///
/// reward_type values are <c>Wizard.UserGoods.Type</c>: 1=RedEther, 2=Crystal, 4=Item, 5=Card,
/// 6=Sleeve, 7=Emblem, 8=Degree, 9=Rupy, 10=Skin, 11=SpotCard, 12=SpotCardPoint, etc.
/// reward_id is 0 for non-instanced goods (Rupy, Crystal, RedEther) and the entity id for cards.
/// </summary>
[MessagePackObject]
public class RewardListEntry
{
[JsonPropertyName("reward_type")]
[Key("reward_type")]
public int RewardType { get; set; }
[JsonPropertyName("reward_id")]
[Key("reward_id")]
public long RewardId { get; set; }
[JsonPropertyName("reward_num")]
[Key("reward_num")]
public int RewardNum { get; set; }
}

View File

@@ -6,6 +6,7 @@ using SVSim.Database.Repositories.Card;
using SVSim.Database.Repositories.Collectibles;
using SVSim.Database.Repositories.Deck;
using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Pack;
using SVSim.Database.Repositories.Viewer;
using SVSim.EmulatedEntrypoint.Configuration;
using SVSim.EmulatedEntrypoint.Extensions;
@@ -61,6 +62,10 @@ public class Program
builder.Services.AddTransient<ICollectionRepository, CollectionRepository>();
builder.Services.AddTransient<IGlobalsRepository, GlobalsRepository>();
builder.Services.AddTransient<IDeckRepository, DeckRepository>();
builder.Services.AddTransient<IPackRepository, PackRepository>();
builder.Services.AddScoped<ICardPoolProvider, DbCardPoolProvider>();
builder.Services.AddSingleton<PackOpenService>();
builder.Services.AddSingleton<IRandom, SystemRandom>();
#endregion

View File

@@ -0,0 +1,42 @@
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:
// Standard pack — pool comes from the card set whose id equals base_pack_id.
return _db.CardSets
.Include(s => s.Cards)
.Where(s => s.Id == pack.BasePackId)
.SelectMany(s => s.Cards)
.ToList();
case PackCategory.SpecialCardPack:
case PackCategory.LimitedSpecialCardPack:
// Legendary-special packs pull from all rotation sets. The slot-8 forced-Legendary
// rule in PackOpenService delivers the "at least one legendary" promise.
return _db.CardSets
.Where(s => s.IsInRotation)
.Include(s => s.Cards)
.SelectMany(s => s.Cards)
.Distinct()
.ToList();
default:
// Skin / starter / leader-skin packs aren't drawn in v1 — controller rejects earlier.
return Array.Empty<ShadowverseCardEntry>();
}
}
}

View File

@@ -0,0 +1,7 @@
using SVSim.Database.Enums;
namespace SVSim.EmulatedEntrypoint.Services;
public record DrawnCard(long CardId, Rarity Rarity);
public record DrawResult(IReadOnlyList<DrawnCard> Cards);

View File

@@ -0,0 +1,9 @@
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);
}

View File

@@ -0,0 +1,10 @@
namespace SVSim.EmulatedEntrypoint.Services;
/// <summary>RNG seam for testable draw logic. Same contract as <see cref="System.Random"/>.</summary>
public interface IRandom
{
/// <summary>Returns a value in [0.0, 1.0).</summary>
double NextDouble();
/// <summary>Returns a value in [0, maxExclusive).</summary>
int Next(int maxExclusive);
}

View File

@@ -0,0 +1,110 @@
using SVSim.Database.Enums;
using SVSim.Database.Models;
namespace SVSim.EmulatedEntrypoint.Services;
/// <summary>
/// Draws cards from a pack's pool using the original Shadowverse Classic rates:
/// Slots 1-7: Bronze 67.44% / Silver 25% / Gold 6% / Legendary 1.5%
/// Slot 8: Silver 76.92% / Gold 18.46% / Legendary 4.62% (no Bronze)
/// Legendary-special packs (category 2/3, base >= 90001): slot 8 forced to Legendary.
///
/// The 0.06% slack in slots 1-7 (rates sum to 99.94%) is folded into Bronze so cumulative
/// weights add to exactly 1.0 — any RNG roll past the Gold band lands in either Legendary or
/// Bronze, and we put it in Bronze to err on the player-unfriendly side of the spec.
/// </summary>
public class PackOpenService
{
private const int CardsPerPack = 8;
public DrawResult Draw(
PackConfigEntry pack,
ICardPoolProvider pools,
int packNumber,
IReadOnlyCollection<long> excludeCardIds,
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)
.ToDictionary(g => g.Key, g => g.ToList());
bool isLegendarySpecial =
pack.PackCategory == PackCategory.SpecialCardPack ||
pack.PackCategory == PackCategory.LimitedSpecialCardPack;
var slots = new List<DrawnCard>(packNumber * CardsPerPack);
for (int p = 0; p < packNumber; p++)
{
for (int s = 0; s < CardsPerPack; s++)
{
Rarity rarity;
if (s == CardsPerPack - 1)
{
// Slot 8
if (isLegendarySpecial)
{
rarity = Rarity.Legendary;
}
else
{
rarity = PickSlot8Rarity(rng);
}
}
else
{
rarity = PickSlot1To7Rarity(rng);
}
var card = PickCardOfRarity(rarity, poolByRarity, rng);
slots.Add(new DrawnCard(card.Id, card.Rarity));
}
}
return new DrawResult(slots);
}
private static Rarity PickSlot1To7Rarity(IRandom rng)
{
double r = rng.NextDouble();
// Build cumulative bands in this order: Legendary -> Gold -> Silver -> Bronze (catch-all).
if (r < 0.0150) return Rarity.Legendary; // 1.5%
if (r < 0.0750) return Rarity.Gold; // +6% = 7.5%
if (r < 0.3250) return Rarity.Silver; // +25% = 32.5%
return Rarity.Bronze; // remaining (~67.5%; absorbs 0.06% slack)
}
private static Rarity PickSlot8Rarity(IRandom rng)
{
double r = rng.NextDouble();
// Renormalized over 32.5: Legendary 4.62%, Gold 18.46%, Silver 76.92%.
if (r < 0.0462) return Rarity.Legendary;
if (r < 0.2308) return Rarity.Gold; // 0.0462 + 0.1846
return Rarity.Silver;
}
private static ShadowverseCardEntry PickCardOfRarity(
Rarity rarity,
Dictionary<Rarity, List<ShadowverseCardEntry>> poolByRarity,
IRandom rng)
{
// Fallback if the rolled rarity has no cards (e.g. pool has no Legendaries):
// walk down to Gold -> Silver -> Bronze. This is a safety net for sparse master data;
// healthy production pools have all four rarities.
Rarity[] fallback = { rarity, Rarity.Gold, Rarity.Silver, Rarity.Bronze };
foreach (var r in fallback)
{
if (poolByRarity.TryGetValue(r, out var list) && list.Count > 0)
{
return list[rng.Next(list.Count)];
}
}
throw new InvalidOperationException("PackOpenService: pool empty after exclude filter.");
}
}

View File

@@ -0,0 +1,10 @@
namespace SVSim.EmulatedEntrypoint.Services;
public class SystemRandom : IRandom
{
private readonly Random _rng;
public SystemRandom() { _rng = new Random(); }
public SystemRandom(int seed) { _rng = new Random(seed); }
public double NextDouble() => _rng.NextDouble();
public int Next(int maxExclusive) => _rng.Next(maxExclusive);
}

View File

@@ -0,0 +1,133 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers;
public class PackControllerInfoTests
{
private const string EmptyEnvelope = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json");
private static async Task SeedActivePack(SVSimTestFactory f, int parentId, int baseId, PackCategory cat)
{
using var scope = f.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.Packs.Add(new PackConfigEntry
{
Id = parentId, BasePackId = baseId, PackCategory = cat,
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
GachaType = 1, GachaDetail = "test",
ChildGachas = { new PackChildGachaEntry { GachaId = parentId * 10 + 7, TypeDetail = 7, Cost = 100, CardCount = 8 } },
});
await db.SaveChangesAsync();
}
[Test]
public async Task Info_returns_active_packs_only()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedActivePack(factory, 10001, 10001, PackCategory.None);
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/pack/info", JsonBody(EmptyEnvelope));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
var list = doc.RootElement.GetProperty("pack_config_list");
Assert.That(list.GetArrayLength(), Is.EqualTo(1));
Assert.That(list[0].GetProperty("parent_gacha_id").GetInt32(), Is.EqualTo(10001));
}
[Test]
public async Task Info_overlays_viewer_open_count()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedActivePack(factory, 10001, 10001, PackCategory.None);
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db.Viewers.Include(x => x.PackOpenCounts).FirstAsync(x => x.Id == viewerId);
v.PackOpenCounts.Add(new ViewerPackOpenCount { PackId = 10001, OpenCount = 7 });
await db.SaveChangesAsync();
}
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/pack/info", JsonBody(EmptyEnvelope));
var body = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
var p = doc.RootElement.GetProperty("pack_config_list")[0];
Assert.That(p.GetProperty("open_count").GetInt32(), Is.EqualTo(7));
}
[Test]
public async Task Info_emits_child_gacha_info_with_correct_wire_keys()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedActivePack(factory, 10001, 10001, PackCategory.None);
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/pack/info", JsonBody(EmptyEnvelope));
var body = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
var children = doc.RootElement.GetProperty("pack_config_list")[0].GetProperty("child_gacha_info");
Assert.That(children.GetArrayLength(), Is.EqualTo(1));
Assert.That(children[0].GetProperty("type_detail").GetInt32(), Is.EqualTo(7));
Assert.That(children[0].GetProperty("cost").GetInt32(), Is.EqualTo(100));
}
[Test]
public async Task Info_emits_gacha_point_key_as_null_when_pack_has_no_gacha_point_config()
{
// PackInfoTask.cs:126 does `if (jsonData2["gacha_point"] != null)`. LitJson's JsonData
// indexer throws KeyNotFoundException on missing keys — the null check protects against
// null *value*, not missing *key*. With Program.cs's global WhenWritingNull, a null
// PackGachaPointDto would be omitted entirely and crash the client. Override per
// [[project_wire_null_policy]].
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
// Seed a pack WITHOUT GachaPointConfig — matches packs 80047, 92001, 99047 in prod
// (legendary specials whose `gacha_point` is null).
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.Packs.Add(new PackConfigEntry
{
Id = 92001, BasePackId = 90001, PackCategory = PackCategory.LegendCardPack,
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
GachaType = 1, GachaDetail = "legendary special", SleeveId = 5090001,
GachaPointConfig = null,
ChildGachas = { new PackChildGachaEntry { GachaId = 920002, TypeDetail = 5, Cost = 1, CardCount = 8, ItemId = 92001 } },
});
await db.SaveChangesAsync();
}
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/pack/info", JsonBody(EmptyEnvelope));
var body = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
var pack = doc.RootElement.GetProperty("pack_config_list")[0];
// The key MUST be present, even though its value is null.
Assert.That(pack.TryGetProperty("gacha_point", out var gachaPoint), Is.True,
"gacha_point key must always be present in /pack/info — client at PackInfoTask.cs:126 does a direct key access guarded only by a null check, not Keys.Contains.");
Assert.That(gachaPoint.ValueKind, Is.EqualTo(JsonValueKind.Null),
"gacha_point should serialize as explicit null when no GachaPointConfig is set.");
}
}

View File

@@ -0,0 +1,357 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers;
public class PackControllerOpenTests
{
private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json");
/// <summary>
/// Seeds an active pack (parent 10001, base = first seeded card set) with one rupee child
/// gacha (gacha_id=400002, cost=100), then gives the viewer enough rupees to buy once.
/// </summary>
private static async Task<int> SeedOpenablePack(SVSimTestFactory f, long viewerId, ulong rupees = 200)
{
int baseId;
using (var scope = f.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
baseId = await db.CardSets.Where(s => s.Cards.Count > 0).Select(s => s.Id).FirstAsync();
db.Packs.Add(new PackConfigEntry
{
Id = 10001, BasePackId = baseId, PackCategory = PackCategory.None,
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
GachaType = 1, GachaDetail = "test", SleeveId = 3000011,
ChildGachas = {
new PackChildGachaEntry { GachaId = 400002, TypeDetail = 7, Cost = 100, CardCount = 8 },
},
});
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
v.Currency.Rupees = rupees;
await db.SaveChangesAsync();
}
return baseId;
}
[Test]
public async Task Open_with_rupees_returns_8_cards_and_deducts_currency()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedOpenablePack(factory, viewerId);
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
var response = await client.PostAsync("/pack/open", JsonBody(json));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
var pack = doc.RootElement.GetProperty("pack_list");
Assert.That(pack.GetArrayLength(), Is.EqualTo(8));
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
Assert.That(v.Currency.Rupees, Is.EqualTo(100UL), "200 starting - 100 cost");
}
[Test]
public async Task Open_persists_drawn_cards_to_viewer_collection()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedOpenablePack(factory, viewerId);
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
await client.PostAsync("/pack/open", JsonBody(json));
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db.Viewers.Include(x => x.Cards).ThenInclude(c => c.Card).FirstAsync(x => x.Id == viewerId);
var totalGranted = v.Cards.Sum(c => c.Count);
Assert.That(totalGranted, Is.EqualTo(8), "8 cards drawn, all persisted (Count sums to 8 even when duplicates collapse).");
}
[Test]
public async Task Open_increments_viewer_open_count()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedOpenablePack(factory, viewerId);
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
await client.PostAsync("/pack/open", JsonBody(json));
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db.Viewers.Include(x => x.PackOpenCounts).FirstAsync(x => x.Id == viewerId);
Assert.That(v.PackOpenCounts.Single(p => p.PackId == 10001).OpenCount, Is.EqualTo(1));
}
[Test]
public async Task Open_rejects_when_class_id_present()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedOpenablePack(factory, viewerId);
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"class_id":3}""";
var response = await client.PostAsync("/pack/open", JsonBody(json));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotImplemented));
}
[Test]
public async Task Open_rejects_when_target_card_id_present()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedOpenablePack(factory, viewerId);
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"target_card_id":12345}""";
var response = await client.PostAsync("/pack/open", JsonBody(json));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotImplemented));
}
[Test]
public async Task Open_rejects_skin_pack_category()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.Packs.Add(new PackConfigEntry
{
Id = 70001, BasePackId = 70001, PackCategory = PackCategory.LeaderSkinPack,
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
GachaType = 1, GachaDetail = "skin pack",
ChildGachas = { new PackChildGachaEntry { GachaId = 700017, TypeDetail = 7, Cost = 100, CardCount = 8 } },
});
await db.SaveChangesAsync();
}
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":70001,"gacha_id":700017,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
var response = await client.PostAsync("/pack/open", JsonBody(json));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotImplemented));
}
[Test]
public async Task Open_rejects_ticket_type_detail()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.Packs.Add(new PackConfigEntry
{
Id = 92001, BasePackId = 90001, PackCategory = PackCategory.SpecialCardPack,
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
GachaType = 1, GachaDetail = "ticket-only pack", SleeveId = 5090001,
ChildGachas = { new PackChildGachaEntry { GachaId = 920002, TypeDetail = 5, Cost = 1, CardCount = 8, ItemId = 92001 } },
});
await db.SaveChangesAsync();
}
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":92001,"gacha_id":920002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
var response = await client.PostAsync("/pack/open", JsonBody(json));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotImplemented));
}
[Test]
public async Task Open_rejects_insufficient_rupees()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedOpenablePack(factory, viewerId, rupees: 50); // need 100
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
var response = await client.PostAsync("/pack/open", JsonBody(json));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
Assert.That(v.Currency.Rupees, Is.EqualTo(50UL), "no deduction on insufficient-funds reject");
}
[Test]
public async Task Open_with_crystals_deducts_crystals()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
int baseId = await db.CardSets.Where(s => s.Cards.Count > 0).Select(s => s.Id).FirstAsync();
db.Packs.Add(new PackConfigEntry
{
Id = 10001, BasePackId = baseId, PackCategory = PackCategory.None,
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
GachaType = 1, GachaDetail = "test",
ChildGachas = { new PackChildGachaEntry { GachaId = 100002, TypeDetail = 2, Cost = 100, CardCount = 8 } },
});
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
v.Currency.Crystals = 250;
await db.SaveChangesAsync();
}
using var client = factory.CreateAuthenticatedClient(viewerId);
// gacha_type:1 (parent pack's gacha_type) not :2 (child's type_detail) — see project_wire_pack_gacha_type memory.
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":100002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
var response = await client.PostAsync("/pack/open", JsonBody(json));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
using (var scope2 = factory.Services.CreateScope())
{
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v2 = await db2.Viewers.FirstAsync(x => x.Id == viewerId);
Assert.That(v2.Currency.Crystals, Is.EqualTo(150UL));
}
}
[Test]
public async Task Open_daily_marks_last_daily_free_at_and_rejects_second_attempt()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
int baseId = await db.CardSets.Where(s => s.Cards.Count > 0).Select(s => s.Id).FirstAsync();
db.Packs.Add(new PackConfigEntry
{
Id = 10001, BasePackId = baseId, PackCategory = PackCategory.None,
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
GachaType = 1, GachaDetail = "daily test",
ChildGachas = { new PackChildGachaEntry { GachaId = 200001, TypeDetail = 3, Cost = 0, CardCount = 1, IsDailySingle = true } },
});
await db.SaveChangesAsync();
}
using var client = factory.CreateAuthenticatedClient(viewerId);
// gacha_type:1 (parent pack's gacha_type) not :3 (child's type_detail=DAILY) — prod-correct.
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":200001,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
var first = await client.PostAsync("/pack/open", JsonBody(json));
Assert.That(first.StatusCode, Is.EqualTo(HttpStatusCode.OK), await first.Content.ReadAsStringAsync());
var second = await client.PostAsync("/pack/open", JsonBody(json));
Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest),
"Second daily-free open the same UTC day should be rejected.");
}
// ---------------- Regression tests for wire-shape quirks ----------------
[Test]
public async Task Open_succeeds_when_request_gacha_type_differs_from_child_type_detail()
{
// Prod client sends gacha_type=1 (parent pack's gacha_type) for every buy on a
// gacha_type=1 pack, regardless of which child gacha is being bought (RUPY_MULTI=7,
// DAILY=3, CRYSTAL_MULTI=2, TICKET_MULTI=5, ...). Server must NOT reject on
// `child.TypeDetail != request.GachaType` — gacha_id alone identifies the child.
// See project_wire_pack_gacha_type memory.
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedOpenablePack(factory, viewerId);
using var client = factory.CreateAuthenticatedClient(viewerId);
// gacha_id 400002 is RUPY_MULTI (type_detail=7); request sends gacha_type=1 — should succeed.
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
var response = await client.PostAsync("/pack/open", JsonBody(json));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
}
[Test]
public async Task Open_reward_list_includes_post_state_rupee_balance()
{
// Client's PlayerStaticData.UpdateHaveUserGoodsNum does `UserRupyCount = reward_num`
// (direct assignment). Without an entry, the on-screen rupee count stays stale until
// restart / /mypage/refresh. Verify the entry shape: reward_type=9 (Rupy), reward_id=0,
// reward_num=new post-state balance (starting 200 - cost 100 = 100).
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedOpenablePack(factory, viewerId, rupees: 200);
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
var response = await client.PostAsync("/pack/open", JsonBody(json));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
var rewardList = doc.RootElement.GetProperty("reward_list");
Assert.That(rewardList.GetArrayLength(), Is.GreaterThan(0),
"reward_list must be populated — empty list leaves client cache stale.");
var rupyEntry = Enumerable.Range(0, rewardList.GetArrayLength())
.Select(i => rewardList[i])
.FirstOrDefault(e => e.GetProperty("reward_type").GetInt32() == 9);
Assert.That(rupyEntry.ValueKind, Is.Not.EqualTo(JsonValueKind.Undefined),
"missing Rupy (type=9) entry — client will keep showing the old rupee balance.");
Assert.That(rupyEntry.GetProperty("reward_id").GetInt64(), Is.EqualTo(0L));
Assert.That(rupyEntry.GetProperty("reward_num").GetInt32(), Is.EqualTo(100),
"reward_num is the new TOTAL balance (200 starting - 100 cost), not a delta.");
}
[Test]
public async Task Open_reward_list_includes_post_state_card_counts()
{
// For each unique drawn card the client expects `{reward_type:5, reward_id:<card_id>,
// reward_num:<new total owned count>}`. Without these the in-session collection cache
// is stale (cards appear in the open animation but the collection view doesn't update).
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedOpenablePack(factory, viewerId);
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
var response = await client.PostAsync("/pack/open", JsonBody(json));
var body = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
var packList = doc.RootElement.GetProperty("pack_list");
var rewardList = doc.RootElement.GetProperty("reward_list");
// Every distinct card_id in pack_list must have a matching type=5 reward_list entry.
var drawnCardIds = Enumerable.Range(0, packList.GetArrayLength())
.Select(i => packList[i].GetProperty("card_id").GetInt64())
.Distinct()
.ToHashSet();
var cardEntryIds = Enumerable.Range(0, rewardList.GetArrayLength())
.Select(i => rewardList[i])
.Where(e => e.GetProperty("reward_type").GetInt32() == 5)
.Select(e => e.GetProperty("reward_id").GetInt64())
.ToHashSet();
Assert.That(cardEntryIds, Is.SupersetOf(drawnCardIds),
"Every unique drawn card_id must appear in reward_list with reward_type=5.");
}
}

View File

@@ -0,0 +1,71 @@
using System.Net;
using System.Text;
using System.Text.Json;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers;
/// <summary>
/// Drives the importer + controller against the real prod capture (35 packs). Guards against
/// regressions in either layer caused by future capture refreshes.
/// </summary>
public class PackControllerProdCaptureTests
{
[Test]
public async Task Info_returns_full_35_pack_catalog_from_prod_capture()
{
// The default captures dir has both pack-info-fixture.json (3 packs) and
// pack-info-2026-05-23.json (35 packs). LoadCapture sorts by name descending and
// "pack-info-fixture.json" > "pack-info-2026-05-23.json" lexicographically, so the
// fixture would win. Copy captures to a temp dir, drop the fixture, then seed from there.
var sourceDir = Path.Combine(AppContext.BaseDirectory, "Data", "prod-captures");
var tempDir = Path.Combine(Path.GetTempPath(), "svsim-pack-prod-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
foreach (var src in Directory.EnumerateFiles(sourceDir))
{
if (Path.GetFileName(src).Equals("pack-info-fixture.json", StringComparison.OrdinalIgnoreCase))
continue;
File.Copy(src, Path.Combine(tempDir, Path.GetFileName(src)));
}
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync(tempDir); // imports the 35-pack pack-info-2026-05-23.json
long viewerId = await factory.SeedViewerAsync();
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync(
"/pack/info",
new StringContent("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""",
Encoding.UTF8, "application/json"));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
var list = doc.RootElement.GetProperty("pack_config_list");
Assert.That(list.GetArrayLength(), Is.EqualTo(35),
"Full prod capture should yield 35 active packs as of 2026-05-23.");
// Spot-check pack 99047 (LegendCardPack throwback, pack_category=1)
bool sawSpecial = false;
for (int i = 0; i < list.GetArrayLength(); i++)
{
var el = list[i];
if (el.GetProperty("parent_gacha_id").GetInt32() == 99047)
{
Assert.That(el.GetProperty("pack_category").GetInt32(), Is.EqualTo(1),
"99047 is a LegendCardPack (category 1) in the prod capture.");
sawSpecial = true;
break;
}
}
Assert.That(sawSpecial, Is.True, "pack 99047 must be in the prod capture output.");
}
finally
{
try { Directory.Delete(tempDir, recursive: true); } catch { /* best-effort cleanup */ }
}
}
}

View File

@@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Importers;
public class GlobalsImporterPackTests
{
[Test]
public async Task ImportAll_loads_pack_catalog_from_fixture()
{
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync(); // uses prod-captures fixture dir copied into test output
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var packs = await db.Packs.OrderBy(p => p.Id).ToListAsync();
Assert.That(packs.Count, Is.GreaterThanOrEqualTo(3), "fixture has at least 3 packs");
var p10001 = packs.Single(p => p.Id == 10001);
Assert.That(p10001.PackCategory, Is.EqualTo(PackCategory.None));
Assert.That(p10001.BasePackId, Is.EqualTo(10001));
Assert.That(p10001.SleeveId, Is.EqualTo(3000011));
Assert.That(p10001.GachaPointConfig, Is.Not.Null);
Assert.That(p10001.GachaPointConfig!.ExchangeablePoint, Is.EqualTo(400));
}
[Test]
public async Task ImportAll_persists_child_gachas_with_correct_types_and_costs()
{
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var pack = await db.Packs.AsNoTracking()
.FirstAsync(p => p.Id == 10001);
var children = pack.ChildGachas.OrderBy(c => c.GachaId).ToList();
Assert.That(children.Count, Is.EqualTo(3));
Assert.That(children.Select(c => c.TypeDetail), Is.EqualTo(new[] { 2, 3, 7 }));
Assert.That(children.Select(c => c.Cost), Is.EqualTo(new[] { 100, 50, 100 }));
Assert.That(children.Single(c => c.TypeDetail == 3).IsDailySingle, Is.True);
}
[Test]
public async Task ImportAll_is_idempotent_on_rerun()
{
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync();
await factory.SeedGlobalsAsync(); // second run must not duplicate or stack child gachas
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var pack = await db.Packs.AsNoTracking().FirstAsync(p => p.Id == 10001);
Assert.That(pack.ChildGachas.Count, Is.EqualTo(3),
"child_gacha_info is owned — rerun must replace, not stack.");
}
}

View File

@@ -57,9 +57,37 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.Database.EnsureCreated();
// Seed a minimal card set so card-pool tests can resolve a non-empty pool without
// requiring the full CardImporter tool or a cards.json file. The set is marked
// IsInRotation so both standard-pack (by setId) and special-pack (rotation scan)
// tests see real data.
SeedMinimalCardSet(db);
return host;
}
private static void SeedMinimalCardSet(SVSimDbContext db)
{
if (db.CardSets.Any())
return; // Already seeded (e.g. if CreateHost is called more than once)
var set = new ShadowverseCardSetEntry
{
Id = 10001,
Name = "TestSet",
IsInRotation = true,
IsBasic = false,
Cards =
[
new ShadowverseCardEntry { Id = 10001001L, Name = "TestCard1", Rarity = Rarity.Bronze },
new ShadowverseCardEntry { Id = 10001002L, Name = "TestCard2", Rarity = Rarity.Gold },
new ShadowverseCardEntry { Id = 10001003L, Name = "TestCard3", Rarity = Rarity.Legendary },
]
};
db.CardSets.Add(set);
db.SaveChanges();
}
private void ReplaceDbContext(IServiceCollection services)
{
// Production registered DbContextOptions<SVSimDbContext> with the Npgsql provider; tear

View File

@@ -0,0 +1,119 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Pack;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Repositories;
public class PackRepositoryTests
{
private static async Task SeedPack(SVSimTestFactory factory, int parentId, int baseId, PackCategory cat,
DateTime commence, DateTime complete)
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.Packs.Add(new PackConfigEntry
{
Id = parentId, BasePackId = baseId, PackCategory = cat,
CommenceDate = commence, CompleteDate = complete,
GachaType = 1, GachaDetail = "test",
ChildGachas = { new PackChildGachaEntry { GachaId = parentId * 10, TypeDetail = 7, Cost = 100, CardCount = 8 } },
});
await db.SaveChangesAsync();
}
[Test]
public async Task GetActivePacks_filters_by_date_window()
{
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(-30), now.AddDays(30)); // active
await SeedPack(factory, 10002, 10002, PackCategory.None, now.AddDays(+1), now.AddDays(30)); // not started
await SeedPack(factory, 10003, 10003, PackCategory.None, now.AddDays(-30), now.AddDays(-1)); // expired
using var scope = factory.Services.CreateScope();
var repo = scope.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()
{
using var factory = new SVSimTestFactory();
var now = DateTime.UtcNow;
await SeedPack(factory, 10001, 10001, PackCategory.None, now.AddDays(-1), now.AddDays(1));
using var scope = factory.Services.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IPackRepository>();
var pack = await repo.GetPack(10001);
Assert.That(pack, Is.Not.Null);
Assert.That(pack!.ChildGachas.Count, Is.EqualTo(1));
Assert.That(pack.ChildGachas[0].GachaId, Is.EqualTo(100010));
}
[Test]
public async Task IncrementOpenCount_creates_then_updates()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using (var scope = factory.Services.CreateScope())
{
var repo = scope.ServiceProvider.GetRequiredService<IPackRepository>();
await repo.IncrementOpenCount(viewerId, 10001, 3);
await repo.IncrementOpenCount(viewerId, 10001, 2);
}
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db.Viewers.Include(x => x.PackOpenCounts).FirstAsync(x => x.Id == viewerId);
var row = v.PackOpenCounts.Single(p => p.PackId == 10001);
Assert.That(row.OpenCount, Is.EqualTo(5));
}
}
[Test]
public async Task GrantCardsToViewer_inserts_new_and_increments_existing()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
// Cards are not seeded by BaseDataSeeder (they come from CardImport). Insert one directly.
const long seedCardId = 100000001L;
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.Cards.Add(new ShadowverseCardEntry { Id = seedCardId, Name = "Test Card", Rarity = Rarity.Bronze });
await db.SaveChangesAsync();
}
long sampleCardId;
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
sampleCardId = await db.Cards.OrderBy(c => c.Id).Select(c => c.Id).FirstAsync();
}
using (var scope = factory.Services.CreateScope())
{
var repo = scope.ServiceProvider.GetRequiredService<IPackRepository>();
await repo.GrantCardsToViewer(viewerId, new[] { sampleCardId, sampleCardId });
}
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db.Viewers.Include(x => x.Cards).ThenInclude(c => c.Card)
.FirstAsync(x => x.Id == viewerId);
var owned = v.Cards.Single(c => c.Card.Id == sampleCardId);
Assert.That(owned.Count, Is.EqualTo(2));
}
}
}

View File

@@ -40,6 +40,14 @@
<Content Include="..\SVSim.Bootstrap\Data\prod-captures\*.json" Link="Data\prod-captures\%(Filename)%(Extension)">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<!-- Test-only fixtures live outside prod-captures so the production bootstrap glob doesn't
pick them up (a fixture-named file would win the importer's reverse-alphabetical sort
against a dated capture). Linked into the same test output dir so SeedGlobalsAsync sees
them; per-feature tests rely on the fixture, the prod-capture smoke test routes around it
via a temp dir. -->
<Content Include="..\SVSim.Bootstrap\Data\test-fixtures\*.json" Link="Data\prod-captures\%(Filename)%(Extension)">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,67 @@
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);
}
}

View File

@@ -0,0 +1,131 @@
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.EmulatedEntrypoint.Services;
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 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 readonly IReadOnlyList<ShadowverseCardEntry> _cards;
public StubPool(IReadOnlyList<ShadowverseCardEntry> cards) { _cards = cards; }
public IReadOnlyList<ShadowverseCardEntry> GetPool(PackConfigEntry _) => _cards;
}
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 },
};
private static PackConfigEntry StandardPack() => new()
{
Id = 10001, BasePackId = 10001, PackCategory = PackCategory.None,
};
[Test]
public void Draw_returns_eight_cards_for_one_pack()
{
var svc = new PackOpenService();
var pool = new StubPool(MakeFourCards());
var rng = new ScriptedRandom(0.5); // anything in Bronze/Silver range for non-slot-8
var result = svc.Draw(StandardPack(), pool, packNumber: 1, excludeCardIds: Array.Empty<long>(), rng: rng);
Assert.That(result.Cards.Count, Is.EqualTo(8));
}
[Test]
public void Draw_slot_8_never_returns_bronze_for_standard_pack()
{
var svc = new PackOpenService();
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 = new PackOpenService();
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 = new PackOpenService();
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 = new PackOpenService();
// 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");
}
}
}