Pack opening
This commit is contained in:
File diff suppressed because one or more lines are too long
48
SVSim.Bootstrap/Data/test-fixtures/pack-info-fixture.json
Normal file
48
SVSim.Bootstrap/Data/test-fixtures/pack-info-fixture.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
17
SVSim.Database/Enums/PackCategory.cs
Normal file
17
SVSim.Database/Enums/PackCategory.cs
Normal 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,
|
||||
}
|
||||
34843
SVSim.Database/Migrations/20260524044535_AddPackCatalog.Designer.cs
generated
Normal file
34843
SVSim.Database/Migrations/20260524044535_AddPackCatalog.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
135
SVSim.Database/Migrations/20260524044535_AddPackCatalog.cs
Normal file
135
SVSim.Database/Migrations/20260524044535_AddPackCatalog.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
11
SVSim.Database/Models/PackBannerEntry.cs
Normal file
11
SVSim.Database/Models/PackBannerEntry.cs
Normal 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;
|
||||
}
|
||||
26
SVSim.Database/Models/PackChildGachaEntry.cs
Normal file
26
SVSim.Database/Models/PackChildGachaEntry.cs
Normal 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; }
|
||||
}
|
||||
40
SVSim.Database/Models/PackConfigEntry.cs
Normal file
40
SVSim.Database/Models/PackConfigEntry.cs
Normal 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();
|
||||
}
|
||||
16
SVSim.Database/Models/PackGachaPointConfig.cs
Normal file
16
SVSim.Database/Models/PackGachaPointConfig.cs
Normal 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; }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
17
SVSim.Database/Models/ViewerPackOpenCount.cs
Normal file
17
SVSim.Database/Models/ViewerPackOpenCount.cs
Normal 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; }
|
||||
}
|
||||
13
SVSim.Database/Repositories/Pack/IPackRepository.cs
Normal file
13
SVSim.Database/Repositories/Pack/IPackRepository.cs
Normal 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);
|
||||
}
|
||||
90
SVSim.Database/Repositories/Pack/PackRepository.cs
Normal file
90
SVSim.Database/Repositories/Pack/PackRepository.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
233
SVSim.EmulatedEntrypoint/Controllers/PackController.cs
Normal file
233
SVSim.EmulatedEntrypoint/Controllers/PackController.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
16
SVSim.EmulatedEntrypoint/Models/Dtos/PackBannerDto.cs
Normal file
16
SVSim.EmulatedEntrypoint/Models/Dtos/PackBannerDto.cs
Normal 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;
|
||||
}
|
||||
43
SVSim.EmulatedEntrypoint/Models/Dtos/PackChildGachaDto.cs
Normal file
43
SVSim.EmulatedEntrypoint/Models/Dtos/PackChildGachaDto.cs
Normal 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";
|
||||
}
|
||||
110
SVSim.EmulatedEntrypoint/Models/Dtos/PackConfigDto.cs
Normal file
110
SVSim.EmulatedEntrypoint/Models/Dtos/PackConfigDto.cs
Normal 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; }
|
||||
}
|
||||
32
SVSim.EmulatedEntrypoint/Models/Dtos/PackGachaPointDto.cs
Normal file
32
SVSim.EmulatedEntrypoint/Models/Dtos/PackGachaPointDto.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
31
SVSim.EmulatedEntrypoint/Models/Dtos/RewardListEntry.cs
Normal file
31
SVSim.EmulatedEntrypoint/Models/Dtos/RewardListEntry.cs
Normal 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; }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
42
SVSim.EmulatedEntrypoint/Services/DbCardPoolProvider.cs
Normal file
42
SVSim.EmulatedEntrypoint/Services/DbCardPoolProvider.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
7
SVSim.EmulatedEntrypoint/Services/DrawResult.cs
Normal file
7
SVSim.EmulatedEntrypoint/Services/DrawResult.cs
Normal 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);
|
||||
9
SVSim.EmulatedEntrypoint/Services/ICardPoolProvider.cs
Normal file
9
SVSim.EmulatedEntrypoint/Services/ICardPoolProvider.cs
Normal 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);
|
||||
}
|
||||
10
SVSim.EmulatedEntrypoint/Services/IRandom.cs
Normal file
10
SVSim.EmulatedEntrypoint/Services/IRandom.cs
Normal 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);
|
||||
}
|
||||
110
SVSim.EmulatedEntrypoint/Services/PackOpenService.cs
Normal file
110
SVSim.EmulatedEntrypoint/Services/PackOpenService.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
10
SVSim.EmulatedEntrypoint/Services/SystemRandom.cs
Normal file
10
SVSim.EmulatedEntrypoint/Services/SystemRandom.cs
Normal 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);
|
||||
}
|
||||
133
SVSim.UnitTests/Controllers/PackControllerInfoTests.cs
Normal file
133
SVSim.UnitTests/Controllers/PackControllerInfoTests.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
357
SVSim.UnitTests/Controllers/PackControllerOpenTests.cs
Normal file
357
SVSim.UnitTests/Controllers/PackControllerOpenTests.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
61
SVSim.UnitTests/Importers/GlobalsImporterPackTests.cs
Normal file
61
SVSim.UnitTests/Importers/GlobalsImporterPackTests.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
119
SVSim.UnitTests/Repositories/PackRepositoryTests.cs
Normal file
119
SVSim.UnitTests/Repositories/PackRepositoryTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
67
SVSim.UnitTests/Services/DbCardPoolProviderTests.cs
Normal file
67
SVSim.UnitTests/Services/DbCardPoolProviderTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
131
SVSim.UnitTests/Services/PackOpenServiceTests.cs
Normal file
131
SVSim.UnitTests/Services/PackOpenServiceTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user